Compare commits

...
Author SHA1 Message Date
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
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 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
Ludovic Marie 1fe21fb861 Merge pull request #11885 from horizon-rust/feature/ms-account-picker
🤖 Agent: opens the code page in the default browser's private window (flag per browser, pure-function tests); no Azure change, Safari/unknown get Copy link + hint
2026-11-07 17:45:36 +01:00
Horizon agentandClaude Opus 5.5 68b03b5184 docs: how the launcher adds another Microsoft account (#3786)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:43:47 +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 00d1e1a46e Launcher: clippy, and the Linux program table only where Linux reads it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:35:45 +01:00
Horizon agentandClaude Opus 5.5 1ee069b6ba Launcher: private-window strings in every language, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:18:12 +01:00
Horizon agentandClaude Opus 5.5 c30dfd55e5 Launcher: add another Microsoft account from a private browser window (#3786)
The device code page goes ahead with the account the browser is signed in to.
Open it in the default browser's private window (Windows ProgId, macOS
LaunchServices, Linux xdg-settings), or give the link to copy, or sign the
browser out of Microsoft first. No Azure app change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:15:21 +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
Ludovic Marie 2ebf00a028 Merge pull request #10305 from martipops/controller/guide
🤖 Agent: requeued at 8cd24bc4e5: the merger stopped on a main conflict, and the author's main merge resolves it. verify is green on this head; the run-parity label reruns parity on it before the merge.
2026-11-07 16:56:15 +01:00
Ludovic Marie 821426be75 Merge pull request #11812 from horizon-rust/cg61/flame-test
🤖 Agent: test-only: Flame is Ignite(constant(100)) seconds, so 2000 ticks; a_flame_crossbow_shoots_burning_arrows passes with prepared resources 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 16:42:22 +01:00
Ludovic Marie 7325d8811b Merge pull request #10365 from MSedgeMC/fix/warden-anger
🤖 Agent: a re-angered suspect keeps its saved anger as increaseAnger does (test now expects 54); carries #10290 for the shared corpus; sculk, wolf_anger_writes 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 16:42:19 +01:00
Ludovic Marie 5bbfe0f311 Merge pull request #10290 from TheMrZZ/fix/h183-spectral-arrow-duration
🤖 Agent: added the hit's Glowing from the arrow's own Duration (SpectralArrow.doPostHurtEffects reads this.duration; Horizon used a fixed 200); carries #10344 since both extend the command corpus; spectral_duration and command_exec 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 16:42:15 +01:00
Ludovic Marie f5c936af5e Merge pull request #10344 from TheMrZZ/fix/h053-item-health-short
🤖 Agent: merged main; the Health doc line now sits on item_entity_from_nbt next to main's numeric-UUID line instead of above the UUID helper; item_health_load and item_numeric_uuid 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 16:42:11 +01:00
Ludovic Marie 0a898f21b0 Merge pull request #10291 from TheMrZZ/fix/saved-empty-entity-tags
🤖 Agent: merged main, keeping main's arrow_owner_tests next to this PR's saved_tag_tests in data.rs; saved_empty_tags, arrow_owner and the explicit-empty ignored test 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 16:42:06 +01:00
Ludovic Marie 6cc93d13ce Merge pull request #11587 from voidsipp/fix/11123-native-hud-mode
🤖 Agent: F1 stays the vanilla two-state toggle and resets the mod's hand override; set_hud_mode is a no-op on servers and during a guest's borrowed turn; hud_mode helper fragment passes tests.test_stamp_native_hooks with main merged in (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 16:23:42 +01:00
Horizon agentandClaude Opus 5.5 9a292609eb Merge origin/main into #11587
hand_geometry: keeps this branch's ui.hands_drawn (native HUD modes) and
main's rule that a studio replay never draws the hands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:04:18 +01:00
Horizon agentandClaude Opus 5.5 fd4d709dc2 Merge #10290 into #10365 (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:13 +01:00
Horizon agentandClaude Opus 5.5 c34114751a 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:12 +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 ffef7b9c7e Merge #10344 into #10290 (both edit the same lines)
Conflicts kept from both sides: tools/command_exec_corpus.py tools/test_command_exec_parity.py

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 744fcd7e01 A spectral arrow gives Glowing for its own Duration
SpectralArrow.doPostHurtEffects builds the effect from this.duration, which this PR now loads and saves; the hit still used a fixed 200.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:10 +01:00
Horizon agentandClaude Opus 5.5 e760415d7b Merge main into #10290
Conflicts kept from both sides: tools/command_exec_corpus.py tools/test_command_exec_parity.py

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:10 +01:00
Horizon agentandClaude Opus 5.5 95f1756a8d Merge main into #10344
Conflicts kept from both sides: crates/horizon-client/src/entity_nbt.rs tools/command_exec_corpus.py tools/test_command_exec_parity.py

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:08 +01:00
Horizon agentandClaude Opus 5.5 e8c56045c9 Merge main into #10291
Conflicts kept from both sides: crates/horizon-client/src/game/commands/data.rs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:41:04 +01:00
Ludovic Marie 1d63e0a5a3 Merge pull request #11640 from masa-masa-0314/feat/realms-service-fixes
🤖 Agent: Realms subscription URL (subscriptions/$WORLD_ID), JSON-bool settings, InviteCodeList array/result parsing and the JSON Content-Type now match the 26.3 RealmsClient; same Microsoft session, no other auth (Mojang rules OK); footer buttons wired to the selected code. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 15:20:47 +01:00
martipopsandClaude Opus 5.5 8cd24bc4e5 Merge main into controller/guide
Creative draws main's mod browser, then the pad's menu guide over it, as
the inventory does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:15:10 -05:00
Ludovic Marie c78187dfff Merge pull request #11763 from TiggerTakkyTTT/codex/launcher-latest-version
Launcher: make the latest Nightly obvious
2026-11-07 14:49:32 +01:00
Ludovic Marie fc2868b2b2 Merge pull request #11773 from horizon-rust/agent/recipes-11443-main-merge
🤖 Agent: #11443 by GabrielNichols with main merged (its fork refuses pushes): the Recipes panel keeps its editable search and query when a recipe returns to an empty result, and opens in Creative inventory. The only resolution is THIRD_PARTY_NOTICES, where the pinned JEI paragraph now names the GUI sprites under the MIT LICENSE.txt shipped beside them. Verify and launcher green; clippy -D warnings OK.
2026-11-07 14:37:29 +01:00
Horizon agent e414a7a14b Merge origin/main into #11443's series branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	THIRD_PARTY_NOTICES.md
2026-11-07 14:17:59 +01:00
Ludovic Marie 2b8d23af24 Merge pull request #11550 from socksprox/client/feature-gate-online
🤖 Agent: discord, friends and heartbeat become default-on Cargo features (compile gating only, no new network call), so the --no-default-features server build drops 14 crates; main's newer native_client_services invite code is gated too. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 14:16:40 +01:00
Ludovic Marie 6560641ba5 Merge pull request #11731 from horizon-rust/fix/wool-vibration-drops
🤖 Agent: harness-only. Kills items and corpses between cases (the drop leak between the cow and item cases), and lists server_wool_vibration as known_flaky for the real-time player timing (1 of 4 on a loaded playhorizon).
2026-11-07 14:12:19 +01:00
Ludovic Marie 295a73028d Merge pull request #11771 from horizon-rust/fix/stab-move-hold
🤖 Agent: exempts only STAB (action 8) from #11223's move hold, so a release still shoots from the position before the move (#8423). lunge_motion: parent 3/3 pass, #11223 3/3 fail, this branch 3/3 pass on playhorizon.
2026-11-07 14:12:15 +01:00
TiggerTakkyTTTandCodex ad89bb3123 Launcher: clarify latest nightly version
Show the newest build with a Latest badge, present Nightly dates in a player-friendly title, and separate older builds on the Versions page.

Co-Authored-By: Codex <[email protected]>
2026-11-07 20:10:13 +07:00
MSedgeMC 8c3f88a194 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-07 13:09:35 +00:00
MSedgeMC ac02477732 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-07 13:09:30 +00:00
Ludovic Marie b893b6ae31 Merge pull request #11767 from horizon-rust/fix/native-level-play-sound-clash
Fix main build: two native_level_play_sound methods break the mod API generator
2026-11-07 14:05:02 +01:00
Ludovic Marie 6416c415a7 Merge pull request #11500 from iseaprojects/i18n/es-es-894
🤖 Agent: es_es now covers all 898 en_us keys (four recipe-transfer strings added after main grew), placeholders checked key by key; the old red verify/launcher was the horizon-checks-1 runner out of disk. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 13:57:23 +01:00
bulkandClaude Opus 5.5 29804afe17 mod API: rename the host-bridge sound and particle helpers that clash with level effects
#7676 and #9222 each added Game::native_level_play_sound and
native_level_send_particles; the mod API generator needs one of each,
so every build on main panics in build.rs. The host-bridge pair becomes
native_play_sound_in_place / native_send_particles_in_place; the names
mods call (play_sound, send_particles) are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:53:23 +00:00
rdbt 0628aa5870 Merge pull request #11403 from TheMrZZ/fix/h065-shrieker-local-capture
Keep the client-level shrieker effect out of shared capture
2026-11-07 12:45:18 +00:00
rdbt fb03dbf6e3 Merge pull request #11402 from ilguerro/fix/w030
Reset the impulse context after an ender pearl teleport
2026-11-07 12:45:06 +00:00
rdbt 915f58a8c5 Merge pull request #11392 from rison1337/perf/cache-bone-rotations
perf(mobs): cache exact bone rotations when TAA is off
2026-11-07 12:44:55 +00:00
rdbt 2557cb185b Merge pull request #11371 from TheMrZZ/fix/saved-max-stack-prototype
Omit prototype max-stack patches in saved ItemEntity stacks
2026-11-07 12:44:44 +00:00
rdbt db2bc09336 Merge pull request #11370 from MSedgeMC/test/brewing
brewing: every recipe's mix, slot filters and the stand tick
2026-11-07 12:44:33 +00:00
rdbt 23af2bfc62 Merge pull request #11361 from MSedgeMC/test/firework-edges
firework_tooltip: every flight duration, long explosion runs and both components
2026-11-07 12:44:22 +00:00
rdbt f6980a83e6 Merge pull request #11360 from AlfaRojo/fix/spectator-food-and-regen
A spectator in loaded chunks keeps its food and regeneration ticks
2026-11-07 12:44:11 +00:00
rdbt dd64878f38 Merge pull request #11296 from TheMrZZ/fix/mob-command-stack-retention
Preserve standard mob equipment stacks in item commands
2026-11-07 12:43:23 +00:00
rdbt f0e0d96b61 Merge pull request #11258 from TheMrZZ/fix/loot-empty-result-positions
Preserve empty loot result positions for command replacement
2026-11-07 12:43:11 +00:00
rdbt 45cc5a91e0 Merge pull request #11253 from TheMrZZ/fix/h189-data-literal-snbt
Preserve modern SNBT literals during data execution
2026-11-07 12:43:00 +00:00
rdbt 132aa6b682 Merge pull request #11133 from Ragbie/fix/enderman-hurt-teleport-now
An enderman hurt by the game layer teleports at the hurt, as vanilla's does
2026-11-07 12:42:42 +00:00
rdbt fcc57182ae Merge pull request #11122 from Delete228/mod-data-load-in-place-upstream
Preserve live mod block entities when loading command data
2026-11-07 12:42:31 +00:00
rdbt fcc6416b34 Merge pull request #11121 from TheMrZZ/fix/h090-tnt-fuse
Decode primed TNT fuse through numeric short conversion
2026-11-07 12:42:17 +00:00
rdbt c85011b04d Merge pull request #10570 from Detimes/perf-nbt-ascii-strings
NBT: read and write one-byte strings whole
2026-11-07 12:41:58 +00:00
rdbt d93bf565f4 Merge pull request #10531 from PzmuV1517/shaders/perf-parallel
Shader packs: a pack's pipelines compile together on several threads
2026-11-07 12:41:47 +00:00
rdbt 9c5a3734ea Merge pull request #10506 from voidsipp/fix/round3-1-wet-fire-order
Apply burning damage before extinguishing wet mobs
2026-11-07 12:41:36 +00:00
rdbt 2ff274b96c Merge pull request #10493 from voidsipp/fix/mob-water-breathing
Respect breathing effects and bubble columns for mobs
2026-11-07 12:41:25 +00:00
rdbt 13d03ec87f Merge pull request #10384 from voidsipp/fix/10044-forced-respawn
Validate forced Overworld respawn points
2026-11-07 12:41:14 +00:00
rdbt 4ea806a2db Merge pull request #10383 from voidsipp/fix/10369-map-x-offscreen
Keep treasure map targets when carrying undecorated copies
2026-11-07 12:41:08 +00:00
martipopsandClaude Opus 5.5 303255f2d8 Merge main into controller/guide
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 07:40:51 -05:00
martipopsandClaude Opus 5.5 a6339e67b5 Merge main into controller/guide
Brings in #11026 and #11534. docs/controllers.md keeps the guide's slider
hold beside main's server-list bumpers and Playing in the background.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 07:38:09 -05:00
rdbt 1cd80a0bcb Merge pull request #11757 from kacpherek/perf/render-api-tracking
an edit in game/ no longer rebuilds the renderer: dev rebuild 22.7 s -> 12.0 s
2026-11-07 12:37:35 +00:00
rdbt 4ebee0d01e Merge pull request #11218 from oxi-does-dev/feat/launcher-nord
Launcher: Nord theme and the theme picker in the Blocks look
2026-11-07 12:36:10 +00:00
rdbt 0ebe0d40de Merge pull request #11135 from SouceCalve/main
Adding support for installing from PrismLauncher-portable
2026-11-07 12:35:57 +00:00
rdbt 30ee97bbc7 Merge pull request #7690 from levzzz5154/modapi/entity-moves
Native mods: teleport, rotation, head yaw, facing, size and pushes through EntityRef (#7387)
2026-11-07 12:35:28 +00:00
rdbt 2a57a30ae2 Merge pull request #7685 from levzzz5154/modapi/entity-state
Native mods: an entity's state flags through EntityRef (#7387)
2026-11-07 12:35:16 +00:00
rdbt b3f9eddfd3 Merge pull request #7676 from levzzz5154/modapi/level-effects
Native mods: sounds and particles in a level through LevelRef (#7387)
2026-11-07 12:35:04 +00:00
rdbt f5a6eb9835 Merge pull request #7564 from levzzz5154/modapi/level-world
Native mods: a level's seed, difficulty, game rules and weather through LevelRef (#7387)
2026-11-07 12:34:52 +00:00
rdbt dd3f458ba1 Merge pull request #11303 from UMCEKO/dims/world-facts
Block behaviour reads the dimension type, not the dimension (#11282, 2/9)
2026-11-07 12:34:39 +00:00
rdbt d4006f0770 Merge pull request #11415 from UMCEKO/dims/dimension-facts
Dimension type facts read instead of matching the level (#11282, 1b/9)
2026-11-07 12:34:27 +00:00
rdbt bed953b207 Merge pull request #11291 from UMCEKO/dims/level-key
LevelKey: a level's key for any dimension, not three variants (#11282, 1a/9)
2026-11-07 12:34:11 +00:00
Horizon agentandClaude Opus 5.5 0e674b827c lan hub: a STAB keeps the move behind it in its tick
#11223 made every PlayerAction hold the move queued behind it for the next
tick. For a spear's STAB that moved the jump the move carries
(handlePlayerPositionChange's jumpFromGround) a tick after the Lunge's
impulse, and the server took two moves in the next tick: lunge_motion has
failed every jump phase on parity-main since. Releases, drops and digs keep
the hold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:34:01 +01:00
rdbt 2f0b066f38 Merge pull request #11026 from martipops/fix/pad-read
Controllers: fixes from reviewing the pad reader, typing and joining
2026-11-07 12:34:00 +00:00
rdbt 7d8bb9e69b Merge pull request #11645 from vegetableovosh/fix/11214-chunk-refresh
Fix/11214 chunk refresh
2026-11-07 12:33:47 +00:00
rdbt dd3c1d17a2 Merge pull request #11756 from TheMrZZ/fix/item-display-default-transformation
Save item-display transformations from modeled state
2026-11-07 12:33:35 +00:00
rdbt d5f43e0967 Merge pull request #11256 from MSedgeMC/test/transformer-use
use_item_on: roots on the clicked face, and the copper chest's other half
2026-11-07 12:33:23 +00:00
rdbt d372562a73 Merge pull request #11744 from yahya-nusseibeh/fix/launcher-account-key-salt
Seal the launcher's secrets file with a per-install secret
2026-11-07 12:33:11 +00:00
rdbt 99ddbc629e Merge pull request #11739 from yahya-nusseibeh/fix/relay-per-address-limits
Relay: a cap on open connections per address, and a per-site join budget
2026-11-07 12:32:59 +00:00
rdbt 37f69234e7 Merge pull request #11707 from doodlyyousuf/251106-fix-mob-equip-vibration
fix: mob saddle and gear post equip vibrations at full volume
2026-11-07 12:32:47 +00:00
rdbt 8fd21e02d4 Merge pull request #11746 from NongTham/fix/advancement-parent-normalization
Advancements: normalize parent and lookup IDs with full resource locations (#11653)
2026-11-07 12:32:36 +00:00
rdbt 32191d5b6a Merge pull request #11745 from yahya-nusseibeh/fix/deps-tokio-lru
Update tokio to 1.53.3 and mysql to 28 (lru 0.18) for advisories
2026-11-07 12:32:24 +00:00
rdbt 8331bedf2b Merge pull request #11725 from MSedgeMC/test/spawn-obstruction
test: compare mob spawn obstruction
2026-11-07 12:32:13 +00:00
rdbt 37656cc775 Merge pull request #11738 from yahya-nusseibeh/fix/shader-pack-bounded-reads
Shader packs: bound what one zipped file may unpack to
2026-11-07 12:32:01 +00:00
rdbt ae0ccc199c Merge pull request #11737 from yahya-nusseibeh/fix/hshader-running-total
Shader packages: count the 8 MiB total while reading, not after
2026-11-07 12:31:50 +00:00
rdbt 4c1d1990e1 Merge pull request #11736 from yahya-nusseibeh/fix/report-gh-refs
Website bug reports: GH-123 cross-references nothing either
2026-11-07 12:31:38 +00:00
rdbt bc0ddfbff0 Merge pull request #11529 from horizon-rust/fix/followup-10571
Text displays: animated text is never cached, and a culled one keeps its glyphs
2026-11-07 12:31:27 +00:00
rdbt 330d3db811 Merge pull request #11089 from CocofireHD/docs/perf-comparison-readme
README: a Performance section from the clean Horizon vs vanilla runs
2026-11-07 12:31:22 +00:00
rdbt a7a2cf9305 Merge pull request #11749 from M0-kii/fix/windows-launcher-bin-tests
Run launcher binary tests in the Windows PR checker
2026-11-07 12:30:56 +00:00
rdbt 1087b5a6ff Merge pull request #11724 from horizon-rust/ci/runner-gc-busy-versioned-bin
ci: runner-gc sees jobs after a runner self-update
2026-11-07 12:30:51 +00:00
martipopsandClaude Opus 5.5 fb3e73d3e2 Merge main into controller/guide
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 07:26:09 -05:00
Ludovic Marie 951fc0f0cb Merge pull request #11534 from martipops/settings/shell-api
🤖 Agent: Extras moves onto the new settings_shell (one row per setting, search, description strip), same items and saves; design-system.md now lists Extras with Graphics/Controller as Blocks-frame pages; size (+1748) accepted because ~860 lines are the shell the next pages reuse (#11487 plan). (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 13:24:55 +01:00
Horizon agent 2f88719668 Merge remote-tracking branch 'origin/main' into fix/wool-vibration-drops 2026-11-07 13:19:20 +01:00
Ludovic Marie 25a1add0a2 Merge pull request #10208 from ilguerro/fix/entity-die-non-mobs
🤖 Agent: Requeued after merging main twice (lan_e2e.py keep-both: this PR's entity-die check beside main's new mob-thorns check; then #11735's clippy/lock fix); verify green. Breaking a painting, cushion, boat, minecart, frame or armor stand emits ENTITY_DIE like vanilla's kill path, and its sculk/vibration harnesses passed on the earlier head.
2026-11-07 13:16:57 +01:00
Horizon agentandClaude Opus 5.5 4e1c0d68be Merge main into #10384 (main's clippy and launcher lockfile fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:16:21 +01:00
martipopsandClaude Opus 5.5 7753a08ca9 Merge main into controller/guide
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 07:15:00 -05:00
Florian ERNSTandCodex 34a9bfaa88 Save item-display transformations from modeled state
Co-Authored-By: Codex <[email protected]>
2026-11-07 13:11:42 +01:00
Horizon agentandClaude Opus 5.5 5fdf9fb0ac Merge origin/main into fix/11214-chunk-refresh
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:07:40 +01:00
Horizon agentandClaude Opus 5.5 6e19f95786 Merge origin/main into feat/realms-service-fixes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:07:15 +01:00
Kacpherek cf2ab15de3 Build the mod API generator and syn optimized: its build script parses all of game/ on every gameplay edit 2026-11-07 13:06:14 +01:00
M0-kii 68ec463175 Run launcher binary tests in the Windows PR checker
Keep --bins in the Windows launcher test command so binary-owned tests
are checked alongside the library, matching the planned validation.

Fixes #11747
2026-11-07 15:35:53 +03:30
Horizon agentandClaude Opus 5.5 35d60b45b5 Merge origin/main into client/feature-gate-online
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:04:55 +01:00
Horizon agentandClaude Opus 5.5 a4df22049c Merge origin/main into settings/shell-api
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:04:01 +01:00
Horizon agentandClaude Opus 5.5 4bf0560d40 Merge origin/main into i18n/es-es-894
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:03:34 +01:00
Ludovic Marie 18840db914 Merge pull request #11451 from MSedgeMC/fix/armadillo-state-save
🤖 Agent: An armadillo's state is saved always and restored through switchToState on load, as Armadillo.add/readAdditionalSaveData do.
2026-11-07 12:59:39 +01:00
Horizon agent 4c4728ead5 Merge remote-tracking branch 'origin/main' into cg65v-10208 2026-11-07 12:54:16 +01:00
Ludovic Marie ea55e8156c Merge pull request #11735 from MSedgeMC/fix/clippy-is-multiple-of
🤖 Agent: Unbreaks main: clippy 1.99's manual_is_multiple_of in horizon-core (from #10553) and the socket2 0.5.10 entry missing from both lockfiles under --locked; both workspaces' cargo metadata --locked and clippy -p horizon-core -D warnings pass on this head.
2026-11-07 12:53:19 +01:00
Horizon agentandClaude Opus 5.5 320d55a88c launcher: the socket2 0.5.10 entry its lock was missing
Same break as the workspace lock: #11464 and #11569 left "socket2 0.5.10"
referenced with no package entry, so main's launcher build fails under
--locked. Regenerated with cargo metadata; nothing else moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:51:04 +01:00
MSedgeMC 3b7dde0cd5 horizon-core: use is_multiple_of for the stride check
clippy::manual_is_multiple_of is in the stable toolchain now, and the workspace clippy step runs with -D warnings,
so main's own horizon-core fails verify before any PR's code is compiled.
2026-11-07 11:48:18 +00:00
NongThamandAntigravity <gemini> 4df3bd4db0 Advancements: normalize parent and lookup IDs with full resource locations (#11653)
Ensure advancement parent references and command lookups handle both bare
and namespaced identifiers, preventing orphaned advancement nodes and
matching vanilla's progression count on '/advancement grant @s everything'.

Co-Authored-By: Antigravity <gemini> <[email protected]>
2026-11-07 18:45:55 +07:00
Horizon agent f16cc3d6d0 Merge remote-tracking branch 'origin/main' into cg65v-10208
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 12:37:32 +01:00
martipopsandClaude Opus 5.5 9495790d0b Merge main into controller/guide
main hides the HUD in Replay Studio; the guide's guide_from stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:31:23 -05:00
Horizon agentandClaude Opus 5.5 301765dc69 List server_wool_vibration as known flaky: its player cases are paced by the wall clock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:28:35 +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
MSedgeMC f64b1b9d55 Add the who_lookup seam to living.rs's test fixture 2026-11-07 11:22:07 +00:00
Kacpherek 59dff021a1 Don't rebuild the renderer on every gameplay edit: its mod API reads only native_hooks.rs 2026-11-07 12:14:32 +01:00
MSedgeMC 1748b489eb Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-07 11:08:58 +00:00
Horizon agentandClaude Opus 5.5 ba61774567 server_wool_vibration: let the killed cow's corpse go before the arena changes
A /kill-ed cow stays 20 ticks (LivingEntity.tickDeath) and still moves;
the fill that removes the carpet or slab under it drops it onto the floor
while the next case measures. item-slab read power 2 before its summon on
vanilla's side in a playhorizon run. Wait 25 server ticks after the kills.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:02:52 +01:00
vegetableandOpenAI Codex 5eeb4d5678 Keep interpolation compatible with the MSRV
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-07 16:01:42 +05:00
Horizon agentandClaude Opus 5.5 4f2f4078d1 ci: see runner jobs after a runner self-update; keep the Mac's disk free
GitHub's runner moved its binaries to bin.<version>/ after a self-update, so
`pgrep -f "$runner/bin/Runner.Worker"` never matched: runner-gc took busy
runners for idle and deleted the probe builds and old prepared resources of
two running parity jobs (2026-11-07). Match bin*/ in runner-gc,
checks-runners.sh and linux-runner.sh.

runner-gc also counts shared-local/parity-target (19-24 GB per runner),
recreates it empty after removing it (a dangling .local link failed every
harness), and drops harness folders over 2 GB untouched for 6 hours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:53:10 +01:00
MSedgeMC 6ac83b92d7 test: compare mob spawn obstruction
Extend the spawn rule suite with Mob.checkSpawnObstruction: real entities
against water, lava, an obstructed level and Ocelot's special base, with
--no-obstruction reproducing the previous corpus.
2026-11-07 10:46:37 +00:00
Horizon agentandClaude Opus 5.5 7fcc1c900a server_wool_vibration: clear the previous cow's drops between cases
The arena's /kill @e[type=!player] kills the previous case's cow, and the
cow drops beef and leather as it dies, after the selector was evaluated.
Those items fall onto the next arena and can hit the floor while the next
case measures, at a wall-clock moment: parity-main 42359055402 read power
2 before item-stone on vanilla's side, and playhorizon read 2 after
item-carpet in 1 of 3 runs at 67902962fd. Both cases follow a cow case.
A second /kill @e[type=item] removes the drops; setup lines aren't compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:43:17 +01:00
Horizon agentandClaude Opus 5.5 6cbece0baa Chunk refresh: name the 26.3 class, LevelExtractor.allChanged
LevelExtractor.setupRender calls allChanged when the effective render
distance changes; LevelRenderer has no allChanged in 26.3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:15:23 +01:00
Horizon agentandClaude Opus 5.5 99901a6359 Merge origin/main into feat/realms-service-fixes; wire the invite codes footer
Main's backup screens (#9982) came in beside the invite codes screen.
The footer's Copy/Delete/Edit used ids 180-182, which no arm handled (the
100..=200 arm read them as rows 16-17) and which main's Backups arm
(170..=189) now takes first: they carry the selected code's row id
(130 + 3 * row + Copy 0 / Edit 1 / Delete 2) instead. Request.Post's
Content-Type is application/json; charset=utf-8.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:14:16 +01:00
Horizon agentandClaude Opus 5.5 b6ab7d72b5 Native client services: friend world ids behind the friends feature
Main added invite_public_part (horizon_friends::invite) after this branch
was cut; without the feature the server build's clippy
(--no-default-features --all-targets) could not resolve it. A friend's
world then shows no part of the invite, and the invite test needs the
feature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:10:50 +01:00
Horizon agent 725f9bb17d Merge remote-tracking branch 'origin/main' into HEAD 2026-11-07 11:10:25 +01:00
Horizon agentandClaude Opus 5.5 db673e9b76 Merge main into #10383: keep the craft-menu and map-file ops beside map_carried and treasure map copies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:08:30 +01:00
Horizon agentandClaude Opus 5.5 5564c36339 Merge main into #10384
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:07:24 +01:00
Horizon agentandClaude Opus 5.5 0eba976485 Spanish: the four recipe transfer strings main added since
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:05:21 +01:00
Horizon agent 28cb958bb2 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-07 11:05:09 +01:00
Horizon agentandClaude Opus 5.5 9fa7986397 design-system: Extras and Controller pages draw the Blocks frame too
Extras moves onto settings_shell, which draws with graphics_style, so the
doc names the three settings screens that take the Blocks palette.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:03:49 +01:00
Horizon agentandClaude Opus 5.5 765f388607 Merge main into #10493; take main's harness drivers
#11532 and main since make loaded_pig_tags, loot_mine_pot_components, mob_discard
and command_exec run under a plain parity.py call, which is what ccb85ce45 and
aae27797d did here. Main's versions replace them, so this PR keeps only its own change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:44:51 +01:00
Horizon agentandClaude Opus 5.5 0007d5c2a8 Merge main into #10506; take main's harness drivers
#11532 and main since make loaded_pig_tags, loot_mine_pot_components, mob_discard
and command_exec run under a plain parity.py call, which is what ccb85ce45 and
aae27797d did here. Main's versions replace them, so this PR keeps only its own change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:44:46 +01:00
Horizon agentandClaude Opus 5.5 f58b34cd26 Merge origin/main into #11443
#10523's settings gear now takes its width from the Recipes search box of
this layout; both Escape paths (mod_browser_back, close_transferred_recipe)
kept; one JEI notice for the sprites and the icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:27:14 +01:00
536b3316bd fix: mob saddle and gear post equip vibrations at full volume
Vanilla LivingEntity.onEquipItem posts GameEvent.EQUIP (or UNEQUIP) at
the mob and plays the equip sound at volume 1.0. Horizon only did that
for dispensers, copper golems and the player's armor, so saddling a pig
did not activate a sculk sensor and pig/strider/horse saddles played at
0.5.

Interact, inventory, pickup and /item paths now post the event from the
mob, and saddle/harness/armor sounds play at 1.0.

Fixes #9956

Co-Authored-By: OpenCode <[email protected]>
Co-authored-by: monkeycode-ai <[email protected]>
Co-authored-by: monkeycode-ai <[email protected]>
2026-11-07 09:19:20 +00:00
Horizon agentandClaude Opus 5.5 3aaef65c5d Merge origin/main into #11121
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:58:25 +01:00
Horizon agentandClaude Opus 5.5 5c18aa614e Merge origin/main into #11253
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:56:12 +01:00
Horizon agentandClaude Opus 5.5 f7ba58871b Merge origin/main into #11256
Main's Clicker already posts the use-start vibration; take its version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:55:32 +01:00
Horizon agentandClaude Opus 5.5 017aee7bcf Drop the run notes from the PR's docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:54:50 +01:00
Horizon agentandClaude Opus 5.5 09ec3f2452 Merge origin/main into #11258
Keep the loot position test module beside main's mine component tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:54:49 +01:00
Horizon agentandClaude Opus 5.5 d89ca171f3 Drop the run notes from the PR's docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:53:31 +01:00
Horizon agentandClaude Opus 5.5 59f6b76699 Merge origin/main into #11296
The saddle slot (main) is read before the PR's equipment stacks; both test
modules kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:53:30 +01:00
Horizon agentandClaude Opus 5.5 5fe63f13e8 Merge origin/main into #11403
Keep the shriek capture test module beside main's command damage tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:52:38 +01:00
Horizon agentandClaude Opus 5.5 e8d2cf4ee2 Drop the run report from docs/
The evidence stays in the PR description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:29:54 +01:00
Horizon agentandClaude Opus 5.5 199ef4ad95 Restore the docs the previous commit removed by mistake
Only the PR's own run notes were meant to go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:29:19 +01:00
Horizon agentandClaude Opus 5.5 f2658dff92 Restore the docs the previous commit removed by mistake
Only the PR's own run notes were meant to go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:28:54 +01:00
Horizon agentandClaude Opus 5.5 07b7935bae Drop the run notes from the PR's docs
Validation and benchmark logs belong in the PR description, not in docs/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:28:15 +01:00
Horizon agentandClaude Opus 5.5 b9bc67509a Drop the run notes from the PR's docs
Validation and benchmark logs belong in the PR description, not in docs/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:27:42 +01:00
Horizon agentandClaude Opus 5.5 70f9c6e829 Keep eye_columns' doc comment on eye_columns
The new touching_unloaded_chunk was inserted between eye_columns and its
doc comment, which then documented the wrong function.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:26:21 +01:00
yahya-nusseibehandClaude Opus 5.5 b984398cbc Seal the launcher's secrets file with a per-install secret
Without a keychain, account-secrets.json was sealed under SHA-256 of a
constant and /etc/machine-id, which every local user can read; on a
system with none of the machine-id files the key was a constant built
into every launcher. Only the file's 0600 mode protected the Microsoft
refresh token inside, so a copy restored with wider permissions, or a
shared home, gave it away.

Format 2 (HZSECv2) adds a random 32-byte install secret to the key,
kept beside the file in account-secrets.key (0600, made at the first
write through a temporary file and a rename, under the file's lock).
The key is never the bare constant any more, and a v2 file whose
secret is missing or belongs to another install is refused like any
unreadable file, never written over.

A file sealed by an older launcher (HZSECv1) is still read with the old
key and sealed again under the new one at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 08:20:48 +02:00
vegetableandOpenAI Codex 3baa4f56ae Cover chunk reload invariants
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-07 11:03:01 +05:00
masa-masa-0314andZCode 59bafaf8b6 Realms: the invite screen's selection reads as focused on main's draw
The invite screen's list is the screen's one focusable beside its
footer, so its extractSelection outline is white; main's draw has no
list_focused parameter to ask.

Co-Authored-By: ZCode <[email protected]>
2026-11-07 13:59:09 +08:00
yahya-nusseibehandClaude Opus 5.5 c76534d9f1 Update tokio to 1.53.3 and mysql to 28 (lru 0.18) for advisories
cargo audit flagged two crates in the locks:

- tokio 1.53.2 (RUSTSEC-2026-0386), reached through quinn in
  horizon-mod-deps and in the launcher's lock. Patched in 1.53.3, a
  patch release, so both locks move there.
- lru 0.16.4 (RUSTSEC-2026-0253), reached only through mysql 27 in
  horizon-mod-deps. No 0.16 release carries the fix; mysql 28 depends
  on lru 0.18, which does, so the crate mods may use moves to mysql 28
  (mysql_common 0.37, pem 4, base64 0.23 and arc-swap come with it).

`cargo check -p horizon-mod-deps --features mysql` builds, and cargo
audit no longer reports either advisory. A native mod that asks for
`mysql = "27"` now has to ask for 28, since the lock is the allow list;
docs/native-mods.md names the new version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:59:08 +02:00
vegetableandOpenAI Codex 2b9cb6ba26 Fix chunk reload invalidation
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-07 10:56:42 +05:00
yahya-nusseibehandClaude Opus 5.5 bddd5ebf76 Website bug reports: GH-123 cross-references nothing either
neutralise breaks @mentions, #123 and github.com/ issue links in what
people write in the website's report form, so an anonymous report can't
put backlinks on the repository's issues. GitHub also links GH-123
(either case) to issue 123, which got through.

- At the start of a word, GH- followed by a digit gets a zero-width
  space after GH, the way @ and # already do: the text reads the same
  and links nothing. Other words (high-5, GH-pages, laugh-1) are left
  alone.
- Test: GH-12, gh-7 and (Gh-8) are neutralised; look-alikes aren't.

Found in yahya-nusseibeh's review of Horizon (L2, 7 Nov).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:54:10 +02:00
yahya-nusseibehandClaude Opus 5.5 07ed5dac67 Relay: a cap on open connections per address, and a per-site join budget
Two ways one client could lock every user out of the Play with Friends
relay, with no account:

(a) The only connection cap was global (4,096), counted before anything
    is read, and each connection may stay silent for the 10 s first-line
    wait: about 410 idle connections a second from one address kept the
    relay full, so hosts couldn't register and guests couldn't join.
(b) Every join, known code or not, is refused while the global
    unknown-code budget (120 a minute) is empty. Each /64 may try 5
    unknown codes per 10 minutes, so ~240 /64s keep it empty, and one
    residential /56 has 256 of them.

- Limiter::open_connection / close_connection: connections open at
  once per address key (IPv6 /64; a /48 four times as many), 64 by
  default (RELAY_MAX_OPEN_PER_ADDRESS; a host keeps one connection per
  guest, hence the margin). Counted before anything is read, or right
  after the PROXY line behind the PROXY protocol; past the cap the
  connection is closed at once, not held for the first-line wait. An
  RAII OpenSlot gives the place back however the connection ends.
- Each site (IPv4 /24, IPv6 /48) has its own unknown-code budget, 24 a
  minute with a burst of 6, checked before the global one and spent on
  every unknown code, so emptying the global budget takes many sites,
  not one home.
- docs/play-with-friends.md: the new setting and the site budget.
- Tests: the per-address cap (IPv4, IPv6 /64 and /48, off without
  per-address limits), the per-site budget (shared within a /24 and a
  /48, refilling), and a running relay closing a fifth silent
  connection at once with a cap of 4, then letting new ones in once
  the held ones go.

Found in yahya-nusseibeh's review of Horizon (H2, 7 Nov).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:51:55 +02:00
yahya-nusseibehandClaude Opus 5.5 bda6cb38ae Shader packs: bound what one zipped file may unpack to
Pack::read preallocated from a zip entry's declared size and inflated it
with no limit. Shader packs are zips players download, so a deflate bomb
(or a forged size field) in any file under shaders/ aborted the game
with an allocation failure as soon as shaders.properties was read: 512
MiB of spaces zip to 509 KiB and took peak RSS from 4 to 516 MiB; at
1.5 GiB the review measured 1,543 MiB.

- Nothing is allocated from the declared size; a file that declares or
  inflates to more than MAX_FILE (64 MiB, far above any .properties,
  .lang or GLSL file, with room for the largest textures) reads as
  missing, like a file the pack doesn't have. The same pack now stays
  at 4 MiB.
- Folder packs are unchanged (files on disk, not inflated).
- Test: a small zip whose shaders.properties unpacks past a 16 KiB cap
  reads as missing; the rest of the pack still reads.

Found in yahya-nusseibeh's review of Horizon (M1, 7 Nov).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:48:23 +02:00
masa-masa-0314andZCode 0787a9e7ff Realms: the invite codes screen is vanilla's selection list
RealmsInviteCodesScreen lays out a footer of two rows (Copy/Delete/Edit
97 wide over Create/Back 150 wide, FOOTER_HEIGHT 64), titles itself
Invite Codes (n/5) once fetched with the gray subtitle over an empty
list, and shows the loading dots without a list behind them. Its rows
are 28 high with only the code's text, selected like every
ObjectSelectionList (the port's extractSelection); Copy answers the
clipboard and Delete/Edit act on the selection (ids 180-182), not on
per-row buttons.

Co-Authored-By: ZCode <[email protected]>
2026-11-07 13:44:56 +08:00
yahya-nusseibehandClaude Opus 5.5 22e1582558 Shader packages: count the 8 MiB total while reading, not after
Pack::read inflated each of up to 512 zip entries to 1 MiB + 1 and kept
them all, and only Pack::from_files then checked the pack's 8 MiB total.
511 entries of zeros take 561 KiB zipped, and the mod registry reads
every submitted .hshader (index::inspect, Kind::Shader) on its blocking
pool: one upload made it hold 1 GiB (measured: peak RSS 3 -> 1,026 MiB),
a few in parallel could get it OOM-killed.

- Each entry is read with room for no more than what is left of
  MAX_TOTAL (and its own 1 MiB cap), and refused as soon as it passes
  it: "the pack is over 8 MiB", or the file named when it alone is over.
  The same package now peaks at 6 MiB.
- A folder pack counts its total the same way.
- Tests: 64 MiB of zeros in a small zip, one file over its cap, and a
  folder over the total are all refused.

Found in yahya-nusseibeh's review of Horizon (H1, 7 Nov).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-07 07:44:44 +02:00
masa-masa-0314andZCode 9b524815b9 Realms: the invite-code list is an array or its result
InviteCodeList.parse reads a JSON array, or an object whose 'result' is
one, dropping entries without a code; the port waited for an
'inviteCodes' object the service never sends, so the codes screen
listed nothing to manage.

Co-Authored-By: ZCode <[email protected]>
2026-11-07 13:43:42 +08:00
masa-masa-0314andZCode 4b1561050d Realms: the detail is the screen's server, and POSTs say json
- RealmsConfigureWorldScreen.fetchServerData replaces its serverData with
  getOwnRealm's answer; the port kept the server list's copy, whose
  regionSelectionPreference is stale, so a saved region read back as
  automatic. The detail answer now becomes the screen's server.
- Request.post sends Content-Type: application/json; the frontend
  answered 415 to the invite codes' text/plain POST.
- RealmsPreferredRegionSelectionScreen draws each region's ServiceQuality
  icon (ServiceQuality.getIcon, 10x8 at the row's right).

Co-Authored-By: ZCode <[email protected]>
2026-11-07 13:42:26 +08:00
masa-masa-0314andZCode 8d50119388 Realms: the region screen carries the settings tab's data back
Done used to rebuild the Configure screen with detail and tier cleared,
so the save that leaving the tab triggers found no slot to send and the
chosen region was dropped. The screen now carries both back, and the
save runs on the data it had.

Co-Authored-By: ZCode <[email protected]>
2026-11-07 13:41:51 +08:00
masa-masa-0314andZCode 6f4a6a3865 Realms: the region rows answer a click
The click arm that handles the screens' drawn rows was guarded to
SubScreen::Configure, so the region preference's rows (and the create
screen's fields) never saw a press. The guard now covers the screens
whose rows it handles; the region selection draws extractSelection's
black entry with its white outline and the footer is vanilla's
150-wide Done/Cancel pair.

Co-Authored-By: ZCode <[email protected]>
2026-11-07 13:41:51 +08:00
masa-masa-0314andZCode ffa3a4d413 Realms: a slot setting's value reads as Gson's getAsString
The service sends settings like {"name":"hardcore","value":false},
a JSON bool where the DTO says string; Gson's JsonPrimitive.getAsString
accepts it, serde did not, and the detail answer the Worlds and Players
tabs need failed to parse.

Co-Authored-By: ZCode <[email protected]>
2026-11-07 13:40:25 +08:00
masa-masa-0314andZCode aaccb9b51e Realms: the subscription is at subscriptions/{id}, not worlds/{id}/subscription
RealmsClient.subscriptionFor builds 'subscriptions/'; the port's
'worlds/{id}/subscription' answered 404, which the configure screen showed
as a fatal service error instead of its tabs.

Co-Authored-By: ZCode <[email protected]>
2026-11-07 13:40:25 +08:00
martipopsandClaude Opus 5.5 1edfd2f7ea Merge main into controller/guide
main removed the HUD's bar_height; the guide's guide_from stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:46:42 -05:00
levzzzandCodex 1301820f0d Run movement parity through the gameplay library tests
Select the library artifact that owns Game, retaining the exact one-test assertion so a thin executable with no tests cannot count as a pass.

Co-Authored-By: Codex <[email protected]>
2026-11-07 09:29:40 +05:00
voidsip b888cebdf6 Merge maintainer parity import correction 2026-11-06 22:24:07 -06:00
voidsip 337c5bbd4d Merge maintainer parity import correction 2026-11-06 22:24:05 -06:00
Horizon agentandClaude Opus 5.5 aef2dc6528 tests: load the parity drivers as tools.* in the CLI admission test
Under `unittest discover -s tests`, the bare name test_rust_parity is
tests/test_rust_parity.py, so the drivers' fallback import took the wrong
module and four tests failed (no attribute verify_artifacts) in verify.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:22:44 +01:00
Horizon agentandClaude Opus 5.5 65c57efffe tests: load the parity drivers as tools.* in the CLI admission test
Under `unittest discover -s tests`, the bare name test_rust_parity is
tests/test_rust_parity.py, so the drivers' fallback import took the wrong
module and four tests failed (no attribute verify_artifacts) in verify.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:22:20 +01:00
voidsip 446e36c1a6 Merge commit 'aae27797d94680928d6767419a51b1838b427e73' into fix/round3-1-wet-fire-order 2026-11-06 22:17:51 -06:00
voidsip 4efc3f6bb1 Merge commit 'aae27797d94680928d6767419a51b1838b427e73' into fix/mob-water-breathing 2026-11-06 22:17:50 -06:00
voidsip aae27797d9 Isolate parity helper imports from unittest discovery names 2026-11-06 22:17:17 -06:00
voidsip c59cb034a2 Merge commit 'ccb85ce454b9ea064dc4c5a99f95bdbea836ee35' into fix/round3-1-wet-fire-order 2026-11-06 21:49:42 -06:00
voidsip fa733b3efa Merge commit 'ccb85ce454b9ea064dc4c5a99f95bdbea836ee35' into fix/mob-water-breathing 2026-11-06 21:49:41 -06:00
voidsip ccb85ce454 Keep specialized parity harnesses compatible with normal CI invocation 2026-11-06 21:49:02 -06:00
levzzzandCodex 3cb56882d9 Match vanilla movement bounds and facing at numeric limits
Reuse the native summon spawn-bounds predicate so negative endpoints stay valid, and use Java's integer width for the yaw direction lookup. Record 26 independent outcomes from both pinned vanilla jars and add a differential harness for the actual native calculations.

Co-Authored-By: Codex <[email protected]>
2026-11-07 08:44:37 +05:00
levzzzandCodex eba31f178c Keep native rotation on the rotation-only command path
Reuse the current forceSetRotation path so turning an entity preserves its mount, position and velocity. Extend the existing resource-backed rider regression.

Co-Authored-By: Codex <[email protected]>
2026-11-07 08:23:11 +05:00
levzzzandCodex e539cd328f Merge current main into native entity movement
Preserve the current teleport passenger handling and supply its rotation policy from the native movement methods.

Co-Authored-By: Codex <[email protected]>
2026-11-07 08:18:25 +05:00
voidsip f71a05a062 Keep the HUD mode export separate from client snapshots 2026-11-06 21:11:23 -06:00
voidsip 48886daeb6 Merge commit '1647c1b4664f4221b224e81ec800df4331f81bcc' into fix/11123-native-hud-mode
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-06 20:57:05 -06:00
voidsip f423aeea61 Keep HUD mode accessors in the native helper section 2026-11-06 20:56:39 -06:00
Horizon agentandClaude Opus 5.5 e494a9d1f5 rustfmt the boat Ride condition
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:06:44 +01:00
Horizon agentandClaude Opus 5.5 3fc2ed796f Controllers: the guide's boat Ride drops the boarding cooldown main removed
#11158 took the player's boarding cooldown out (Player.removeVehicle zeroes it), and
may_board with it, so this branch no longer built once merged with main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:51:40 +01:00
Horizon agentandClaude Opus 5.5 0da0d2b260 Merge main into #10383: keep the macro-type and mob-name corpus groups beside treasure map copies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:40:25 +01:00
socksproxandClaude Opus 5.5 be8d3d59d9 horizon-client: discord, friends and heartbeat features
Discord Rich Presence, Play with Friends (horizon-friends) and the
anonymous "I'm playing" ping (horizon-telemetry/live) were built
unconditionally, though a server never runs them. Each is now a
default-on feature of horizon-client and horizon-client-lib, so the
normal build is unchanged and the server build (--no-default-features,
horizon-engine) leaves them out: horizon-friends and 13 crypto and
network crates drop from its dependency tree.

Without `friends` the game keeps working: game/friends_off.rs stubs the
host side (no Friends button in the pause menu, as on another server),
no code reads as an invite, and a friend: address is refused as an
invalid address.

Fixes #11539

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:34:02 +01:00
Horizon agentandClaude Opus 5.5 818c598db9 Text displays: animated text is never cached, and a culled one keeps its glyphs
Follow-up to #10571 (Skr1zzzz). The cache checked obfuscation on the raw
spans, so a plugin hologram sending §k in literal JSON stopped
animating, and an inline head whose skin arrives later stayed stale: the
animated test now runs once on the resolved text, as drawing reads it.
The key also holds lang::bidirectional() as the name tag cache does, and
a display out of view keeps its cached quads instead of dropping them
before the view check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:01:34 +01:00
Horizon agentandClaude Opus 5.5 d59814a196 Shader packs: a pack build no longer wanted stops (follow-up to #10532)
#10532 builds a pack on a thread of its own, but dropping PackLoading (a
pack switch, packs off, another dimension) only detached that thread,
which went on for 5 to 50 s; switching back and forth stacked builds.
PackLoading now holds a cancel flag set when it is dropped: PackPost::new
checks it per program and compile() stops handing out jobs, and the
refusal it returns (is_cancelled) is not reported as the pack failing.
A thread that cannot be spawned now turns the pack off for the
dimension instead of retranslating it every frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:32 +01:00
Horizon agentandClaude Opus 5.5 955c1ba290 Merge main into #10531; the sky parts are pipelines of the compile too
Main gave a sky program two more pipelines (its vanilla blends for the
disc and void, and for the stars). Here they are RenderJobs made with the
others, in their own slots, and the draw reads them with get().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:15 +01:00
Horizon agent 34d078e57b Merge main into #10208
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 01:56:19 +01:00
Horizon agentandClaude Opus 5.5 1ddc0b8a4d Flame crossbow test: a Flame arrow burns for 2000 ticks
a_flame_crossbow_shoots_burning_arrows (#10488) expected 100 fire ticks and failed with prepared resources. Enchantments.FLAME is Ignite(LevelBasedValue.constant(100.0F)), and Ignite calls igniteForSeconds: 100 seconds, 2000 ticks, as the bow path already gives. The code was right; only the expectation in the test was off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:54:10 +01:00
Horizon agent 868f88a2ce Merge main into #10570 2026-11-07 01:54:00 +01:00
Kevin RamirezandClaude Fable 5.1 69ceb53897 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 18:53:43 -06:00
martipopsandClaude Opus 5.5 ecd3764d93 Settings shell: leaving search gives the keys back, and fixes from reviewing it
Once the search field had the focus it kept the keyboard, so Enter did nothing and letters filtered
the list with the ring on a row. Moving the focus away now ends typing.

A key that moves nothing shows no ring, a row greyed out while focused does nothing, the wheel stops
behind a resource error as `scroll_at` does, and a description too long for the strip ends in an
ellipsis, with room for one more line on Extras.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:50:14 -05:00
Kevin RamirezandClaude Fable 5.1 747b958bd9 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 18:23:06 -06:00
martipopsandClaude Opus 5.5 7f1eef46b2 Settings shell: a screen describes its rows, and Horizon Extras moves onto it
A screen now implements settings_shell::Screen, describing its pages, groups,
rows and values, and Shell does the drawing, clicks, scrolling, the controller's
focus and declared buttons, keys and each row's description. OptionsUi reaches
it through one accessor, so the wheel and a stick's scrolling can't part ways.

Horizon Extras is the first screen on it. Its settings do what they did and are
saved the same way. It gains the shell's look and search, and its descriptions
move from tooltips to the strip, which grows for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:21:17 -05:00
iseaprojectsandOpenCode a0d291e2bb i18n(es_es): complete the Spanish strings after en_us grew to 894 keys
es_es.json held 271 of the 894 keys en_us.json now ships, so 623 strings
(70% of the file) fell back to English on Spanish screens: the whole PvP
and HUD page, splitscreen setup and profiles, Horizon extras, the mod
browser, the resource pack browser, friends, the on-screen keyboard,
Replay Studio, the music page, cosmetics and the shader and graphics
descriptions.

This fills the table: 894/894 keys, no missing or extra ones, every
placeholder kept. The four values that were still English (Particle
Culling and its three descriptions) are translated, and the shader page
name now says "shaders" like the rest of the file.

lang.rs also serves es_ar, es_cl, es_ec, es_mx, es_uy and es_ve from this
file.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 20:08:07 -04:00
Kevin RamirezandClaude Fable 5.1 733d3abd7e CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 17:43:05 -06:00
Evrim Cicek 71b527b7c2 Merge remote-tracking branch 'upstream/main' into fix/enderman-hurt-teleport-now
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 02:30:09 +03:00
MSedgeMC 85a97ed37c Re-run CI: the previous run failed in setup with the self-hosted runner out of disk 2026-11-06 23:13:26 +00:00
Kevin RamirezandClaude Fable 5.1 c14c063507 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 17:03:06 -06:00
GabrielNichols fe28bf9738 Route Recipes shortcuts in Creative inventory 2026-11-06 19:54:08 -03:00
GabrielNichols 99a593a6bf Match the item browser header and Creative transfer affordance 2026-11-06 19:37:54 -03:00
martipopsandClaude Opus 5.5 c752fee870 Controllers: Play in Background, rumble in splitscreen, the keyboard after a new renderer
Play in Background, off by default and per player, lets the pad keep driving the game while its
window has no focus, and a game the pad is playing doesn't pause on losing focus.

While several play, the router now plays each player's rumble on that player's pad. Rumble no
longer stops when the window loses focus: nothing asked for that, and it's useful to an AFK player.

Starting or leaving splitscreen builds a new renderer, whose atlas has none of what was drawn into
it at run time. Those two paths only forgot the skins, so the on-screen keyboard lost its letters
and rounded corners, and maps were exposed the same way. All renderer swaps now forget the same
things.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:37:00 -05:00
MSedgeMC 3e484d6adf port_map: Armadillo's state is saved and read
`tools/port_map/animal-1.tsv` had no answer for
`Armadillo.addAdditionalSaveData` or `Armadillo.readAdditionalSaveData`. Both
now name `entity_nbt::mob_to_nbt` / `mob_from_nbt`: the state written as its
`ArmadilloState` name beside `scute_time`, and read back through
`switchToState`, idle when the save names none.
2026-11-06 22:27:24 +00:00
MSedgeMC 3ba815a17a test: an armadillo's rolled-up state through the chunk save
`mob_from_nbt` and `mob_to_nbt` are the pair `Game::load_mob` and
`Game::mob_tree_nbt` go through. An armadillo whose save names
`state: rolling` loads rolling and takes 4.5 of a hit for 10; six ticks into
the roll its shell is out; a save that names no state, or a name that is no
`ArmadilloState`, loads idle and takes the whole 10. Saving a rolling armadillo
writes `rolling` even when the compound it came in with said `idle`, and the
reloaded one has its time in the state restarted, so the shell is not out until
it has been rolling for six ticks again.

Without the state in the pair the loaded armadillo is idle (left: 0, right: 1)
and takes 10 for a 10 hit where it took 4.5 rolled up.
2026-11-06 22:27:24 +00:00
MSedgeMC d2d4e99101 fix: keep an armadillo's rolled-up state in its chunk save
`Armadillo.addAdditionalSaveData` writes the state the armadillo is in beside
its scute timer, and `readAdditionalSaveData` switches to it:

    output.store("state", Armadillo.ArmadilloState.CODEC, this.getState());
    output.putInt("scute_time", this.scuteTime);
    ...
    this.switchToState(input.<Armadillo.ArmadilloState>read("state", Armadillo.ArmadilloState.CODEC)
       .orElse(Armadillo.ArmadilloState.IDLE));

`ArmadilloState.CODEC` is a `StringRepresentable.fromEnum`, so the key holds
`idle`, `rolling`, `scared` or `unrolling`; `switchToState` sets the synched
state, and `onSyncedDataUpdated` restarts `inStateTicks` when it changes.

Horizon models the same state (`extra.armadillo.state`, the value
`Armadillo.getState` is synched with) and wrote only `scute_time`, so an
armadillo that unloaded while rolled up came back idle: a hit for 10 went from
4.5 back to 10.0 (`Armadillo.hurtServer`: a ball takes half of the damage past
the first point), `hiding_in_shell` went false so the model lost its shell, and a
brush or a food click was answered by an idle armadillo instead of a ball.

`state` is now written from the live state - `idle` for one that is not rolled
up, as vanilla writes it unconditionally - and read back through `set_state`,
which is `switchToState` plus `onSyncedDataUpdated`: a save that names no state
(or names one that is not an `ArmadilloState`) loads as idle, and the time in
the state starts again at 0.
2026-11-06 22:27:24 +00:00
GabrielNichols a92071933a Bring Recipes search and its skin to Creative inventory 2026-11-06 19:27:01 -03:00
Kevin RamirezandClaude Fable 5.1 786bc6d8ca CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 16:23:11 -06:00
GabrielNichols 254d2c8e10 Keep the Recipes footer through empty search transitions 2026-11-06 19:13:52 -03:00
voidsip 5a6a6e4e77 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11123-native-hud-mode
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-06 16:12:55 -06:00
voidsip 476bb33916 Expose native HUD mode with independent hand visibility 2026-11-06 16:12:32 -06:00
MSedgeMC 73737f9e6c test: brewing stand tick, brew time and fuel uses
The brewing suite stopped at recipe matching, assemble and the slot filters;
nothing advanced the block entity. The tick family now runs the real
BrewingStandBlockEntity.serverTick and BrewingStand::tick for 0..8001 ticks
and compares slots, brew time, total brew time, fuel, total fuel, speed,
bottle bits, ingredient and the brew-event count. It covers the 400-tick brew
divided by speed_multiplier and its ceiling (2, 3, 0.3 and 0), blaze powder's
twenty uses and speed 1, fuel loaded with nothing to brew, splash and
lingering outputs, ingredient shrink and mismatch, finishing without fuel and
twenty brews from one blaze powder. --no-tick leaves the tick corpus out.

2860/2860 cases match the pinned client.
2026-11-06 21:54:20 +00:00
martipops 37b174ada3 Merge remote-tracking branch 'upstream/main' into fix/pad-read
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-06 16:46:14 -05:00
Delete228andCodex 39f7f1bf42 Merge Horizon main into the PR branch
Co-authored-by: Codex <[email protected]>
2026-11-07 00:45:54 +03:00
Kevin RamirezandClaude Fable 5.1 75a078b0ce CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:43:56 -06:00
Kevin RamirezandClaude Fable 5.1 58859564a8 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:36:34 -06:00
umcekoandClaude Sonnet 5.5 b37e409c9c Block behaviour reads the dimension type, not the dimension (#11282, 2/9)
Vanilla reads each level's DimensionType and its attributes (fast lava, water
evaporates, the day timeline, infiniburn, sky light) rather than naming the
Overworld, Nether and End, so a datapack dimension using the Nether's type
behaves as the Nether. horizon-world block behaviour now does the same through
LevelAccess::dimension_type(); sites vanilla keys on the level compare LevelKey.
No behaviour change for the three built-in levels.

Part of #11282

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:27:09 +03:00
umcekoandClaude Sonnet 5.5 3874099e52 Dimension type facts read instead of matching the level (#11282, 1b/9)
Code that matches on the Overworld, the Nether or the End should read the
level's dimension type, as vanilla does. Add DimensionFacts: the
DimensionType fields and gameplay attributes Horizon reads, the four
vanilla types built in code, and from_json for packs. A test checks the
built-in table against the prepared 26.3 dimension_type and timeline tag
files.

No callers yet; nothing changes for players, saves or packets.

Part of #11282.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:26:56 +03:00
umcekoandClaude Sonnet 5.5 dfbfc81aa9 LevelKey: a level's key for any dimension, not three variants (#11282, 1a/9)
Vanilla makes one level per entry of the dimension registry and keys each
by its ResourceKey<Level>. Add LevelKey: the three built-in levels as
constants, any other id validated as Identifier.parse does and interned,
plus builtin_or_overworld for today's lossy lookups from saves, guests
and packets.

No callers yet; nothing changes for players, saves or packets.

Part of #11282.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 00:26:56 +03:00
Kevin RamirezandClaude Fable 5.1 df010b6204 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:24:27 -06:00
Kevin RamirezandClaude Fable 5.1 83d3c8afa5 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 15:14:48 -06:00
TheMrZZandCodex gpt-6.1-sol a3d9d8f2c3 Keep client shrieker playback outside shared capture
Use the existing unshared audio path for level event 3007 and cover actual event capture, local playback, and random continuation.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 22:08:41 +01:00
rison1337andCodex 332f312b38 perf(mobs): cache exact bone rotations when TAA is off
Reuse bounded exact local geometry and normal rotations for GPU mob posing.
Keep the original walk and transform helpers when TAA is enabled, with the
policy refreshed every frame from the existing anti-aliasing setting.

Add byte-exact churn and bypass-switching coverage and measured crowd results.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 02:07:56 +05:00
Kevin RamirezandClaude Fable 5.1 4ee0e8dd33 CI: run the checks again after the runner ran out of disk
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:57:41 -06:00
Florian ERNSTandCodex 9634732468 Omit prototype max-stack patches in saved ItemEntity stacks
Co-Authored-By: Codex <[email protected]>
2026-11-06 21:54:00 +01:00
MSedgeMC bb24c87d5d test: every brewing recipe's mix and the three slot filters
No parity suite reached the brewing recipes: brewing.rs's Brewing::find,
PotionIngredient and the BrewingStandMenu slot filters had no differential
corpus. A new suite loads the vanilla data pack's real RecipeManager and
compares every bundled BrewingRecipe's assemble with Horizon's find output,
the brewing_input and brewing_reagent property sets against is_bottle and
is_reagent, and the fuel slot's BrewingFuel against brewing_fuel. Cases cover
every recipe, every potion item and potion id against every reagent, the whole
item registry as a slot candidate, and non-matching pairs. --baseline keeps
only the exact recipe pairs and reproduces the corpus before the cross-product.

2811/2811 cases match the pinned client. The brewing-stand tick, brew time,
fuel use and remainders remain pending.
2026-11-06 20:51:50 +00:00
martipopsandClaude Opus 5.5 197eb01adf Controllers: the guide says Open only for what opens, and nothing for a click a block keeps
Portals and an empty lectern showed Open, and a block that keeps the click and does nothing
(an empty flower pot or bookshelf slot, a pressed button) showed Use, or the held item's own
row. An ender chest said Use and now says Open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 15:40:39 -05:00
MSedgeMC 176caeb5fd test: every firework flight duration and long explosion runs
The firework tooltip corpus sampled flight durations 0-3, 7 and 255 and runs
of at most six equal explosions, so Fireworks.addToTooltip's run count above
six and the rest of the unsigned-byte flight range were never compared. A
deterministic edge corpus now adds all 256 flight durations, runs of 0..256
equal explosions in every shape, colour and fade lists up to 32 entries
(custom, negative and duplicate colours), equality-sensitive runs (same shape
with different colours), both components on one stack, firework components on
non-rocket/non-star items, and every combination of hidden components.
--no-edges writes the random corpus exactly as before.

3331/3331 cases match the pinned client.
2026-11-06 20:30:47 +00:00
rdbtCVSandClaude Opus 5.5 1e7af3a2c5 Restore the #[test] the merge of main took from this test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:29:27 +00:00
Kevin RamirezandClaude Fable 5.1 9dac406ddc A spectator in loaded chunks keeps its food and regeneration ticks (#11312)
ServerPlayer.doTick runs super.tick() (Player.aiStep's tickRegeneration)
and foodData.tick for every player but a spectator touching an unloaded
chunk. Horizon's guest tick returned for a spectator before the damage
clock, peaceful_regeneration and food_tick, so health, saturation and
exhaustion froze while watching and were still there on the way back to
survival.

A spectator whose box, inflated by one block, lies in loaded chunks
(Entity.touchingUnloadedChunk) now runs those three; hazards, travel
and movement statistics stay skipped. food_tick already counts a
spectator as invulnerable, so starvation hurts none.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-06 14:23:40 -06:00
rdbtCVSandClaude Opus 5.5 40ffd36b11 TNT fuse test: skip without prepared resources, as its neighbours do
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:17:33 +00: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
rdbtCVSandClaude Opus 5.5 1088c588e4 Merge origin/main into PR #11121
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:11 +00:00
rdbtCVS c6735e2d8e Merge origin/main into PR #10290
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/command_harness.rs
#	mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-06 20:00:43 +00:00
rdbtCVS 98afd549d4 Merge origin/main into PR #10344
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-06 20:00:39 +00:00
rdbtCVS b9526c300c Merge origin/main into PR #10291
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-06 19:59:42 +00:00
Florian ERNSTandCodex 399171f0e0 Preserve standard mob equipment stacks in item commands
Co-Authored-By: Codex <[email protected]>
2026-11-06 20:16:54 +01:00
1a43ca3439 Reset the impulse context after an ender pearl teleport
ThrownEnderpearl.onHit calls resetCurrentImpulseContext after the fall
distance reset, so a wind charge's or mace smash's impact height no
longer limits the next fall.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:26:03 +01:00
Florian ERNSTandCodex 070a8019e0 Preserve empty loot result positions for command replacement
Co-Authored-By: Codex <[email protected]>
2026-11-06 18:12:15 +01:00
TheMrZZandCodex gpt-6.1-sol daefbd84e7 Preserve modern SNBT literals during data execution
Reuse the admitted modern SNBT decode contract for merge and literal modify.
Add actual Game storage and command-oracle regressions for typed values and
no-change copy callbacks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 17:51:01 +01:00
MSedgeMC 33ec06ab12 fix: roots on the clicked face, and the chest beside a copper one
tools/test_use_item_on_parity.py never held an axe, hoe or shovel against a
block the minecraft:block_transformer registry names, so game.rs's
use_block_transformer was never exercised. It now compares log stripping,
copper scraping/waxing, copper doors and trapdoors, double copper chests and
soil tilling, which found two differences from vanilla:

- rooted dirt's hanging roots must use Block.popResourceFromFace, on the
  clicked face with the face's own motion, and drop before the block changes;
- scraping or waxing a double copper chest must send a second block_change
  game event and the same particle at the chest's other half.

The VanillaDispenserOracle stand-in player also swallowed the item use start
vibration LivingEntity.startUsingItem posts; it now posts it too.

5200/5200 cases match on the new corpus.
2026-11-06 16:49:46 +00:00
oxi-does-dev 147c6bceef Launcher: keep the partner card's corners in the glass look 2026-11-06 18:39:41 +03:00
oxi-does-dev e35010eb7d Merge remote-tracking branch 'origin/main' into feat/launcher-nord 2026-11-06 18:16:00 +03:00
oxi-does-dev 014f25363a Launcher: Nord theme, square solid look, sky without resize bakes
Adds the Nord palette (polar night, snow storm, frost) as a launcher theme with its own dark and light tokens and sky art. Palette themes draw the square Blocks look: no glass, no rounded corners. The night tokens are square everywhere, and the Blocks status dots and controls follow. Blocks and the light glass look draw the baked sky, so resizing no longer rebakes the scene per frame and stays responsive. Settings gains the launcher-theme picker in the Blocks look. Nord now shows the sky instead of the scene's pale halo.
2026-11-06 18:11:28 +03:00
martipopsandClaude Opus 5.5 b0286fb598 Controllers: the bumpers move the selected server
Its arrows are hit-tested inside the icon, where a pad's click on the row never
lands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:10:52 -05:00
martipopsandClaude Opus 5.5 cf73e1d25f Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:05:53 -05:00
martipopsandClaude Opus 5.5 62298d7c36 Controllers: the guide's rows promise what the button does, from reviewing it
- The pad's own click hid the guide, and so did Alt or Super on their own. The
  inventory rows now hide for the keyboard and mouse too.
- Rows that promised something the game refuses: Equip on what can't be swapped,
  Throw on a Riptide trident out of water or about to break, Use on an empty or
  fixed item frame, Trade on a baby villager.
- Slots: Put rows on a slot that refuses the carried stack or has no room, Put Down
  for stacks with different components, Take over a result slot with another stack
  in hand, and any rows on a crafter's output or Put rows on a furnace's.
- Rows drew over a panel that fills a small view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:28:21 -05:00
homayk228 266040ff8e Merge branch 'main' of https://github.com/SouceCalve/horizon 2026-11-06 19:52:48 +07:00
homayk228 a123c03b16 Added support for installing from PrismLauncher-portable
Now installer supports native to PrismLauncher json and correctly finds json, client.jar and other sources from minecraft, allowing it to install from portable installations without additional downloading from internet
2026-11-06 19:52:43 +07:00
Delete228andCodex 90b9df2899 Load same-type mod block entity command data in place
Replace saved NBT and item slots through the existing entity while preserving its live identity and runtime menu data. Keep normal decoding, dirty marking and update logging; verify real data commands, typed opaque fields, inventory removals and fresh different-type/chunk-loaded identities.

Co-Authored-By: Codex <[email protected]>
2026-11-06 15:47:54 +03:00
Evrim CicekandClaude Opus 5.5 0b0a58af3e Settle an enderman's teleport inside explosions, falling blocks and conduits; its hurt first
Review: hurts dealt after the creature tick (ordered TNT, beds and anchors,
falling blocks, conduits) still waited for the next tick. Explosions and
falling blocks now settle right after each hurt, before the push, and the
conduit pass after it runs. And the hurt's own events (its sound,
InfestedMobEffect.onMobHurt) are handled before the teleport, where the
enderman was hurt and from its random's next draws, as
LivingEntity.hurtServer runs them before Enderman.hurtServer teleports.

Tests: an_explosion_teleports_endermen_before_it_returns (11/12 left
pending without the explosion settle), an_infested_endermans_silverfish_
come_out_where_it_was_hurt (no silverfish when the outbox only joined the
teleport's events).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 15:42:29 +03:00
TheMrZZandCodex gpt-6.1-sol afd49ac9c2 Fix primed TNT numeric fuse loading
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 13:40:09 +01:00
CocofireHDandOpenCode Agent c81dc55a1c README: a Performance section from the 2026-10-29 Horizon vs vanilla runs
The comparison #1073 asks for, in the repository: FPS and 1% lows at render
distances 8, 16 and 32 for Horizon, vanilla 26.3 and vanilla with the common
performance mods, from the clean, all-windowed, idle-machine run already saved
in benchmarks/perf/, with the conditions, the startup/load/memory figures and
links to the full reports, the method and playhorizon.dev/benchmarks.

Co-Authored-By: OpenCode Agent <[email protected]>

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-06 13:25:46 +01:00
Evrim CicekandClaude Opus 5.5 335326296c An enderman hurt by the game layer teleports at the hurt, not on its next AI step
Enderman.hurtServer teleports inside the hurt (teleport(), or
repeatedlyTryToTeleport() for a projectile or a thrown potion). Mob::damage
has no world, so it only leaves the tries in counter2 and the enderman's
next AI step ran them. Game::settle_hurt_teleports runs them right after
/damage, and in the creature tick before the mobs tick and after the
systems' outbox: a command after /damage no longer finds it in place, and
under /tick freeze it teleports at all.

Command harness group "enderman command damage": 3/5 -> 5/5.

Fixes #11069

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 15:24:29 +03:00
martipopsandClaude Opus 5.5 6e0b26dbc2 Controllers: fixes from reviewing this branch
- Take back the server list's move arrows as targets: they split Tab's stop on
  the list, and the D-pad still couldn't land on them.
- A right stick on Z takes over in every direction, not only its pulled end.
- A pad's toggle-sneak doesn't count as holding, or a rebound key's release could
  leave the player sneaking.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:22:43 -05:00
Horizon agentandClaude Opus 5.5 7ba6892f93 wet_fire harness: default to the prepared resources when HORIZON_TEST_RESOURCES is unset
parity.py does not set it, so the harness failed at once under parity.py and CI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:18:19 +01:00
Horizon agentandClaude Opus 5.5 b02930a2f2 Compile every pack pipeline even after one is refused
compile() stopped handing out jobs once one pipeline was refused, so the
serial rebuild that follows compiled all the remaining programs cold.
Now every job runs and only the first refusal is recorded, so the
rebuild finds the driver cache warm. The test checks the jobs after a
refused one are still made.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:06:55 +01:00
Horizon agent 35e7942094 Merge main into #10208
# Conflicts:
#	tools/lan_e2e.py
2026-11-06 13:00:21 +01:00
martipopsandClaude Opus 5.5 84efc873cf Input: one device letting go of sneak or sprint leaves the other's hold
Releasing Shift while R3 held sneak in hold mode dropped it for both, and so did
the other way round.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:59:30 -05:00
Horizon agent 2abb7c86a1 Merge remote-tracking branch 'origin/main' into pr-10531 2026-11-06 12:55:29 +01:00
martipopsandClaude Opus 5.5 00879f19d7 Move the keyboard-took-over mark out of the mouse button handler
tests/test_mouse_button_events.py compiles that handler alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:16:01 -05:00
martipopsandClaude Opus 5.5 a4ffae3402 Controllers: reach the recipe search, suggestions, server arrows, Realms tabs and HUD editor
Each was painted by hand or hit-tested with no target, so a pad couldn't use it:
the recipe book's search box had no keyboard, a command's suggestions couldn't be
picked, a server couldn't be moved, Realms Configure's tabs had no bumpers, and
the HUD editor's footer ended its crosshair.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:14:06 -05:00
martipopsandClaude Opus 5.5 701af0a693 Controllers: the guide's Ride is what the boat would do, and the keyboard hides it
The boat row said Ride while sneaking, in a boat or on a full one, which refuses
or opens a chest boat's chest. In play only moving the mouse took the pad out
of use, so the guide stayed up through keyboard and mouse-button play.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 06:07:12 -05:00
martipopsandClaude Opus 5.5 e41c28a92b Controllers: typing on signs and books, the death screen, and joining
- A book's page and signing title weren't fields, so a pad couldn't write one.
- A sign's keyboard closed on Enter after line 1, and nothing reopened it. Enter
  now keeps it open, and A opens it again.
- A keyboard open over chat stayed over the death screen and took every button.
- B answered the death confirm during the second it's meant to wait.
- A guest who joined with a pad got P1's Controllers off in a new profile, so
  that pad did nothing, on this join and every later one.
- Pad presses in another window could join a player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 05:37:01 -05:00
voidsip edc6751798 Keep new lava and fire ignition silent in rain 2026-11-06 04:28:43 -06:00
voidsip f214ffcc8a Merge remote-tracking branch 'origin/feedback-main-20261106' into fix/round3-1-wet-fire-order 2026-11-06 04:15:07 -06:00
voidsip a82ad5eed6 Merge remote-tracking branch 'origin/feedback-main-20261106' into fix/mob-water-breathing 2026-11-06 04:14:24 -06:00
martipopsandClaude Opus 5.5 1a6fba23c7 Controllers: what reading a real pad got wrong
- An idle pad's trigger rests at -1, which counted as moved past 0.5, so touching
  it took over from the pad in use.
- An analog trigger jumped to full at gilrs' 0.75, so a higher threshold did
  nothing.
- gilrs drops `~` from SDL rows, so sticks on the nine Linux rows with one ran
  backwards, the Switch 2 Pro among them.
- The 8BitDo Ultimate 2 without a row looked up when pushed down, with X and Y
  crossed.
- Rumble reached the pad from an unfocused window, adaptive triggers stayed on
  over books and signs, a stale lightbar stayed after splitscreen, and in_use
  outlived an unplugged pad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 05:11:50 -05:00
martipopsandClaude Opus 5.5 81bcb3950f Controllers: a slider's hold belongs to one slider on one screen
The hold shared the keyboard's slider_edit, so Start, a scroll, the mouse or a
Tab could leave some other slider held. The pad's own position also survived
Undo, so the next step jumped back near the undone value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 05:06:34 -05:00
Horizon agentandClaude Opus 5.5 58f31cdf25 Merge #10330 (main merged) into #10365
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:27:33 +01:00
Horizon agentandClaude Opus 5.5 2e779eed59 Merge main into #10330 (#10304 and #10544 landed)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:27:30 +01:00
martipopsandClaude Opus 5.5 8578927d70 Merge main into the gameplay guide
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 04:27:19 -05:00
Horizon agentandClaude Opus 5.5 d9b7730a6f Warden anger test: a re-angered suspect keeps its saved anger, as increaseAnger does
min(150, 35) + 20 = 55, then one anger tick: 54. The test pinned the dropped
anger the earlier port left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:05:08 +01:00
Horizon agentandClaude Opus 5.5 eb649daff5 Merge main into #10506: keep the Enderman hurt teleport
Main added enderman_hurt_teleports after the block contact damage; this
branch drops that call's world argument. Both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:34:58 +01:00
Horizon agentandClaude Opus 5.5 1716888f1c Merge main into #10493: keep both ai.rs test modules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:34:32 +01:00
Horizon agentandClaude Opus 5.5 9267013546 rustfmt the bullet Dir/Steps test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:23:57 +01:00
Horizon agentandClaude Opus 5.5 e95b6a26c5 Merge #10330 (main call site, rustfmt) into #10365
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:23:57 +01:00
Horizon agentandClaude Opus 5.5 cfab7464ed Merge #10304 (main call site fixed) into #10330
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:23:11 +01:00
Horizon agentandClaude Opus 5.5 597adf33bd Pass no UUID map at the mob_to_nbt call main added since
c843c1aa0 (the loot-pickup save test) calls mob_to_nbt with the old four
arguments; the bridge's fifth, uuid_of, is |_| None there like the other tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:23:08 +01:00
martipopsandClaude Opus 5.5 9d141f2e48 Merge main into the gameplay guide
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 03:22:32 -05:00
Horizon agent 790d68e500 Merge remote-tracking branch 'origin/main' into fix-10383
# Conflicts:
#	crates/horizon-client/src/game/maps.rs
#	tools/command_exec_corpus.py
2026-11-06 09:15:51 +01:00
Horizon agentandClaude Opus 5.5 c72e75d8cf Merge main into #10384: keep the exported liquid flag
Main checked bubble columns by name (6bd507f89); this branch reads the
block's exported liquid flag, which covers the same three blocks and any
mod block marked liquid, so the name list and its test go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:13:58 +01:00
Detimes 3d47396e9b NBT: read and write one-byte strings whole
Most NBT strings in a saved chunk (names, ids, compound keys) are ASCII.
The reader decoded modified UTF-8 one byte at a time and the disk writer
put every unit through its own limit check. Now an all-ASCII string is
widened in one pass when read, and a string whose units are all one byte
(1 to 127) is written in one extend when it fits under the byte limit;
anything else takes the old per-unit loop, so the bytes and the error
offset are unchanged. A new test checks ASCII, zero, mixed and non-ASCII
strings against a per-unit reference and the limit's error offset.
2026-11-06 08:10:31 +00:00
PzmuV1517 155bca35fd Shader packs: a pack's pipelines compile together on several threads
The driver compiles each program of a shaders/ pack as its pipeline is made,
and a pack's pipelines were made one at a time while the pack was laid out:
Metal's compiler service sat on one core for 5 to 50 s on a big pack (BSL,
Complementary, Photon) with four others idle.

The pack is now laid out first, its pipelines recorded as jobs whose pipeline
goes in a slot of its pass, then made all at once on up to 8 threads (wgpu
keeps error scopes per thread, so each is still checked). When wgpu or the
driver refuses one, the pack is built again a program at a time, as before,
so that program is skipped with its note exactly as it was.

Cold loads on an M5 (each pack's first load, nothing in the Metal cache),
pipeline creation: Bliss 6.3 s -> 1.8 s, Solas 5.2 s -> 1.6 s, Complementary
Unbound 9.0 s -> 2.3 s.
2026-11-06 09:58:20 +02:00
Horizon agentandClaude Opus 5.5 8e05ff9b80 Warden anger: a suspect angered again before its UUID lookup keeps its saved anger
AngerManagement.increaseAnger adds angerByUuid's anger for a new suspect
(removeInt, uncapped). The mob layer knows no UUIDs, so convertFromUuids' port
dropped the saved anger of a suspect that was angered again first (a reloaded
warden hearing the player within its first anger tick); it is added there now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:52:28 +01:00
Horizon agent c05ef4bc91 Merge #10330 (bullet target, bridge, main) into #10365
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-06 08:51:57 +01:00
Horizon agentandClaude Opus 5.5 a04eee71ba Shulker bullet load: wrap Dir as from3DDataValue, keep a negative Steps
Direction.LEGACY_ID_CODEC reads a byte through from3DDataValue, which wraps it
(abs(b % 6)), and getIntOr("Steps", 0) keeps any int; the read clamped Steps to
0 and passed an out-of-range Dir through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:51:18 +01:00
Horizon agent c29349aaf0 Merge #10304 (UUID bridge, with main) into #10330
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-06 08:50:35 +01:00
Horizon agentandClaude Opus 5.5 8e35638625 Merge main into #10304
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 08:50:22 +01:00
martipopsandClaude Opus 5.5 1cfaf0bc96 Controllers: each choice of a controller setting describes itself
The description at the bottom of the page stayed the same whichever value was
picked, on the Gameplay Guide, Button Glyphs, On-screen Keyboard and Lightbar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 02:13:53 -05:00
voidsip 23dcce8ab0 Merge main and retain both mob regression suites 2026-11-06 00:43:10 -06:00
voidsip e58593bb50 Fix wet mob extinguishing after fire damage 2026-11-06 00:39:49 -06:00
martipopsandClaude Opus 5.5 426786c862 Controllers: A takes hold of a slider, and the gameplay guide has levels
Left and right changed a slider as soon as the focus passed over it, so moving
through a settings page could change a value. A now takes hold of it, left and
right move it, A or B lets go and up or down moves on, with a white outline
while held.

The gameplay guide's on/off switch becomes Full, Basic and Off. Basic keeps the
right column. A file saved with the old switch keeps its meaning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 01:16:58 -05:00
voidsip 3e90d34739 Respect breathing effects and bubble columns for mobs 2026-11-06 00:13:10 -06:00
martipopsandClaude Opus 5.5 a7954d4bdc Merge the pad fix (#10474) into the gameplay guide
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:51:17 -05:00
voidsip b67b146635 Validate forced Overworld respawn points 2026-11-05 20:03:51 -06:00
voidsip 04f7aa963b Keep treasure map targets when carrying undecorated copies 2026-11-05 19:55:51 -06:00
MSedgeMC 6fa16423ac Entity saves: a warden's anger keeps its suspects by UUID
`Warden.addAdditionalSaveData` stores an `AngerManagement`: a `suspects` list of
{uuid, anger} pairs, and nothing else about the anger. Horizon wrote an empty
`anger` compound and read nothing back, so a reloaded warden was calm.

`AngerManagement` now keeps the suspects that are not in the level by UUID next
to the live ones, both calming by one an anger tick, and looks the UUIDs up
again every two anger ticks through the game layer's map
(`Game::who_by_uuid`, reached from the mob layer through `MobWorld`'s new
`WhoLookup` seam). The chunk save names a suspect by its UUID, minting one for
a loaded suspect that has none yet.
2026-11-06 01:09:42 +00:00
MSedgeMC 712305de32 Entity saves: a UUID bridge for references to a mob
A save can now name an entity it only knows as a `Who`: `mob_to_nbt` takes a
`uuid_of` resolver, `who_uuid` writes a `Who` as a UUID and `who_by_uuid` turns a
stored UUID back into the `Who` of a loaded player or mob. `EntityMemory::uuid_of`
supplies the id -> UUID map the game layer keeps, and the game layer's `mob_by_uuid`
now resolves through the same seam.

The neutral mobs' `angry_at` is the first writer: anger at another mob now saves
that mob's UUID instead of nothing. The warden's suspect list and the shulker
bullet's target are the two follow-ups this bridge unblocks; neither is written
here.
2026-11-06 01:09:42 +00:00
TheMrZZandCodex gpt-6.1-sol 94b5bf1aa0 Fix dropped-item Health short conversion
Floor floating Health values before short narrowing and preserve integer narrowing. Add an actual decode-to-save regression and an isolated Game/Java command fixture.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 01:18:04 +01:00
MSedgeMC fc8db41b0d fix: a shulker's bullet keeps its target through a save
ShulkerBullet.addAdditionalSaveData writes `Target` (the UUID it homes on, only
while it has one), `Dir` (`Direction.LEGACY_ID_CODEC`, the step it is on, absent
until it picks one), `Steps` and the per-axis target deltas `TXD`/`TYD`/`TZD`,
over `Projectile`'s owner and LeftOwner; readAdditionalSaveData reads them back
and keeps `Target` as an `EntityReference` it re-resolves every tick.

Horizon's `saved_with_chunk` turned a bullet with a target away - which is every
bullet a shulker fires - and `spit_or_bullet_to_nbt` refused it too, so the chunk
save dropped the bullet, and nothing read a step or a delta back.

The writer writes those keys now, the reader reads `Dir`, `Steps` and the deltas
into the kind, and `Target` goes through the UUID bridge: `Game::save_who_uuid`
answers it with `who_uuid` over `EntityMemory::uuid_of` (nothing for a mob the
level has no UUID for, never a made-up one), and `Game::bullet_target` resolves
the stored UUID with `Game::who_by_uuid`, which names the player or a mob that is
loaded. A bullet whose target is not loaded comes back with none and falls, as
`ShulkerBullet.tick` does with a target it cannot find, rather than homing on
whatever its old id means now.

A loaded bullet's own random stream is seeded from where it is, since
`Entity.random` is not saved: the same stand-in `Projectile::new` gives an
arrow's.
2026-11-06 00:03:08 +00:00
MSedgeMC b07b8f865b Entity saves: a UUID bridge for references to a mob
A save can now name an entity it only knows as a `Who`: `mob_to_nbt` takes a
`uuid_of` resolver, `who_uuid` writes a `Who` as a UUID and `who_by_uuid` turns a
stored UUID back into the `Who` of a loaded player or mob. `EntityMemory::uuid_of`
supplies the id -> UUID map the game layer keeps, and the game layer's `mob_by_uuid`
now resolves through the same seam.

The neutral mobs' `angry_at` is the first writer: anger at another mob now saves
that mob's UUID instead of nothing. The warden's suspect list and the shulker
bullet's target are the two follow-ups this bridge unblocks; neither is written
here.
2026-11-06 00:03:08 +00:00
martipopsandClaude Opus 5.5 0c0d33cb29 Controller e2e: run cases at once with --jobs
Each case already has its own game, home and socket. One at a time stays the default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:06:04 -05:00
martipopsandClaude Opus 5.5 edc49a0118 Controllers: the gameplay guide, what the buttons do now, in play and in inventories
In play the HUD's top corners name the buttons for what the crosshair is on, what's held and how the player is
moving. In an inventory the bottom corners do: plain face buttons at the bottom right, everything else at the
bottom left, by what the crosshair is on and what's carried. Rows follow bindings and step around what was drawn.
The mouse or keyboard taking over, a prompt, F1, F3 and the setting hide them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:06:04 -05:00
Florian ERNSTandCodex 27d81ba1b9 Omit empty Tags from modeled entity saves
Co-Authored-By: Codex <[email protected]>
2026-11-05 23:35:58 +01:00
TheMrZZandCodex gpt-6.1-sol e5e49e6242 Save spectral arrow duration and its default
Keep spectral duration in the loaded arrow state and always write its integer
value, including the default 200. Cover the live command route and load/save
without raw input while retaining broader type and display diagnostics.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 23:33:29 +01: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
Riccardo GuerrieroandClaude Opus 5.5 e2b9c29ff0 Breaking a painting, a cushion or a boat makes a vibration
Killing an entity that is no mob posts ENTITY_DIE (frequency 15) at the
entity, as vanilla's kill paths do: BlockAttachedEntity.kill (cushions
punched, burnt, unsupported, struck or blown up; paintings; empty item
frames), ArmorStand.kill (broken, burnt out, blown up, /kill's
generic_kill), VehicleEntity.destroy (boats and minecarts, not a creative
hit, which only discards them) and Entity.kill from /kill for the other
non-living kinds. The attacker is the source when a hit kills it
(attributedTo), otherwise the entity itself. A frame holding an item only
gives the item up and does not die (ItemFrame.hurtServer). The event runs
on the server's path, so single player, LAN guests and dedicated servers
all get it.

New lan_e2e check entity-die: a sneaking host and LAN guest break each
kind 3 blocks from a sculk sensor, which now hears 15.

Part of #10046

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 20:06:34 +01:00
rdbtCVSandCodex gpt-6.1-sol d3e25901ff Adapt native level particle speed to the per-axis packet representation
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:14:10 +00:00
rdbtCVSandCodex gpt-6.1-sol 54bef9b2fb Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:01:48 +00:00
rdbtCVSandCodex gpt-6.1-sol 4e294c46f1 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:01:45 +00:00
rdbtCVSandCodex gpt-6.1-sol 67268cb5b4 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:01:43 +00:00
rdbtCVSandCodex gpt-6.1-sol 6bd3217727 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:01:41 +00:00
levzzz 9a344e0c31 Merge main into modapi/entity-state 2026-11-03 01:08:57 +05:00
levzzzandClaude Opus 5.5 d48b349d72 Native mods: teleport, rotation, head yaw, facing, size and pushes through EntityRef (#7387)
teleport_to and set_rotation move and turn any entity as /tp and /rotate
do; head_yaw, set_head_yaw, direction, dimensions and push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 00:19:06 +05:00
levzzzandClaude Opus 5.5 3568cc4cc7 Native mods: name the shared sound and particle checks outside the hook catalog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 00:11:41 +05:00
levzzzandClaude Opus 5.5 d68ada1e13 Native mods: an entity's state flags through EntityRef (#7387)
is_shift_key_down, is_crouching, is_sprinting, is_in_water, is_in_lava,
fire_immune, fire_ticks, air_supply, max_air_supply, is_silent,
set_silent, is_invisible and pose, for players and mobs, and the ones
Horizon keeps for armor stands and items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 00:10:46 +05:00
levzzzandClaude Opus 5.5 c367cb95a2 Native mods: sounds and particles in a level through LevelRef (#7387)
level.play_sound and level.send_particles send to every player in range,
as ServerLevel.playSound and sendParticles do, from any server hook (not
only a level hook's LevelWriteHost). The checks the level-hook versions
make move into native_sound_ok and native_particles_ok, shared by both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 00:03:19 +05:00
levzzzandClaude Opus 5.5 25d9cc84c2 Native mods: a level's seed, difficulty, game rules and weather through LevelRef (#7387)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:05:17 +05:00
431 changed files with 30363 additions and 11149 deletions

No files matched your search

+10 -10
View File
@@ -121,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio, controllers, `discord`, `friends` and `heartbeat` (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
@@ -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.
Generated
+52 -26
View File
@@ -147,6 +147,15 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
dependencies = [
"rustversion",
]
[[package]]
name = "arcstr"
version = "1.2.0"
@@ -243,6 +252,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -1554,7 +1569,7 @@ name = "horizon-client-render"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"base64 0.22.1",
"bytemuck",
"chrono",
"cpal",
@@ -1623,7 +1638,7 @@ dependencies = [
name = "horizon-cosmetics"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"glam",
"png",
"serde",
@@ -1651,7 +1666,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2",
"socket2 0.6.5",
"spake2",
]
@@ -1819,7 +1834,7 @@ dependencies = [
name = "horizon-release-sign"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"ring",
]
@@ -1828,7 +1843,7 @@ name = "horizon-server"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"base64 0.22.1",
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
@@ -1859,7 +1874,7 @@ dependencies = [
name = "horizon-server-files"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"horizon-core",
"horizon-protocol",
"horizon-world",
@@ -2138,7 +2153,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"socket2 0.6.5",
"widestring",
"windows-registry",
"windows-result 0.4.1",
@@ -2392,9 +2407,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.16.4"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
[[package]]
name = "lru-slab"
@@ -2493,10 +2508,11 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "mysql"
version = "27.0.0"
version = "28.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
checksum = "ca3de8a73b7c70f76dbcdd4b6abc9b53bbb6c4f7bd6c79d8e8a7ea2157c02487"
dependencies = [
"arc-swap",
"bufstream",
"bytes",
"crossbeam-queue",
@@ -2511,7 +2527,7 @@ dependencies = [
"percent-encoding",
"rustls",
"rustls-pemfile",
"socket2",
"socket2 0.6.5",
"twox-hash",
"url",
"webpki",
@@ -2520,11 +2536,11 @@ dependencies = [
[[package]]
name = "mysql_common"
version = "0.36.2"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a"
dependencies = [
"base64",
"base64 0.22.1",
"bitflags 2.13.2",
"btoi",
"byteorder",
@@ -3140,7 +3156,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
dependencies = [
"base64",
"base64 0.22.1",
"cbc",
"cms",
"der",
@@ -3192,11 +3208,11 @@ dependencies = [
[[package]]
name = "pem"
version = "3.0.6"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
dependencies = [
"base64",
"base64 0.23.1",
"serde_core",
]
@@ -3447,7 +3463,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"socket2",
"socket2 0.6.5",
"thiserror 2.0.21",
"tokio",
"tracing",
@@ -3485,7 +3501,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
@@ -3603,7 +3619,7 @@ dependencies = [
"itoa",
"percent-encoding",
"ryu",
"socket2",
"socket2 0.6.5",
"url",
"xxhash-rust",
]
@@ -4055,6 +4071,16 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -4368,14 +4394,14 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"socket2 0.6.5",
"windows-sys 0.61.2",
]
@@ -4549,7 +4575,7 @@ version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"base64 0.22.1",
"log",
"once_cell",
"rustls",
+15
View File
@@ -82,6 +82,21 @@ libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
[profile.dev]
debug = "line-tables-only"
# The native mod API is generated by a build script that parses every file of game/ with syn on
# each gameplay edit; unoptimized that took ~6 s of every rebuild, optimized ~1.5 s.
[profile.dev.package.horizon-mod-api-gen]
opt-level = 3
[profile.dev.package.syn]
opt-level = 3
[profile.dev.package.proc-macro2]
opt-level = 3
[profile.release.package.horizon-mod-api-gen]
opt-level = 3
[profile.release.package.syn]
opt-level = 3
[profile.release.package.proc-macro2]
opt-level = 3
[profile.release]
lto = "thin"
codegen-units = 1
+12
View File
@@ -49,6 +49,18 @@ Everything below is in the current release.
<sub>Nightly 2026-11-05 on a Mac mini (Apple M4 Pro, Metal): vanilla's textures, no mods or shader packs, render distance 32 with distant terrain on.</sub>
## Performance
**Horizon draws more frames per second than Java Edition, and loads faster, on the same machine, world and path.** `tools/perf_bench.py` runs each game in turn, from a fresh world (seed 1234), waiting for the terrain and then flying it over a village, a forest, an ocean and a mountain. Measured on 2026-10-29/30 on an idle Windows 11 machine (Ryzen 7 9800X3D, RTX 5080), Horizon v0.2.97 as the launcher installs it, 1280x720 in a window, VSync off, three games per figure. Frames per second, mean of the four scenes, with the slowest 1% of frames in brackets:
| Render distance | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- |
| 8 chunks | 3,289 (1,327) | 2,437 (640) | 2,375 (709) |
| 16 chunks | 2,074 (598) | 1,661 (375) | 1,586 (432) |
| 32 chunks | 1,207 (333) | 1,031 (176) | 879 (264) |
"Vanilla + mods" is Sodium, Lithium, FerriteCore and ImmediatelyFast. Horizon reaches the title screen in 2.9–3.2 s against vanilla's 7.5–7.7 s (the modded client's 10.8–11.1 s), loads a scene's chunks 2.9–3.8 times faster, and playing holds a steady working set of 1.1–1.7 GB against 1.9–2.9 GB. Every scene with its confidence interval and the run's load log: [the 2026-10-29 reports](benchmarks/perf/windows-9800x3d-5080-2026-10-29.md). Method, and how to measure your own machine: [docs/benchmark.md](docs/benchmark.md). [playhorizon.dev/benchmarks](https://playhorizon.dev/benchmarks) puts these runs beside a 2016 laptop, a Mac mini and a gaming desktop.
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev), or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true):
+53 -1
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
@@ -225,7 +256,9 @@ THE SOFTWARE.
## Just Enough Items
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
The Recipes GUI sprites and settings icon under `crates/horizon-client-render/src/jei_recipes` are from
[Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems/tree/0371fa6fafa14635a356019e4604f5580b26a34f)
by mezz and contributors, under the MIT license in that directory (`LICENSE.txt`).
## Flywheel native model directional shading
@@ -254,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.
```
+62 -3
View File
@@ -490,6 +490,37 @@ impl GlyphStyle {
}
}
/// How much the gameplay guide says.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GuideDetail {
#[default]
Full,
/// Only what can be done to what's in front of the player or under the crosshair.
Basic,
Off,
}
impl GuideDetail {
pub const ALL: [GuideDetail; 3] = [GuideDetail::Full, GuideDetail::Basic, GuideDetail::Off];
/// The name in `horizon-controller.txt`.
pub fn name(self) -> &'static str {
match self {
GuideDetail::Full => "full",
GuideDetail::Basic => "basic",
GuideDetail::Off => "off",
}
}
/// Reads a name, or the `true` and `false` this option first saved. Anything else is full.
pub fn parse(value: &str) -> Self {
match value {
"basic" => GuideDetail::Basic,
"off" | "false" => GuideDetail::Off,
_ => GuideDetail::Full,
}
}
}
/// The on-screen keyboard A opens on a text field in focus mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextKeyboard {
@@ -537,6 +568,8 @@ pub fn steam_keyboard(choice: TextKeyboard) -> bool {
pub struct PadSettings {
pub enabled: bool,
pub vibration: bool,
/// The pad keeps driving the game while its window has no focus.
pub play_in_background: bool,
/// A DualSense's triggers push back (Horizon's; docs/controllers.md).
pub adaptive_triggers: bool,
pub lightbar: Lightbar,
@@ -556,6 +589,8 @@ pub struct PadSettings {
/// by default on the Switch only.
pub nintendo_letters: bool,
pub keyboard: TextKeyboard,
/// The gameplay guide: what the buttons do now, in the HUD's corners while the pad plays.
pub guide_detail: GuideDetail,
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
pub pointer_speed: f32,
pub scroll_speed: f32,
@@ -569,6 +604,7 @@ impl Default for PadSettings {
Self {
enabled: true,
vibration: true,
play_in_background: false,
adaptive_triggers: true,
lightbar: Lightbar::Health,
look_sensitivity: 0.5,
@@ -581,6 +617,7 @@ impl Default for PadSettings {
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
guide_detail: GuideDetail::Full,
pointer_speed: 1.0,
scroll_speed: 12.0,
repeat_delay: 0.35,
@@ -635,6 +672,7 @@ impl PadSettings {
match key.trim() {
"enabled" => s.enabled = flag(s.enabled),
"vibration" => s.vibration = flag(s.vibration),
"playInBackground" => s.play_in_background = flag(s.play_in_background),
"adaptiveTriggers" => s.adaptive_triggers = flag(s.adaptive_triggers),
"lightbar" => s.lightbar = Lightbar::parse(value),
"lookSensitivity" => s.look_sensitivity = unit(s.look_sensitivity),
@@ -649,6 +687,7 @@ impl PadSettings {
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
"gameplayGuide" => s.guide_detail = GuideDetail::parse(value),
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
@@ -706,7 +745,8 @@ impl PadSettings {
self.keyboard.name(),
);
text.push_str(&format!(
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
self.guide_detail.name(),
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
for (action, button) in &self.bindings {
@@ -715,6 +755,10 @@ impl PadSettings {
button.map_or("none", Button::name)
));
}
text.push_str(&format!(
"playInBackground:{}\n",
bool_text(self.play_in_background)
));
text.push_str("bindLayout:1\n");
text
}
@@ -786,7 +830,8 @@ pub fn stick_axes(
{
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
// is the right trigger and must not drive look.
(left, [left_z, right[0]])
// gilrs turns over only the axes it calls Y, and this Y is on one it doesn't.
(left, [left_z, -right[0]])
} else {
(left, right)
}
@@ -1752,6 +1797,15 @@ bind_key.hotbar.2:north
}
}
#[test]
fn playing_in_the_background_is_off_until_saved_on() {
assert!(!PadSettings::parse("vibration:false\n").play_in_background);
let mut on = PadSettings::default();
assert!(!on.play_in_background);
on.play_in_background = true;
assert!(PadSettings::parse(&on.serialize()).play_in_background);
}
#[test]
fn a_file_from_before_these_defaults_keeps_its_buttons() {
// Saved settings that never bound D-pad down: it stays on the advancements.
@@ -1793,6 +1847,7 @@ bind_key.hotbar.2:north
s.look_sensitivity = 0.75;
s.glyphs = GlyphStyle::PlayStation;
s.nintendo_letters = !s.nintendo_letters;
s.guide_detail = GuideDetail::Basic;
let text = s.serialize();
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
assert!(text.contains("bind_key.jump:east\n"));
@@ -1820,7 +1875,11 @@ bind_key.hotbar.2:north
false,
);
assert_eq!(left, [0.2, 0.3]);
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
assert_eq!(
right,
[-0.5, -0.7],
"X from LeftZ, Y from RightStickX, turned over"
);
let kept = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
@@ -0,0 +1,962 @@
//! The gameplay guide: what the buttons do right now, in the corners of the HUD while a pad plays
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
//! places the rows around what was drawn.
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
/// What a use does, by what takes it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verb {
Use,
Open,
Ride,
Trade,
Feed,
Place,
Eat,
Drink,
Shoot,
Load,
Throw,
Raise,
Equip,
Fish,
Zoom,
Boost,
}
impl Verb {
fn caption(self) -> &'static str {
match self {
Verb::Use => "horizon.controller.guide.use",
Verb::Open => "horizon.controller.guide.open",
Verb::Ride => "horizon.controller.guide.ride",
Verb::Trade => "horizon.controller.guide.trade",
Verb::Feed => "horizon.controller.guide.feed",
Verb::Place => "horizon.controller.guide.place",
Verb::Eat => "horizon.controller.guide.eat",
Verb::Drink => "horizon.controller.guide.drink",
Verb::Shoot => "horizon.controller.guide.shoot",
Verb::Load => "horizon.controller.guide.load",
Verb::Throw => "horizon.controller.guide.throw",
Verb::Raise => "horizon.controller.guide.raise",
Verb::Equip => "horizon.controller.guide.equip",
Verb::Fish => "horizon.controller.guide.fish",
Verb::Zoom => "horizon.controller.guide.zoom",
Verb::Boost => "horizon.controller.guide.boost",
}
}
}
/// `Minecraft.hitResult`, with what each button would do to it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Target {
#[default]
None,
Block {
breakable: bool,
/// The block takes the use before the held item gets its turn.
takes: Option<Verb>,
},
Entity {
attackable: bool,
takes: Option<Verb>,
},
}
/// An item in use, by what letting go of it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Active {
Shoot,
Throw,
Stop,
}
/// What the guide knows of the player this moment.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Facts {
pub spectator: bool,
pub target: Target,
/// What `Item.use` does on the target or in the air: the main hand's, else the off hand's.
pub item_use: Option<Verb>,
/// Pick Block finds something it can select or, in creative, add.
pub can_pick: bool,
pub using: Option<Active>,
/// `LocalPlayer.isHandsBusy`: paddling a boat.
pub hands_busy: bool,
pub riding: bool,
/// The vehicle jumps when told (a saddled horse).
pub vehicle_jumps: bool,
pub on_ground: bool,
pub in_water: bool,
pub flying: bool,
pub may_fly: bool,
pub gliding: bool,
/// In the air with a glider on: a jump would start gliding.
pub can_glide: bool,
/// Sneaking that the button latched, so the next press stands up.
pub sneak_latched: bool,
pub can_sprint: bool,
pub main_hand_item: bool,
pub off_hand_item: bool,
}
/// One button's line: the button and the translation key of what it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Row {
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
pub with: Option<Button>,
pub button: Button,
pub caption: &'static str,
}
impl Row {
fn new(button: Button, caption: &'static str) -> Self {
Row {
with: None,
button,
caption,
}
}
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
Row {
with: Some(with),
button,
caption,
}
}
}
/// The two columns, most useful row first.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Guide {
/// Top left: movement and the player's own actions.
pub left: Vec<Row>,
/// Top right: break, use and pick block.
pub right: Vec<Row>,
}
impl Guide {
/// The rows this detail keeps. Basic is the right column: what the buttons do to what's in
/// front of the player, or the plain face buttons in an inventory.
pub fn at_detail(self, detail: GuideDetail) -> Self {
match detail {
GuideDetail::Full => self,
GuideDetail::Basic => Guide {
left: Vec::new(),
right: self.right,
},
GuideDetail::Off => Guide {
left: Vec::new(),
right: Vec::new(),
},
}
}
}
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
/// on Start, which is always pause.
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
let row = |action: &str, caption: Option<&'static str>| {
let button = settings
.binding(action)
.filter(|button| *button != Button::Start)?;
Some(Row::new(button, caption?))
};
let player = !facts.spectator;
let any_item = facts.main_hand_item || facts.off_hand_item;
Guide {
left: [
row("key.jump", jump_caption(facts)),
row("key.sneak", sneak_caption(facts)),
row("key.sprint", facts.can_sprint.then_some("key.sprint")),
row("key.inventory", player.then_some("key.inventory")),
row(
"key.drop",
(player && facts.main_hand_item).then_some("horizon.controller.guide.drop"),
),
row(
"key.swapOffhand",
(player && any_item).then_some("horizon.controller.guide.swap"),
),
]
.into_iter()
.flatten()
.collect(),
right: [
row("key.attack", attack_caption(facts)),
row("key.use", use_caption(facts)),
row("key.pickItem", pick_caption(facts)),
]
.into_iter()
.flatten()
.collect(),
}
}
fn jump_caption(f: &Facts) -> Option<&'static str> {
if f.riding {
f.vehicle_jumps.then_some("key.jump")
} else if f.flying {
Some("horizon.controller.guide.fly_up")
} else if f.gliding {
None
} else if f.in_water {
Some("horizon.controller.guide.swim_up")
} else if f.on_ground {
Some("key.jump")
} else if f.can_glide {
Some("horizon.controller.guide.glide")
} else {
f.may_fly.then_some("horizon.controller.guide.fly")
}
}
fn sneak_caption(f: &Facts) -> Option<&'static str> {
if f.riding {
Some("horizon.controller.guide.dismount")
} else if f.flying {
Some("horizon.controller.guide.fly_down")
} else if f.gliding {
None
} else if f.in_water && !f.on_ground {
Some("horizon.controller.guide.swim_down")
} else if f.sneak_latched {
Some("horizon.controller.guide.stand")
} else {
Some("key.sneak")
}
}
/// `Minecraft.handleKeybinds` spends attack clicks while an item is in use, and
/// `startAttack` does nothing with busy hands.
fn attack_caption(f: &Facts) -> Option<&'static str> {
if f.using.is_some() || f.hands_busy {
return None;
}
match f.target {
Target::Entity {
attackable: true, ..
} if f.spectator => Some("horizon.controller.guide.spectate"),
Target::Entity {
attackable: true, ..
} => Some("horizon.controller.guide.attack"),
Target::Block {
breakable: true, ..
} => Some("horizon.controller.guide.break"),
_ => None,
}
}
/// `Minecraft.startUseItem`: what is aimed at first, then the item.
fn use_caption(f: &Facts) -> Option<&'static str> {
match f.using {
Some(Active::Shoot) => return Some("horizon.controller.guide.release_shoot"),
Some(Active::Throw) => return Some("horizon.controller.guide.release_throw"),
Some(Active::Stop) => return Some("horizon.controller.guide.release_stop"),
None => {}
}
if f.hands_busy {
return None;
}
let takes = match f.target {
Target::Block { takes, .. } | Target::Entity { takes, .. } => takes,
Target::None => None,
};
takes.or(f.item_use).map(Verb::caption)
}
fn pick_caption(f: &Facts) -> Option<&'static str> {
if f.spectator {
// key.spectatorHotbar is this button: it opens the teleport menu.
return Some("horizon.controller.guide.spectator_menu");
}
(f.can_pick && f.using.is_none()).then_some("key.pickItem")
}
/// What the crosshair is on in an inventory.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Under {
#[default]
Nothing,
/// A slot. `same` is the carried stack being its kind; `result` a crafting result, which is
/// only ever taken whole.
Slot {
item: bool,
same: bool,
result: bool,
/// The carried stack can go here.
accepts: bool,
},
Recipe {
direct_craft: bool,
alternatives: bool,
},
/// One of the recipe book's other controls.
Control,
}
/// What an inventory's rows are resolved from.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct MenuFacts {
pub under: Under,
pub carrying: bool,
/// The recipe book is showing: the bumpers turn its pages, the triggers its categories.
pub book: bool,
/// The bumpers switch tabs.
pub tabs: bool,
}
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
pub fn resolve_menu(f: &MenuFacts) -> Guide {
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
use Button::{Select, South, West};
const TAKE: &str = "horizon.controller.guide.take";
const MOVE: &str = "horizon.controller.guide.quick_move";
let under = match f.under {
// A result takes only into empty hands or the same stack.
Under::Slot {
item: true,
result: true,
same,
..
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
Under::Slot { result: true, .. } => Vec::new(),
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
Under::Slot { item, same, .. } if f.carrying => vec![
Row::new(
South,
if item && !same {
"horizon.controller.guide.swap_stack"
} else {
"horizon.controller.guide.put"
},
),
Row::new(West, "horizon.controller.guide.put_one"),
],
Under::Slot { item: true, .. } => vec![
Row::new(South, TAKE),
Row::new(West, "horizon.controller.guide.take_half"),
Row::new(North, MOVE),
Row::new(Select, "horizon.controller.guide.drop"),
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
],
Under::Nothing | Under::Slot { .. } => Vec::new(),
Under::Recipe {
direct_craft,
alternatives,
} => [
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
direct_craft.then_some(Row::pair(
LeftTrigger,
South,
"horizon.controller.direct_craft",
)),
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
]
.into_iter()
.flatten()
.collect(),
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
};
let screen = [
f.book.then_some(Row::pair(
LeftBumper,
RightBumper,
"horizon.controller.guide.pages",
)),
f.book.then_some(Row::pair(
LeftTrigger,
RightTrigger,
"horizon.controller.guide.categories",
)),
(f.tabs && !f.book).then_some(Row::pair(
LeftBumper,
RightBumper,
"horizon.controller.guide.tabs",
)),
];
let close = Row::new(East, "horizon.controller.guide.close");
let rows = std::iter::once(close)
.chain(under)
.chain(screen.into_iter().flatten());
let (right, left) =
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
Guide { left, right }
}
/// A row's height and the view's margin, in GUI pixels.
pub const ROW_HEIGHT: f32 = 11.0;
const MARGIN: f32 = 2.0;
fn overlap(a: &Rect, b: &Rect) -> bool {
a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3]
}
/// Where a column's rows go, down from a top corner of the `[width, height]` view: under whatever
/// a row would overlap in `taken`, or nowhere when that leaves the view's upper three quarters (the
/// row is dropped: the rows come in the order they matter). The placed rows join `taken`.
pub fn place_column(
view: [f32; 2],
left: bool,
widths: &[f32],
taken: &mut Vec<Rect>,
) -> Vec<Option<Rect>> {
place(view, left, false, widths, taken)
}
/// `place_column`'s rows up from a bottom corner, over what they would overlap, within the view's
/// lower quarter: the first row is the lowest.
pub fn place_column_up(
view: [f32; 2],
left: bool,
widths: &[f32],
taken: &mut Vec<Rect>,
) -> Vec<Option<Rect>> {
place(view, left, true, widths, taken)
}
fn place(
view: [f32; 2],
left: bool,
up: bool,
widths: &[f32],
taken: &mut Vec<Rect>,
) -> Vec<Option<Rect>> {
let mut y = if up {
view[1] - MARGIN - ROW_HEIGHT
} else {
MARGIN
};
widths
.iter()
.map(|&width| {
if width > view[0] - 2.0 * MARGIN {
return None;
}
let x = if left {
MARGIN
} else {
view[0] - MARGIN - width
};
let mut at = y;
loop {
let outside = if up {
at < view[1] * 0.75
} else {
at + ROW_HEIGHT > view[1] * 0.75
};
if outside {
return None;
}
let rect = [x, at, width, ROW_HEIGHT];
let hit = taken.iter().filter(|t| overlap(&rect, t));
let past = if up {
hit.map(|t| t[1]).fold(f32::INFINITY, f32::min)
} else {
hit.map(|t| t[1] + t[3]).fold(f32::NEG_INFINITY, f32::max)
};
if past.is_infinite() {
taken.push(rect);
y = if up {
at - ROW_HEIGHT - 1.0
} else {
at + ROW_HEIGHT + 1.0
};
return Some(rect);
}
at = if up {
past.floor() - ROW_HEIGHT - 1.0
} else {
past.ceil() + 1.0
};
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn walking() -> Facts {
Facts {
on_ground: true,
can_sprint: true,
main_hand_item: true,
..Facts::default()
}
}
fn on_block(f: Facts, breakable: bool, takes: Option<Verb>) -> Facts {
Facts {
target: Target::Block { breakable, takes },
..f
}
}
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
rows.iter().map(|r| (r.button, r.caption)).collect()
}
fn captions(rows: &[Row]) -> Vec<&'static str> {
rows.iter().map(|r| r.caption).collect()
}
#[test]
fn survival_on_a_block_shows_break_use_and_pick_on_the_default_buttons() {
let facts = Facts {
can_pick: true,
item_use: Some(Verb::Place),
..on_block(walking(), true, None)
};
let guide = resolve(&facts, &PadSettings::default());
assert_eq!(
rows(&guide.right),
[
(Button::RightTrigger, "horizon.controller.guide.break"),
(Button::LeftTrigger, "horizon.controller.guide.place"),
(Button::DPadLeft, "key.pickItem"),
]
);
assert_eq!(
rows(&guide.left),
[
(Button::South, "key.jump"),
(Button::RightStick, "key.sneak"),
(Button::LeftStick, "key.sprint"),
(Button::North, "key.inventory"),
(Button::DPadDown, "horizon.controller.guide.drop"),
(Button::West, "horizon.controller.guide.swap"),
]
);
}
#[test]
fn a_rebound_action_moves_its_row_and_an_unbound_one_loses_it() {
let mut settings = PadSettings::default();
settings.bind("key.attack", Some(Button::North));
settings.bind("key.pickItem", None);
settings.bind("key.jump", Some(Button::Start));
let facts = Facts {
can_pick: true,
..on_block(walking(), true, None)
};
let guide = resolve(&facts, &settings);
assert_eq!(
rows(&guide.right),
[(Button::North, "horizon.controller.guide.break")]
);
// Start only ever pauses.
assert!(!captions(&guide.left).contains(&"key.jump"));
}
#[test]
fn what_a_block_or_entity_takes_comes_before_the_held_item() {
let settings = PadSettings::default();
let chest = Facts {
item_use: Some(Verb::Place),
..on_block(walking(), true, Some(Verb::Open))
};
assert_eq!(
captions(&resolve(&chest, &settings).right),
[
"horizon.controller.guide.break",
"horizon.controller.guide.open"
]
);
let villager = Facts {
item_use: Some(Verb::Eat),
target: Target::Entity {
attackable: true,
takes: Some(Verb::Trade),
},
..walking()
};
assert_eq!(
captions(&resolve(&villager, &settings).right),
[
"horizon.controller.guide.attack",
"horizon.controller.guide.trade"
]
);
// A mob the item means nothing to: the item is used on.
let cow = Facts {
target: Target::Entity {
attackable: true,
takes: None,
},
..villager
};
assert_eq!(
captions(&resolve(&cow, &settings).right)[1],
"horizon.controller.guide.eat"
);
}
#[test]
fn nothing_to_do_leaves_the_right_column_empty() {
let settings = PadSettings::default();
assert!(resolve(&walking(), &settings).right.is_empty());
// An unbreakable block too (bedrock, a creative sword, adventure without can_break).
assert!(
resolve(&on_block(walking(), false, None), &settings)
.right
.is_empty()
);
}
#[test]
fn an_item_in_use_leaves_only_letting_go() {
let facts = Facts {
using: Some(Active::Shoot),
can_pick: true,
..on_block(walking(), true, Some(Verb::Open))
};
assert_eq!(
captions(&resolve(&facts, &PadSettings::default()).right),
["horizon.controller.guide.release_shoot"]
);
}
#[test]
fn busy_hands_neither_attack_nor_use() {
let facts = Facts {
hands_busy: true,
riding: true,
item_use: Some(Verb::Eat),
..on_block(Facts::default(), true, None)
};
assert!(resolve(&facts, &PadSettings::default()).right.is_empty());
}
#[test]
fn movement_follows_what_the_player_is_doing() {
let settings = PadSettings::default();
let first_two = |f: Facts| {
let left = resolve(&f, &settings).left;
captions(&left).into_iter().take(2).collect::<Vec<_>>()
};
assert_eq!(
first_two(Facts {
flying: true,
may_fly: true,
..Facts::default()
}),
[
"horizon.controller.guide.fly_up",
"horizon.controller.guide.fly_down"
]
);
assert_eq!(
first_two(Facts {
in_water: true,
..Facts::default()
}),
[
"horizon.controller.guide.swim_up",
"horizon.controller.guide.swim_down"
]
);
// In the air, only a glider or creative flight makes a jump do anything.
assert_eq!(first_two(Facts::default()), ["key.sneak", "key.inventory"]);
assert_eq!(
first_two(Facts {
can_glide: true,
..Facts::default()
})[0],
"horizon.controller.guide.glide"
);
assert_eq!(
first_two(Facts {
sneak_latched: true,
on_ground: true,
..Facts::default()
})[1],
"horizon.controller.guide.stand"
);
// A boat can't jump; a saddled horse can; both dismount.
assert_eq!(
first_two(Facts {
riding: true,
on_ground: true,
..Facts::default()
})[0],
"horizon.controller.guide.dismount"
);
assert_eq!(
first_two(Facts {
riding: true,
vehicle_jumps: true,
..Facts::default()
}),
["key.jump", "horizon.controller.guide.dismount"]
);
// Gliding: neither jump nor sneak does anything.
assert_eq!(
first_two(Facts {
gliding: true,
..Facts::default()
}),
["key.inventory"]
);
}
#[test]
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
let facts = Facts {
spectator: true,
flying: true,
may_fly: true,
main_hand_item: true,
target: Target::Entity {
attackable: true,
takes: None,
},
..Facts::default()
};
let guide = resolve(&facts, &PadSettings::default());
assert_eq!(
captions(&guide.right),
[
"horizon.controller.guide.spectate",
"horizon.controller.guide.spectator_menu"
]
);
assert_eq!(
captions(&guide.left),
[
"horizon.controller.guide.fly_up",
"horizon.controller.guide.fly_down"
]
);
}
#[test]
fn rows_stack_from_their_corner() {
let mut taken = Vec::new();
let left = place_column([200.0, 200.0], true, &[50.0, 30.0], &mut taken);
assert_eq!(left[0], Some([2.0, 2.0, 50.0, 11.0]));
assert_eq!(left[1], Some([2.0, 14.0, 30.0, 11.0]));
let right = place_column([200.0, 200.0], false, &[40.0], &mut taken);
assert_eq!(right[0], Some([158.0, 2.0, 40.0, 11.0]));
}
#[test]
fn a_row_goes_under_what_it_would_overlap() {
// The effect icons in the top right corner.
let mut taken = vec![[150.0, 1.0, 50.0, 24.0]];
let right = place_column([200.0, 200.0], false, &[40.0, 40.0], &mut taken);
assert_eq!(right[0], Some([158.0, 26.0, 40.0, 11.0]));
assert_eq!(right[1], Some([158.0, 38.0, 40.0, 11.0]));
}
#[test]
fn the_columns_of_a_narrow_view_never_overlap_and_late_rows_drop() {
// One quarter of a small window, a boss bar across its top: the rows have 31..67 left.
let view = [160.0, 90.0];
let bar = [0.0, 12.0, 160.0, 19.0];
let mut taken = vec![bar];
let right = place_column(view, false, &[90.0, 90.0, 90.0], &mut taken);
let left = place_column(view, true, &[90.0; 6], &mut taken);
let placed: Vec<Rect> = right.iter().chain(&left).flatten().copied().collect();
for (i, a) in placed.iter().enumerate() {
assert!(a[1] + a[3] <= 67.5 && a[0] >= 0.0 && a[0] + a[2] <= 160.0);
assert!(!overlap(a, &bar));
for b in &placed[i + 1..] {
assert!(!overlap(a, b), "{a:?} overlaps {b:?}");
}
}
// Three rows' worth of room, and the right column came first.
assert_eq!(right.iter().flatten().count(), 3);
assert_eq!(left.iter().flatten().count(), 0);
}
#[test]
fn rows_stack_up_from_a_bottom_corner_and_drop_above_its_quarter() {
let view = [200.0, 100.0];
let mut taken = vec![[0.0, 80.0, 60.0, 5.0]];
let left = place_column_up(view, true, &[50.0, 50.0], &mut taken);
assert_eq!(left[0], Some([2.0, 87.0, 50.0, 11.0]));
// Over the panel at 80 would be 68, under the lower quarter's 75.
assert_eq!(left[1], None);
let right = place_column_up(view, false, &[40.0], &mut taken);
assert_eq!(right[0], Some([158.0, 87.0, 40.0, 11.0]));
}
fn short(rows: &[Row]) -> Vec<&str> {
rows.iter()
.map(|r| r.caption.rsplit('.').next().unwrap_or(""))
.collect()
}
#[test]
fn an_inventorys_face_buttons_go_right_and_the_rest_left() {
let on = |under, carrying| {
resolve_menu(&MenuFacts {
under,
carrying,
..MenuFacts::default()
})
};
let slot = |item, same, result| Under::Slot {
item,
same,
result,
accepts: true,
};
let close_only = ["close"];
let empty: [&str; 0] = [];
for quiet in [Under::Nothing, slot(false, false, false)] {
let guide = on(quiet, false);
assert_eq!(short(&guide.right), close_only);
assert_eq!(short(&guide.left), empty);
}
let item = on(slot(true, false, false), false);
assert_eq!(
short(&item.right),
["close", "take", "take_half", "quick_move"]
);
assert_eq!(short(&item.left), ["drop", "drop_stack"]);
assert_eq!(
short(&on(slot(false, false, false), true).right),
["close", "put", "put_one"]
);
assert_eq!(
short(&on(slot(true, false, false), true).right),
["close", "swap_stack", "put_one"]
);
assert_eq!(
short(&on(slot(true, true, false), true).right),
["close", "put", "put_one"]
);
assert_eq!(
short(&on(slot(true, false, true), false).right),
["close", "take", "quick_move"]
);
let recipe = on(
Under::Recipe {
direct_craft: true,
alternatives: true,
},
false,
);
assert_eq!(
short(&recipe.right),
["close", "place_recipe", "place_recipe_all", "alternatives"]
);
assert_eq!(recipe.left.len(), 1);
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
assert_eq!(recipe.left[0].button, Button::South);
let plain = on(
Under::Recipe {
direct_craft: false,
alternatives: false,
},
false,
);
assert_eq!(
short(&plain.right),
["close", "place_recipe", "place_recipe_all"]
);
assert!(plain.left.is_empty());
}
#[test]
fn an_inventorys_screen_rows_follow_its_book_and_tabs() {
let left = |book, tabs| {
resolve_menu(&MenuFacts {
book,
tabs,
..MenuFacts::default()
})
.left
};
assert!(left(false, false).is_empty());
assert_eq!(short(&left(true, false)), ["pages", "categories"]);
assert_eq!(short(&left(false, true)), ["tabs"]);
}
#[test]
fn a_row_wider_than_the_view_is_dropped() {
let mut taken = Vec::new();
assert_eq!(
place_column([50.0, 200.0], true, &[60.0, 20.0], &mut taken),
[None, Some([2.0, 2.0, 20.0, 11.0])]
);
}
}
#[cfg(test)]
mod detail_tests {
use super::*;
fn rows() -> Guide {
Guide {
left: vec![Row::new(Button::South, "left")],
right: vec![Row::new(Button::East, "right")],
}
}
#[test]
fn full_keeps_both_columns() {
let guide = rows().at_detail(GuideDetail::Full);
assert_eq!((guide.left.len(), guide.right.len()), (1, 1));
}
#[test]
fn basic_keeps_the_right_column() {
let guide = rows().at_detail(GuideDetail::Basic);
assert_eq!((guide.left.len(), guide.right.len()), (0, 1));
}
#[test]
fn off_keeps_nothing() {
let guide = rows().at_detail(GuideDetail::Off);
assert_eq!((guide.left.len(), guide.right.len()), (0, 0));
}
#[test]
fn a_file_saved_with_the_old_switch_keeps_its_meaning() {
assert_eq!(GuideDetail::parse("true"), GuideDetail::Full);
assert_eq!(GuideDetail::parse("false"), GuideDetail::Off);
assert_eq!(GuideDetail::parse("basic"), GuideDetail::Basic);
}
}
#[cfg(test)]
mod slot_tests {
use super::*;
/// The rows for the slot: South or West, whichever column they're in.
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
let guide = resolve_menu(&MenuFacts {
under: Under::Slot {
item,
same,
result,
accepts,
},
carrying,
..MenuFacts::default()
});
[guide.left, guide.right]
.iter()
.flatten()
.filter(|row| matches!(row.button, Button::South | Button::West))
.count()
}
#[test]
fn a_slot_that_refuses_the_carried_stack_offers_nothing() {
assert_eq!(rows(false, false, false, false, true), 0);
assert_eq!(rows(true, true, false, false, true), 0);
assert!(rows(false, false, false, true, true) > 0);
}
#[test]
fn a_result_is_taken_only_into_empty_hands_or_the_same_stack() {
assert!(rows(true, false, true, true, false) > 0);
assert!(rows(true, true, true, true, true) > 0);
assert_eq!(rows(true, false, true, true, true), 0);
}
}
+1
View File
@@ -6,6 +6,7 @@
pub mod actions;
pub mod clipboard;
pub mod gamepad;
pub mod gameplay_guide;
pub mod held;
pub mod input;
pub mod interaction_input;
+11 -4
View File
@@ -42,7 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -73,8 +74,8 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
@@ -113,12 +114,18 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
# 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"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
heartbeat = ["horizon-telemetry/live"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
+1 -1
View File
@@ -26,7 +26,7 @@ horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
+5
View File
@@ -2071,6 +2071,11 @@ impl ChatScreen {
for i in 0..limit {
let y = ry + 12 * i as i32;
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
// A row for the pad: nothing else can pick a suggestion without Tab.
crate::widgets::target_as(
[rx as f32, y as f32, rw as f32, 12.0],
crate::widgets::Kind::Row,
);
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
if moved {
self.select(l, (i + l.offset) as isize);
+103 -10
View File
@@ -65,6 +65,44 @@ pub struct Setting {
pub item: Item,
pub text: &'static str,
}
/// What the chosen value does, for a setting whose values each do something different.
fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
Some(match opt {
Opt::PadGameplayGuide => match pad.guide_detail {
gamepad::GuideDetail::Full => {
"Shows what your buttons do right now, in play and in inventories."
}
gamepad::GuideDetail::Basic => {
"Shows only what you can do with what you're looking at, and the main inventory buttons."
}
gamepad::GuideDetail::Off => "Doesn't show what your buttons do.",
},
Opt::PadGlyphs => match pad.glyphs {
GlyphStyle::Auto => "Shows the buttons of the controller you're using.",
GlyphStyle::Xbox => "Always shows Xbox buttons.",
GlyphStyle::PlayStation => "Always shows PlayStation buttons.",
GlyphStyle::Nintendo => "Always shows Nintendo buttons.",
},
Opt::PadKeyboard => match pad.keyboard {
gamepad::TextKeyboard::Auto => {
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise."
}
gamepad::TextKeyboard::Horizon => "Always opens Horizon's keyboard.",
gamepad::TextKeyboard::Steam => "Always opens Steam's keyboard.",
},
Opt::PadLightbar => match pad.lightbar {
gamepad::Lightbar::Health => {
"A DualSense's light shows your health, from green through amber to red."
}
gamepad::Lightbar::Dimension => {
"A DualSense's light changes with the dimension you're in."
}
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
},
_ => return None,
})
}
fn option(opt: Opt, text: &'static str) -> Setting {
Setting {
item: Item::Option(opt),
@@ -97,10 +135,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
vec![
(
"Controller",
vec![option(
Opt::PadEnabled,
"Off, the game ignores every controller for this player.",
)],
vec![
option(
Opt::PadEnabled,
"Off, the game ignores every controller for this player.",
),
option(
Opt::PadPlayInBackground,
"The controller keeps playing while this window isn't focused, and losing focus won't pause.",
),
],
),
(
"Look",
@@ -135,10 +179,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
(
"Play",
vec![option(
Opt::PadTriggerThreshold,
"How far a trigger is pulled before it counts as pressed.",
)],
vec![
option(
Opt::PadGameplayGuide,
"Show the buttons for what you can do, while you play with a controller.",
),
option(
Opt::PadTriggerThreshold,
"How far a trigger is pulled before it counts as pressed.",
),
],
),
(
"Menus",
@@ -279,6 +329,7 @@ impl Item {
match self {
Item::Option(Opt::PadEnabled) => Some(c.enabled),
Item::Option(Opt::PadVibration) => Some(c.vibration),
Item::Option(Opt::PadPlayInBackground) => Some(c.play_in_background),
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
@@ -604,6 +655,7 @@ impl ControllerUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
}
@@ -818,8 +870,12 @@ impl ControllerUi {
/// What a row does. A binding names the controller's own buttons, and what else its button
/// does when it shares it.
fn description(&self, setting: Setting, s: &Settings) -> String {
let Item::Bind(action) = setting.item else {
return lang::get(setting.text);
let action = match setting.item {
Item::Bind(action) => action,
Item::Option(opt) => {
return lang::get(choice_description(opt, &s.controller).unwrap_or(setting.text));
}
_ => return lang::get(setting.text),
};
let pad = &s.controller;
let style = self.context.style;
@@ -1265,6 +1321,7 @@ mod tests {
for opt in [
Opt::PadEnabled,
Opt::PadVibration,
Opt::PadPlayInBackground,
Opt::PadAdaptiveTriggers,
Opt::PadLightbar,
Opt::PadLookSensitivity,
@@ -1372,6 +1429,30 @@ mod tests {
assert!((s.controller.move_deadzone - before - 0.01).abs() < 0.001);
}
/// What the screen draws at the bottom of the page changes with the choice, in every one of
/// these settings.
#[test]
fn each_choice_describes_itself() {
let ui = ControllerUi::default();
let mut s = Settings::default();
for (opt, choices) in [
(Opt::PadGameplayGuide, 3),
(Opt::PadGlyphs, 4),
(Opt::PadKeyboard, 3),
(Opt::PadLightbar, 3),
] {
let setting = option(opt, "the fixed sentence");
let mut seen = Vec::new();
for _ in 0..choices {
let text = ui.description(setting, &s);
assert_ne!(text, "the fixed sentence", "{opt:?}");
assert!(!seen.contains(&text), "{opt:?} repeats {text}");
seen.push(text);
opt.cycle(&mut s, false, FB);
}
}
}
#[test]
fn every_text_is_in_the_language_table() {
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
@@ -1385,6 +1466,18 @@ mod tests {
}
}
}
let mut settings = Settings::default();
for opt in [
Opt::PadGameplayGuide,
Opt::PadGlyphs,
Opt::PadKeyboard,
Opt::PadLightbar,
] {
for _ in 0..4 {
lang::get(choice_description(opt, &settings.controller).unwrap());
opt.cycle(&mut settings, false, FB);
}
}
for text in [
"Controller",
"Player profile",
@@ -0,0 +1,231 @@
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
use crate::{
extras::{self, Item},
lang,
options_ui::{Event, Page},
settings::Settings,
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
};
const TITLE: &str = "horizon.pages.extras";
const ABOUT: &str = "horizon.pages.extras.tooltip";
/// The group of the mod that replaced an extra.
const MODS: &str = "horizon.pages.mods";
const ZOOM: &str = "horizon.extras.zoom";
const ZOOM_INSTALL: &str = "horizon.extras.zoom_mod_install";
const MOD_SETTINGS: &str = "horizon.mods.settings";
/// Its descriptions run long, as tooltips they were: most need more than two lines.
const NOTE_LINES: usize = 5;
/// The official native mod that took over from the Zoom extra (#8713).
const ZOOM_ID: &str = "zoom";
/// A row of the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ExtraRow {
Item(Item),
/// The Zoom mod, at this place among the installed ones, on or off.
Zoom(usize),
/// Its own settings page.
ZoomSettings(usize),
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
ZoomInstall,
}
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
let e = &s.extras;
let value = match (item.key_name(), item.switch(e)) {
(Some(key), _) => Value::Key(key, item.value(e)),
(None, Some(on)) => Value::Switch(on),
(None, None) => Value::Choice(item.value(e)),
};
// A setting of a switch that is off does nothing, so it is greyed out.
let live = item
.parent()
.is_none_or(|parent| parent.switch(e) != Some(false));
Row::new(ExtraRow::Item(item), item.caption_text(), value)
.about(item.description_text())
.live(live)
}
/// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now; Extras keeps a way to
/// the one players look for here.
fn zoom_rows(s: &Settings) -> Vec<Row<ExtraRow>> {
let installed = &s.mods.installed;
let found = installed
.iter()
.position(|m| m.id == ZOOM_ID && m.native.is_some());
let Some(i) = found else {
let note = Row::new(ExtraRow::ZoomInstall, lang::get(ZOOM_INSTALL), Value::Note);
return vec![note.live(false)];
};
let about = installed[i].problem.as_ref();
let about = about.unwrap_or(&installed[i].description).clone();
let on = Value::Switch(s.mods.is_on(i));
let mut rows = vec![Row::new(ExtraRow::Zoom(i), lang::get(ZOOM), on).about(about)];
let page = installed[i].native.as_ref();
if page.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty()) {
let name = lang::get(MOD_SETTINGS);
rows.push(Row::new(ExtraRow::ZoomSettings(i), name, Value::Press));
}
rows
}
/// The page itself holds nothing: everything it shows is in the settings.
#[derive(Default)]
pub(crate) struct ExtrasScreen;
impl Screen for ExtrasScreen {
type Page = ();
type Row = ExtraRow;
fn title(&self) -> &'static str {
TITLE
}
fn note_lines(&self) -> usize {
NOTE_LINES
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
name: MODS,
rows: zoom_rows(s),
}];
for row in extras::rows() {
match row {
Err(name) => groups.push(Group {
name,
rows: Vec::new(),
}),
Ok(item) => {
if let Some(group) = groups.last_mut() {
group.rows.push(item_row(item, s));
}
}
}
}
vec![Sheet {
id: (),
name: lang::get(TITLE),
about: lang::get(ABOUT),
groups,
}]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
match row {
ExtraRow::Item(item) => match item.key_name() {
Some(key) => vec![Output::BindKey(key)],
None => {
item.cycle(&mut s.extras, back);
vec![Output::Event(Event::Changed)]
}
},
ExtraRow::Zoom(i) => {
s.mods.toggle(i);
vec![Output::Event(Event::Changed)]
}
ExtraRow::ZoomSettings(i) => vec![
Output::Event(Event::ButtonClick),
Output::Push(Page::NativeMod(i)),
Output::Event(Event::NativeSettings(i)),
],
ExtraRow::ZoomInstall => Vec::new(),
}
}
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
/// let the list scroll.
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
let ExtraRow::Item(item) = row else {
return None;
};
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings_shell::{Hosted, Shell};
const SIZE: (f32, f32) = (854.0, 480.0);
fn rows_of(s: &Settings) -> Vec<Row<ExtraRow>> {
let sheets = ExtrasScreen.sheets(s);
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
groups.flat_map(|group| group.rows).collect()
}
/// Greyed exactly while its own switch is off, so it takes no press then.
#[test]
fn a_setting_of_a_switch_that_is_off_is_greyed_and_takes_no_press() {
let mut s = Settings::default();
let live = |s: &Settings| {
let wheel = ExtraRow::Item(Item::MouseTweaksWheel);
rows_of(s).iter().find(|r| r.id == wheel).unwrap().live
};
s.extras.mouse_tweaks = false;
assert!(!live(&s));
let mut shell = Shell::<ExtrasScreen>::default();
let row = ExtraRow::Item(Item::MouseTweaksWheel);
let at = shell.point_at(&s, SIZE, row);
let before = s.extras.mouse_tweaks_wheel;
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert_eq!(s.extras.mouse_tweaks_wheel, before);
s.extras.mouse_tweaks = true;
assert!(live(&s));
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert_ne!(s.extras.mouse_tweaks_wheel, before);
}
#[test]
fn the_three_way_cosmetics_row_turns_both_ways() {
use horizon_cosmetics::prefs::Others;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::CosmeticsOthers);
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
assert_eq!(s.extras.effective_others(), Others::Hidden);
screen.press(&mut s, row, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
}
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::SortKey);
let key = s.extras.sort_key.clone();
assert_eq!(
screen.press(&mut s, row, false),
vec![Output::BindKey(extras::SORT_KEY)]
);
screen.wheel_step(&mut s, row, true);
assert_eq!(s.extras.sort_key, key);
}
#[test]
fn the_wheel_flips_a_switch_and_leaves_the_mod_to_scroll() {
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::Sort);
assert_eq!(
screen.wheel_step(&mut s, row, true),
Some(vec![Output::Event(Event::Changed)])
);
assert!(s.extras.sort);
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
}
}
+24 -2
View File
@@ -6669,6 +6669,7 @@ impl Renderer {
post,
self.frame_format,
world,
&std::sync::atomic::AtomicBool::new(false),
)
});
self.pack_loading = None;
@@ -6785,6 +6786,8 @@ impl Renderer {
.map(|f| f.draw_layout.clone());
let terrain = self.pack_atlas.is_some();
let clouds = self.cloud_bindings.clone();
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancelled = cancel.clone();
let build = move || {
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let vertex = wgpu::VertexBufferLayout {
@@ -6819,7 +6822,7 @@ impl Renderer {
weather: true,
hand: true,
};
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world)
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world, &cancelled)
};
// A pack is not the game's code: a panic building it turns the pack off (#3525).
let thread = std::thread::Builder::new()
@@ -6834,9 +6837,15 @@ impl Renderer {
thread,
label,
started,
_cancel: CancelOnDrop(cancel),
});
}
Err(e) => log::error!("shader pack {label}: no thread to build it on: {e}"),
Err(e) => {
log::error!("shader pack {label}: no thread to build it on: {e}");
// Off for this dimension, as a pack that fails, not tried again every frame.
self.pack_loading = None;
self.pack_post = Some((dimension, None));
}
}
}
/// The pack `load_shader_pack` builds, once its thread is done: it runs from now on, as
@@ -14563,8 +14572,12 @@ mod shadow_caster_tests {
/// A `shaders/` pack building on its own thread (`Renderer::load_shader_pack`): the dimension it
/// is for, the thread (a panic in it contained), and what the game named it with and when.
/// Dropping it (another pack, none, or another dimension) cancels the build: the thread stops at
/// the next program instead of compiling the rest for nothing.
struct PackLoading {
dimension: i32,
/// Held to be dropped with it: that cancels the build.
_cancel: CancelOnDrop,
#[allow(clippy::type_complexity)]
thread: std::thread::JoinHandle<
std::result::Result<std::result::Result<pack_post::PackPost, pack_post::Refused>, String>,
@@ -14572,3 +14585,12 @@ struct PackLoading {
label: String,
started: std::time::Instant,
}
/// Sets its flag when dropped: `PackLoading`'s cancel, which `PackPost::new` checks per program.
struct CancelOnDrop(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelOnDrop {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
@@ -149,6 +149,7 @@ void main() {
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
&Default::default(),
)
.expect("the pack runs");
let mut pass = post
+357 -69
View File
@@ -381,12 +381,13 @@ impl Gpu {
}
}
/// A pass's pipeline: a draw, or a compute dispatch.
/// A pass's pipeline: a draw, or a compute dispatch. Its slot is filled when the pack's
/// pipelines are compiled (`compile`), all of them at once once the pack is laid out.
#[derive(Clone)]
enum Pipeline {
Render(wgpu::RenderPipeline),
Render(Slot<wgpu::RenderPipeline>),
Compute {
pipeline: wgpu::ComputePipeline,
pipeline: Slot<wgpu::ComputePipeline>,
/// `workGroups`, else `workGroupsRender` and the local size.
groups: Option<[u32; 3]>,
render: [f32; 2],
@@ -401,7 +402,7 @@ struct Pass {
pipeline: Pipeline,
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
@@ -472,7 +473,7 @@ impl Pass {
fn render(&self) -> &wgpu::RenderPipeline {
match &self.pipeline {
Pipeline::Render(p) => p,
Pipeline::Render(p) => p.get().expect("compiled with its pack"),
Pipeline::Compute { .. } => unreachable!("a compute pass draws nothing"),
}
}
@@ -515,6 +516,7 @@ struct Targets {
}
/// What a pack's gbuffers programs read and draw from the game's world.
#[derive(Clone)]
pub(super) struct World<'a> {
/// The block atlas and its size.
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
@@ -1243,6 +1245,7 @@ fn block_ids(
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
/// (flat.rs), the layout of their bind group 1.
#[derive(Clone)]
pub(super) struct Geometry<'a> {
pub buffers: &'a [Option<wgpu::VertexBufferLayout<'a>>],
pub table: Option<&'a wgpu::BindGroupLayout>,
@@ -1286,6 +1289,177 @@ struct Resources<'a> {
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
/// unset): the translator's helpers blend a gather of it (`hz_depth_linear`).
shadow_linear: u8,
/// While the pack is laid out for `compile`: the pipelines its programs need. `None`
/// compiles each as its program is built.
jobs: Option<std::cell::RefCell<Vec<Job>>>,
}
impl Resources<'_> {
/// The slot of the render pipeline `job` makes: filled now, or by `compile` when the pack
/// is laid out.
fn render_pipeline(&self, device: &wgpu::Device, job: RenderJob) -> Slot<wgpu::RenderPipeline> {
let slot = Slot::default();
let job = Job::Render(slot.clone(), job);
match &self.jobs {
Some(jobs) => jobs.borrow_mut().push(job),
None => job.create(device),
}
slot
}
/// As `render_pipeline`, for a compute pipeline.
fn compute_pipeline(
&self,
device: &wgpu::Device,
job: ComputeJob,
) -> Slot<wgpu::ComputePipeline> {
let slot = Slot::default();
let job = Job::Compute(slot.clone(), job);
match &self.jobs {
Some(jobs) => jobs.borrow_mut().push(job),
None => job.create(device),
}
slot
}
}
/// A pipeline, made once: `Pipeline`'s, filled by its `Job`.
type Slot<T> = std::sync::Arc<std::sync::OnceLock<T>>;
/// What a render pipeline of a pack's program is made of, kept to make it later.
#[derive(Clone)]
struct RenderJob {
label: String,
layout: wgpu::PipelineLayout,
vs: wgpu::ShaderModule,
fs: wgpu::ShaderModule,
/// The vertex buffers: stride, step mode and attributes.
buffers: Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>>,
primitive: wgpu::PrimitiveState,
depth_stencil: Option<wgpu::DepthStencilState>,
targets: Vec<Option<wgpu::ColorTargetState>>,
}
/// What a compute pipeline of a pack's program is made of.
struct ComputeJob {
label: String,
layout: wgpu::PipelineLayout,
module: wgpu::ShaderModule,
}
/// A pipeline to make, and the slot it goes in.
enum Job {
Render(Slot<wgpu::RenderPipeline>, RenderJob),
Compute(Slot<wgpu::ComputePipeline>, ComputeJob),
}
impl Job {
/// Makes the pipeline (the driver compiles its shaders here) into its slot.
fn create(&self, device: &wgpu::Device) {
match self {
Job::Render(slot, j) => {
let buffers: Vec<Option<wgpu::VertexBufferLayout>> = j
.buffers
.iter()
.map(|b| {
b.as_ref()
.map(|(stride, step, attributes)| wgpu::VertexBufferLayout {
array_stride: *stride,
step_mode: *step,
attributes,
})
})
.collect();
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&j.label),
layout: Some(&j.layout),
vertex: wgpu::VertexState {
module: &j.vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &buffers,
},
primitive: j.primitive,
depth_stencil: j.depth_stencil.clone(),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &j.fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &j.targets,
}),
multiview_mask: None,
cache: None,
});
let _ = slot.set(pipeline);
}
Job::Compute(slot, j) => {
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(&j.label),
layout: Some(&j.layout),
module: &j.module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let _ = slot.set(pipeline);
}
}
}
fn label(&self) -> &str {
match self {
Job::Render(_, j) => &j.label,
Job::Compute(_, j) => &j.label,
}
}
}
/// Makes the pipelines of `jobs` on as many threads as the machine has cores (up to 8): the
/// driver compiles a big pack's programs for tens of seconds one at a time, on one core. The
/// first one wgpu or the driver refuses, with why; `None` when all are made.
///
/// A refusal doesn't stop the others: every job is made, so that the serial rebuild `new` then
/// runs finds the driver's shader cache warm instead of compiling the rest cold, one at a time.
/// `cancel` set stops handing out jobs: the caller then drops the pack (`PackPost::new`).
fn compile(
device: &wgpu::Device,
jobs: &[Job],
cancel: &std::sync::atomic::AtomicBool,
) -> Option<(String, String)> {
use std::sync::atomic::{AtomicUsize, Ordering};
let threads = std::thread::available_parallelism()
.map_or(1, |n| n.get())
.clamp(1, 8)
.min(jobs.len().max(1));
let next = AtomicUsize::new(0);
let refused = std::sync::Mutex::new(None);
std::thread::scope(|scope| {
for _ in 0..threads {
scope.spawn(|| {
while !cancel.load(Ordering::Relaxed)
&& let Some(job) = jobs.get(next.fetch_add(1, Ordering::Relaxed))
{
if let Err(e) = checked(device, || job.create(device)) {
let mut refused = refused.lock().unwrap_or_else(|e| e.into_inner());
refused.get_or_insert((job.label().to_owned(), e));
}
}
});
}
});
refused.into_inner().unwrap_or_else(|e| e.into_inner())
}
/// Vertex buffer layouts owned, for a `RenderJob`.
fn owned_buffers(
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
) -> Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>> {
buffers
.iter()
.map(|b| {
b.as_ref()
.map(|b| (b.array_stride, b.step_mode, b.attributes.to_vec()))
})
.collect()
}
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
@@ -1894,60 +2068,47 @@ fn build_pass(
packed::quantise_outputs(&mut fragment, &packed_outputs);
packed::bitcast_outputs(&mut fragment, &int_outputs);
let (vs, fs) = (module(program.vertex), module(fragment));
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or(&[], |g| g.buffers),
let job = RenderJob {
label: p.name.clone(),
layout: pipeline_layout,
vs,
fs,
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or_else(Vec::new, |g| owned_buffers(g.buffers)),
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets,
}),
multiview_mask: None,
cache: None,
})
}),
targets: targets.to_vec(),
};
let pipeline = descriptor(&targets);
// The program's own blend replaced by vanilla's for the part; a buffer's own
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
// buffer only, with a probe pack of ours).
// buffer only, with a probe pack of ours). Two more pipelines of the program, made
// with the others (`compile`).
let sky_parts = p.vanilla_sky_blends.then(|| {
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
let targets: Vec<_> = (targets.iter().enumerate())
let targets: Vec<_> = (job.targets.iter().enumerate())
.map(|(l, t)| {
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
t.clone().map(|t| wgpu::ColorTargetState {
@@ -1956,10 +2117,16 @@ fn build_pass(
})
})
.collect();
descriptor(&targets)
pack.render_pipeline(
device,
RenderJob {
targets,
..job.clone()
},
)
})
});
(layout, pipeline, sky_parts)
(layout, pack.render_pipeline(device, job), sky_parts)
});
let (layout, pipeline, sky_parts) = match created {
Ok(created) => created,
@@ -2099,15 +2266,12 @@ fn build_compute(
label: Some(&name),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(&name),
layout: Some(&pipeline_layout),
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
(layout, pipeline)
let job = ComputeJob {
label: name.clone(),
layout: pipeline_layout,
module,
};
(layout, pack.compute_pipeline(device, job))
});
let (layout, pipeline) = match created {
Ok(created) => created,
@@ -2176,17 +2340,73 @@ pub struct Refused {
pub notes: Vec<String>,
}
impl Refused {
/// The reason of a build stopped by its `cancel` flag (`PackPost::new`).
const CANCELLED: &str = "cancelled";
fn cancelled(notes: Vec<String>) -> Self {
Self {
reason: Self::CANCELLED.into(),
notes,
}
}
/// Whether the build was cancelled (another pack or dimension asked for): not the pack's
/// fault, so nothing to report to the player.
pub fn is_cancelled(&self) -> bool {
self.reason == Self::CANCELLED
}
}
impl PackPost {
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
///
/// The pack is laid out first and its pipelines are made after, all at once (`compile`).
/// When wgpu or the driver refuses one, the pack is built again a program at a time, so that
/// the program is skipped with its note as it always was.
///
/// `cancel` set (the pack isn't wanted any more) stops the build between programs: a refusal
/// whose `is_cancelled` is true.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
post: Post,
screen: wgpu::TextureFormat,
world: World<'_>,
cancel: &std::sync::atomic::AtomicBool,
) -> Result<Self, Refused> {
use std::sync::atomic::Ordering;
let spare = (post.clone(), world.clone());
let (built, jobs) = Self::build(device, queue, adapter, post, screen, world, true, cancel)?;
let refused = compile(device, &jobs, cancel);
// Some pipelines may be left unmade: the pack can't draw.
if cancel.load(Ordering::Relaxed) {
return Err(Refused::cancelled(built.notes));
}
let Some((program, refused)) = refused else {
return Ok(built);
};
log::warn!(
"shader pack: {program} was refused ({refused}): building it again a program at a time"
);
drop((built, jobs));
let (post, world) = spare;
Self::build(device, queue, adapter, post, screen, world, false, cancel)
.map(|(built, _)| built)
}
/// `new`'s work: with `defer`, the pipelines are left to make (the jobs returned).
#[allow(clippy::too_many_arguments)]
fn build(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
mut post: Post,
screen: wgpu::TextureFormat,
world: World<'_>,
) -> Result<Self, Refused> {
defer: bool,
cancel: &std::sync::atomic::AtomicBool,
) -> Result<(Self, Vec<Job>), Refused> {
// What the pack's shaders need of its storage buffers, before its parts move out.
let minimums = post.buffer_minimums();
let features = device.features();
@@ -2338,8 +2558,13 @@ impl PackPost {
shadow_linear: (0..2)
.filter(|&n| !shadow_config.nearest[n])
.fold(0, |m, n| m | 1 << n),
jobs: defer.then(Default::default),
};
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
// Not wanted any more: the rest is skipped and the build refused below.
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
let in_shadow = p.group.shadow();
let mut compute = std::mem::take(&mut p.compute);
if !gpu.compute {
@@ -2688,6 +2913,9 @@ impl PackPost {
.filter(|p| !skip.split(',').any(|s| s == p.name))
.filter_map(|p| build(p, None))
.collect();
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return Err(Refused::cancelled(notes));
}
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
// Terrain in two parts draws both or neither: one alone would leave the other part's
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
@@ -3021,7 +3249,10 @@ impl PackPost {
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Ok(Self {
let jobs = (pack.jobs.as_ref())
.map(std::cell::RefCell::take)
.unwrap_or_default();
let built = Self {
notes,
setup_pending: true,
passes,
@@ -3190,7 +3421,8 @@ impl PackPost {
u
},
shadow_cache: Default::default(),
})
};
Ok((built, jobs))
}
/// `Renderer::finish_sequence_frame`: the centre depth the finished frame copied.
@@ -3518,6 +3750,7 @@ impl PackPost {
else {
return;
};
let pipeline = pipeline.get().expect("compiled with its pack");
let groups = groups.unwrap_or_else(|| {
let n = |k: usize| (size[k] as f32 * render[k] / local[k] as f32).ceil() as u32;
[n(0), n(1), 1]
@@ -3732,8 +3965,8 @@ impl PackPost {
pass.sky_parts
.as_ref()
.and_then(|[plain, stars]| match part.stage {
"SKY" | "VOID" => Some(plain),
"STARS" => Some(stars),
"SKY" | "VOID" => plain.get(),
"STARS" => stars.get(),
_ => None,
});
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
@@ -5418,6 +5651,57 @@ mod tests {
assert_eq!(used, [1, 2, 3]);
}
/// A pack's pipelines are made together on several threads: each lands in its pass's slot,
/// and one wgpu refuses comes back by name (the pack is then built a program at a time).
#[test]
fn pipelines_compile_together_and_a_refused_one_is_named() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = device.create_pipeline_layout(&Default::default());
let module = |source: &str| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
})
};
let job = |label: &str, module: wgpu::ShaderModule| {
let slot = Slot::default();
let job = ComputeJob {
label: label.into(),
layout: layout.clone(),
module,
};
(slot.clone(), Job::Compute(slot, job))
};
let plain = "@compute @workgroup_size(1) fn main() {}";
let go = std::sync::atomic::AtomicBool::new(false);
let (slots, jobs): (Vec<_>, Vec<_>) = (0..12)
.map(|i| job(&format!("composite{i}"), module(plain)))
.unzip();
// Cancelled before it starts: nothing is made (`new` then drops the pack).
assert_eq!(compile(&device, &jobs, &true.into()), None);
assert!(slots.iter().all(|s| s.get().is_none()));
assert_eq!(compile(&device, &jobs, &go), None);
assert!(slots.iter().all(|s| s.get().is_some()));
// A binding the layout doesn't have: wgpu refuses the pipeline.
let bound = "@group(0) @binding(0) var<storage, read_write> b: array<u32>;\n\
@compute @workgroup_size(1) fn main() { b[0] = 1u; }";
let (_, bad) = job("deferred3", module(bound));
let (slots, mut jobs): (Vec<_>, Vec<_>) =
(0..4).map(|i| job(&format!("c{i}"), module(plain))).unzip();
jobs.insert(2, bad);
let (program, _why) = compile(&device, &jobs, &go).expect("the pipeline is refused");
assert_eq!(program, "deferred3");
// The refusal stops none of the others: they are all made.
assert!(slots.iter().all(|s| s.get().is_some()));
}
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
#[test]
@@ -5507,6 +5791,7 @@ mod tests {
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
jobs: None,
};
let reads = |group: Group| {
let (_, samplers, ..) = bindings(
@@ -5601,6 +5886,7 @@ mod tests {
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
jobs: None,
packed: [None; BUFFERS],
};
let (_, samplers, ..) = bindings(
@@ -5684,6 +5970,7 @@ mod tests {
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
jobs: None,
};
let mut notes = Vec::new();
let (.., images) = bindings(
@@ -5826,6 +6113,7 @@ mod tests {
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
jobs: None,
};
let draws = DrawUniforms::new(&device);
let mut notes = Vec::new();
@@ -99,6 +99,7 @@ fn the_atlas_blends_its_two_nearest_levels() {
weather: false,
hand: false,
},
&Default::default(),
)
.expect("the pack runs");
for p in gpu.render(&mut post, [4, 4]) {
@@ -83,6 +83,7 @@ impl Gpu {
post,
T::Rgba8Unorm,
world,
&Default::default(),
)
.expect("the pack runs");
assert!(
@@ -244,6 +244,7 @@ impl Fixture {
weather: false,
hand: false,
},
&Default::default(),
)
}
@@ -187,6 +187,7 @@ fn pack(
weather: true,
hand: false,
},
&Default::default(),
)
.expect("weather pack runs");
assert!(
@@ -304,6 +304,7 @@ fn pack_with(
weather: false,
hand: false,
},
&Default::default(),
)
.expect("the pack runs");
assert!(post.notes.is_empty(), "{:?}", post.notes);
@@ -1663,6 +1663,7 @@ impl GraphicsUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, cols, y, &view);
}
+30
View File
@@ -1333,6 +1333,36 @@ impl HudAtlas {
}
}
if full {
// Official Recipes artwork from Just Enough Items, under MIT.
for (name, png) in [
(
"button_enabled_v2",
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
),
(
"button_disabled_v2",
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
),
(
"button_highlight_v2",
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
),
(
"icons/arrow_previous_v2",
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
),
(
"icons/arrow_next_v2",
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
),
(
"search_background_v2",
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
),
] {
let (width, height, rgba) = decode_bytes(png)?;
images.push((format!("horizon/jei/{name}"), width, height, rgba));
}
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
// and black quarters.
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

+1
View File
@@ -42,6 +42,7 @@ pub mod dynamic_light;
pub mod effects;
pub mod environment;
pub mod extras;
pub(crate) mod extras_ui;
pub mod fast_hash;
pub mod fluid_render;
pub mod fog;
+200 -198
View File
@@ -12,6 +12,7 @@ use crate::{
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
lang::{self, LanguageInfo},
settings::{SOUND_SOURCES, Settings},
settings_shell as shell,
text::{self, Style, Text},
ui,
};
@@ -293,6 +294,7 @@ impl Opt {
// the namespace the client bundles (assets/horizon/lang).
Opt::PadEnabled => "horizon.options.controller",
Opt::PadVibration => "horizon.options.vibration",
Opt::PadPlayInBackground => "horizon.options.play_in_background",
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
Opt::PadLightbar => "horizon.options.lightbar",
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
@@ -308,6 +310,7 @@ impl Opt {
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
Opt::PadGlyphs => "horizon.options.button_glyphs",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
Opt::ModelPart(i) => [
"options.modelPart.cape",
"options.modelPart.jacket",
@@ -828,7 +831,13 @@ impl Opt {
Opt::JapaneseGlyphs => on_off(s.japanese_glyphs),
Opt::PadEnabled => on_off(s.controller.enabled),
Opt::PadVibration => on_off(s.controller.vibration),
Opt::PadPlayInBackground => on_off(s.controller.play_in_background),
Opt::PadAdaptiveTriggers => on_off(s.controller.adaptive_triggers),
Opt::PadGameplayGuide => tr(match s.controller.guide_detail {
gamepad::GuideDetail::Full => "horizon.options.gameplay_guide.full",
gamepad::GuideDetail::Basic => "horizon.options.gameplay_guide.basic",
gamepad::GuideDetail::Off => "horizon.options.gameplay_guide.off",
}),
Opt::PadInvertX => on_off(s.controller.invert_x),
Opt::PadInvertY => on_off(s.controller.invert_y),
Opt::PadToggleSneak => tr(if s.controller.toggle_sneak {
@@ -1125,6 +1134,9 @@ impl Opt {
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
Opt::PadVibration => s.controller.vibration = !s.controller.vibration,
Opt::PadPlayInBackground => {
s.controller.play_in_background = !s.controller.play_in_background
}
Opt::PadAdaptiveTriggers => {
s.controller.adaptive_triggers = !s.controller.adaptive_triggers
}
@@ -1146,6 +1158,14 @@ impl Opt {
.unwrap_or(0) as u8;
s.controller.glyphs = GlyphStyle::ALL[usize::from(next(i, 4))];
}
Opt::PadGameplayGuide => {
let all = gamepad::GuideDetail::ALL;
let i = all
.iter()
.position(|d| *d == s.controller.guide_detail)
.unwrap_or(0) as u8;
s.controller.guide_detail = all[usize::from(next(i, 3))];
}
Opt::PadKeyboard => {
let all = gamepad::TextKeyboard::ALL;
let i = all
@@ -1619,6 +1639,8 @@ pub struct OptionsUi {
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
pub controller: crate::controller_ui::ControllerUi,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
pub shader: crate::shader_ui::ShaderUi,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
@@ -1651,7 +1673,7 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// The Extras or PvP & HUD items that take a whole row (`wide_extras`), measured
/// The PvP & HUD items that take a whole row (`wide_extras`), measured
/// when the page is drawn.
wide_extras: Vec<crate::extras::Item>,
}
@@ -1697,6 +1719,7 @@ impl Default for OptionsUi {
center_language: false,
graphics: Default::default(),
controller: Default::default(),
extras: Default::default(),
shader: Default::default(),
split_profiles: Default::default(),
calibration: None,
@@ -1765,24 +1788,23 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
Kind::Cycle => button(x, y, w, opt.label(s), Act::Cycle(opt)),
}
}
/// The Extras or PvP & HUD page's rows: its items under their headers.
fn extras_rows(page: Page) -> Vec<Result<crate::extras::Item, &'static str>> {
match page {
Page::Pvp => crate::pvp::rows()
.into_iter()
.map(|r| r.map(crate::extras::Item::Pvp))
.collect(),
_ => crate::extras::rows(),
}
/// The least GUI size a shell screen lays its keys out for, before it has been drawn at its own.
const MIN_KEY_GUI: (f32, f32) = (480.0, 240.0);
/// The PvP & HUD page's rows: its items under their headers.
fn pvp_rows() -> Vec<Result<crate::extras::Item, &'static str>> {
crate::pvp::rows()
.into_iter()
.map(|r| r.map(crate::extras::Item::Pvp))
.collect()
}
/// The items of `page`'s rows whose label, with any of their first values, is wider than a
/// The items of PvP & HUD's rows whose label, with any of their first values, is wider than a
/// half-width button holds (`width` measures text with the screen's font): those take the
/// whole row, so no label slides past its edges, in any language.
fn wide_extras(s: &Settings, page: Page, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
// A button's label keeps 2 px off each edge (`AbstractButton.renderString`'s margin),
// past which it slides back and forth.
let room = 150.0 - 4.0;
extras_rows(page)
pvp_rows()
.into_iter()
.filter_map(Result::ok)
.filter(|&item| {
@@ -2006,6 +2028,9 @@ impl OptionsUi {
if !self.focus_from_keyboard {
self.focus = None;
}
if let Some(shell) = self.shell_mut() {
shell.blur_search();
}
}
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
@@ -2058,7 +2083,10 @@ impl OptionsUi {
}
Page::Controller | Page::PadBinds => self.controller.typing(),
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
_ => self.settings_search_active(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
},
}
}
/// Jump straight to a page (control socket / tests).
@@ -2174,6 +2202,9 @@ impl OptionsUi {
if let Some(at) = Self::shader_page(page) {
self.shader.open(at);
}
if let Some(shell) = self.shell_mut() {
shell.open();
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
@@ -2184,6 +2215,34 @@ impl OptionsUi {
self.studio.refresh();
}
}
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
_ => None,
}
}
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&mut self.extras),
_ => None,
}
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::BindKey(name) => self.selected_key = Some(name),
shell::Output::Push(page) => self.push(page),
shell::Output::Done => events.extend(self.back(s)),
}
}
events
}
/// What the graphics screen's actions do to these options.
fn graphics_events(
&mut self,
@@ -2534,41 +2593,6 @@ impl OptionsUi {
}
rows
}
/// The row that reaches the Zoom mod that replaced the extra: its switch and settings page
/// when it is built in, else a note saying where it is installed (Options > Mods). Zoom
/// became an official native mod, so Extras has no setting of its own for it (#8713).
fn zoom_mod_row(s: &Settings) -> Row {
let Some(i) = s
.mods
.installed
.iter()
.position(|m| m.id == "zoom" && m.native.is_some())
else {
// A note, not a button: Options > Mods already has the one way into the Mods page
// (`every_setting_and_page_is_reached_from_one_place`).
return Row::Note(lang::get("horizon.extras.zoom_mod_install"));
};
let label = || Text::option_name_value(tr("horizon.extras.zoom"), on_off(s.mods.is_on(i)));
let toggle = |width| button(0.0, 0.0, width, label(), Act::ToggleMod(i));
let settings = s.mods.installed[i]
.native
.as_ref()
.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty());
if settings {
Row::Buttons(
toggle(150.0),
Some(button(
0.0,
0.0,
150.0,
tr("horizon.mods.settings"),
Act::ModSettings(i),
)),
)
} else {
Row::Buttons(toggle(310.0), None)
}
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
@@ -2609,7 +2633,7 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => Vec::new(),
Page::Extras | Page::Pvp => {
Page::Pvp => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen; one whose label would run
// past half a row in this language (`wide_extras`) gets the whole row.
@@ -2637,21 +2661,12 @@ impl OptionsUi {
widget
}
};
// PvP & HUD opens with its preset; Extras doesn't repeat it, nor the way there
// (Horizon Settings has it).
// PvP & HUD opens with its preset (Horizon Settings has the way there).
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
let mut rows = Vec::new();
if self.page() == Page::Extras {
// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now;
// Extras keeps a way to the one players look for here (#8713).
rows.push(Self::zoom_mod_row(s));
}
if self.page() == Page::Pvp {
// The editor moves what the game shows: only from a world.
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
let edit = if self.in_world { edit } else { inactive(edit) };
rows.push(Row::Buttons(preset, Some(edit)));
}
// The editor moves what the game shows: only from a world.
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
let edit = if self.in_world { edit } else { inactive(edit) };
let mut rows = vec![Row::Buttons(preset, Some(edit))];
let mut section = Vec::new();
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
let mut half = None;
@@ -2667,7 +2682,7 @@ impl OptionsUi {
}
rows.extend(half.map(|h| Row::Buttons(extra(h, 150.0), None)));
};
for row in extras_rows(self.page()) {
for row in pvp_rows() {
match row {
Err(header) => {
flush(&mut rows, &mut section);
@@ -3649,6 +3664,12 @@ impl OptionsUi {
self.shader.draw(s, batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.selected_key;
let in_world = self.in_world;
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
// The controller picks a tile up between presses: the screen's size from the draw.
@@ -3677,8 +3698,8 @@ impl OptionsUi {
crate::screen_draw::paint(batch, atlas, &draws);
return;
}
if matches!(self.page(), Page::Extras | Page::Pvp) {
self.wide_extras = wide_extras(s, self.page(), |t| atlas.font.width(t));
if self.page() == Page::Pvp {
self.wide_extras = wide_extras(s, |t| atlas.font.width(t));
}
let (widgets, top, bottom, content) = self.layout(s, w, h);
self.size = (w, h);
@@ -4015,6 +4036,22 @@ impl OptionsUi {
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
return self.graphics_events(s, actions);
}
if self.shell().is_some() {
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
winit::event::MouseButton::Right
} else {
winit::event::MouseButton::Left
};
return self.bind_mouse(s, button);
}
let shift = self.shift;
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
shell.click(s, (x, y), size, drag, right, shift)
});
return self.shell_events(s, outputs);
}
if Self::controller_page(self.page()).is_some() {
// Waiting on a button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
@@ -4291,7 +4328,9 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let (shader_slider, shader_pack) = self.shader.release();
let graphics_slider = self.graphics.release() | self.controller.release() | shader_slider;
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
let graphics_slider =
self.graphics.release() | self.controller.release() | shader_slider | shell_slider;
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
@@ -4369,6 +4408,13 @@ impl OptionsUi {
) -> Vec<Event> {
self.size = size;
self.framebuffer = framebuffer;
if self.resource_error.is_some() {
return Vec::new();
}
if let Some(shell) = self.shell_mut() {
let outputs = shell.wheel(s, size, cursor, amount);
return self.shell_events(s, outputs);
}
if Self::graphics_page(self.page()).is_some() {
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
return self.graphics_events(s, actions);
@@ -4460,6 +4506,10 @@ impl OptionsUi {
if self.resource_error.is_some() {
return;
}
if let Some(shell) = self.shell_mut() {
shell.scroll(s, size, amount);
return;
}
if Self::graphics_page(self.page()).is_some() {
self.graphics.scroll(s, size, amount);
return;
@@ -4656,6 +4706,10 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if let Some(shell) = self.shell_mut() {
if shell.typing() {
shell.type_text(text);
}
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
self.graphics.type_text(text);
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
@@ -4693,6 +4747,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
if Self::graphics_page(self.page()).is_some() {
return self.graphics.shortcut(shortcut, clipboard);
}
@@ -4826,6 +4883,14 @@ impl OptionsUi {
}
return events;
}
let gui = (
self.size.0.max(MIN_KEY_GUI.0),
self.size.1.max(MIN_KEY_GUI.1),
);
if let Some(shell) = self.shell_mut() {
let outputs = shell.key(s, key, text, shift, gui);
return self.shell_events(s, outputs);
}
if Self::controller_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
@@ -5184,6 +5249,7 @@ impl OptionsUi {
#[cfg(test)]
mod tests {
use super::*;
use crate::extras_ui::ExtraRow;
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
@@ -5629,6 +5695,9 @@ mod tests {
if ui.controller_open() {
texts.extend(ui.controller.row_names(&s));
}
if ui.page() == Page::Extras {
texts.extend(ui.extras.row_labels(&s));
}
}
texts
})
@@ -5997,7 +6066,7 @@ mod tests {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
ui.show(Page::Pvp);
let (widgets, _, _, _) = ui.layout(&s, 480.0, 480.0);
let extra = widgets
.iter()
@@ -6007,7 +6076,7 @@ mod tests {
WKind::Button {
act: Act::Extra(item),
..
} if !matches!(item, crate::extras::Item::SortKey)
} if item.key_name().is_none()
)
})
.unwrap();
@@ -6053,19 +6122,17 @@ mod tests {
#[test]
fn extras_reaches_the_zoom_mod_that_replaced_the_extra() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
let english = || lang::Language::load(&[], "en_us");
// Without the mod, Extras says where it is installed (a note, not a second way into
// Options > Mods).
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Text { text, .. } if text.plain() == "Zoom: install from Mods"
)),
"Extras has no hint for the Zoom mod"
labels.iter().any(|l| l == "Zoom: install from Mods"),
"Extras has no hint for the Zoom mod: {labels:?}"
);
// Built in, Zoom's settings live on its page: Extras opens that page.
s.mods.installed.push(crate::mod_list::Installed {
@@ -6079,30 +6146,28 @@ mod tests {
buttons: Vec::new(),
}),
});
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::ModSettings(0),
label,
..
} if label.plain() == "Settings"
)),
"Extras doesn't open the Zoom mod's settings"
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
assert!(labels.iter().any(|l| l == "Settings"), "{labels:?}");
// The mod's switch is beside it, so Extras keeps the Zoom setting it had.
assert!(labels.iter().any(|l| l.starts_with("Zoom: ")), "{labels:?}");
let size = (480.0, 480.0);
let on = s.mods.is_on(0);
let at = ui.extras.point_at(&s, size, ExtraRow::Zoom(0));
assert_eq!(
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
vec![Event::ButtonClick, Event::Changed]
);
// The mod's switch is on the same row, so Extras keeps the Zoom setting it had.
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::ToggleMod(0),
label,
..
} if label.plain().starts_with("Zoom")
)),
"Extras doesn't switch the Zoom mod"
assert_ne!(s.mods.is_on(0), on);
let at = ui.extras.point_at(&s, size, ExtraRow::ZoomSettings(0));
assert_eq!(
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
vec![
Event::ButtonClick,
Event::ButtonClick,
Event::NativeSettings(0)
]
);
assert_eq!(ui.page(), Page::NativeMod(0));
}
#[test]
@@ -6446,32 +6511,27 @@ mod tests {
);
assert_eq!(ui.page(), page);
}
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
assert_eq!(
widgets
.iter()
.filter(|w| matches!(
w.kind,
WKind::Button {
act: Act::Extra(_),
..
}
))
.count(),
crate::extras::rows().iter().filter(|r| r.is_ok()).count()
ui.extras.row_labels(&s).len(),
// And the note about the Zoom mod over them.
1 + crate::extras::rows().iter().filter(|r| r.is_ok()).count()
);
let press = |ui: &mut OptionsUi, s: &mut Settings, item: crate::extras::Item| {
let at = ui.extras.point_at(s, size, ExtraRow::Item(item));
ui.click(s, &atlas, at.0, at.1, size, [960, 960], false, false)
};
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Sort)),
press(&mut ui, &mut s, crate::extras::Item::Sort),
vec![Event::ButtonClick, Event::Changed]
);
assert!(s.extras.sort);
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Pregen)),
press(&mut ui, &mut s, crate::extras::Item::Pregen),
vec![Event::ButtonClick, Event::Changed]
);
assert!(s.extras.pregen);
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::SortKey)),
press(&mut ui, &mut s, crate::extras::Item::SortKey),
vec![Event::ButtonClick]
);
assert_eq!(ui.selected_key, Some(crate::extras::SORT_KEY));
@@ -6484,12 +6544,6 @@ mod tests {
crate::extras::Extras::parse(&s.extras.serialize()),
s.extras
);
// In a window too short for the page, the wheel scrolls to its end.
let short = (480.0, 180.0);
ui.scroll(&s, short, -1000.0);
let (_, top, bottom, content) = ui.layout(&s, short.0, short.1);
assert!(content > bottom - top);
assert_eq!(ui.scroll, content - (bottom - top));
assert_eq!(
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None),
vec![Event::Changed]
@@ -6497,6 +6551,24 @@ mod tests {
assert_eq!(ui.page(), Page::Horizon);
}
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
#[test]
fn the_wheel_and_the_stick_scroll_a_page_on_the_shell_alike() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
// Too short for the page.
let short = (480.0, 180.0);
ui.wheel(&mut s, short, (0.0, 0.0), [960, 360], -1.0);
let one = ui.extras.scroll_amount();
assert!(one > 0.0);
ui.scroll_at(&s, short, (0.0, 0.0), -1.0);
assert_eq!(ui.extras.scroll_amount(), 2.0 * one);
ui.scroll_at(&s, short, (0.0, 0.0), 2.0);
assert_eq!(ui.extras.scroll_amount(), 0.0);
}
#[test]
fn classic_graphics_presets_set_the_distances() {
let atlas = atlas();
@@ -7327,7 +7399,7 @@ mod tests {
/// An Extras or PvP & HUD label wider than half a row takes the whole row, so every label
/// fits its button: "Keep channels for UI sounds: Off" did not.
#[test]
fn extras_labels_fit_their_buttons() {
fn pvp_labels_fit_their_buttons() {
// Vanilla's font: 6 px a glyph, a few narrower ones.
let width = |t: &str| {
t.chars()
@@ -7342,9 +7414,9 @@ mod tests {
};
let s = Settings::default();
let mut ui = OptionsUi::default();
for page in [Page::Extras, Page::Pvp] {
ui.stack = vec![Page::Main, page];
ui.wide_extras = wide_extras(&s, page, width);
{
ui.stack = vec![Page::Main, Page::Pvp];
ui.wide_extras = wide_extras(&s, width);
let rows = ui.list_rows(&s);
let buttons: Vec<&Widget> = rows
.iter()
@@ -7358,20 +7430,11 @@ mod tests {
continue;
};
let label = label.plain();
assert!(width(&label) <= b.w - 4.0, "{page:?}: {label} on {}", b.w);
assert!(width(&label) <= b.w - 4.0, "{label} on {}", b.w);
}
// Short ones keep vanilla's two to a row.
assert!(buttons.iter().any(|b| b.w == 150.0), "{page:?}");
assert!(buttons.iter().any(|b| b.w == 150.0));
}
ui.stack = vec![Page::Main, Page::Extras];
ui.wide_extras = wide_extras(&s, Page::Extras, width);
for item in [
crate::extras::Item::UiSoundReserve,
crate::extras::Item::WheelCyclesSettings,
] {
assert!(ui.wide_extras.contains(&item), "{item:?}");
}
assert!(!ui.wide_extras.contains(&crate::extras::Item::AutoReconnect));
}
/// Every setting a player reaches, from the Options screen through every button that opens
@@ -7787,20 +7850,9 @@ mod tests {
s.extras.wheel_cycles_settings = true;
ui.show(Page::Extras);
let size = (480.0, 600.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let sort_wd = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::Sort),
..
}
)
})
.unwrap();
let cursor = (sort_wd.x + 5.0, sort_wd.y + 5.0);
let cursor = ui
.extras
.point_at(&s, size, ExtraRow::Item(crate::extras::Item::Sort));
assert!(!s.extras.sort);
ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
assert!(s.extras.sort);
@@ -8283,55 +8335,5 @@ mod tests {
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 0);
ui.shift = false;
// Also verify Act::Extra on Page::Extras
ui.show(Page::Extras);
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
if let Some(extra_btn) = widgets.iter().find(|wd| {
matches!(
wd.kind,
WKind::Button {
active: true,
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
}) {
// Extras has enough rows to scroll: click the cycle button inside
// the list's viewport, as a player would after scrolling to it.
ui.scroll = (extra_btn.y - top).max(0.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let extra_btn = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
})
.unwrap();
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
use horizon_cosmetics::prefs::Others;
// Not chosen, nothing owned: shows Hidden.
assert_eq!(s.extras.cosmetics_others, None);
assert_eq!(s.extras.effective_others(), Others::Hidden);
// Forward (wraps to Shown), now a choice
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
// Backward with shift
ui.shift = true;
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
// Backward again
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
ui.shift = false;
}
}
}
@@ -830,6 +830,7 @@ pub enum Opt {
/// Horizon's controller settings (`horizon-controller.txt`).
PadEnabled,
PadVibration,
PadPlayInBackground,
PadAdaptiveTriggers,
PadLightbar,
PadLookSensitivity,
@@ -845,6 +846,7 @@ pub enum Opt {
PadTriggerThreshold,
PadGlyphs,
PadKeyboard,
PadGameplayGuide,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// 3D skin layers: the player's outer layer as extruded slabs.
File diff suppressed because it is too large. Load diff
@@ -644,6 +644,7 @@ impl ShaderUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
+51 -5
View File
@@ -1126,18 +1126,29 @@ impl Streaming {
pub fn lod_source(&self) -> Arc<dyn LodSource> {
self.saves.clone()
}
/// Resource packs reloaded (`LevelRenderer.allChanged`): mesh with the new
/// Resource packs reloaded (`LevelExtractor.allChanged`): mesh with the new
/// models and sprites, and rebuild every loaded section. Meshes in flight
/// were made with the old resources; their results are dropped.
pub fn reload_resources(&mut self, world: &mut World, context: Arc<WorkerContext>) {
*self.shared.context.write().unwrap() = context;
self.reload_sections(world);
}
/// `LevelExtractor.allChanged`: discard mesh work made before the reload and
/// rebuild every loaded, lit section.
pub fn reload_sections(&mut self, world: &mut World) {
self.meshing.clear();
self.compile_started.clear();
Self::mark_loaded_sections_dirty(world, &self.lit);
}
/// The resident sections `LevelExtractor.allChanged` queues again. An
/// unlit column will queue itself when its light job completes.
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
for &(x, z) in world.chunks.keys() {
if self.lit.contains(&(x, z)) {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x, sy, z));
}
if !lit.contains(&(x, z)) {
continue;
}
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x, sy, z));
}
}
}
@@ -4462,3 +4473,38 @@ mod cover_tests {
}
}
}
#[cfg(test)]
mod reload_tests {
use super::*;
#[test]
fn reload_marks_every_section_of_loaded_lit_columns() {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
world.chunks.insert((2, -3), Arc::new(Chunk::empty(2, -3)));
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
let lit = FxHashSet::from_iter([(2, -3), (99, 99)]);
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
let sections = world.layout().sections() as i32;
assert_eq!(world.dirty_sections.len(), sections as usize);
for sy in 0..sections {
assert!(world.dirty_sections.contains(&(2, sy, -3)));
assert!(!world.dirty_sections.contains(&(4, sy, 5)));
}
}
#[test]
fn reload_keeps_existing_work_and_ignores_unloaded_lit_columns() {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
world.dirty_sections.insert((4, 3, 5));
let lit = FxHashSet::from_iter([(99, 99)]);
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
assert_eq!(world.dirty_sections.len(), 1);
assert!(world.dirty_sections.contains(&(4, 3, 5)));
}
}
+17 -4
View File
@@ -134,6 +134,10 @@ fn scaling(sprite: &str, r: &Rect) -> NineSlice {
width: r.w,
height: r.h,
border: match sprite {
"horizon/jei/button_enabled_v2"
| "horizon/jei/button_disabled_v2"
| "horizon/jei/button_highlight_v2" => [6; 4],
"horizon/jei/search_background_v2" => [6; 4],
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
@@ -1636,10 +1640,19 @@ impl EditBox {
}
/// Draw the field; `format` styles the visible text (command colouring).
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, format: Option<&Text>) {
self.draw_with_skin(batch, atlas, format, None);
}
pub fn draw_with_skin(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
format: Option<&Text>,
skin: Option<&str>,
) {
self.layout(atlas);
crate::widgets::target_as(
self.frame
.unwrap_or([self.x, self.y, self.width, self.height]),
[self.x, self.y, self.width, self.height],
crate::widgets::Kind::Field,
);
crate::widgets::narratable(|| {
@@ -1651,11 +1664,11 @@ impl EditBox {
.focused(self.focused)
});
if self.bordered {
let sprite = if self.focused {
let sprite = skin.unwrap_or(if self.focused {
"gui/sprites/widget/text_field_highlighted"
} else {
"gui/sprites/widget/text_field"
};
});
nine_slice(
batch,
atlas,
+13 -5
View File
@@ -37,7 +37,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -65,9 +66,9 @@ log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md). The
# anonymous "I'm playing" ping is horizon-client-lib's (`heartbeat.rs`), the `heartbeat` feature.
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
@@ -108,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"]
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
# 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"]
# 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"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs) and joining one.
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
# The anonymous "I'm playing" ping (docs/telemetry.md).
heartbeat = ["horizon-client-lib/heartbeat"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
@@ -5,7 +5,60 @@
"horizon.options.controller": "Controller",
"horizon.options.three_d_skin_layers": "3D Skin Layers",
"horizon.options.vibration": "Vibration",
"horizon.options.play_in_background": "Play in Background",
"horizon.options.adaptive_triggers": "Adaptive Triggers",
"horizon.options.gameplay_guide": "Gameplay Guide",
"horizon.options.gameplay_guide.basic": "Basic",
"horizon.options.gameplay_guide.full": "Full",
"horizon.options.gameplay_guide.off": "Off",
"horizon.controller.guide.use": "Use",
"horizon.controller.guide.open": "Open",
"horizon.controller.guide.ride": "Ride",
"horizon.controller.guide.trade": "Trade",
"horizon.controller.guide.feed": "Feed",
"horizon.controller.guide.place": "Place",
"horizon.controller.guide.eat": "Eat",
"horizon.controller.guide.drink": "Drink",
"horizon.controller.guide.shoot": "Shoot",
"horizon.controller.guide.load": "Load",
"horizon.controller.guide.throw": "Throw",
"horizon.controller.guide.raise": "Raise",
"horizon.controller.guide.equip": "Equip",
"horizon.controller.guide.fish": "Fish",
"horizon.controller.guide.zoom": "Zoom",
"horizon.controller.guide.boost": "Boost",
"horizon.controller.guide.break": "Break",
"horizon.controller.guide.attack": "Attack",
"horizon.controller.guide.spectate": "Spectate",
"horizon.controller.guide.spectator_menu": "Spectator Menu",
"horizon.controller.guide.release_shoot": "Release to Shoot",
"horizon.controller.guide.release_throw": "Release to Throw",
"horizon.controller.guide.release_stop": "Release to Stop",
"horizon.controller.guide.fly": "Fly",
"horizon.controller.guide.fly_up": "Fly Up",
"horizon.controller.guide.fly_down": "Fly Down",
"horizon.controller.guide.swim_up": "Swim Up",
"horizon.controller.guide.swim_down": "Swim Down",
"horizon.controller.guide.glide": "Glide",
"horizon.controller.guide.dismount": "Dismount",
"horizon.controller.guide.stand": "Stand Up",
"horizon.controller.guide.drop": "Drop Item",
"horizon.controller.guide.swap": "Swap Hands",
"horizon.controller.guide.take": "Take",
"horizon.controller.guide.take_half": "Take Half",
"horizon.controller.guide.put": "Put Down",
"horizon.controller.guide.put_one": "Put One",
"horizon.controller.guide.swap_stack": "Swap",
"horizon.controller.guide.quick_move": "Quick Move",
"horizon.controller.guide.drop_stack": "Drop Stack",
"horizon.controller.guide.place_recipe": "Place Recipe",
"horizon.controller.guide.place_recipe_all": "Place All",
"horizon.controller.guide.alternatives": "Alternatives",
"horizon.controller.guide.select": "Select",
"horizon.controller.guide.close": "Close",
"horizon.controller.guide.pages": "Pages",
"horizon.controller.guide.categories": "Categories",
"horizon.controller.guide.tabs": "Tabs",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Look Sensitivity",
"horizon.options.invert_x": "Invert Look X",
@@ -886,6 +939,20 @@
"Hold the button to sneak, or press it once to start and again to stop.": "Hold the button to sneak, or press it once to start and again to stop.",
"How far the left stick moves before you walk. Raise it if you drift.": "How far the left stick moves before you walk. Raise it if you drift.",
"How far a trigger is pulled before it counts as pressed.": "How far a trigger is pulled before it counts as pressed.",
"Shows what your buttons do right now, in play and in inventories.": "Shows what your buttons do right now, in play and in inventories.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Shows only what you can do with what you're looking at, and the main inventory buttons.",
"Doesn't show what your buttons do.": "Doesn't show what your buttons do.",
"Shows the buttons of the controller you're using.": "Shows the buttons of the controller you're using.",
"Always shows Xbox buttons.": "Always shows Xbox buttons.",
"Always shows PlayStation buttons.": "Always shows PlayStation buttons.",
"Always shows Nintendo buttons.": "Always shows Nintendo buttons.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.",
"Always opens Horizon's keyboard.": "Always opens Horizon's keyboard.",
"Always opens Steam's keyboard.": "Always opens Steam's keyboard.",
"A DualSense's light shows your health, from green through amber to red.": "A DualSense's light shows your health, from green through amber to red.",
"A DualSense's light changes with the dimension you're in.": "A DualSense's light changes with the dimension you're in.",
"A DualSense keeps its own light.": "A DualSense keeps its own light.",
"Show the buttons for what you can do, while you play with a controller.": "Show the buttons for what you can do, while you play with a controller.",
"How fast the stick moves the crosshair in inventories.": "How fast the stick moves the crosshair in inventories.",
"How fast the right stick scrolls a list.": "How fast the right stick scrolls a list.",
"How long a held direction waits before it repeats.": "How long a held direction waits before it repeats.",
@@ -899,6 +966,7 @@
"Follow the controller, or always show one family's buttons.": "Follow the controller, or always show one family's buttons.",
"The buttons menus show right now.": "The buttons menus show right now.",
"Rumble when something happens to you.": "Rumble when something happens to you.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "The controller keeps playing while this window isn't focused, and losing focus won't pause.",
"A DualSense's triggers push back on bows and tools.": "A DualSense's triggers push back on bows and tools.",
"What a DualSense's light shows, over USB or Bluetooth.": "What a DualSense's light shows, over USB or Bluetooth.",
"Player profile": "Player profile",
@@ -1,5 +1,9 @@
{
"horizon.extras.header.world": "Mundo",
"horizon.extras.pregen": "Pregeneración de chunks",
"horizon.extras.pregen.tooltip": "Activa /pregen en tus mundos de un jugador y de LAN. Con los trucos activados, usa /pregen start para generar una zona antes de explorarla. No activa el comando en los servidores a los que te unes.",
"horizon.options.controller": "Mando",
"horizon.options.three_d_skin_layers": "Capas de skin 3D",
"horizon.options.vibration": "Vibración",
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
"horizon.options.lightbar": "Barra de luz",
@@ -9,6 +13,7 @@
"horizon.options.sneak_button": "Botón de agacharse",
"horizon.options.move_deadzone": "Zona muerta de movimiento",
"horizon.options.look_deadzone": "Zona muerta de vista",
"horizon.options.trigger_threshold": "Umbral del gatillo",
"horizon.options.button_glyphs": "Símbolos de botones",
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
"horizon.options.chat_delay": "Retardo del chat",
@@ -23,37 +28,99 @@
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Esta función aún no está disponible en Horizon.",
"horizon.options.search": "Buscar ajustes...",
"horizon.studio.record.usage": "Uso: /studio record <start [nombre]|stop|status>",
"horizon.studio.record.started": "Grabación de Studio iniciada: %s",
"horizon.studio.record.saved": "Grabación de Studio guardada: %s",
"horizon.studio.record.error": "Error de grabación de Studio: %s",
"horizon.studio.record.status.active": "Grabación de Studio activa — %s s, %s paquetes, %s KB (%s)",
"horizon.studio.record.status.inactive": "Grabación de Studio inactiva",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Paquetes de shaders",
"horizon.pages.shader_options": "Opciones de shaders",
"horizon.pages.controller": "Ajustes del mando",
"horizon.pages.controller_binds": "Asignaciones del mando",
"horizon.pages.extras": "Extras de Horizon",
"horizon.pages.settings": "Ajustes de Horizon",
"horizon.pages.studio": "Replay Studio",
"horizon.pages.agent": "Agente de IA",
"horizon.split.leave": "Salir",
"horizon.pages.split_settings": "Ajustes de pantalla dividida",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_setup": "Configuración de pantalla dividida",
"horizon.pages.music": "Música",
"horizon.pages.music.menu": "Menú",
"horizon.pages.music.overworld": "Superficie",
"horizon.pages.music.creative": "Creativo",
"horizon.pages.music.underwater": "Bajo el agua",
"horizon.pages.music.end": "El End",
"horizon.pages.music.boss": "Jefe",
"horizon.pages.music.credits": "Créditos",
"horizon.pages.music.overworld_biomes": "Biomas de la Superficie",
"horizon.pages.music.nether_biomes": "Biomas del Inframundo",
"horizon.pages.music.end_biomes": "Biomas del End",
"horizon.pages.music.other": "Otra música",
"horizon.pages.music.empty": "No hay pistas de música: no se han cargado los sonidos del juego.",
"horizon.pages.music.tooltip": "Las pistas integradas, por situación: reproduce una ahora o desactiva cualquiera para que el juego deje de elegirla.",
"horizon.pages.settings.tooltip": "Los ajustes propios de Horizon: gráficos, mandos, extras y mods.",
"horizon.pages.studio.tooltip": "Explora las grabaciones guardadas y sus metadatos.",
"horizon.pages.graphics.tooltip": "Gráficos, sin complicaciones: cada ajuste dice lo que cuesta.",
"horizon.pages.graphics_advanced.tooltip": "Todos los ajustes gráficos, incluidos los de Horizon, con lo que cuesta cada uno.",
"horizon.pages.controller.tooltip": "Mandos: sus botones, palancas, vibración y navegación de menús.",
"horizon.pages.extras.tooltip": "Añadidos opcionales, todos desactivados por defecto: el juego sigue siendo vanilla hasta que actives uno.",
"horizon.pages.agent.tooltip": "Deja que tu propio agente de IA juegue: horizon-ctl y clientes MCP en este equipo.",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.pages.split_setup.tooltip": "Las ventanas y los monitores, y quién juega dónde.",
"horizon.pages.shader_packs.tooltip": "Paquetes de shaders de la carpeta shaderpacks: los propios de Horizon y los del formato shaders/. Se ejecuta uno a la vez.",
"horizon.pages.mods.tooltip": "Mods hechos para Horizon, de la carpeta horizon-mods. Cada uno se ejecuta en un entorno aislado y sigue apagado hasta que lo actives.",
"horizon.pages.open.graphics": "Gráficos...",
"horizon.pages.open.graphics_advanced": "Gráficos avanzados...",
"horizon.pages.open.shader_packs": "Paquetes de shaders...",
"horizon.pages.open.controller": "Ajustes del mando...",
"horizon.pages.open.split_setup": "Pantalla dividida...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agente de IA...",
"horizon.pages.open.horizon_graphics": "Gráficos de Horizon...",
"horizon.pages.open.controller_binds": "Asignaciones del mando...",
"horizon.pages.open.horizon_settings": "Ajustes de Horizon...",
"horizon.pages.open.studio": "Replay Studio...",
"horizon.studio.saved_recordings": "Grabaciones guardadas",
"horizon.studio.empty": "No se han encontrado grabaciones en la carpeta de grabaciones.",
"horizon.studio.empty_hint": "Usa /studio record start <nombre> en un mundo o deja archivos .mcpr allí.",
"horizon.studio.open_folder": "Abrir grabaciones",
"horizon.studio.refresh": "Actualizar",
"horizon.studio.unknown_server": "Desconocido",
"horizon.pages.open.music": "Música...",
"horizon.mods.empty": "No hay mods: pon archivos .hmod en la carpeta horizon-mods",
"horizon.mods.stopped": " (Detenido)",
"horizon.mods.not_built": " (Sin compilar)",
"horizon.mods.dev": " (Desarrollo)",
"horizon.mods.settings": "Ajustes",
"horizon.mods.in_world": " (sal del mundo para cambiarlo)",
"horizon.mods.open_folder": "Abrir carpeta de mods",
"horizon.mods.browse": "Explorar mods...",
"horizon.mods.browse.title": "Explorar mods",
"horizon.mods.browse.search": "Buscar mods...",
"horizon.mods.browse.project": "Mod",
"horizon.mods.browse.loading": "Leyendo el registro de mods de Horizon...",
"horizon.mods.browse.failed": "No se pudo leer el registro de mods: %s",
"horizon.mods.browse.none": "No se ha encontrado ningún mod",
"horizon.mods.browse.has_update": " (actualización)",
"horizon.mods.browse.is_installed": " (instalado)",
"horizon.mods.browse.by": "de %s",
"horizon.mods.browse.installing": "Instalando...",
"horizon.mods.browse.install": "Instalar",
"horizon.mods.browse.update": "Actualizar",
"horizon.mods.browse.installed": "Instalado",
"horizon.mods.browse.open_page": "Abrir página",
"horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Registro de cambios",
"horizon.mods.browse.done": "%s %s se ha instalado y está activado.",
"horizon.mods.browse.install_failed": "No se pudo instalar: %s",
"horizon.mods.switch.worldgen": "cambia la generación del mundo",
"horizon.mods.switch.registries": "añade bloques, objetos u otras entradas del registro",
"horizon.mods.switch.restart": "Reinicia el juego para aplicar %s: añaden bloques, objetos o generación del mundo.",
"horizon.shader_options.profile": "Perfil",
"horizon.agent.copy_details": "Copiar datos de conexión",
"horizon.multiplayer.join": "Unirse",
"horizon.multiplayer.cancel_reconnecting": "Cancelar reconexión",
@@ -64,6 +131,7 @@
"LOD Distance": "Distancia LOD",
"Far Detail": "Detalle lejano",
"Cave Culling": "Oclusión de cuevas",
"GPU Culling": "Descarte en GPU",
"Entity Culling": "Oclusión de entidades",
"Chunk Updates": "Actualizaciones de chunks",
"Upload Budget": "Presupuesto de subida",
@@ -81,8 +149,13 @@
"Peak Brightness": "Brillo máximo",
"Black Level": "Nivel de negro",
"UI Brightness": "Brillo de la interfaz",
"HDR Lighting": "Iluminación HDR",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Las fuentes de luz, el sol y la luz solar brillan más allá del blanco. Desactivado: como SDR.",
"Smooth Gradients": "Degradados suaves",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Fotograma de 16 bits: los cielos y las sombras oscuros no se escalonan. Algo más de VRAM. Próximo inicio.",
"Battery Mode": "Modo batería",
"Graphics API": "API de gráficos",
"Translucent Batching": "Agrupación de translúcidos",
"Performance Overlay": "Superposición de rendimiento",
"Full Detail Distance": "Distancia de detalle completo",
"On": "Activado",
@@ -121,8 +194,12 @@
"Done": "Hecho",
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
"Only applies to asynchronous entity culling.": "Solo se aplica a la oclusión asíncrona de entidades.",
"Turn HDR on to use it.": "Activa el HDR para usarlo.",
"Your GPU or graphics API doesn't support it.": "Tu GPU o API de gráficos no lo admite.",
"Not with software rendering, which runs it on the CPU.": "No con renderizado por software, que lo ejecuta en la CPU.",
"Out of video memory: off until the next start.": "Sin memoria de vídeo: desactivado hasta el próximo inicio.",
"The graphics device was lost: off until the next start.": "Se ha perdido el dispositivo gráfico: desactivado hasta el próximo inicio.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Aún no en DirectX 12: prueba la API de gráficos Vulkan.",
"%s chunks": "%s chunks",
"%s per frame": "%s por fotograma",
"%s MiB/frame": "%s MiB/fotograma",
@@ -145,6 +222,7 @@
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Descarta el terreno cercano en la GPU: menos CPU con mucha distancia de renderizado, más GPU.",
"Hides entities behind opaque blocks.": "Oculta entidades tras bloques opacos.",
"No entity occlusion culling.": "Sin oclusión de entidades.",
"CPU occlusion test runs this frame.": "La CPU comprueba la oclusión en este fotograma.",
@@ -182,21 +260,15 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Límite de 60 FPS (30 en menús o sin foco), la mitad de los hilos: menos consumo.",
"Threads that generate, light and mesh chunks. Next start.": "Hilos que generan, iluminan y mallan chunks. Próximo inicio.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan y, donde falla, DirectX 12 (Windows) u OpenGL (Linux). Próximo inicio.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Agua y cristal en un mismo búfer: menos llamadas de dibujado, algo más de VRAM. Próximo inicio.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.realms.not_signed_in": "Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para jugar en Realms.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
"horizon.pages.shader_options": "Opciones de sombreadores",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.profile": "Perfil",
"Particle Culling": "Particle Culling",
"Draws every particle.": "Draws every particle.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.",
"General": "General",
"Advanced": "Avanzado",
"Undo": "Deshacer",
@@ -223,6 +295,317 @@
"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.title.not_affiliated": "No está afiliado a Mojang ni a Microsoft",
"horizon.extras.header.lighting": "Iluminación",
"horizon.extras.header.third_person": "Tercera persona",
"horizon.extras.header.particles": "Partículas",
"horizon.extras.header.inventory": "Inventario",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.multiplayer": "Multijugador",
"horizon.extras.header.sound": "Sonido",
"horizon.extras.header.menus": "Menús",
"horizon.extras.fullbright": "Fullbright",
"horizon.extras.dynamic_lights": "Luces dinámicas",
"horizon.extras.own_name_tag": "Etiqueta con tu nombre",
"horizon.extras.hide_block_particles": "Ocultar partículas al romper bloques",
"horizon.extras.auto_reconnect": "Reconexión automática",
"horizon.extras.ui_sound_reserve": "Reservar canales para los sonidos de la interfaz",
"horizon.extras.music_page": "Página de música",
"horizon.extras.mouse_tweaks": "Controles de Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "La rueda mueve objetos",
"horizon.extras.sort": "Botones de ordenar",
"horizon.extras.sort_key": "Tecla de ordenar",
"horizon.extras.no_ai": "Sin IA",
"horizon.extras.agent_control": "Control por agente",
"horizon.extras.discord_presence": "Presencia enriquecida de Discord",
"horizon.extras.zoom": "Zoom",
"horizon.extras.zoom_mod_install": "Zoom: instálalo desde Mods",
"horizon.extras.splitscreen": "Pantalla dividida",
"horizon.extras.chat_animation": "Chat fluido",
"horizon.extras.chat_indicator": "Indicador de mensajes",
"horizon.extras.wheel_cycles_list_settings.tooltip": "La rueda sobre un botón de una lista de ajustes cambia su valor. Desactivado, como en vanilla, la rueda desplaza la lista.",
"horizon.extras.wheel_cycles_settings": "La rueda cambia los ajustes",
"horizon.extras.pack_browser": "Explorador de paquetes de recursos",
"horizon.extras.fullbright.tooltip": "Muestra bloques y entidades con luz máxima, incluso en las zonas oscuras.",
"horizon.extras.dynamic_lights.tooltip": "Las antorchas y otras fuentes de luz que llevas, que has soltado o que arden iluminan los bloques a su alrededor.",
"horizon.extras.own_name_tag.tooltip": "Muestra tu nombre sobre tu cabeza en tercera persona, como lo ven los demás jugadores. Se oculta cuando el servidor oculta los nombres de tu equipo o cuando eres invisible.",
"horizon.extras.hide_block_particles.tooltip": "Oculta las partículas de escombros que salen de un bloque roto y se desprenden del que minas. Las demás partículas se mantienen.",
"horizon.extras.auto_reconnect.tooltip": "Vuelve a un servidor tras perder la conexión o tras un reinicio, nunca tras una expulsión o un baneo.",
"horizon.extras.ui_sound_reserve.tooltip": "Los sonidos del mundo dejan libres 16 de los 247 canales de sonido, para que una avalancha de ellos (una gran explosión) no silencie los clics de los menús.",
"horizon.extras.music_page.tooltip": "Música y sonidos gana una página Música...: reproduce cualquier pista integrada ahora o desactiva una para que el juego deje de elegirla. Nada cambia hasta que desactives una pista.",
"horizon.extras.mouse_tweaks.tooltip": "Arrastrar con el botón derecho coloca uno por ranura, arrastrar con el izquierdo reúne el objeto que llevas y Mayús + arrastrar con el izquierdo mueve todas las ranuras.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Abajo envía un objeto de la ranura al otro lado; arriba devuelve uno.",
"horizon.extras.sort.tooltip": "Ordena el inventario, los cofres, los barriles, los cofres del End y las cajas de shulker en el orden de las pestañas del modo creativo, combinando pilas.",
"horizon.extras.sort_key.tooltip": "Ordena la parte que hay bajo el cursor. Haz clic y luego pulsa una tecla; Escape la desasigna.",
"horizon.extras.no_ai.tooltip": "Oculta y desactiva todas las funciones de IA: el control por agente, horizon-ctl y los servidores MCP. También el interruptor del launcher.",
"horizon.extras.agent_control.tooltip": "Tu propio agente de IA puede jugar a este juego mediante horizon-ctl o un cliente MCP, solo en este equipo.",
"horizon.extras.discord_presence.tooltip": "Muestra «Jugando a Horizon» en tu perfil de Discord mientras Discord está abierto: menús, un jugador o multijugador, la dimensión y el tiempo jugado. Nunca el nombre de un mundo ni la dirección de un servidor.",
"Calibrate...": "Calibrar...",
"Calibrate Anyway...": "Calibrar de todos modos...",
"Use Automatic Setup": "Usar la configuración automática",
"Shortcut: %s": "Atajo: %s",
"Next": "Siguiente",
"HDR Calibration %s/%s: %s": "Calibración HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Configura las tres opciones siguientes a pantalla completa, con una imagen de prueba cada vez.",
"A key that opens this screen from the game. Not bound at first.": "Una tecla que abre esta pantalla desde el juego. Sin asignar al principio.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Tu pantalla no indicó cuánto brilla: calíbrala para configurarla.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Ya está configurado para tu pantalla. Calibra solo si el HDR se ve mal.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Devuelve Brillo máximo y Blanco de papel a la configuración de tu pantalla.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Configurado para tu pantalla. Déjalo en Automático salvo que se vea mal.",
"Lower it until the logo just disappears into the black.": "Bájalo hasta que el logo acabe de desaparecer en el negro.",
"Raise it until the logo just disappears into the bright square.": "Súbelo hasta que el logo acabe de desaparecer en el cuadrado brillante.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Haz que la tarjeta sea un blanco cómodo de mirar durante mucho tiempo, con la luz con la que juegas.",
"horizon.splitscreen.profiles.forget": "Olvidar",
"horizon.splitscreen.profiles.create": "Crear",
"horizon.splitscreen.profiles.you": " - tú",
"horizon.splitscreen.profiles.forget_named": "Olvidar a %s",
"horizon.splitscreen.profiles.previous": "Anterior",
"horizon.splitscreen.profiles.next": "Siguiente",
"horizon.splitscreen.profiles.new_button": "Nuevo perfil...",
"horizon.splitscreen.profiles.forget_title": "¿Olvidar el perfil?",
"horizon.splitscreen.profiles.forget_leaves": "Sale de la lista de esta instancia.",
"horizon.splitscreen.profiles.forget_kept": "Se conservan el progreso del mundo y los ajustes.",
"horizon.splitscreen.profiles.new_title": "Nuevo perfil",
"horizon.splitscreen.profiles.name_hint": "Nombre (de 3 a 16 letras, dígitos, _)",
"horizon.splitscreen.profiles.switch_title": "Cambiar de perfil",
"horizon.splitscreen.profiles.offline_note": "Los perfiles sin conexión pertenecen a esta instancia",
"horizon.splitscreen.profiles.move": "Mover dónde juegas",
"horizon.splitscreen.profiles.move_place": "Mover dónde juegas: %s",
"horizon.friends.title": "Jugar con amigos",
"horizon.friends.hint": "Tus amigos pegan el código tras el botón Amigos de la pantalla de título, en Conexión directa o en la página Jugar del launcher.",
"horizon.friends.copied": "Copiado",
"horizon.friends.copy_code": "Copiar código",
"horizon.friends.stop_sharing": "Dejar de compartir",
"horizon.friends.none_yet": "Aún no hay amigos aquí.",
"horizon.friends.here_now": "Aquí ahora: %s",
"horizon.friends.share_intro": "Comparte este mundo con un código de invitación: se abre a la LAN y tus amigos de internet se unen con el código. No interviene ningún servidor nuestro.",
"horizon.friends.start_sharing": "Empezar a compartir",
"horizon.friends.troubleshooting": "Solución de problemas...",
"horizon.friends.stopped": "Se ha dejado de compartir el código de invitación.",
"horizon.friends.short_code_changed": "Alguien intentó varias veces un código corto incorrecto, así que ha cambiado.",
"horizon.friends.wrong_code": "Alguien ha probado un código incorrecto.",
"horizon.friends.relay_stopped": "Demasiados códigos cortos incorrectos: el relé queda apagado hasta que vuelvas a compartir. Tus amigos aún pueden unirse con el código completo.",
"horizon.friends.code_ready": "Tu código de invitación está listo: Menú del juego, Jugar con amigos. Cópialo y envíaselo a tus amigos.",
"horizon.friends.share_failed": "No se pudo compartir el mundo: %s",
"horizon.friends.finding_route": "Buscando cómo pueden llegar tus amigos hasta ti…",
"horizon.osk.shift": "Mayús",
"horizon.osk.space": "Espacio",
"horizon.osk.delete": "Borrar",
"horizon.osk.type": "Escribir",
"horizon.osk.symbols": "Símbolos",
"horizon.osk.close": "Cerrar",
"horizon.extras.chat_animation.tooltip": "Cada línea nueva del chat entra deslizándose desde la izquierda mientras aparece, en lugar de aparecer entera.",
"horizon.extras.chat_indicator.tooltip": "La barra de color a la izquierda de cada línea del chat.",
"horizon.extras.pack_browser.tooltip": "Seleccionar paquetes de recursos gana un botón Añadir que busca en Modrinth e instala un paquete en tu carpeta de paquetes. Desactivado, el juego nunca contacta con Modrinth.",
"horizon.extras.splitscreen.tooltip": "Dentro de un mundo, otro mando puede unirse como jugador nuevo: pulsa A cuando lo pida y luego elige un perfil y dónde jugar.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "un monitor",
"horizon.splitscreen.setup.window": "Ventana %s",
"horizon.splitscreen.setup.new_window": "Nueva ventana",
"horizon.splitscreen.setup.fill_monitor": "Llenar %s",
"horizon.splitscreen.setup.done": "Hecho",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "Activada",
"horizon.splitscreen.setup.off": "Desactivada",
"horizon.splitscreen.setup.player": "Jugador %s",
"horizon.splitscreen.setup.revert": "Revertir",
"horizon.splitscreen.setup.apply": "Aplicar",
"horizon.splitscreen.setup.warning.empty_main": "Nadie juega en la ventana 1: se oculta",
"horizon.splitscreen.setup.warning.empty_close": "Nadie juega en la ventana %s: se cerrará al aplicar",
"horizon.splitscreen.setup.warning.empty_new": "Nadie juega en la ventana %s: no se abrirá",
"horizon.splitscreen.setup.warning.on_main": "La ventana %s ocupa el monitor de la ventana 1: sus jugadores juegan en la ventana 1",
"horizon.splitscreen.setup.warning.unplugged": "El monitor de la ventana %s no está conectado: sus jugadores juegan en la ventana 1",
"horizon.splitscreen.setup.status.off": "La pantalla dividida está desactivada.",
"horizon.splitscreen.setup.status.enable_hint": "Actívala y luego pulsa un botón en otro mando dentro de un mundo.",
"horizon.splitscreen.setup.status.join_hint": "Pulsa A para unirte y elegir un perfil.",
"horizon.splitscreen.setup.status.idle": "Pantalla dividida inactiva",
"horizon.splitscreen.setup.status.alone": "No hay otros jugadores locales.",
"horizon.splitscreen.setup.status.solo_controls": "Los controles individuales y la respuesta del mando no cambian.",
"horizon.splitscreen.setup.window_empty": "Ventana %s: vacía",
"horizon.splitscreen.setup.note.select": "Haz clic en un jugador o arrástralo a una ventana o a un monitor.",
"horizon.splitscreen.setup.note.apply": "Aplica para jugar así; Revertir para deshacer.",
"horizon.splitscreen.setup.note.place": "¿Dónde debería jugar %s?",
"horizon.splitscreen.setup.this_player": "este jugador",
"horizon.splitscreen.setup.switch_disabled": "La pantalla dividida se desactiva cuando los demás se han ido (desde su menú de pausa).",
"horizon.splitscreen.setup.window_place": "Ventana %s (%s de %s)",
"horizon.splitscreen.setup.names": "Nombres",
"horizon.splitscreen.setup.identify": "Identificar",
"horizon.splitscreen.setup.layout.two": "2 compartiendo: %s",
"horizon.splitscreen.setup.layout.three": "3 compartiendo: %s",
"horizon.splitscreen.setup.layout.two.0": "Automático",
"horizon.splitscreen.setup.layout.two.1": "Lado a lado",
"horizon.splitscreen.setup.layout.two.2": "Arriba y abajo",
"horizon.splitscreen.setup.layout.three.0": "Uno ancho arriba",
"horizon.splitscreen.setup.layout.three.1": "Uno alto a la izquierda",
"horizon.splitscreen.setup.layout.three.2": "Lado a lado",
"horizon.splitscreen.setup.help.player": "Haz clic o arrastra a %s para moverlo.",
"horizon.splitscreen.setup.help.layout_auto": "%s (lado a lado cuando la ventana es ancha). Pulsa para cambiar.",
"horizon.splitscreen.setup.help.layout": "%s. Pulsa para cambiar.",
"horizon.splitscreen.setup.help.switch": "Pantalla dividida en este equipo, activada o desactivada.",
"horizon.splitscreen.setup.help.identify": "La vista de cada jugador muestra en qué ventana está.",
"horizon.splitscreen.setup.help.names": "El nombre de cada jugador en la esquina de su vista.",
"horizon.splitscreen.setup.help.revert": "Deshace los cambios aún no aplicados.",
"horizon.splitscreen.setup.help.apply": "Juega como muestra el mapa.",
"horizon.splitscreen.setup.help.unchanged": "Aún no hay cambios.",
"horizon.splitscreen.setup.give_up_pad": "Ceder el mando",
"horizon.splitscreen.prompt.controller_connected": "Mando conectado",
"horizon.splitscreen.prompt.controller_disconnected": "Mando desconectado",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Vuelve a conectarlo o pulsa un botón en otro",
"horizon.splitscreen.prompt.join.one": "%s quiere unirse",
"horizon.splitscreen.prompt.join.many": "%s mandos quieren unirse",
"horizon.splitscreen.prompt.join.yes": "Unirse",
"horizon.splitscreen.prompt.join.as_p1": "Jugar como J1",
"horizon.splitscreen.prompt.join.no": "No",
"horizon.splitscreen.prompt.lost": "%s: mando desconectado",
"horizon.splitscreen.prompt.lost.use_another": "Usar otro",
"horizon.splitscreen.prompt.lost.leave": "Salir",
"horizon.splitscreen.setup.help.give_up_pad": "Tu mando deja de controlarte y se ofrece para unirse (A).",
"horizon.splitscreen.setup.note.held_at": "%s a %s: A lo suelta aquí, B lo devuelve.",
"horizon.splitscreen.setup.note.held": "%s recogido: izquierda/derecha eligen dónde, A lo suelta, B lo devuelve.",
"horizon.splitscreen.online_only": "Este servidor está en modo en línea: los perfiles sin conexión no pueden unirse; cada jugador necesita su propia cuenta de Minecraft.",
"horizon.splitscreen.own_account": "Este servidor requiere la cuenta de Minecraft de cada jugador",
"horizon.splitscreen.local_only": "Otros jugadores solo pueden unirse a este servidor con su propia cuenta de Minecraft: los perfiles sin conexión juegan en tu mundo, en LAN o en un servidor de tu red local.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Oculta modelos tras bloques opacos. Asíncrono comprueba fuera del hilo; las entidades se actualizan.",
"Block Entities": "Entidades de bloque",
"Shulker Boxes": "Cajas de shulker",
"Reuses settled boxes; lid movement stays animated.": "Reutiliza las cajas quietas; el movimiento de la tapa sigue animado.",
"Signs": "Carteles",
"Reuses sign text until text, light, or font changes.": "Reutiliza el texto de los carteles hasta que cambian el texto, la luz o la fuente.",
"Decorated Pots": "Vasijas decoradas",
"Reuses still pots; break wobble and sherd changes redraw.": "Reutiliza las vasijas quietas; el temblor al romperlas y los cambios de fragmentos se redibujan.",
"Bells": "Campanas",
"Reuses still bells; ringing stays animated.": "Reutiliza las campanas quietas; el repique sigue animado.",
"Chests": "Cofres",
"Reuses closed chests; moving lids stay animated.": "Reutiliza los cofres cerrados; las tapas en movimiento siguen animadas.",
"Banners": "Estandartes",
"Reuses poles; flags and pattern layers still update.": "Reutiliza los mástiles; las banderas y las capas del estampado siguen actualizándose.",
"Copper Golem Statues": "Estatuas de gólem de cobre",
"Reuses statue geometry until its block state changes.": "Reutiliza la geometría de la estatua hasta que cambia el estado de su bloque.",
"Leaf Culling": "Descarte de hojas",
"Every leaf face. Vanilla's fancy leaves.": "Todas las caras de las hojas. Las hojas detalladas de vanilla.",
"Distant Plant Dither": "Tramado de plantas lejanas",
"Keeps fine plants in high-res packs from fading far away.": "Evita que las plantas finas de los paquetes de alta resolución se difuminen a lo lejos.",
"Screenshot Quality": "Calidad de las capturas",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Capturas con F2 a este múltiplo de la resolución del juego. Más alto tarda más.",
"Frame Generation": "Generación de fotogramas",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Muestra un fotograma inventado entre cada dos reales: más fluido, algo más de latencia.",
"Shadow Cache": "Caché de sombras",
"Shaders": "Shaders",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Paquetes de shaders, los de Horizon y los de la carpeta shaders/, y sus opciones.",
"Shader packs": "Paquetes de shaders",
"Installed": "Instalados",
"Apply": "Aplicar",
"Reset all options": "Restablecer todas las opciones",
"A Horizon shader pack.": "Un paquete de shaders de Horizon.",
"An OptiFine or Iris shader pack.": "Un paquete de shaders de OptiFine o Iris.",
"In use: press to turn shaders off.": "En uso: pulsa para desactivar los shaders.",
"Press to use it.": "Pulsa para usarlo.",
"Put shader pack .zip files or folders in it.": "Pon en ella archivos .zip o carpetas de paquetes de shaders.",
"Puts every option of this pack back to its default.": "Devuelve todas las opciones de este paquete a sus valores por defecto.",
"Sets many options at once, as the pack groups them.": "Ajusta muchas opciones a la vez, tal como las agrupa el paquete.",
"No shader packs yet: open the pack folder and put some in it.": "Aún no hay paquetes de shaders: abre la carpeta de paquetes y pon algunos en ella.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Elige un paquete de shaders. Elegir el que está en uso desactiva los shaders.",
"Changed options apply with Apply, or when you leave.": "Las opciones cambiadas se aplican con Aplicar, o al salir.",
"Pack settings": "Ajustes del paquete",
"Built-in settings": "Ajustes integrados",
"%s settings": "Ajustes de %s",
"Settings made by the pack's author: they change only this pack.": "Ajustes creados por el autor del paquete: solo cambian este paquete.",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "El preajuste de vanilla: ajusta solo las opciones de vanilla, como hace Minecraft.",
"Fullscreen takes the display over, at the mode below.": "La pantalla completa se apodera del monitor, con el modo de abajo.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "El modo de pantalla que usa la pantalla completa exclusiva. Se aplica al salir.",
"Blurs the world behind menus.": "Desenfoca el mundo tras los menús.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Cómo se muestrean las texturas: más nítidas en ángulo con Anisotrópico.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Hasta dónde llega el filtrado anisotrópico. Más alto es más nítido en ángulo.",
"Only with Anisotropic texture filtering.": "Solo con filtrado anisotrópico de texturas.",
"Shader Packs...": "Paquetes de shaders...",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Elige un paquete de shaders, de Horizon o de la carpeta shaders/, y ajusta sus opciones.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reutiliza el mapa de sombras del paquete cuando coinciden el sol, la vista y el mundo. Desactivado por defecto.",
"Find BlueMap": "Buscar BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "En servidores, busca su mapa web BlueMap para el terreno lejano. Próxima conexión.",
"BlueMap Radius": "Radio de BlueMap",
"How far around you BlueMap tiles are downloaded. Next join.": "Hasta dónde se descargan los mosaicos de BlueMap a tu alrededor. Próxima conexión.",
"Synchronous": "Síncrono",
"Asynchronous": "Asíncrono",
"Hidden Faces": "Caras ocultas",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Descarta las caras de hojas que hay tras dos capas de hojas: más rápido, las mismas copas.",
"Solid": "Sólido",
"Solid Aggressive": "Sólido agresivo",
"Hollow": "Hueco",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Las hojas totalmente dentro de una copa se dibujan sólidas: menos capas transparentes.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Las hojas con un lado abierto también se dibujan sólidas: más rápido, bordes más toscos.",
"Drops faces between leaves: faster, but gaps look through.": "Descarta las caras entre hojas: más rápido, pero se ve a través de los huecos.",
"Hotbar: Previous Slot": "Barra rápida: ranura anterior",
"Hotbar: Next Slot": "Barra rápida: ranura siguiente",
"horizon.agent.connected.one": "Conectado: 1 conexión de agente",
"horizon.agent.connected.many": "Conectado: %s conexiones de agente",
"horizon.agent.connected.one_ago": "Conectado: 1 conexión de agente, último comando hace %s s",
"horizon.agent.connected.many_ago": "Conectado: %s conexiones de agente, último comando hace %s s",
"horizon.agent.waiting": "Esperando a un agente.",
"horizon.agent.off": "Desactivado: los agentes no pueden llegar a este juego.",
"horizon.agent.session_opened": "Abierto para esta sesión por el programa que inició el juego.",
"horizon.agent.socket": "Socket: %s (copia los datos para ver su ruta completa)",
"horizon.agent.local_only": "Solo local: agentes en este equipo, nunca la red.",
"%s nits": "%s nits",
"Auto (%s nits)": "Automático (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
"Entity Culling Precheck": "Comprobación previa de oclusión de entidades",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Ejecuta la comprobación previa del AABB de los mobs tras esta cantidad de mobs cargados. Cero la desactiva.",
"%s mobs": "%s mobs",
"Only applies to asynchronous entity culling.": "Solo se aplica a la oclusión asíncrona de entidades.",
"horizon.extras.repro_keys": "Atajos de grabación",
"horizon.extras.repro_keys.tooltip": "F3+R inicia o detiene una grabación de repro, y F3+E escribe su paquete. Desactivado por defecto: una grabación copia el mundo.",
"horizon.agent.record_session": "Iniciar sesión de grabación",
"horizon.agent.record_stop": "Detener sesión de grabación",
"horizon.agent.export_repro": "Exportar paquete de repro",
"horizon.repro.started": "Grabación iniciada; F3+R la detiene y escribe el paquete.",
"horizon.repro.stopped": "Grabación detenida: %s",
"horizon.repro.bundle": "Paquete de repro: %s",
"horizon.repro.error": "Repro: %s",
"horizon.repro.no_recording": "Aún no hay ninguna grabación que exportar.",
"horizon.repro.replay_started": "Reproducción iniciada: %s",
"horizon.splitscreen.profiles.offline": " (Sin conexión)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Vuelve a iniciar sesión en el launcher",
"horizon.splitscreen.profiles.online": "Microsoft en línea",
"horizon.splitscreen.account_unavailable": "%s está sin conexión. Vuelve a iniciar sesión en el launcher y reinicia el juego.",
"horizon.splitscreen.account_expired": "La sesión de Microsoft de %s ha caducado. Vuelve a iniciar sesión en el launcher y reinicia el juego.",
"Particle Culling": "Descarte de partículas",
"Draws every particle.": "Dibuja todas las partículas.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Omite las partículas fuera de pantalla, subpíxel y ocultas por el terreno.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Omite las partículas fuera de pantalla, subpíxel y ocultas por el terreno con menos trabajo del hilo de renderizado.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Añadir un paquete de recursos desde Modrinth",
"horizon.pack.addWorking": "Consultando Modrinth...",
"horizon.pack.addNone": "No se han encontrado paquetes de recursos",
"horizon.pack.addTitle": "Añadir un paquete de recursos",
"horizon.pack.install": "Instalar",
"horizon.pack.installing": "Instalando",
"horizon.pack.installed": "Instalado",
"horizon.pack.search": "Buscar",
"horizon.pack.searchHint": "Buscar en Modrinth...",
"horizon.pack.filters": "Filtros",
"horizon.pack.filter.realistic": "Realista",
"horizon.pack.filter.simplistic": "Sencillo",
"horizon.pack.filter.vanillaLike": "Estilo vanilla",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "No se pudo añadir el paquete: %s",
"horizon.extras.header.cosmetics": "Cosméticos",
"horizon.extras.cosmetics_mine": "Mis cosméticos",
"horizon.extras.cosmetics_mine.tooltip": "Oculto: todos los jugadores de Horizon te ven sin tus cosméticos, tú incluido. Es el mismo interruptor que el Vestuario del launcher.",
"key.emote": "Rueda de emotes",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Aún no hay emotes",
"horizon.emote_wheel.help": "Suelta o haz clic para reproducir · 1–8 · Esc cierra",
"horizon.extras.cosmetics_others": "Cosméticos de otros jugadores",
"horizon.extras.cosmetics_others.tooltip": "Lo que llevan otros jugadores de Horizon: todo, solo el estilo de sus etiquetas de nombre o nada. Oculto salvo que tengas cosméticos, hasta que elijas. Oculto, el juego nunca pregunta qué llevan.",
"horizon.extras.cosmetics_hide_fx": "Ocultar estelas y auras de otros jugadores",
"horizon.extras.cosmetics_hide_fx.tooltip": "Las estelas y auras de otros jugadores permanecen ocultas; sus demás cosméticos se siguen viendo. Desactivado por defecto.",
"horizon.extras.cosmetics.shown": "Mostrados",
"horizon.extras.cosmetics.hidden": "Ocultos",
"horizon.extras.cosmetics.name_tags": "Solo etiquetas de nombre",
"horizon.moderation.duration.minute": "%s minuto",
"horizon.moderation.duration.minutes": "%s minutos",
"horizon.moderation.duration.hour": "%s hora",
@@ -252,22 +635,266 @@
"horizon.tempban.list.empty": "No hay baneos temporales",
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)",
"Shader Packs...": "Paquetes de shaders...",
"Installed": "Instalados",
"Apply": "Aplicar",
"Reset all options": "Restablecer todas las opciones",
"A Horizon shader pack.": "Un paquete de shaders de Horizon.",
"An OptiFine or Iris shader pack.": "Un paquete de shaders de OptiFine o Iris.",
"In use: press to turn shaders off.": "En uso: pulsa para desactivar los shaders.",
"Press to use it.": "Pulsa para usarlo.",
"Put shader pack .zip files or folders in it.": "Pon en ella archivos .zip o carpetas de paquetes de shaders.",
"Puts every option of this pack back to its default.": "Devuelve todas las opciones de este paquete a sus valores por defecto.",
"Sets many options at once, as the pack groups them.": "Ajusta muchas opciones a la vez, tal como las agrupa el paquete.",
"No shader packs yet: open the pack folder and put some in it.": "Aún no hay paquetes de shaders: abre la carpeta de paquetes y pon algunos en ella.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Elige un paquete de shaders. Elegir el que está en uso desactiva los shaders.",
"Changed options apply with Apply, or when you leave.": "Las opciones cambiadas se aplican con Aplicar, o al salir.",
"Pack settings": "Ajustes del paquete",
"Built-in settings": "Ajustes integrados",
"%s settings": "Ajustes de %s",
"Settings made by the pack's author: they change only this pack.": "Ajustes creados por el autor del paquete: solo cambian este paquete."
"horizon.options.pointer_speed": "Velocidad del puntero en los menús",
"horizon.options.scroll_speed": "Velocidad de desplazamiento en los menús",
"horizon.options.repeat_delay": "Retardo de repetición al navegar",
"horizon.options.repeat_interval": "Intervalo de repetición al navegar",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "En un jugador",
"horizon.extras.cosmetics_solo.tooltip": "Muestra tus cosméticos y la insignia de tu etiqueta de nombre también en tus mundos de un jugador (tu etiqueta con Etiqueta con tu nombre). Desactivado: solo en servidores.",
"horizon.extras.horizon_marks": "Logos de Horizon",
"horizon.extras.horizon_marks.tooltip": "Muestra el logo de Horizon junto a los jugadores de Horizon: un pequeño logo antes del nombre de los demás jugadores que usan Horizon, sobre sus cabezas y en la lista de jugadores. Solo lo ven los jugadores de Horizon: los servidores no reciben nada.",
"horizon.extras.horizon_me": "Mostrarme como Horizon",
"horizon.extras.horizon_me.tooltip": "Muéstrame como jugador de Horizon: los demás jugadores de Horizon ven el logo junto a tu nombre. Activado, el servicio responde a quien pregunte por ti que has jugado a Horizon en las últimas 3 horas; desactivado, nunca te informa.",
"horizon.pages.pvp": "PvP y HUD",
"horizon.pages.pvp.tooltip": "Punto de mira, HUD y ajustes de PvP, todos desactivados por defecto. Nada de aquí muestra lo que vanilla no muestra.",
"horizon.pages.open.pvp": "PvP y HUD...",
"horizon.pvp.header.crosshair": "Punto de mira",
"horizon.pvp.crosshair": "Punto de mira personalizado",
"horizon.pvp.crosshair_shape": "Forma",
"horizon.pvp.crosshair_size": "Tamaño",
"horizon.pvp.crosshair_gap": "Separación",
"horizon.pvp.crosshair_thickness": "Grosor",
"horizon.pvp.crosshair_colour": "Color",
"horizon.pvp.crosshair_outline": "Contorno",
"horizon.pvp.crosshair_dot": "Punto central",
"horizon.pvp.crosshair_hover": "Color sobre entidades",
"horizon.pvp.crosshair.tooltip": "Dibuja tu propio punto de mira en lugar del de vanilla. Sus primeros ajustes se parecen a los de vanilla.",
"horizon.pvp.crosshair_shape.tooltip": "Una cruz, una T, una X, un círculo, un cuadrado o solo un punto.",
"horizon.pvp.crosshair_size.tooltip": "La longitud de cada brazo, en píxeles de la interfaz.",
"horizon.pvp.crosshair_gap.tooltip": "El espacio vacío del centro.",
"horizon.pvp.crosshair_thickness.tooltip": "Lo ancha que es cada línea.",
"horizon.pvp.crosshair_colour.tooltip": "Invertido muestra invertido el color que hay debajo, como hace el punto de mira de vanilla.",
"horizon.pvp.crosshair_outline.tooltip": "Un borde negro fino, para que se vea sobre cualquier fondo.",
"horizon.pvp.crosshair_dot.tooltip": "Un punto en el centro del punto de mira.",
"horizon.pvp.crosshair_hover.tooltip": "Otro color mientras el punto de mira está sobre un mob o un jugador al que podrías golpear, tal como lo sabe el indicador de ataque de vanilla.",
"horizon.pvp.colour.inverted": "Invertido",
"horizon.pvp.colour.white": "Blanco",
"horizon.pvp.colour.red": "Rojo",
"horizon.pvp.colour.orange": "Naranja",
"horizon.pvp.colour.yellow": "Amarillo",
"horizon.pvp.colour.green": "Verde",
"horizon.pvp.colour.cyan": "Cian",
"horizon.pvp.colour.blue": "Azul",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Negro",
"horizon.pvp.shape.cross": "Cruz",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Círculo",
"horizon.pvp.shape.square": "Cuadrado",
"horizon.pvp.shape.dot": "Punto",
"horizon.pvp.outline": "Contorno de bloque personalizado",
"horizon.pvp.outline.tooltip": "Cambia el contorno del bloque que miras.",
"horizon.pvp.outline_colour": "Color",
"horizon.pvp.outline_colour.tooltip": "El color del contorno. Negro al 40 % es el de vanilla.",
"horizon.pvp.outline_opacity": "Opacidad",
"horizon.pvp.outline_opacity.tooltip": "Lo sólido que es el contorno.",
"horizon.pvp.outline_thickness": "Grosor",
"horizon.pvp.outline_thickness.tooltip": "El ancho del contorno, en veces el de vanilla.",
"horizon.pvp.outline_fill": "Relleno",
"horizon.pvp.outline_fill.tooltip": "Tiñe ligeramente las caras del bloque que miras.",
"horizon.pvp.hit": "Color de golpe personalizado",
"horizon.pvp.hit.tooltip": "Cambia el color con el que parpadean los mobs y los jugadores al recibir daño. Rojo al 30 % es el de vanilla.",
"horizon.pvp.hit_colour": "Color",
"horizon.pvp.hit_colour.tooltip": "El color con el que parpadean los mobs y los jugadores heridos.",
"horizon.pvp.hit_opacity": "Opacidad",
"horizon.pvp.hit_opacity.tooltip": "Cuánto los cubre el color.",
"horizon.pvp.header.outline": "Contorno de bloque",
"horizon.pvp.header.hit": "Color de golpe",
"horizon.pvp.hud_armour": "Armadura",
"horizon.pvp.hud_armour.tooltip": "La armadura que llevas, empezando por la cabeza, con los usos que le quedan a cada pieza.",
"horizon.pvp.hud_armour_corner": "Esquina de la armadura",
"horizon.pvp.hud_armour_corner.tooltip": "Dónde se muestra la armadura.",
"horizon.pvp.hud_armour_percent": "Durabilidad en %",
"horizon.pvp.hud_armour_percent.tooltip": "Los usos restantes como porcentaje del máximo de la pieza en lugar de un número.",
"horizon.pvp.hud_effects": "Efectos",
"horizon.pvp.hud_effects.tooltip": "Tus efectos con sus nombres, niveles y tiempo restante, en lugar de los iconos de arriba a la derecha.",
"horizon.pvp.hud_effects_corner": "Esquina de los efectos",
"horizon.pvp.hud_effects_corner.tooltip": "Dónde se muestran los efectos.",
"horizon.pvp.hud_held": "Cantidad del objeto en la mano",
"horizon.pvp.hud_held.tooltip": "Cuántas unidades del objeto que llevas en la mano hay en tu inventario; flechas con arco o ballesta.",
"horizon.pvp.hud_held_corner": "Esquina de la cantidad",
"horizon.pvp.hud_held_corner.tooltip": "Dónde se muestra la cantidad.",
"horizon.pvp.hud_background": "Fondos de los widgets",
"horizon.pvp.hud_background.tooltip": "Una caja oscura detrás de cada widget.",
"horizon.pvp.hud_opacity": "Opacidad del fondo",
"horizon.pvp.hud_opacity.tooltip": "Lo oscuras que son las cajas detrás de los widgets.",
"horizon.pvp.hud_text_colour": "Color del texto",
"horizon.pvp.hud_text_colour.tooltip": "El color del texto de los widgets. La durabilidad sigue yendo de verde a rojo.",
"horizon.pvp.hud_shadow": "Sombra del texto",
"horizon.pvp.hud_shadow.tooltip": "Una sombra bajo el texto de los widgets, como la tiene el texto del HUD de vanilla.",
"horizon.pvp.header.hud": "Widgets del HUD",
"horizon.pvp.corner.top_left": "Arriba a la izquierda",
"horizon.pvp.corner.top_right": "Arriba a la derecha",
"horizon.pvp.corner.bottom_left": "Abajo a la izquierda",
"horizon.pvp.corner.bottom_right": "Abajo a la derecha",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "La latencia que el servidor informa para ti, en milisegundos. Solo multijugador.",
"horizon.pvp.hud_ping_corner": "Esquina del ping",
"horizon.pvp.hud_ping_corner.tooltip": "Dónde se muestra el ping.",
"horizon.pvp.hud_coords": "Coordenadas",
"horizon.pvp.hud_coords.tooltip": "Tu posición en bloques y la dirección a la que miras, como las muestra F3. Se oculta cuando el mundo o el servidor limitan F3.",
"horizon.pvp.hud_coords_corner": "Esquina de las coordenadas",
"horizon.pvp.hud_coords_corner.tooltip": "Dónde se muestran las coordenadas.",
"horizon.pvp.hud_server": "Dirección del servidor",
"horizon.pvp.hud_server.tooltip": "La dirección del servidor en el que juegas. Solo multijugador.",
"horizon.pvp.hud_server_corner": "Esquina del servidor",
"horizon.pvp.hud_server_corner.tooltip": "Dónde se muestra la dirección.",
"horizon.pvp.hud_combo": "Contador de combo",
"horizon.pvp.hud_combo.tooltip": "Los golpes seguidos que el servidor confirmó, hasta que recibes daño o pasan 3 segundos sin ninguno.",
"horizon.pvp.hud_combo_corner": "Esquina del combo",
"horizon.pvp.hud_combo_corner.tooltip": "Dónde se muestra el combo.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Mirando: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "Norte",
"horizon.pvp.hud.direction.south": "Sur",
"horizon.pvp.hud.direction.east": "Este",
"horizon.pvp.hud.direction.west": "Oeste",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Los fotogramas dibujados en el último segundo.",
"horizon.pvp.hud_fps_corner": "Esquina de los FPS",
"horizon.pvp.hud_fps_corner.tooltip": "Dónde se muestran los FPS.",
"horizon.pvp.hud_cps": "Clics por segundo",
"horizon.pvp.hud_cps.tooltip": "Tus clics izquierdo y derecho en el último segundo.",
"horizon.pvp.hud_cps_corner": "Esquina de los clics",
"horizon.pvp.hud_cps_corner.tooltip": "Dónde se muestran los clics por segundo.",
"horizon.pvp.hud_keys": "Teclas y botones",
"horizon.pvp.hud_keys.tooltip": "Tus teclas de movimiento, botones del ratón y salto, iluminados mientras los mantienes, con los clics por segundo.",
"horizon.pvp.hud_keys_corner": "Esquina de las teclas",
"horizon.pvp.hud_keys_corner.tooltip": "Dónde se muestran las teclas.",
"horizon.pvp.hud_clock": "Reloj",
"horizon.pvp.hud_clock.tooltip": "La hora de tu ordenador.",
"horizon.pvp.hud_clock_corner": "Esquina del reloj",
"horizon.pvp.hud_clock_corner.tooltip": "Dónde se muestra el reloj.",
"horizon.pvp.hud_reach": "Alcance",
"horizon.pvp.hud_reach.tooltip": "A qué distancia de tus ojos cayó tu último golpe, durante 3 segundos. Solo mide: tu alcance sigue siendo el de vanilla.",
"horizon.pvp.hud_reach_corner": "Esquina del alcance",
"horizon.pvp.hud_reach_corner.tooltip": "Dónde se muestra el alcance.",
"horizon.pvp.hud_speed": "Velocidad",
"horizon.pvp.hud_speed.tooltip": "A qué velocidad te desplazas por el suelo, en bloques por segundo.",
"horizon.pvp.hud_speed_corner": "Esquina de la velocidad",
"horizon.pvp.hud_speed_corner.tooltip": "Dónde se muestra la velocidad.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Alcance: %s",
"horizon.pvp.hud.speed": "Velocidad: %s b/s",
"horizon.pvp.hud.keys.attack": "Clic izq.",
"horizon.pvp.hud.keys.use": "Clic der.",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Niveles de encantamiento como números",
"horizon.pvp.numeric_enchants.tooltip": "Los tooltips y la mesa de encantamientos muestran Filo 5 en lugar de Filo V.",
"horizon.pvp.header.items": "Objetos",
"horizon.pvp.fire_lower": "Fuego más bajo",
"horizon.pvp.fire_lower.tooltip": "Baja las llamas que ves mientras ardes, para que cubran menos pantalla.",
"horizon.pvp.shield_size": "Tamaño del escudo",
"horizon.pvp.shield_size.tooltip": "El tamaño del escudo en tu mano, en primera persona.",
"horizon.pvp.totem_size": "Tamaño de la animación del tótem",
"horizon.pvp.totem_size.tooltip": "El tamaño del tótem que llena la pantalla cuando te salva.",
"horizon.pvp.glint_colour": "Color del brillo de encantamiento",
"horizon.pvp.glint_colour.tooltip": "El color del brillo de los objetos y armaduras encantados. Desactivado es el morado de vanilla.",
"horizon.pvp.clear_water": "Agua clara",
"horizon.pvp.clear_water.tooltip": "Bajo el agua, ve tan lejos como te deja vanilla tras 30 segundos allí, desde el principio.",
"horizon.pvp.clear_lava": "Lava clara",
"horizon.pvp.clear_lava.tooltip": "En la lava, ve tan lejos como te deja la Resistencia al fuego de vanilla, sin el efecto.",
"horizon.pvp.header.visual": "Ajustes visuales",
"horizon.pvp.view_time": "Hora del día",
"horizon.pvp.view_time.tooltip": "La hora del día que ves: solo el cielo y su luz. La hora del mundo sigue y no se envía nada al servidor.",
"horizon.pvp.view_weather": "Clima",
"horizon.pvp.view_weather.tooltip": "El clima que ves: solo la lluvia, su luz y la niebla. El clima del mundo sigue y no se envía nada al servidor.",
"horizon.pvp.header.view": "Tu vista",
"horizon.pvp.view.world": "La del mundo",
"horizon.pvp.time.morning": "Mañana",
"horizon.pvp.time.noon": "Mediodía",
"horizon.pvp.time.sunset": "Atardecer",
"horizon.pvp.time.midnight": "Medianoche",
"horizon.pvp.weather.clear": "Despejado",
"horizon.pvp.weather.rain": "Lluvia",
"horizon.pvp.weather.thunder": "Tormenta",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Chat",
"horizon.chat.copied": "Mensaje copiado",
"horizon.chat.search": "Buscar en el chat:",
"horizon.pvp.chat_timestamps": "Marcas de tiempo",
"horizon.pvp.chat_timestamps.tooltip": "La hora a la que llegó cada mensaje, delante de él.",
"horizon.pvp.chat_compact": "Chat compacto",
"horizon.pvp.chat_compact.tooltip": "Un mensaje repetido seguido se muestra una vez, con cuántas veces llegó: (x3).",
"horizon.pvp.chat_copy": "Copiar al hacer clic",
"horizon.pvp.chat_copy.tooltip": "Hacer clic en un mensaje del chat abierto copia su texto, salvo que el clic haga otra cosa.",
"horizon.pvp.chat_search": "Búsqueda en el chat",
"horizon.pvp.chat_search.tooltip": "Ctrl+F en el chat abierto: lo que escribas deja solo los mensajes que lo contienen. Intro o Ctrl+F de nuevo lo detiene.",
"horizon.pvp.preset": "Preajuste de PvP",
"horizon.pvp.preset.tooltip": "Activa el punto de mira personalizado, los widgets de armadura y efectos, el sprint y agacharse alternos, el fuego más bajo y, si están instalados, los mods de contador de FPS y de teclas. Tus ajustes de cada uno se mantienen.",
"horizon.pvp.hud_editor_key": "Tecla del editor de HUD",
"horizon.pvp.hud_editor_key.tooltip": "Abre el editor de HUD desde el juego. Haz clic y luego pulsa una tecla; Escape la desasigna.",
"horizon.pvp.editor": "Editar HUD",
"horizon.pvp.editor.tooltip": "Mueve los widgets sobre el juego: arrástralos a cualquier parte, cambia su tamaño con la rueda y haz clic derecho en uno para devolverlo a su esquina.",
"horizon.pvp.editor.hint": "Arrastra los widgets · Rueda: tamaño · Clic derecho: a su esquina",
"horizon.pvp.combat_feel": "Sensación de combate",
"horizon.pvp.combat_feel.tooltip": "Legacy dibuja las peleas como en 1.8.9: el objeto en la mano, su balanceo, comer, el arco y una espada alzada mientras mantienes usar, sin la bajada tras cada golpe ni el indicador de ataque. Solo el aspecto: lo que envías al servidor no cambia.",
"horizon.pvp.combat_indicator": "Indicador de ataque en Legacy",
"horizon.pvp.combat_indicator.tooltip": "Mantiene el indicador de ataque en Legacy, que 1.8.9 no tenía.",
"horizon.pvp.combat_feel.modern": "Moderno",
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
"horizon.pvp.header.combat": "Sensación de combate",
"horizon.pvp.combat_sounds": "Sonidos de golpe de 1.8",
"horizon.pvp.combat_sounds.tooltip": "En Legacy, omite los sonidos de ataque que añadió 1.9 (fuerte, débil, barrido, crítico...). Los sonidos de daño se mantienen.",
"horizon.pvp.combat_rules": "Combate de 1.8 en los mundos que alojas",
"horizon.pvp.combat_rules.tooltip": "El modo un jugador y los mundos que abres a la LAN pelean con las reglas de 1.8.9: sin enfriamiento de ataque ni barrido, daño de arma de 1.8, Fuerza, Filo y golpes críticos. Los servidores a los que te unes mantienen sus propias reglas.",
"Bindings": "Asignaciones",
"Play": "Jugar",
"Device": "Dispositivo",
"Look": "Vista",
"Movement": "Movimiento",
"Menus": "Menús",
"Actions": "Acciones",
"Reset": "Restablecer",
"Detected": "Detectado",
"Glyphs": "Símbolos",
"Feedback": "Respuesta",
"Controller": "Mando",
"How the sticks turn and move you, and how menus repeat and scroll.": "Cómo te giran y te mueven las palancas, y cómo repiten y desplazan los menús.",
"Which button does what. Each player has their own.": "Qué hace cada botón. Cada jugador tiene las suyas.",
"The controller the game sees, its button glyphs and its feedback.": "El mando que ve el juego, sus símbolos de botones y su respuesta.",
"Press this row, then the button that should do it.": "Pulsa esta fila y luego el botón que deba hacerlo.",
"Off, the game ignores every controller for this player.": "Desactivado, el juego ignora todos los mandos de este jugador.",
"How fast the right stick turns the view.": "La rapidez con la que la palanca derecha gira la vista.",
"Turn the view the other way, left and right.": "Gira la vista al revés, de izquierda a derecha.",
"Turn the view the other way, up and down.": "Gira la vista al revés, de arriba abajo.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Cuánto se mueve la palanca derecha antes de que gire la vista. Súbelo si la vista se desplaza sola.",
"Hold the button to sneak, or press it once to start and again to stop.": "Mantén el botón para agacharte, o pulsa una vez para empezar y otra para parar.",
"How far the left stick moves before you walk. Raise it if you drift.": "Cuánto se mueve la palanca izquierda antes de que camines. Súbelo si te desplazas solo.",
"How far a trigger is pulled before it counts as pressed.": "Cuánto hay que apretar un gatillo para que cuente como pulsado.",
"How fast the stick moves the crosshair in inventories.": "La rapidez con la que la palanca mueve el cursor en los inventarios.",
"How fast the right stick scrolls a list.": "La rapidez con la que la palanca derecha desplaza una lista.",
"How long a held direction waits before it repeats.": "Cuánto espera una dirección mantenida antes de repetirse.",
"How long a held direction takes between repeats.": "Cuánto tarda una dirección mantenida entre repeticiones.",
"Which on-screen keyboard opens when a text field is pressed.": "Qué teclado en pantalla se abre al pulsar un campo de texto.",
"Every action back on its default button.": "Devuelve cada acción a su botón por defecto.",
"These settings belong to this player's profile.": "Estos ajustes pertenecen al perfil de este jugador.",
"The controller driving this player, or else the first one connected.": "El mando que controla a este jugador o, si no, el primero conectado.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "El fabricante y el producto USB que informa el sistema. Útil al reportar un mando que falla.",
"The glyph family the game picked from the controller's maker.": "La familia de símbolos que el juego eligió según el fabricante del mando.",
"Follow the controller, or always show one family's buttons.": "Sigue al mando o muestra siempre los botones de una familia.",
"The buttons menus show right now.": "Los botones que muestran los menús ahora mismo.",
"Rumble when something happens to you.": "Vibra cuando te pasa algo.",
"A DualSense's triggers push back on bows and tools.": "Los gatillos de un DualSense ofrecen resistencia con arcos y herramientas.",
"What a DualSense's light shows, over USB or Bluetooth.": "Lo que muestra la luz de un DualSense, por USB o Bluetooth.",
"Player profile": "Perfil de jugador",
"Vendor and product": "Fabricante y producto",
"Detected glyphs": "Símbolos detectados",
"Glyphs in use": "Símbolos en uso",
"Unknown": "Desconocido",
"No controller": "Sin mando",
"Editing: %s": "Editando: %s",
"Press the button for this. %s cancels.": "Pulsa el botón para esto. %s cancela.",
"This button also does: %s": "Este botón también hace: %s",
"horizon.pad.notice.connected.press": "Pulsa para usarlo",
"horizon.recipe_transfer.missing": "Faltan objetos",
"horizon.recipe_transfer.no_room": "Inventario lleno",
"horizon.recipe_transfer.unavailable": "La receta no cabe en este menú",
"horizon.recipe_transfer.unsupported": "El servidor requiere una receta conocida"
}
@@ -0,0 +1,96 @@
//! Brewing parity (tools/test_brewing_parity.py): reads "input reagent
//! candidate" lines of item SNBT and prints the three brewing-slot filters,
//! the matching recipe and its assembled result; "tick" lines build a
//! BrewingStand, run it for their tick count, and print the same state the
//! real BrewingStandBlockEntity.serverTick oracle prints.
//!
//! The first input line is `#resources RESOURCES`; usage: brewing_fixture INPUT OUTPUT
use horizon_client::{
brewing::{Brewing, BrewingStand, brewing_fuel},
item_data::{canon_stack, parse_stack},
items::{ItemStack, Items},
};
use horizon_client_render::providers::Providers;
use horizon_world::loot::PackOverlay;
use std::sync::Arc;
use std::{fmt::Write as _, fs};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
let root = input
.lines()
.find_map(|line| line.strip_prefix("#resources "))
.map(std::path::PathBuf::from)
.ok_or("no #resources line")?;
let items = Items::catalog(&root, &PackOverlay::NONE)?;
let brewing = Brewing::load(&root, &items, &PackOverlay::NONE);
let providers = Providers::load(&root);
let mut out = String::new();
for (n, line) in input
.lines()
.filter(|line| !line.starts_with('#'))
.enumerate()
{
let f: Vec<&str> = line.split(' ').collect();
if f[0] == "tick" {
let ticks: u32 = f[1].parse()?;
let mut slots: [Option<ItemStack>; 5] = Default::default();
for i in 0..5 {
slots[i] = parse_stack(f[2 + i])?;
}
let mut stand = BrewingStand::new();
stand.items = slots;
stand.brew_time = f[7].parse()?;
stand.fuel = f[8].parse()?;
stand.total_brew_time = f[9].parse()?;
stand.total_fuel = f[10].parse()?;
stand.speed = f[11].parse()?;
stand.ingredient = (f[12] != "-").then(|| Arc::<str>::from(f[12]));
let mut brews = 0;
for _ in 0..ticks {
brews += stand.tick(&brewing, &items, &providers).brewed as u32;
}
let bits: String = stand.items[..3]
.iter()
.map(|s| if s.is_some() { '1' } else { '0' })
.collect();
writeln!(
out,
"{n} s0={} s1={} s2={} s3={} s4={} time={} fuel={} total={} totalfuel={} speed={:.3} bits={bits} ing={} brews={brews}",
canon_stack(stand.items[0].as_ref()),
canon_stack(stand.items[1].as_ref()),
canon_stack(stand.items[2].as_ref()),
canon_stack(stand.items[3].as_ref()),
canon_stack(stand.items[4].as_ref()),
stand.brew_time,
stand.fuel,
stand.total_brew_time,
stand.total_fuel,
stand.speed,
stand.ingredient.as_deref().unwrap_or("-"),
)?;
continue;
}
let input = parse_stack(f[0])?;
let reagent = parse_stack(f[1])?;
let candidate = parse_stack(f[2])?;
let potion_slot = candidate.as_ref().is_some_and(|s| brewing.is_bottle(s));
let reagent_slot = candidate.as_ref().is_some_and(|s| brewing.is_reagent(s));
let fuel_slot = candidate
.as_ref()
.is_some_and(|s| brewing_fuel(&items, s).is_some());
let found = match (input.as_ref(), reagent.as_ref()) {
(Some(input), Some(reagent)) => brewing.find(input, reagent),
_ => None,
};
let result = found.map_or_else(|| "-".to_owned(), |r| canon_stack(Some(&r.output)));
let id = found.map_or_else(|| "-".to_owned(), |r| r.id.clone());
writeln!(
out,
"{n} {potion_slot} {reagent_slot} {fuel_slot} {result} {id}"
)?;
}
fs::write(args.get(2).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -24,6 +24,7 @@ mod block_particles_fixture;
mod block_tint_probe;
mod block_transform_fixture;
mod breaking_fixture;
mod brewing_fixture;
mod camera_probe;
mod cauldron_fixture;
mod chat_screen_fixture;
@@ -127,6 +128,7 @@ probes! {
block_entity_tickers_fixture,
block_particles_fixture,
block_transform_fixture,
brewing_fixture,
breaking_fixture,
block_tint_probe,
camera_probe,
@@ -1197,6 +1197,7 @@ pub fn main() -> horizon_client::Result<()> {
others: $snapshot,
dragon: None,
leash_boats: &[],
who_lookup: None,
others_grid: $grid,
others_index: None,
others_villagers: None,
@@ -11,6 +11,10 @@ use horizon_client::{
MobTypes,
data::MobData,
spawn_rules::{self, Dimension, Reason, SpawnLevel},
spawn_selection::{
self, Heightmap, MobType as SelectionMobType, NaturalLevel, SpawnCost, StructureAt,
StructureSpawns, spawn_aabb,
},
},
};
use horizon_core::random::{LegacyRandom, RandomSource};
@@ -44,6 +48,9 @@ struct Column<'a> {
slime: f32,
/// A player position and whether it is survival or adventure.
player: Option<([f64; 3], bool)>,
/// `LevelReader.isUnobstructed` for the obstruction family.
unobstructed: bool,
structure_spawns: StructureSpawns,
}
impl SpawnLevel for Column<'_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
@@ -104,6 +111,73 @@ impl SpawnLevel for Column<'_> {
})
}
}
impl NaturalLevel for Column<'_> {
fn min_y(&self) -> i32 {
-64
}
fn max_y(&self) -> i32 {
320
}
fn ambient_light(&self) -> f32 {
0.0
}
fn has_ceiling(&self) -> bool {
false
}
fn mob_type(&self, _entity: &str) -> Option<SelectionMobType> {
None
}
fn biome_spawns(
&self,
_x: i32,
_y: i32,
_z: i32,
) -> Option<&HashMap<String, Vec<horizon_client::biome::SpawnEntry>>> {
None
}
fn spawn_cost(&self, _entity: &str, _x: i32, _y: i32, _z: i32) -> Option<SpawnCost> {
None
}
fn creature_probability(&self, _x: i32, _y: i32, _z: i32) -> f32 {
0.0
}
fn structures_at(
&self,
_x: i32,
_y: i32,
_z: i32,
_wanted: &dyn Fn(&str) -> bool,
) -> Vec<StructureAt> {
Vec::new()
}
fn structure_spawns(&self) -> &StructureSpawns {
&self.structure_spawns
}
fn nearest_player_positions(&self) -> &[[f64; 3]] {
&[]
}
fn respawn(&self) -> Option<[i32; 3]> {
None
}
fn can_spawn_in_chunk(&self, _cx: i32, _cz: i32) -> bool {
true
}
fn no_collision(&self, _bounds: horizon_core::geometry::Aabb) -> bool {
true
}
fn unobstructed(&self, _bounds: horizon_core::geometry::Aabb) -> bool {
self.unobstructed
}
fn spawn_mobs(&self) -> bool {
true
}
fn heightmap(&self, _kind: Heightmap, _x: i32, _z: i32) -> i32 {
self.surface
}
fn pathfindable_land(&self, _x: i32, _y: i32, _z: i32) -> bool {
false
}
}
/// `name[key=value,...]`.
fn parse_state(spec: &str) -> BlockState {
@@ -213,7 +287,24 @@ pub fn main() -> horizon_client::Result<()> {
biome: get("biome", "minecraft:plains"),
slime: get("slime", "0.0").parse()?,
player,
unobstructed: !get("unobstructed", "1").eq("0"),
structure_spawns: StructureSpawns::default(),
};
if c.get("mode") == Some(&"obstruction") {
let s = kind.species;
let bounds = spawn_aabb(
s,
f64::from(x0) + 0.5,
f64::from(y),
f64::from(z0) + 0.5,
kind.width as f32,
kind.height as f32,
);
let obstruction =
spawn_selection::spawn_obstruction(s, &level, bounds, Some(&[]), x0, y, z0);
let _ = writeln!(out, "{name} {obstruction}");
continue;
}
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
let s = kind.species;
let ok = spawn_rules::is_spawn_position_ok(s, &level, x0, y, z0);
+174 -4
View File
@@ -343,8 +343,17 @@ impl Tree {
out.sort_unstable();
out
}
pub fn find_id(&self, id: &str) -> Option<usize> {
self.by_id.get(id).copied().or_else(|| {
if let Some(rest) = id.strip_prefix("minecraft:") {
self.by_id.get(rest).copied()
} else {
self.by_id.get(full(id).as_ref()).copied()
}
})
}
pub fn get(&self, id: &str) -> Option<&Advancement> {
self.by_id.get(id).map(|&i| &self.list[i])
self.find_id(id).map(|i| &self.list[i])
}
}
@@ -467,7 +476,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
};
Some(Advancement {
id: id.to_owned(),
parent: v["parent"].as_str().map(str::to_owned),
parent: v["parent"].as_str().map(|p| full(p).into_owned()),
display,
criteria,
requirements,
@@ -1652,8 +1661,8 @@ impl Tree {
pending.retain(|&i| {
let parent = match &self.list[i].parent {
None => None,
Some(p) => match self.by_id.get(p) {
Some(&p) if inserted[p] => Some(p),
Some(p) => match self.find_id(p) {
Some(p) if inserted[p] => Some(p),
_ => return true,
},
};
@@ -3101,4 +3110,165 @@ mod tests {
);
assert_eq!(date_millis("not a date"), None);
}
#[test]
fn simulate_progression_advancements() {
let mut tree = Tree::default();
let advs = [
("minecraft:story/root", None),
("minecraft:story/mine_stone", Some("story/root")),
(
"minecraft:story/upgrade_tools",
Some("minecraft:story/mine_stone"),
),
("minecraft:story/smelt_iron", Some("story/upgrade_tools")),
(
"minecraft:story/obtain_armor",
Some("minecraft:story/smelt_iron"),
),
(
"minecraft:story/lava_bucket",
Some("minecraft:story/smelt_iron"),
),
(
"minecraft:story/iron_tools",
Some("minecraft:story/smelt_iron"),
),
(
"minecraft:story/deflect_arrow",
Some("minecraft:story/obtain_armor"),
),
(
"minecraft:story/form_obsidian",
Some("minecraft:story/lava_bucket"),
),
(
"minecraft:story/mine_diamond",
Some("minecraft:story/iron_tools"),
),
(
"minecraft:story/enter_the_nether",
Some("minecraft:story/form_obsidian"),
),
(
"minecraft:story/shiny_gear",
Some("minecraft:story/mine_diamond"),
),
(
"minecraft:story/enchant_item",
Some("minecraft:story/mine_diamond"),
),
(
"minecraft:story/cure_zombie_villager",
Some("minecraft:story/enter_the_nether"),
),
(
"minecraft:story/follow_ender_eye",
Some("minecraft:story/enter_the_nether"),
),
(
"minecraft:story/enter_the_end",
Some("minecraft:story/follow_ender_eye"),
),
];
for (id, parent) in advs {
let a = parse(
id,
&serde_json::json!({
"parent": parent,
"criteria": {"crit": {"trigger": "minecraft:tick"}},
"display": {"icon": {"id": "minecraft:stone"}, "title": "t", "description": "d"}
}),
)
.unwrap();
tree.by_id.insert(id.to_string(), tree.list.len());
tree.list.push(a);
}
for i in 16..124 {
let id = format!("minecraft:custom/{i}");
let a = parse(
&id,
&serde_json::json!({
"criteria": {"crit": {"trigger": "minecraft:tick"}},
"display": {"icon": {"id": "minecraft:stone"}, "title": "t", "description": "d"}
}),
)
.unwrap();
tree.by_id.insert(id, tree.list.len());
tree.list.push(a);
}
fn get_list(tree: &Tree, target: Option<&str>, mode: &str) -> Vec<usize> {
match target {
None => (0..tree.list.len()).collect(),
Some(id) => {
let i = *tree.by_id.get(id).unwrap();
let mut out = Vec::new();
if mode == "until" || mode == "through" {
let mut p = tree.list[i].parent.as_ref();
while let Some(&pi) = p.and_then(|pid| tree.by_id.get(pid)) {
out.push(pi);
p = tree.list[pi].parent.as_ref();
}
}
out.push(i);
if mode == "from" || mode == "through" {
let kids = tree.children();
fn add(kids: &[Vec<usize>], i: usize, out: &mut Vec<usize>) {
for &c in &kids[i] {
out.push(c);
add(kids, c, out);
}
}
add(&kids, i, &mut out);
}
out
}
}
}
fn grant(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
let mut count = 0;
for &i in list {
let a = &tree.list[i];
if prog.done(a) {
continue;
}
for c in &a.criteria {
prog.grant(&a.id, &c.name, "now");
}
count += 1;
}
count
}
fn revoke(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
let mut count = 0;
for &i in list {
let a = &tree.list[i];
let mut any = false;
for c in &a.criteria {
any |= prog.revoke(&a.id, &c.name);
}
if any {
count += 1;
}
}
count
}
let mut p = Progress::default();
let until = get_list(&tree, Some("minecraft:story/smelt_iron"), "until");
assert_eq!(grant(&tree, &mut p, &until), 4);
let from = get_list(&tree, Some("minecraft:story/root"), "from");
assert_eq!(grant(&tree, &mut p, &from), 12);
let through = get_list(&tree, Some("minecraft:story/iron_tools"), "through");
assert_eq!(revoke(&tree, &mut p, &through), 8);
let all = get_list(&tree, None, "everything");
let granted_everything = grant(&tree, &mut p, &all);
assert_eq!(granted_everything, 116);
}
}
+3
View File
@@ -172,6 +172,8 @@ pub struct ArrowState {
pub pickup: Pickup,
/// Its entity type is `SpectralArrow` (the type is its own, not the pickup item's).
pub spectral: bool,
/// `SpectralArrow` saved duration; its constructor default is 200.
pub spectral_duration: i32,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
@@ -240,6 +242,7 @@ impl ArrowState {
last_state: None,
pickup: Pickup::Disallowed,
spectral: false,
spectral_duration: 200,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
+3
View File
@@ -56,6 +56,7 @@ pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
#[cfg(feature = "discord")]
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
@@ -74,6 +75,7 @@ pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
#[cfg(feature = "heartbeat")]
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
@@ -164,6 +166,7 @@ pub use horizon_client_core::text;
pub use horizon_client_input::actions;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::gameplay_guide;
pub use horizon_client_input::held;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+23
View File
@@ -12,6 +12,29 @@ use horizon_core::random::RandomSource;
impl Game<'_> {
/// The client's `Allay.mobInteract` runs before the server's response.
/// Whether `predict_allay_interaction` would act, on a copy of the allay.
pub(super) fn allay_would_take(
&self,
index: usize,
hand: Hand,
offered: Option<ItemStack>,
) -> bool {
let duplicates = offered.as_ref().is_some_and(|s| {
self.items()
.item_tags
.get("minecraft:duplicates_allays")
.is_some_and(|tag| tag.contains(&*s.item))
});
allay::interaction::predict(
&mut self.mobs[index].clone(),
offered.as_ref(),
hand == Hand::Main,
duplicates,
self.acting_uuid(),
)
.is_some()
}
pub(super) fn predict_allay_interaction(
&mut self,
index: usize,
@@ -856,6 +856,8 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
// ArmorStand.kill(level, player).
self.entity_killed_event(at, true);
return true;
}
if time - self.armor_stands[i].last_hit > 5 {
@@ -888,6 +890,8 @@ impl Game<'_> {
];
self.spawn_item(stack, center, v, 10);
}
// ArmorStand.kill(level, player), after the drops.
self.entity_killed_event(at, true);
self.world
.modified
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
@@ -1087,6 +1091,7 @@ impl Game<'_> {
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.entity_killed_event(at, false);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
@@ -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);
});
}
}
+6 -2
View File
@@ -1519,8 +1519,12 @@ impl Game<'_> {
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
}
// `VehicleEntity.destroy(level, dropItem)` drops the boat item on
// the death path only, and only with `entity_drops` on.
// `VehicleEntity.destroy(level, dropItem)`: `kill` (the boat's own
// ENTITY_DIE), then the boat item on the death path only, and only
// with `entity_drops` on.
if !creative {
self.entity_killed_event(at, false);
}
if !creative && self.ui.rules.bool("entity_drops") {
let item = b.entity_type();
self.spawn_item(
+15
View File
@@ -46,6 +46,9 @@ fn book_page_box() -> ui::EditBox {
edit
}
/// Where a book's text is, from its top left corner, above its page buttons.
const BOOK_TEXT: [f32; 4] = [36.0, 16.0, 120.0, 136.0];
pub(super) enum BookScreen {
Edit {
/// Inventory slot of the book (the selected hotbar slot).
@@ -1220,6 +1223,18 @@ impl Game<'_> {
r.v1 = r.v0 + 192.0 * dv;
batch.rect_uv(left, top, 192.0, 192.0, r, u32::MAX);
}
// The page, or the signing title on it: a field to a controller, which opens its keyboard.
if matches!(book, BookScreen::Edit { .. }) {
widgets::target_as(
[
left + BOOK_TEXT[0],
top + BOOK_TEXT[1],
BOOK_TEXT[2],
BOOK_TEXT[3],
],
widgets::Kind::Field,
);
}
let tr = |key: &str, fallback: &str| self.translate(key, fallback);
let page_buttons = |batch: &mut HudBatch, back: bool, forward: bool| {
for (show, x, name) in [
@@ -356,7 +356,7 @@ mod tests {
y,
rot,
name: None,
key: Key::Item,
key: Key::Remote,
};
// The centre, facing north: 8 map pixels wide, nudged by (-0.5, 0.5).
close(
+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)
}
}
+175 -1
View File
@@ -164,6 +164,8 @@ pub struct UiState {
pub difficulty: u8,
/// F1: hide the HUD.
pub hide_gui: bool,
/// Native HUD mode: keep hands visible while the HUD is hidden.
pub show_hand_when_hidden: bool,
/// Death message key set by the cause of death (e.g. /kill).
pub death_cause: Option<String>,
/// Path of an F2 screenshot waiting for its frame.
@@ -183,6 +185,46 @@ pub struct UiState {
}
impl UiState {
pub fn hud_mode(&self) -> super::native_client_services::HudMode {
use super::native_client_services::HudMode;
match (self.hide_gui, self.show_hand_when_hidden) {
(false, _) => HudMode::AllVisible,
(true, true) => HudMode::NoHud,
(true, false) => HudMode::AllHidden,
}
}
pub fn set_hud_mode(&mut self, mode: super::native_client_services::HudMode) {
use super::native_client_services::HudMode;
self.hide_gui = mode != HudMode::AllVisible;
self.show_hand_when_hidden = mode == HudMode::NoHud;
}
pub fn hand_hidden(&self) -> bool {
self.hide_gui && !self.show_hand_when_hidden
}
/// The effective first-person hand gate, before per-hand mod cancellation.
pub fn hands_drawn(
&self,
spectator: bool,
sleeping: bool,
first_person: bool,
alive: bool,
dead_screen: bool,
) -> bool {
!spectator && !self.hand_hidden() && !sleeping && first_person && alive && !dead_screen
}
fn toggle_hide_gui(&mut self) {
use super::native_client_services::HudMode;
self.set_hud_mode(if self.hide_gui {
HudMode::AllVisible
} else {
HudMode::AllHidden
});
}
/// The state a window starts with: the chat remembers the commands kept
/// in the game folder (`ChatComponent`'s constructor).
pub fn new() -> Self {
@@ -194,6 +236,138 @@ impl UiState {
}
}
#[cfg(test)]
mod hud_mode_tests {
use super::UiState;
use crate::mod_api::{self, ClientTickPreEvent, Hooks, Host, HudMode};
#[test]
fn hud_modes_drive_the_effective_hud_and_hand_gate() {
let mut ui = UiState::default();
for (mode, hidden, hand) in [
(HudMode::AllVisible, false, true),
(HudMode::NoHud, true, true),
(HudMode::AllHidden, true, false),
] {
ui.set_hud_mode(mode);
assert_eq!(ui.hud_mode(), mode);
// This is the same flag that every HUD gate and API 410 read.
assert_eq!(ui.hide_gui, hidden);
assert_eq!(ui.hands_drawn(false, false, true, true, false), hand);
// No mode can bypass the other first-person hand restrictions.
assert!(!ui.hands_drawn(true, false, true, true, false));
assert!(!ui.hands_drawn(false, true, true, true, false));
assert!(!ui.hands_drawn(false, false, false, true, false));
assert!(!ui.hands_drawn(false, false, true, false, false));
assert!(!ui.hands_drawn(false, false, true, true, true));
}
}
#[test]
fn vanilla_f1_remains_two_state_and_clears_the_hand_override() {
let mut ui = UiState::default();
assert_eq!(ui.hud_mode(), HudMode::AllVisible);
ui.toggle_hide_gui();
assert_eq!(ui.hud_mode(), HudMode::AllHidden);
assert!(ui.hand_hidden());
ui.toggle_hide_gui();
assert_eq!(ui.hud_mode(), HudMode::AllVisible);
ui.set_hud_mode(HudMode::NoHud);
ui.toggle_hide_gui();
assert_eq!(ui.hud_mode(), HudMode::AllVisible);
ui.toggle_hide_gui();
assert_eq!(ui.hud_mode(), HudMode::AllHidden);
// Existing hidden state also hides hands without a separate setting.
let ui = UiState {
hide_gui: true,
..Default::default()
};
assert_eq!(ui.hud_mode(), HudMode::AllHidden);
assert!(ui.hand_hidden());
}
struct Client(UiState);
impl Host for Client {
fn hud_mode(&self) -> HudMode {
self.0.hud_mode()
}
fn set_hud_mode(&mut self, mode: HudMode) {
self.0.set_hud_mode(mode);
}
}
struct ThreeState;
impl Hooks for ThreeState {
fn client_tick_pre(&self, host: &mut dyn Host, _: &mut ClientTickPreEvent) {
host.set_hud_mode(match host.hud_mode() {
HudMode::AllVisible => HudMode::NoHud,
HudMode::NoHud => HudMode::AllHidden,
HudMode::AllHidden => HudMode::AllVisible,
});
}
}
#[test]
fn generated_dispatch_exposes_hud_mode_helpers_to_existing_callbacks() {
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(ThreeState)];
let mut client = Client(UiState::default());
for (mode, hidden, hand) in [
(HudMode::NoHud, true, true),
(HudMode::AllHidden, true, false),
(HudMode::AllVisible, false, true),
] {
mod_api::dispatch_client_tick_pre(&mut client, &mods);
assert_eq!(client.hud_mode(), mode);
assert_eq!(client.0.hide_gui, hidden);
assert_eq!(client.0.hands_drawn(false, false, true, true, false), hand);
}
mod_api::dispatch_client_tick_pre(&mut client, &[]);
assert_eq!(client.hud_mode(), HudMode::AllVisible);
}
/// Exercise the generated Host bridge against a real Game with silent audio
/// and no window, renderer, server loop or network connection.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn native_hud_mode_game_bridge_respects_client_and_guest_guards() {
use crate::game::{execution_role::ExecutionRole, native_machines::tests::server_game};
let mut game = server_game();
assert!(game.window.is_none());
assert!(game.renderer.is_none());
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(ThreeState)];
for role in [
ExecutionRole::IntegratedServer,
ExecutionRole::DedicatedServer,
] {
game.options.execution_role = role;
mod_api::dispatch_client_tick_pre(&mut game, &mods);
assert_eq!(Host::hud_mode(&game), HudMode::AllVisible);
}
game.options.execution_role = ExecutionRole::Client;
mod_api::dispatch_client_tick_pre(&mut game, &mods);
assert_eq!(Host::hud_mode(&game), HudMode::NoHud);
assert!(game.ui.hide_gui);
assert!(game.ui.hands_drawn(false, false, true, true, false));
// A server callback borrowing another actor cannot change the window's mode.
game.lan_acting = Some(17);
mod_api::dispatch_client_tick_pre(&mut game, &mods);
assert_eq!(Host::hud_mode(&game), HudMode::NoHud);
game.lan_acting = None;
mod_api::dispatch_client_tick_pre(&mut game, &mods);
assert_eq!(Host::hud_mode(&game), HudMode::AllHidden);
assert!(!game.ui.hands_drawn(false, false, true, true, false));
game.toggle_hide_gui();
assert_eq!(Host::hud_mode(&game), HudMode::AllVisible);
Host::set_hud_mode(&mut game, HudMode::NoHud);
game.toggle_hide_gui();
assert_eq!(Host::hud_mode(&game), HudMode::AllVisible);
game.toggle_hide_gui();
assert_eq!(Host::hud_mode(&game), HudMode::AllHidden);
assert!(game.ui.hand_hidden());
}
}
/// The ids `build_registries` lists from the prepared resources' folders.
/// The folders don't change while the game runs, so each root is read once
/// instead of at every world opening (thousands of files).
@@ -1680,7 +1854,7 @@ impl Game<'_> {
}
/// F1 hides the HUD (see `hud`); the screenshot key still works.
pub(super) fn toggle_hide_gui(&mut self) {
self.ui.hide_gui = !self.ui.hide_gui;
self.ui.toggle_hide_gui();
}
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name. The
/// file it will write, `None` without a window to draw.
@@ -294,6 +294,36 @@ impl Game<'_> {
}
}
}
"projectile_saved_type" => {
let uuid = u128::from_str_radix(
&op["uuid"]
.as_str()
.ok_or("projectile type: missing UUID")?
.replace('-', ""),
16,
)?;
let state = self.harness_owner(|g| {
let Some(super::commands::Target::Projectile(id)) = g.target_by_uuid(uuid) else {
return Err("projectile type: missing loaded projectile".into());
};
let actual = *g.persist.uuids.get(&id).ok_or("projectile type: missing actual UUID")?;
if horizon_client::lan::playerdata::ints_uuid(actual) != uuid {
return Err("projectile type: UUID mismatch".into());
}
let p = g.creatures.projectiles.iter().find(|p| p.id == id)
.ok_or("projectile type: missing model")?;
// Read the live save adapter's type, not the generic selector alias.
// No Duration value is supplied by this admission observation.
let saved = horizon_client::entity_nbt::arrow_to_nbt(p, actual, None, None, None)
.ok_or("projectile type: not an actual arrow")?;
Ok(json!({"uuid":actual,"kind":horizon_client::entity_nbt::kind(&saved)
.ok_or("projectile type: missing saved type")?,
"pos":[p.position.x,p.position.y,p.position.z],"no_gravity":p.no_gravity}))
})?;
out.push_str(&json!({"cmd":format!("#projectile_saved_type {}",op["label"].as_str().unwrap_or("")),
"messages":[],"results":[],"state":state}).to_string());
out.push('\n');
}
"raid_state" => {
let state = self.harness_owner(|g| g.raid_fixture_state(&op))?;
let row = json!({"cmd":format!("#raid_state {} {}",op["label"].as_str().unwrap_or(""),op["id"]),
@@ -726,6 +756,12 @@ impl Game<'_> {
);
out.push('\n');
}
"map_carried" => {
self.harness_owner(|g| {
g.tick_maps();
Ok(())
})?;
}
"craft_menu_click" | "craft_menu_state" | "craft_menu_close" => {
let owner = self.serving.as_ref().ok_or("no server")?.owner;
let state = self.in_owner_level(|g| -> Result<Value> {
@@ -824,10 +860,21 @@ impl Game<'_> {
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
let value = self.harness_owner(|g| {
Ok(match g.map(id) {
Some(m) => format!(
"scale={},locked={},center={},{}",
m.scale, m.locked, m.center[0], m.center[1]
),
Some(m) => {
let mut value = format!(
"scale={},locked={},center={},{}",
m.scale, m.locked, m.center[0], m.center[1]
);
if op["red_x"].as_bool().unwrap_or(false) {
let count = m
.decorations
.iter()
.filter(|d| d.sprite == "map/decorations/red_x")
.count();
value.push_str(&format!(",red_x={count}"));
}
value
}
None => "missing".to_owned(),
})
})?;
+137 -21
View File
@@ -93,6 +93,7 @@ pub(in crate::game) struct OtherEntity {
pub position: Vec3,
pub yaw: f32,
pub pitch: f32,
pub item_transformation: Option<super::native_entities::ItemDisplayTransformation>,
}
pub(super) fn err(key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
CommandError::tr(key, args)
@@ -1517,7 +1518,7 @@ impl Game<'_> {
self.feedback(message);
Ok(targets.len() as i32)
}
fn teleport_to(
pub(in crate::game) fn teleport_to(
&mut self,
t: Target,
pos: [f64; 3],
@@ -2281,6 +2282,30 @@ impl Game<'_> {
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
}
fn kill_target_in_level(&mut self, t: Target) {
// Entity.kill (BlockAttachedEntity.kill; ArmorStand's generic_kill hit,
// which bypasses its invulnerability; a vehicle is no LivingEntity):
// removed, then ENTITY_DIE by the entity itself. Mobs post theirs as
// they die, the crystal and the dragon on their own paths, and an item
// entity can dampen it (ItemEntity.dampensVibrations), so it is not here.
let died = matches!(
t,
Target::Stand(_)
| Target::Frame(_)
| Target::Painting(_)
| Target::Boat(_)
| Target::Cart(_)
| Target::Orb(_)
| Target::Projectile(_)
| Target::Fangs(_)
| Target::Tnt(_)
| Target::Falling(_)
| Target::Rocket(_)
| Target::Trident(_)
| Target::Spawner(_)
| Target::Cloud(_)
)
.then(|| self.target_position(t))
.flatten();
match t {
Target::Guest(_) => {
self.for_player(t, |g| g.kill_target(Target::Player));
@@ -2356,6 +2381,9 @@ impl Game<'_> {
}
// The orbs marked for removal are gone (Entity.kill removes them).
self.orbs.retain(|o| o.age < u32::MAX / 2);
if let Some(at) = died {
self.entity_killed_event(at, false);
}
self.native_leaves_flush();
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
@@ -3118,9 +3146,7 @@ impl Game<'_> {
let key = if grant { "grant" } else { "revoke" };
let index = match ctx.get("advancement") {
Some(Arg::Resource(id)) => Some(
*tree
.by_id
.get(id)
tree.find_id(id)
.ok_or_else(|| err("advancement.advancementNotFound", &[id]))?,
),
_ => None,
@@ -3165,7 +3191,7 @@ impl Game<'_> {
let mut out = Vec::new();
if ctx.has("until") || ctx.has("through") {
let mut p = tree.list[i].parent.as_ref();
while let Some(&pi) = p.and_then(|id| tree.by_id.get(id)) {
while let Some(pi) = p.and_then(|id| tree.find_id(id)) {
out.push(pi);
p = tree.list[pi].parent.as_ref();
}
@@ -3462,22 +3488,13 @@ impl Game<'_> {
if cancel {
return;
}
if level == Dim::Overworld {
g.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
g.respawn_config = None;
} else {
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
}
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
});
}
let dim = Text::literal(self.dim.id());
@@ -4560,6 +4577,21 @@ mod teleport_rider_tests {
rider2.yaw, 80.0,
"a vehicle turned by `facing` leaves its rider"
);
// Native rotation preserves a rider's mount, position and motion.
game.native_lifecycle
.begin_open(horizon_client::mod_api::NativeAuthority::IntegratedServer);
let rider = game.mobs.iter_mut().find(|m| m.id == 51).unwrap();
rider.velocity = Vec3::new(0.2, 0.3, 0.4);
let position = rider.position;
let velocity = rider.velocity;
let handle = game.native_entity(51).unwrap();
assert!(game.native_entity_set_rotation(handle, -135.0, 120.0));
let rider = game.mobs.iter().find(|m| m.id == 51).unwrap();
assert_eq!(rider.extra.mount, Some(50));
assert_eq!(rider.position, position);
assert_eq!(rider.velocity, velocity);
assert_eq!((rider.yaw, rider.head_pitch), (-135.0, 90.0));
assert!(!game.native_entity_set_rotation(handle, f32::NAN, 0.0));
}
}
@@ -4720,3 +4752,87 @@ mod query_feedback_tests {
assert!(lines[0].plain().contains("1234"), "{:?}", lines[0].plain());
}
}
#[cfg(test)]
mod forced_respawn_tests {
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(5.5, 64.0, 5.5))
.unwrap(),
);
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.level.world_spawn = Some([0, 80, 0]);
let registry = game.pack.assets.registry;
let air = registry.default_state("minecraft:air").unwrap();
// Liquid is a block property: aquatic plants such as kelp still permit
// a forced point, while a bubble column does not, at feet or head.
for (block, allowed) in [
("air", true),
("water", false),
("lava", false),
("bubble_column", false),
("kelp", true),
("stone", false),
] {
let state = registry
.default_state(&format!("minecraft:{block}"))
.unwrap();
for height in [0, 1] {
game.world.set(5, 64, 5, air).unwrap();
game.world.set(5, 65, 5, air).unwrap();
game.world.set(5, 64 + height, 5, state).unwrap();
assert!(game.run_command("spawnpoint @s 5 64 5"));
let config = game
.respawn_config
.expect("Overworld commands must retain a forced configuration");
assert!(config.forced);
assert_eq!(config.pos, [5, 64, 5]);
let actual = game.resolve_server_respawn(config);
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
assert_eq!(game.respawn_config.is_some(), allowed);
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
if let Some((at, yaw)) = actual {
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
assert_eq!(yaw, 0.0);
}
}
}
}
}
+168 -9
View File
@@ -2,6 +2,7 @@
//! block entity and storage data accessors over the game state.
use super::{Arg, Context, Game, R, Target, err};
use horizon_client::{
containers::BlockEntity,
data_cmd::{self, Op, path_error},
entity_nbt,
nbt_path::NbtPath,
@@ -117,7 +118,12 @@ impl Game<'_> {
Target::Item(id) => {
let uuid = self.uuid_for(id);
let e = self.dropped.iter().find(|e| e.id == id)?;
entity_nbt::item_entity_to_nbt(e, uuid, self.persist.raw.get(&id))
entity_nbt::item_entity_to_nbt_with_items(
e,
uuid,
self.persist.raw.get(&id),
self.items(),
)
}
Target::Stand(id) => {
let uuid = self.uuid_for(id);
@@ -173,6 +179,21 @@ impl Game<'_> {
}
Target::Projectile(id) => {
let uuid = self.uuid_for(id);
// A homing bullet's `Target` is the entity's, not the raw
// compound's, as `save_projectile` writes it.
let who = self
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.and_then(|p| match &p.kind {
horizon_client::projectiles::ProjectileKind::ShulkerBullet {
target: Some(who),
..
} => Some(*who),
_ => None,
});
let target = who.and_then(|who| self.save_who_uuid(who));
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
@@ -190,7 +211,7 @@ impl Game<'_> {
entity_nbt::arrow_to_nbt(p, uuid, p.owner_uuid.or(owner), in_block, raw)
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw))
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw))
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, uuid, owner, raw))?
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, uuid, owner, target, raw))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
@@ -244,6 +265,7 @@ impl Game<'_> {
&super::OtherEntity {
id,
kind: "minecraft:area_effect_cloud".into(),
item_transformation: None,
position: horizon_core::geometry::Vec3::new(x, y, z),
yaw: 0.0,
pitch: 0.0,
@@ -742,7 +764,18 @@ impl Game<'_> {
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
self.take_loot_table(key, &tag, &mut new);
self.load_block_name(key, &new, &tag);
self.world.block_entities.insert(key, new);
if let (Some(BlockEntity::Mod(current)), BlockEntity::Mod(loaded)) =
(self.world.block_entities.get_mut(&key), &mut new)
&& current.id == loaded.id
{
// BlockDataAccessor.setData loads the existing entity.
// Replace its saved fields, preserving runtime data and
// identity; DerefMut still invalidates content caches.
current.nbt = std::mem::take(&mut loaded.nbt);
current.items = std::mem::take(&mut loaded.items);
} else {
self.world.block_entities.insert(key, new);
}
self.world.set_block_lock(key, &tag);
self.update_spawner_display(key);
// BlockDataAccessor.setData: setChanged and sendBlockUpdated(3),
@@ -874,13 +907,15 @@ impl Game<'_> {
}
self.cmd_data_in(id, ctx, target)
}
/// `DataCommands.mergeData`: literal values keep the modern argument
/// parser's decoded Tag contract through execution.
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
match id {
"data.merge" => {
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
return Ok(0);
};
let (nbt, _) = parse_snbt(nbt).map_err(|e| err("argument.nbt.invalid", &[&e]))?;
let nbt = horizon_client::command::snbt::parse_fully(nbt)?;
let old = self.data_of(&target)?;
if horizon_client::nbt_path::too_deep(&nbt, 0) {
return Err(err("arguments.nbtpath.too_deep", &[]));
@@ -934,6 +969,8 @@ impl Game<'_> {
fn system_feedback(&mut self, message: Text) {
self.feedback(message);
}
/// `DataCommands.manipulateData`: literal sources use the same SNBT
/// decode contract as command admission; stored sources are unchanged.
fn cmd_data_modify(&mut self, ctx: &Context, target: DataRef) -> R<i32> {
let target_path = self.path_arg(ctx, "targetPath")?;
let after_modify: Vec<&String> = ctx
@@ -959,11 +996,7 @@ impl Game<'_> {
let Some(Arg::Str(text)) = ctx.get("value") else {
return Ok(0);
};
vec![
parse_snbt(text)
.map_err(|e| err("argument.nbt.invalid", &[&e]))?
.0,
]
vec![horizon_client::command::snbt::parse_fully(text)?]
} else {
let source = self.data_ref(ctx, 1)?;
let data = self.data_of(&source)?;
@@ -1546,6 +1579,12 @@ mod loot_table_tests {
}
}
#[cfg(test)]
mod saved_tag_tests;
#[cfg(test)]
mod mod_block_entity_tests;
#[cfg(test)]
mod arrow_owner_tests {
use super::Target;
@@ -1654,3 +1693,123 @@ mod arrow_owner_tests {
}
}
}
#[cfg(test)]
mod modern_literal_tests {
use super::*;
use crate::game::commands::execute::ExecState;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn admitted_modern_literals_reach_actual_storage_as_typed_tags() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let run = |g: &mut Game<'_>, line: &str| {
g.exec = ExecState::default();
let source = g.player_source();
g.execute_line(line, source)
};
let cases = [
("hex", "0x10", "16", Tag::Int(16)),
("underscore", "1_000", "1000", Tag::Int(1000)),
("unicode4", r#""\u0041""#, r#""A""#, Tag::String("A".into())),
("hex2", r#""\x41""#, r#""A""#, Tag::String("A".into())),
(
"unicode8",
r#""\U00000041""#,
r#""A""#,
Tag::String("A".into()),
),
(
"trailing",
"{n:3,}",
"{n:3}",
Tag::compound([("n", Tag::Int(3))]),
),
("plain", "plain", r#""plain""#, Tag::String("plain".into())),
];
let clock = game.world.game_time;
let mut differences = Vec::new();
for (name, modern, plain, expected) in &cases {
for route in ["merge", "modify"] {
let target = format!("h189:{route}_{name}");
let reference = format!("h189:ref_{route}_{name}");
let seed = ["data merge storage ", &target, r#" {v:"seed"}"#].concat();
assert_eq!(run(&mut game, &seed).unwrap(), 1);
assert_eq!(
game.score.storage.get(&target).get("v"),
Some(&Tag::String("seed".into()))
);
let canonical = ["data merge storage ", &reference, " {v:", plain, "}"].concat();
assert_eq!(run(&mut game, &canonical).unwrap(), 1);
assert_eq!(game.score.storage.get(&reference).get("v"), Some(expected));
let command = if route == "merge" {
["data merge storage ", &target, " {v:", modern, "}"].concat()
} else {
["data modify storage ", &target, " v set value ", modern].concat()
};
let dispatcher = horizon_client::command::vanilla::game_dispatcher_with_features(
&game.level.enabled_features,
);
assert!(
dispatcher
.parse(&command, &game.ui.registries)
.executable()
.is_ok(),
"admission: {route}/{name}"
);
let result = run(&mut game, &command);
let actual = game.score.storage.get(&target).get("v").cloned();
if result.as_ref().ok() != Some(&1) || actual.as_ref() != Some(expected) {
differences.push(format!("{route}/{name}: result={result:?}, stored={actual:?}, expected={expected:?}"));
}
// Copy actual canonical stored data back: equality is a real
// no-change failure, independent of a formatter or parser stub.
let copy = [
"data modify storage ",
&target,
" v set from storage ",
&reference,
" v",
]
.concat();
if run(&mut game, &copy).is_ok() {
differences.push(format!(
"{route}/{name}: canonical copy changed the stored Tag"
));
}
assert_eq!(game.score.storage.get(&target).get("v"), Some(expected));
}
}
assert_eq!(game.world.game_time, clock);
assert!(differences.is_empty(), "{}", differences.join("\n"));
}
}
@@ -0,0 +1,203 @@
//! Actual command execution with prepared resources. No native mod bundle or
//! process-wide trait hooks are installed; this is a generic content fixture.
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::{
chunk::Chunk,
containers::{ModBlockEntity, ModRevision},
mod_content::{ContentMod, build},
world::World,
};
use horizon_mods::content::{Block, Content};
use std::{path::PathBuf, sync::Arc};
const KEY: (i32, i32, i32) = (2, 64, 2);
const POS: [i32; 3] = [2, 64, 2];
const BLOCK: &str = "fixture:machine";
const ENTITY: &str = "fixture:machine_entity";
fn entity<'a>(game: &'a Game<'_>) -> &'a ModBlockEntity {
let Some(BlockEntity::Mod(entity)) = game.world.block_entities.get(&KEY) else {
panic!("fixture mod block entity missing");
};
entity
}
fn changed(game: &Game<'_>, before: ModRevision) {
let entity = entity(game);
assert_ne!(
entity.revision(),
before,
"saved data invalidates content caches"
);
assert_eq!(
entity.data,
[71, -3, 907],
"runtime menu data survives loading"
);
assert!(game.world.modified.contains(&(0, 0)), "chunk must be dirty");
assert!(
game.world.block_log.as_ref().unwrap().contains(&KEY),
"normal update must be queued"
);
}
fn run(game: &mut Game<'_>, command: &str) -> ModRevision {
let before = entity(game).revision();
game.world.modified.clear();
game.world.block_log.as_mut().unwrap().clear();
assert!(game.run_command(command), "{command}");
before
}
#[test]
#[ignore = "requires private prepared 26.3 resources in HORIZON_TEST_RESOURCES"]
fn actual_data_commands_preserve_same_mod_entity_runtime_state() {
let base = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let root =
PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("data-command-resources");
let mut content = Content::declared("fixture");
content
.declare(
&[Block {
id: BLOCK.into(),
template: "minecraft:stone".into(),
..Default::default()
}],
&[],
)
.unwrap();
build(
&base,
&[ContentMod {
id: "fixture".into(),
content,
}],
&root,
)
.unwrap();
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&base,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world = World::new(game.res.facts().air, 1);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let state = game.pack.assets.registry.default_state(BLOCK).unwrap();
game.world.set(POS[0], POS[1], POS[2], state).unwrap();
let initial = parse_snbt(
r#"{id:"fixture:machine_entity",x:2,y:64,z:2,fuel:4,Opaque:{typed:[L;7L,-9L]},Items:[{Slot:0b,id:"minecraft:coal",count:2}]}"#,
).unwrap().0;
let (_, initial) = save::decode_block_entity(&initial).unwrap();
game.world.block_entities.insert(KEY, initial);
let Some(BlockEntity::Mod(initial)) = game.world.block_entities.get_mut(&KEY) else {
panic!()
};
initial.data = vec![71, -3, 907];
let transient_allocation = initial.data.as_ptr();
game.world.block_log = Some(Vec::new());
game.build_registries();
game.set_allow_commands(true);
let before = run(
&mut game,
r#"data merge block 2 64 2 {fuel:41,Opaque:{added:9s},Items:[{Slot:1b,id:"minecraft:diamond",count:3,components:{"minecraft:custom_data":{stamp:8b}}}]}"#,
);
changed(&game, before);
let loaded = entity(&game);
assert_eq!(loaded.id, ENTITY);
assert_eq!(loaded.data.as_ptr(), transient_allocation);
assert_eq!(loaded.nbt.get(&"fuel".into()), Some(&Tag::Int(41)));
assert_eq!(
loaded.nbt.get(&"Opaque".into()),
Some(&Tag::compound([
("typed", Tag::LongArray(vec![7, -9])),
("added", Tag::Short(9)),
]))
);
assert_eq!(loaded.items.len(), 2);
assert!(
loaded.items[0].is_none(),
"saved inventory replaces the old slots"
);
let stack = loaded.items[1].as_ref().unwrap();
assert_eq!(stack.item.as_ref(), "minecraft:diamond");
assert_eq!(stack.count, 3);
assert_eq!(
stack.data().unwrap().other("minecraft:custom_data"),
Some(&Tag::compound([("stamp", Tag::Byte(8))]))
);
let before = run(&mut game, "data remove block 2 64 2 Opaque.typed");
changed(&game, before);
assert_eq!(
entity(&game).nbt.get(&"Opaque".into()),
Some(&Tag::compound([("added", Tag::Short(9))]))
);
assert_eq!(entity(&game).items[1].as_ref().unwrap().count, 3);
let before = run(&mut game, "data remove block 2 64 2 Items");
changed(&game, before);
assert!(
entity(&game).items.is_empty(),
"removed inventory must not resurrect"
);
assert!(!entity(&game).nbt.contains_key(&"Items".into()));
let before = run(&mut game, "data remove block 2 64 2 Opaque");
changed(&game, before);
assert!(!entity(&game).nbt.contains_key(&"Opaque".into()));
// Unknown, non-inventory Items belongs to the mod's opaque saved data.
let before = run(&mut game, "data merge block 2 64 2 {Items:{opaque:5b}}");
changed(&game, before);
assert!(entity(&game).items.is_empty());
assert_eq!(
entity(&game).nbt.get(&"Items".into()),
Some(&Tag::compound([("opaque", Tag::Byte(5))]))
);
let before = run(&mut game, "data remove block 2 64 2 Items");
changed(&game, before);
assert!(!entity(&game).nbt.contains_key(&"Items".into()));
// Keep the pre-existing type-changing behavior: this patch adds no new
// block/type validation policy to /data, and a new type has fresh state.
let before = run(
&mut game,
r#"data merge block 2 64 2 {id:"fixture:replacement",fuel:12}"#,
);
assert_ne!(entity(&game).revision(), before);
assert!(entity(&game).data.is_empty());
assert_eq!(entity(&game).id, "fixture:replacement");
assert_eq!(entity(&game).nbt.get(&"fuel".into()), Some(&Tag::Int(12)));
assert_eq!(game.world.block(POS[0], POS[1], POS[2]), state);
// Persistence/chunk loading still constructs a fresh live entity; neither
// the identity nor runtime menu data is encoded in the normal saved tag.
let saved = game.data_of(&DataRef::Block(POS)).unwrap();
let before = entity(&game).revision();
let (_, decoded) = save::decode_block_entity(&saved).unwrap();
game.world.block_entities.load_chunk(vec![(KEY, decoded)]);
assert_ne!(entity(&game).revision(), before);
assert!(entity(&game).data.is_empty());
assert_eq!(game.data_of(&DataRef::Block(POS)).unwrap(), saved);
}
@@ -0,0 +1,104 @@
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn tag_commands_and_explicit_empty_loads_save_without_empty_tags() {
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-saved-tags-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 200.0, 0.5)).unwrap());
game.build_registries();
game.set_allow_commands(true);
let start = game.world.game_time;
let uuid = "00000148-0000-0000-0000-000000000001";
assert!(game.run_command("summon pig 4.5 200 4.5 {UUID:[I;328,0,0,1],Health:10f,NoAI:1b,NoGravity:1b,PortalCooldown:17}"));
let target = game.target_with_uuid([328, 0, 0, 1]).unwrap();
assert!(matches!(target, Target::Mob(_)));
assert!(game.tags_of(target).is_empty());
assert!(
game.data_of(&DataRef::Entity(target))
.unwrap()
.get("Tags")
.is_none()
);
for tagged in [true, false, true, false] {
assert!(game.run_command(&format!(
"tag {uuid} {} audit",
if tagged { "add" } else { "remove" }
)));
let expected: Vec<String> = if tagged { vec!["audit".into()] } else { vec![] };
assert_eq!(game.tags_of(target), expected);
for _ in 0..2 {
let saved = game.data_of(&DataRef::Entity(target)).unwrap();
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(vec![328, 0, 0, 1])));
assert_eq!(saved.get("Health"), Some(&Tag::Float(10.0_f32.to_bits())));
assert_eq!(saved.get("NoAI"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("NoGravity"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("PortalCooldown"), Some(&Tag::Int(17)));
assert_eq!(
saved.get("Tags"),
if tagged {
Some(Tag::List(vec![Tag::String("audit".into())]))
} else {
None
}
.as_ref()
);
assert_eq!(game.tags_of(target), expected);
}
}
assert!(game.run_command(
"summon pig 8.5 200 4.5 {UUID:[I;328,0,0,2],NoAI:1b,Tags:[],PortalCooldown:23}"
));
let empty = game.target_with_uuid([328, 0, 0, 2]).unwrap();
assert!(game.tags_of(empty).is_empty());
let saved = game.data_of(&DataRef::Entity(empty)).unwrap();
assert!(saved.get("Tags").is_none());
assert_eq!(saved.get("PortalCooldown"), Some(&Tag::Int(23)));
assert!(game.run_command("tag 00000148-0000-0000-0000-000000000002 add keep"));
assert_eq!(game.tags_of(empty), ["keep"]);
assert_eq!(
game.data_of(&DataRef::Entity(empty)).unwrap().get("Tags"),
Some(&Tag::List(vec![Tag::String("keep".into())]))
);
assert_eq!(game.world.game_time, start);
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
@@ -52,6 +52,20 @@ pub(in crate::game) enum Owner {
Block([i32; 3]),
}
/// The standard equipment slots with existing whole-stack storage.
fn mob_stack_slot(key: &str) -> Option<usize> {
use horizon_client::mobs::finalize;
Some(match key {
"mainhand" => finalize::MAINHAND,
"offhand" => finalize::OFFHAND,
"head" => finalize::HEAD,
"chest" => finalize::CHEST,
"legs" => finalize::LEGS,
"feet" => finalize::FEET,
_ => return None,
})
}
/// The player's slot names and the inventory index each addresses.
fn player_slot_names() -> Vec<(String, usize)> {
let mut v = Vec::new();
@@ -390,6 +404,20 @@ impl Game<'_> {
if *key == "saddle" {
return m.saddle_item();
}
if let Some(slot) = mob_stack_slot(key) {
if let Some(stack) = horizon_client::mobs::finalize::stored_stack(m, slot) {
return Some(stack);
}
let worn = ["head", "chest", "legs", "feet"]
.iter()
.position(|name| name == key);
if let Some(stack) = worn.and_then(|i| m.extra.armor[i].clone()).filter(|s| {
horizon_client::mobs::finalize::item(m, slot).as_ref() == Some(&s.item)
}) {
return Some(stack);
}
return horizon_client::mobs::finalize::item_stack(m, slot);
}
let item = match *key {
"mainhand" => &m.extra.main_hand,
"offhand" => &m.extra.off_hand,
@@ -569,9 +597,50 @@ impl Game<'_> {
None => false,
},
Slot::Mob(id, key) => {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) else {
let Some(index) = self.mobs.iter().position(|m| m.id == *id) else {
return false;
};
let m = &self.mobs[index];
let first_tick = m.first_tick;
let at = [m.position.x, m.position.y, m.position.z];
let old = match *key {
"mainhand" => m
.extra
.main_hand_stack()
.cloned()
.or_else(|| m.extra.main_hand.as_ref().map(|i| ItemStack::new(i, 1))),
"offhand" => m.extra.off_hand.as_ref().map(|i| ItemStack::new(i, 1)),
"head" => m.extra.armor[0]
.clone()
.or_else(|| m.extra.head.as_ref().map(|i| ItemStack::new(i, 1))),
"chest" => m.extra.armor[1]
.clone()
.or_else(|| m.extra.chest.as_ref().map(|i| ItemStack::new(i, 1))),
"legs" => m.extra.armor[2]
.clone()
.or_else(|| m.extra.legs.as_ref().map(|i| ItemStack::new(i, 1))),
"feet" => m.extra.armor[3]
.clone()
.or_else(|| m.extra.feet.as_ref().map(|i| ItemStack::new(i, 1))),
"body" => m.extra.body.as_ref().map(|i| ItemStack::new(i, 1)),
"saddle" => m.saddle_item(),
_ => None,
};
let same = match (old.as_ref(), stack.as_ref()) {
(None, None) => true,
(Some(a), Some(b)) => a.same_item_same_components(b),
_ => false,
};
let equippable = stack.as_ref().is_some_and(|s| {
let data = s.data();
!data.is_some_and(|d| d.other("!minecraft:equippable").is_some())
&& (data.is_some_and(|d| d.other("minecraft:equippable").is_some())
|| self
.items()
.get(&s.item)
.is_some_and(|i| i.equip_slot.is_some()))
});
let m = &mut self.mobs[index];
let item = stack.as_ref().map(|s| s.item.clone());
// The worn armour stack goes with the id: a kept stack of the
// previous piece would otherwise still be what the mob wears.
@@ -585,6 +654,13 @@ impl Game<'_> {
if let Some(i) = worn {
m.extra.armor[i] = stack.clone();
}
if let Some(slot) = mob_stack_slot(key) {
if let Some(stack) = stack.as_ref() {
horizon_client::mobs::finalize::set_item_stack(m, slot, stack.clone());
} else {
horizon_client::mobs::finalize::set_slot_enchantments(m, slot, Vec::new());
}
}
match *key {
"mainhand" => m.extra.set_main_hand_stack(stack),
"offhand" => m.extra.off_hand = item,
@@ -596,6 +672,9 @@ impl Game<'_> {
"saddle" => m.set_saddle(stack),
_ => return false,
}
if !first_tick && !same && self.remote.is_none() {
self.mob_equip_event(*id, at, equippable);
}
true
}
Slot::Stand(id, i) => {
@@ -1714,3 +1793,190 @@ mod mob_equipment_command_tests {
});
}
}
#[cfg(test)]
mod mob_equipment_stack_tests {
use crate::game::commands::{Target, data::DataRef};
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{chunk::Chunk, mobs::finalize};
use horizon_core::position::ChunkPos;
use horizon_world::nbt::Tag;
use std::sync::Arc;
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
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.game_dir = Some(
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join(format!("h023-{:?}", std::thread::current().id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("difficulty normal"));
assert!(game.run_command("forceload add 0 0"));
test(&mut game);
}
fn actor(game: &mut Game<'_>, kind: &str, n: i32) -> (String, u32) {
let uuid = format!("00000017-0000-0000-0000-{n:012x}");
assert!(game.run_command(&format!("summon {kind} 4.5 200 5.5 {{UUID:[I;23,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,h023_raw:7,Tags:[h023]}}")), "{kind}/{n}: {:?}", game.ui.chat.recent_lines(3));
let mob = game
.mobs
.iter()
.find(|m| game.persist.uuid_of(m.id) == Some([23, 0, 0, n]))
.unwrap();
assert!(mob.no_ai);
(uuid, mob.id)
}
fn saved(game: &mut Game<'_>, id: u32) -> Tag {
game.data_of(&DataRef::Entity(Target::Mob(id))).unwrap()
}
fn audit(stack: &horizon_client::items::ItemStack, value: i32) {
assert_eq!(
stack.custom_data().and_then(|c| c.get(&"audit".into())),
Some(&Tag::Int(value))
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn offhand_commands_adopt_and_copy_the_whole_stack() {
use super::Slot;
with_game(|game| {
let (uuid, id) = actor(game, "husk", 1);
assert!(game.run_command("setblock 4 200 4 chest"));
assert!(game.slot_get(&Slot::Mob(id, "offhand")).is_none());
assert!(game.run_command(&format!(
"item replace entity {uuid} weapon.mainhand with stone"
)));
assert!(game.run_command(&format!(
"item replace entity {uuid} weapon.offhand with stick[custom_data={{audit:7}}] 3"
)));
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
assert_eq!(mob.extra.off_hand.as_deref(), Some("minecraft:stick"));
let adopted = finalize::item_stack(mob, finalize::OFFHAND).unwrap();
assert_eq!(
adopted.count, 3,
"actual command adoption must retain count"
);
audit(&adopted, 7);
let tag = saved(game, id);
assert_eq!(
tag.get("equipment")
.unwrap()
.get("offhand")
.unwrap()
.get("count"),
Some(&Tag::Int(3))
);
assert_eq!(
tag.get("equipment")
.unwrap()
.get("mainhand")
.unwrap()
.get("id"),
Some(&Tag::String("minecraft:stone".into()))
);
assert!(game.run_command(&format!(
"item replace block 4 200 4 container.0 from entity {uuid} weapon.offhand"
)));
let copied = game.slot_get(&Slot::Block([4, 200, 4], 0)).unwrap();
assert_eq!(copied.count, 3);
audit(&copied, 7);
assert!(game.run_command(&format!(
"item replace entity {uuid} weapon.offhand with air"
)));
assert!(game.slot_get(&Slot::Mob(id, "offhand")).is_none());
assert!(game.run_command(&format!(
"item replace entity {uuid} weapon.offhand with stick"
)));
let plain = game.slot_get(&Slot::Mob(id, "offhand")).unwrap();
assert_eq!(plain.count, 1);
assert!(
plain.custom_data().is_none(),
"stale components must not return for the same id"
);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn armor_command_copy_keeps_the_already_adopted_stack() {
use super::Slot;
with_game(|game| {
let (uuid, id) = actor(game, "husk", 2);
assert!(game.run_command("setblock 4 200 4 chest"));
assert!(game.run_command(&format!("item replace entity {uuid} armor.head with iron_helmet[damage=3,custom_data={{audit:8}}]")));
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
let adopted = mob.extra.armor[0].as_ref().unwrap();
assert_eq!(
adopted.damage, 3,
"existing armor state already adopts the full stack"
);
audit(adopted, 8);
assert!(game.run_command(&format!(
"item replace block 4 200 4 container.0 from entity {uuid} armor.head"
)));
let copied = game.slot_get(&Slot::Block([4, 200, 4], 0)).unwrap();
assert_eq!(
copied.damage, 3,
"actual command copy must use the full stack"
);
audit(&copied, 8);
let tag = saved(game, id);
let components = tag
.get("equipment")
.unwrap()
.get("head")
.unwrap()
.get("components")
.unwrap();
assert_eq!(components.get("minecraft:damage"), Some(&Tag::Int(3)));
assert_eq!(
components
.get("minecraft:custom_data")
.unwrap()
.get("audit"),
Some(&Tag::Int(8))
);
assert!(game.run_command(&format!(
"item replace entity {uuid} armor.head with iron_helmet"
)));
let plain = game.slot_get(&Slot::Mob(id, "head")).unwrap();
assert_eq!(plain.damage, 0);
assert!(plain.custom_data().is_none());
assert!(game.run_command(&format!("item replace entity {uuid} armor.head with air")));
assert!(game.slot_get(&Slot::Mob(id, "head")).is_none());
});
}
}
@@ -531,6 +531,7 @@ impl Game<'_> {
/// `LootCommand.dropBlockLoot`: copy the actual block entity's components
/// into the mine result without removing the source block or its contents.
pub(super) fn cmd_loot(&mut self, id: &str, ctx: &Context) -> R<i32> {
let keep_empty = ctx.literals.iter().any(|literal| literal == "replace");
// The drops.
let (drops, table): (Vec<ItemStack>, Option<String>) = match id {
"loot.loot" | "loot.fish" => {
@@ -553,16 +554,18 @@ impl Game<'_> {
let drops = match json["random_sequence"].as_str() {
Some(sequence) => {
let random = self.rand_sequences.get(sequence, self.level.seed);
world_items!(self).inline_loot_table_with_tool(
world_items!(self).inline_loot_table_for_command(
&json,
held.as_ref(),
random,
keep_empty,
)
}
None => world_items!(self).inline_loot_table_with_tool(
None => world_items!(self).inline_loot_table_for_command(
&json,
held.as_ref(),
&mut self.world.random,
keep_empty,
),
};
(drops, None)
@@ -578,13 +581,20 @@ impl Game<'_> {
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
world_items!(self).loot_table(&table, false, held.as_ref(), random)
world_items!(self).loot_table_for_command(
&table,
false,
held.as_ref(),
random,
keep_empty,
)
}
None => world_items!(self).loot_table(
None => world_items!(self).loot_table_for_command(
&table,
false,
held.as_ref(),
&mut self.world.random,
keep_empty,
),
};
(drops, None)
@@ -613,13 +623,20 @@ impl Game<'_> {
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
world_items!(self).entity_drops(&ty, by_player, held.as_ref(), random)
world_items!(self).entity_drops_for_command(
&ty,
by_player,
held.as_ref(),
random,
keep_empty,
)
}
None => world_items!(self).entity_drops(
None => world_items!(self).entity_drops_for_command(
&ty,
by_player,
held.as_ref(),
&mut self.world.random,
keep_empty,
),
};
(drops, Some(table))
@@ -649,18 +666,20 @@ impl Game<'_> {
let mut drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
let random = self.rand_sequences.get(&seq, self.level.seed);
world_items!(self).block_drops(
world_items!(self).block_drops_for_command(
self.pack.assets.registry,
state,
tool.as_ref(),
random,
keep_empty,
)
}
None => world_items!(self).block_drops(
None => world_items!(self).block_drops_for_command(
self.pack.assets.registry,
state,
tool.as_ref(),
&mut self.world.random,
keep_empty,
),
};
if has_block_entity {
@@ -671,7 +690,10 @@ impl Game<'_> {
(drops, Some(table))
}
};
let drops: Vec<ItemStack> = drops.into_iter().filter(|d| d.count > 0).collect();
let drops: Vec<ItemStack> = drops
.into_iter()
.filter(|d| keep_empty || d.count > 0)
.collect();
// Where they go.
let mut used: Vec<ItemStack> = Vec::new();
let literals = &ctx.literals;
@@ -795,7 +817,7 @@ impl Game<'_> {
None => first.first().cloned().filter(|_| i == 0),
};
let Some(slot) = slot else { continue };
let item = drops.get(i).cloned();
let item = drops.get(i).filter(|drop| drop.count > 0).cloned();
if self.slot_accepts(&slot, item.as_ref()) && self.slot_set(&slot, item.clone())
{
used.push(item.unwrap_or(ItemStack {
@@ -820,7 +842,7 @@ impl Game<'_> {
false,
None,
|h, _| {
let n = counts.iter().copied().find(|c| *c > 0).unwrap_or(1);
let n = counts.iter().copied().find(|c| *c > 0).unwrap_or(0);
match &table {
Some(t) => Text::translate(
"commands.drop.success.single_with_table",
@@ -1064,3 +1086,108 @@ mod loot_mine_component_tests {
);
}
}
#[cfg(test)]
mod loot_position_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn actual_loot_replacement_retains_the_emitted_zero_slot() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
game.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("setblock 4 200 4 chest"));
let slot = |game: &mut Game<'_>, i| {
game.slot_get(&crate::game::commands::items::Slot::Block([4, 200, 4], i))
};
let reset = |game: &mut Game<'_>| {
assert!(game.run_command("item replace block 4 200 4 container.0 with diamond 3"));
assert!(game.run_command("item replace block 4 200 4 container.1 with emerald 4"));
assert!(game.run_command("item replace block 4 200 4 container.2 with gold_ingot 5"));
assert_eq!(
slot(game, 0).map(|s| (s.item.to_string(), s.count)),
Some(("minecraft:diamond".into(), 3))
);
assert_eq!(
slot(game, 1).map(|s| (s.item.to_string(), s.count)),
Some(("minecraft:emerald".into(), 4))
);
};
let table = |counts: &[i32]| {
serde_json::json!({"pools":counts.iter().map(|count| serde_json::json!({
"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:iron_ingot",
"modifier":{"type":"minecraft:set_count","count":count}}]
})).collect::<Vec<_>>()})
.to_string()
};
let time = game.world.game_time;
reset(&mut game);
assert!(game.run_command(&format!(
"loot replace block 4 200 4 container.0 loot {}",
table(&[1])
)));
assert_eq!(
slot(&mut game, 0).map(|s| (s.item.to_string(), s.count)),
Some(("minecraft:iron_ingot".into(), 1))
);
reset(&mut game);
assert!(game.run_command(&format!(
"loot replace block 4 200 4 container.0 loot {}",
table(&[0, 1])
)));
assert!(
slot(&mut game, 0).is_none(),
"the emitted zero occupies and clears slot0"
);
assert_eq!(
slot(&mut game, 1).map(|s| (s.item.to_string(), s.count)),
Some(("minecraft:iron_ingot".into(), 1))
);
assert_eq!(slot(&mut game, 2).unwrap().count, 5);
reset(&mut game);
assert!(game.run_command(&format!(
"loot replace block 4 200 4 container.0 2 loot {}",
table(&[])
)));
assert!(slot(&mut game, 0).is_none());
assert!(slot(&mut game, 1).is_none());
assert_eq!(slot(&mut game, 2).unwrap().count, 5);
assert_eq!(game.world.game_time, time);
}
}
@@ -502,6 +502,10 @@ impl Game<'_> {
for (mob, target) in reinforcements {
self.call_reinforcements(mob, target);
}
// Enderman.hurtServer's teleport, refused hit or not, before the command returns.
if let Err(e) = self.settle_hurt_teleports() {
log::warn!("Horizon: /damage: enderman teleport: {e}");
}
hurt
}
+5 -2
View File
@@ -242,7 +242,7 @@ impl Game<'_> {
self.world_options_text(None);
return Ok(());
}
Screen::Inventory if self.mod_browser_typing() => {
Screen::Inventory | Screen::Creative if self.mod_browser_typing() => {
self.mod_browser_text(None, true);
return Ok(());
}
@@ -301,7 +301,7 @@ impl Game<'_> {
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
Screen::Inventory | Screen::Creative if self.mod_browser_typing() => {
self.mod_browser_text(Some(text), false)
}
Screen::Inventory if self.recipe_search_focused() => {
@@ -608,6 +608,9 @@ impl Game<'_> {
)
.into());
}
if arg(2)? != "up" {
self.pad.keyboard_took_over = true;
}
// The command confirmation takes the keys; none go past it.
if self.command_prompt.is_some() {
if arg(2)? != "up" {
@@ -314,8 +314,7 @@ impl Game<'_> {
let antenna = self.mobs[index].saddle_item();
self.mobs[index].set_saddle(None);
let id = self.mobs[index].id;
let ctx = self.who_context(Some(horizon_client::mobs::Who::Mob(id)), None);
self.post_game_event("minecraft:unequip", at, ctx);
self.mob_equip_event(id, at, false);
if let Some(a) = antenna {
let p = self.mobs[index].position;
self.spawn_at_location(p, 1.5, a);
@@ -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);
}
}
}
@@ -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
}
}
+101 -1
View File
@@ -955,6 +955,13 @@ impl Game<'_> {
} else {
None
},
// A warden's away suspects are UUIDs: on the server side, where the
// entity UUIDs live, they resolve back to the mobs that are here.
who_lookup: if self.remote.is_none() {
Some(self)
} else {
None
},
border: Some(self.border.geometry()),
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
@@ -1014,6 +1021,9 @@ impl Game<'_> {
}
/// Advance every mob one tick, then apply their events.
pub(super) fn tick_creatures(&mut self) -> Result<()> {
// Hurts dealt since the last creature tick (explosions, commands, the
// systems after it) teleport an enderman before it ticks, not in its AI step.
self.settle_hurt_teleports()?;
self.attack_ticks = self.attack_ticks.saturating_add(1);
self.refresh_gear_knockback_resistance();
self.tick_leads();
@@ -1513,6 +1523,8 @@ impl Game<'_> {
queued.append(&mut m.outbox);
}
self.handle_mob_events(queued)?;
// The enderman teleports those hurts asked for (`Enderman.hurtServer`).
self.settle_hurt_teleports()?;
laps.lap("creature_outbox");
Ok(())
}
@@ -2350,7 +2362,8 @@ impl Game<'_> {
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
// (ENTITY_PLACE), Enderman.teleport's TELEPORT where it was.
// (ENTITY_PLACE), Enderman.teleport's TELEPORT,
// LivingEntity.onEquipItem's EQUIP/UNEQUIP.
MobEvent::GameEvent {
event, at, source, ..
} if matches!(
@@ -2362,6 +2375,8 @@ impl Game<'_> {
| "minecraft:container_close"
| "minecraft:entity_place"
| "minecraft:teleport"
| "minecraft:equip"
| "minecraft:unequip"
) =>
{
let ctx = self.who_context(Some(Who::Mob(source)), None);
@@ -5572,6 +5587,7 @@ impl Game<'_> {
pl.position = to;
pl.velocity = Vec3::ZERO;
pl.fall_distance = 0.0;
pl.impulse.reset();
pl.teleported = true;
}
if self.lan_acting.is_some() {
@@ -6171,6 +6187,7 @@ impl Game<'_> {
let center = m.center();
// Wolf armor the owner takes off with shears or leaves a dropped stack of.
let mut armor_drop: Option<ItemStack> = None;
let mut equip_event: Option<bool> = None;
let mut consume = false;
let mut give: Option<&str> = None;
let mut sound: Option<&str> = None;
@@ -6319,6 +6336,7 @@ impl Game<'_> {
m.extra.body = None;
m.extra.body_damage = 0;
eat = Some(("item.armor.unequip_wolf", 1.0, 1.0));
equip_event = Some(false);
self.damage_held_tool();
}
// Wolf.mobInteract: heal, dye the collar or sit/stand when tame;
@@ -6348,6 +6366,7 @@ impl Game<'_> {
m.extra.body_extra = held_extra;
consume = true;
eat = Some(("item.armor.equip_wolf", 1.0, 1.0));
equip_event = Some(true);
}
// Wolf.mobInteract: an armadillo scute repairs a sitting wolf's
// armor by an eighth of its durability.
@@ -6796,6 +6815,11 @@ impl Game<'_> {
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
if let Some(equip) = equip_event {
let id = self.mobs[index].id;
let pos = self.mobs[index].position;
self.mob_equip_event(id, [pos.x, pos.y, pos.z], equip);
}
if let Some((at, height)) = shear_puff {
self.mooshroom_shear_puff(at, height);
}
@@ -7282,6 +7306,9 @@ impl Game<'_> {
match renderer.and_then(|r| r.skinned_models()) {
Some(stored) => {
let mut models = self.skin_models.borrow_mut();
// Reuse helps CPU-bound crowds, but regressed TAA frame tails in
// windowed measurements. Keep the original arithmetic with TAA on.
models.set_rotation_cache_enabled(!self.settings.horizon.taa);
models.begin(&mut self.skin_frame.borrow_mut(), stored);
models.tagged = renderer.is_some_and(|r| r.skins_tagged());
}
@@ -8376,6 +8403,78 @@ mod tests {
)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn ender_pearl_clears_impulse_before_next_fall() {
use super::{Game, Hit, Projectile, Vec3, Who};
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::motion::{MotionWorld, PlayerMotion};
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 mut player = PlayerMotion::new(game.motion, Vec3::new(0.0, 64.0, 0.0)).unwrap();
player
.impulse
.set_ignore_fall_damage(true, Some(player.position));
player.fall_distance = 4.0;
game.player = Some(player);
let destination = Vec3::new(0.0, 80.0, 0.0);
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, destination, Some(Who::Player));
pearl.previous = destination;
game.projectile_hit(
&pearl,
Hit::Block {
pos: [0, 80, 0],
at: destination,
face: [0; 3],
},
)
.unwrap();
let player = game.player.as_mut().unwrap();
assert_eq!(player.position, destination);
assert_eq!(player.fall_distance, 0.0);
let damaged = MotionWorld {
entities: &[],
player,
scene: &mut game.world,
assets: &game.pack.assets,
collision_binding: game.collision,
resources: game.motion,
health: &game.health_resources,
}
.cause_mount_fall(10.0, 1.0)
.unwrap();
assert!(
damaged,
"a ten-block fall after the teleport must cause damage"
);
}
#[test]
fn nautilus_breath_lands_in_creative_as_ambient() {
// AbstractNautilus.applyEffects: MobEffectInstance(BREATH_OF_THE_NAUTILUS,
@@ -8689,6 +8788,7 @@ mod pearl_tests {
others: &[],
dragon: None,
leash_boats: &[],
who_lookup: None,
others_grid: None,
others_index: None,
others_villagers: None,
+4 -4
View File
@@ -492,8 +492,8 @@ mod tests {
/// `AbstractArrow`'s constructor runs `EnchantmentHelper.onProjectileSpawned`
/// for the weapon the arrow is fired from, bow or crossbow, so a Flame
/// crossbow shoots burning arrows: Flame's 5 second ignite, 100 ticks, on
/// every arrow of a Multishot volley (#10482).
/// crossbow shoots burning arrows: Flame's `Ignite(constant(100))` is 100
/// seconds, 2000 ticks, on every arrow of a Multishot volley (#10482).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_flame_crossbow_shoots_burning_arrows() {
@@ -516,10 +516,10 @@ mod tests {
.collect()
};
assert_eq!(shoot(&[], 1), vec![0]);
assert_eq!(shoot(&["minecraft:flame"], 1), vec![100]);
assert_eq!(shoot(&["minecraft:flame"], 1), vec![2000]);
assert_eq!(
shoot(&["minecraft:flame", "minecraft:multishot"], 3),
vec![100; 3]
vec![2000; 3]
);
}
@@ -387,6 +387,8 @@ impl Game<'_> {
if self.riding == Some(c.id) {
self.dismount_removed_vehicle(p.y);
}
// BlockAttachedEntity.kill, before dropItem.
self.entity_killed_event([p.x, p.y, p.z], by_player);
self.audio.play(
"minecraft:entity.cushion.break",
Some([p.x, p.y, p.z]),
+2 -1
View File
@@ -1534,10 +1534,11 @@ impl Game<'_> {
}
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let uuid = self.uuid_for(id);
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
let tag = horizon_client::entity_nbt::item_entity_to_nbt_with_items(
&self.dropped[i],
uuid,
self.persist_raw(id),
self.items(),
);
// A gateway keeps the item in its level (teleportSameDimension
// moves it): it doesn't leave, so no native_entity_leave_level.
@@ -11,6 +11,50 @@ use horizon_world::{
};
impl Game<'_> {
/// `Enderman.hurtServer` teleports inside the hurt (`teleport()`,
/// `repeatedlyTryToTeleport()`). `Mob::damage` has no world, so it leaves
/// the tries in `counter2`; a hurt the game layer deals runs them here,
/// right after it, not on the enderman's next AI step.
pub(super) fn settle_hurt_teleports(&mut self) -> Result<()> {
let pending: Vec<u32> = self
.mobs
.iter()
.filter(|m| m.species() == Species::Enderman && m.extra.counter2 > 0)
.map(|m| m.id)
.collect();
for id in pending {
// What `LivingEntity.hurtServer` raised comes first, where the
// enderman was hurt and from its random's next draws: the hurt
// sound, `InfestedMobEffect.onMobHurt`'s silverfish.
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
continue;
};
let hurt = std::mem::take(&mut self.mobs[i].outbox);
if !hurt.is_empty() {
self.handle_mob_events(hurt)?;
}
// The world is read while the enderman moves: take it out, then
// put it back where it was (the mob order stays).
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
continue;
};
let mut mob = self.mobs.swap_remove(i);
let mut events = Vec::new();
{
let world = self.mob_world_base(&[]);
horizon_client::mobs::enderman::run_pending_teleports(
&mut mob,
&world,
&mut events,
);
}
self.mobs.push(mob);
let last = self.mobs.len() - 1;
self.mobs.swap(i, last);
self.handle_mob_events(events)?;
}
Ok(())
}
/// `EndermanLeaveBlockGoal.tick`: normalize, check, place, then clear.
pub(super) fn enderman_place_block(
&mut self,
@@ -0,0 +1,164 @@
//! `Enderman.hurtServer` teleports inside the hurt the game layer deals.
use super::{
Audio, Game, Options, Progress, Settings, explosion::BlockInteraction, load_resources,
};
use horizon_client::{
chunk::Chunk,
mobs::{Mob, MobEvent},
};
use horizon_core::{
geometry::Vec3,
position::ChunkPos,
random::{LegacyRandom, RandomSource},
};
use std::sync::Arc;
/// A game over 5x5 empty chunks around (0, 0) with a stone floor at y = 3:
/// every enderman teleport from (8.5, 4, 8.5) lands on it or fails.
fn floored_game(root: &std::path::Path) -> Game<'static> {
let settings = Settings::default();
let loaded = load_resources(
root.to_path_buf(),
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.to_path_buf();
options.execution_role = super::execution_role::ExecutionRole::IntegratedServer;
let resources = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(root),
None,
)
.unwrap();
for cx in -2..=2 {
for cz in -2..=2 {
game.world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(cx, cz))
.unwrap();
}
}
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
for x in -32..48 {
for z in -32..48 {
game.world.set(x, 3, z, stone).unwrap();
}
}
game
}
fn enderman(game: &Game, id: u32, at: Vec3, seed: i64) -> Mob {
let kind = game
.res
.mob_types()
.get("minecraft:enderman")
.unwrap()
.clone();
let mut mob = Mob::new(id, kind, at, 0.0);
mob.random = LegacyRandom::new(seed);
mob.persistent = true;
mob
}
/// `ServerExplosion.hurtEntities` -> `Enderman.hurtServer`: an enderman the
/// blast hurts has teleported (or drawn not to) when the explosion returns.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_explosion_teleports_endermen_before_it_returns() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let mut game = floored_game(&root);
let center = Vec3::new(8.5, 4.0, 8.5);
for k in 0..12 {
let angle = f64::from(k) * std::f64::consts::TAU / 12.0;
let at = Vec3::new(
center.x + 2.5 * angle.cos(),
center.y,
center.z + 2.5 * angle.sin(),
);
let mob = enderman(&game, 500 + k as u32, at, 900 + i64::from(k));
game.mobs.push(mob);
}
let before: Vec<Vec3> = game.mobs.iter().map(|m| m.position).collect();
game.server_explode(
[center.x, center.y, center.z],
4.0,
BlockInteraction::Keep,
false,
)
.unwrap();
let pending: Vec<u32> = game
.mobs
.iter()
.filter(|m| m.extra.counter2 > 0)
.map(|m| m.id)
.collect();
assert!(
pending.is_empty(),
"teleports left for the AI step: {pending:?}"
);
let moved = game
.mobs
.iter()
.zip(&before)
.filter(|(m, b)| m.position.distance(**b) > 3.0)
.count();
assert!(moved > 0, "no enderman teleported");
}
/// `LivingEntity.hurtServer` runs `InfestedMobEffect.onMobHurt` before
/// `Enderman.hurtServer` teleports: the silverfish come out where the enderman
/// was hurt, from its random's next draws.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_infested_endermans_silverfish_come_out_where_it_was_hurt() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let mut game = floored_game(&root);
let at = Vec3::new(8.5, 4.0, 8.5);
// A seed whose first draw passes the effect's 1-in-10 chance (`nextFloat() <= 0.1`).
let seed = (0..)
.find(|&s| LegacyRandom::new(s).next_float() <= 0.1)
.unwrap();
let mut mob = enderman(&game, 600, at, seed);
mob.add_effect("minecraft:infested", 200, 0);
// What `Mob::damage` leaves for a projectile's hit: the effect's spawn and
// `repeatedlyTryToTeleport`.
mob.outbox.push(MobEvent::Infested { mob: 600 });
mob.extra.counter2 = 64;
game.mobs.push(mob);
game.settle_hurt_teleports().unwrap();
let ender = game.mobs.iter().find(|m| m.id == 600).unwrap();
assert!(
ender.position.distance(at) > 3.0,
"the enderman did not teleport"
);
let silverfish: Vec<Vec3> = game
.mobs
.iter()
.filter(|m| m.kind.id == "minecraft:silverfish")
.map(|m| m.position)
.collect();
assert!(!silverfish.is_empty(), "no silverfish");
for p in silverfish {
assert!(p.distance(at) < 3.0, "silverfish at {p:?}, hurt at {at:?}");
}
}
@@ -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"));
}
@@ -672,6 +672,11 @@ impl Game<'_> {
// its base is 0 for every mob type, so iron golems and
// ravagers are pushed.
hurt_mob(m, hurtable, cause, (amount, source));
// Enderman.hurtServer teleports inside the hurt, before
// the push (the mob order stays as it was).
if let Err(e) = self.settle_hurt_teleports() {
log::warn!("Horizon: explosion: enderman teleport: {e}");
}
}
// `ItemEntity.hurtServer`: the damage comes off `Health`
// (5 to start) in f32 and the item is destroyed at zero, so
@@ -27,6 +27,10 @@ impl Game<'_> {
impact.amount,
mob_kind,
);
// Enderman.hurtServer: a falling block is no living attacker.
if let Err(e) = self.settle_hurt_teleports() {
log::warn!("Horizon: falling block: enderman teleport: {e}");
}
let kind = if stalactite {
"minecraft:falling_stalactite"
} else if anvil {
+6
View File
@@ -363,6 +363,10 @@ impl Game<'_> {
);
if !with_frame {
self.item_frames[i].item = None;
} else {
// BlockAttachedEntity.kill, before dropItem. A frame holding an
// item only gives the item up (ItemFrame.hurtServer).
self.entity_killed_event(at, true);
}
self.drop_frame_items(&f, with_frame, true);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
@@ -470,6 +474,7 @@ impl Game<'_> {
continue;
}
let f = self.item_frames.remove(i);
self.entity_killed_event(center(&f), false);
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
@@ -497,6 +502,7 @@ impl Game<'_> {
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
{
self.item_frames.remove(i);
self.entity_killed_event(center(&f), false);
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
@@ -0,0 +1,32 @@
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
//! no world is shared, the pause menu has no Friends button (`FriendsView::shown` is false, as on
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
use super::{Game, Screen};
use horizon_client::{Result, friends_screen::FriendsView, hud::HudBatch};
pub const HELP: &[&str] = &[];
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
pub(super) fn tick_friends(&mut self) {}
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
self.screen = Screen::Paused;
}
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_view(&self) -> FriendsView {
FriendsView::default()
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
if words[0] != "friends" {
return Ok(None);
}
Err("this build has no Play with Friends (the `friends` feature)".into())
}
}
+6
View File
@@ -74,6 +74,9 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.happy_ghast.equip", Some(at), 1.0, 1.0);
self.consume_held();
let id = self.mobs[index].id;
let pos = self.mobs[index].position;
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
return true;
}
// Equippable.canBeSheared: shears take the harness off again.
@@ -86,6 +89,9 @@ impl Game<'_> {
.play("minecraft:entity.happy_ghast.unequip", Some(at), 1.0, 1.0);
self.spawn_item(ItemStack::new(&harness, 1), at, [0.0, 0.2, 0.0], 10);
self.damage_held_tool();
let id = self.mobs[index].id;
let pos = self.mobs[index].position;
self.mob_equip_event(id, [pos.x, pos.y, pos.z], false);
return true;
}
if harnessed && !sneaking {
+29 -5
View File
@@ -652,9 +652,34 @@ impl Game<'_> {
{
return menu;
}
let Some(hit) = self.pick() else {
self.pick()
.is_some_and(|hit| self.block_interactable_for_spectator(&hit))
}
/// `BlockBehaviour.showAsInteractableInSpectatorMode`: a block with a menu, or a portal.
fn block_interactable_for_spectator(&self, hit: &super::Hit) -> bool {
let block = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
matches!(
&*self.res.facts().get(block).name,
"minecraft:nether_portal" | "minecraft:end_portal" | "minecraft:end_gateway"
) || self.block_opens_menu(hit)
}
/// A lectern with no book (`LecternBlock.useWithoutItem` consumes the click and does nothing).
pub(super) fn block_is_empty_lectern(&self, hit: &super::Hit) -> bool {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
&*self.res.facts().get(state).name == "minecraft:lectern"
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_book"))
.is_none_or(|v| v != "true")
}
/// `BlockState.getMenuProvider`: the block's use opens a screen.
pub(super) fn block_opens_menu(&self, hit: &super::Hit) -> bool {
if self.block_is_empty_lectern(hit) {
return false;
};
}
let id = &*self
.res
.facts()
@@ -662,12 +687,11 @@ impl Game<'_> {
.name;
let name = id.trim_start_matches("minecraft:");
match name {
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
// BlockBehaviour.getMenuProvider.
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
| "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table"
| "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper"
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
| "end_portal" | "end_gateway" => true,
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" => true,
// ChestBlock.getMenuProvider: none while a redstone conductor
// sits on top of either half (a sitting cat aside). Copper
// chests are `ChestBlock`s too.
+3 -3
View File
@@ -33,7 +33,7 @@ impl Game<'_> {
// screen's search (OptionsUi::typing).
Screen::Options => self.ui.options.typing(),
Screen::WorldOptions => self.world_options_typing(),
Screen::Creative => self.creative_screen.tab().search,
Screen::Creative => self.mod_browser_typing() || self.creative_screen.tab().search,
Screen::BlockEditor => self.block_editor.as_ref().is_some_and(|s| s.text_focused()),
Screen::Inventory => {
self.mod_browser_typing() || self.recipe_search_focused() || self.station_typing()
@@ -138,11 +138,11 @@ impl Game<'_> {
Screen::DebugOptions => self.debug_options_type(&text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, &text),
Screen::WorldOptions => self.world_options_text(Some(&text)),
Screen::Creative => self.creative_search_edit(Some(&text)),
Screen::BlockEditor => self.block_editor_type(&text),
Screen::Inventory if self.mod_browser_typing() => {
Screen::Inventory | Screen::Creative if self.mod_browser_typing() => {
self.mod_browser_text(Some(&text), false)
}
Screen::Creative => self.creative_search_edit(Some(&text)),
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(&text), false)
}
@@ -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(),
}
}
}
+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
}
+9 -6
View File
@@ -274,16 +274,19 @@ pub(super) fn field_click(rect: Rect) -> (f32, f32) {
/// (`AbstractSelectionList`) and of a focused `EditBox`. Buttons, sliders
/// and tabs show their highlighted sprite, drawn under the hidden pointer.
pub(super) fn draw_outline(atlas: &HudAtlas, batch: &mut HudBatch, focus: Target) {
let [x, y, w, h] = focus.rect;
if matches!(focus.kind, Kind::Row | Kind::Field) {
let white = rgba(255, 255, 255, 255);
batch.fill(atlas, x, y, w, 1.0, white);
batch.fill(atlas, x, y + h - 1.0, w, 1.0, white);
batch.fill(atlas, x, y, 1.0, h, white);
batch.fill(atlas, x + w - 1.0, y, 1.0, h, white);
draw_white_outline(atlas, batch, focus.rect);
}
}
pub(super) fn draw_white_outline(atlas: &HudAtlas, batch: &mut HudBatch, [x, y, w, h]: Rect) {
let white = rgba(255, 255, 255, 255);
batch.fill(atlas, x, y, w, 1.0, white);
batch.fill(atlas, x, y + h - 1.0, w, 1.0, white);
batch.fill(atlas, x, y, 1.0, h, white);
batch.fill(atlas, x + w - 1.0, y, 1.0, h, white);
}
#[cfg(test)]
mod tests {
use super::*;
+94 -2
View File
@@ -4929,11 +4929,34 @@ impl Game<'_> {
g.moves_from_ground
};
if g.spectator() {
// Spectators' effects still run (LivingEntity.baseTick).
// ServerPlayer.doTick runs the player's whole tick for a spectator
// too, unless its box touches an unloaded chunk
// (`touchingUnloadedChunk`): its effects (LivingEntity.baseTick),
// Player.aiStep's tickRegeneration and foodData.tick go on while
// it watches (#11312). The hazards, the travel and the movement
// statistics below are what a spectator's body skips.
let (at, body) = (g.position, g.dimensions());
let loaded =
!touching_unloaded_chunk(at, body, |x, z| self.world.chunks.contains_key(&(x, z)));
if !g.effects.list.is_empty() {
self.as_guest(host, conn, |game| game.tick_mob_effects());
}
self.as_guest(host, conn, |game| game.handle_shoulder_entities());
self.as_guest(host, conn, |game| {
game.handle_shoulder_entities();
if !loaded {
return;
}
let rules = game.world.health_rules;
let Some(player) = game.player.as_mut() else {
return;
};
// Entity.baseTick's clocks (hurtTime, invulnerableTime) run too.
player.health.advance_clock();
let _ = player.peaceful_regeneration(rules);
let _ = player.food_tick(&game.health_resources, rules);
let events = std::mem::take(&mut player.events);
game.record_combat(&events);
});
return;
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.dimensions());
@@ -6012,6 +6035,27 @@ fn swing_packet(id: i32, hand: Hand, animation: SwingAnimation) -> C {
})
}
/// `Entity.touchingUnloadedChunk`: the box at `at` inflated by one block
/// reaches a chunk that is not loaded (`Level.hasChunksAt` over the floor of
/// its minimum and the ceiling of its maximum, as section coordinates).
fn touching_unloaded_chunk(
at: [f64; 3],
body: Dimensions,
loaded: impl Fn(i32, i32) -> bool,
) -> bool {
let half = f64::from(body.width) / 2.0 + 1.0;
let section = |v: f64| (v as i32) >> 4;
let (x0, x1) = (
section((at[0] - half).floor()),
section((at[0] + half).ceil()),
);
let (z0, z1) = (
section((at[2] - half).floor()),
section((at[2] + half).ceil()),
);
!(x0..=x1).all(|x| (z0..=z1).all(|z| loaded(x, z)))
}
/// The block columns `Entity.isInWall` looks at: `AABB.ofSize` around the
/// eyes, 0.8 of the body's width across (`BlockPos.betweenClosedStream`).
fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>; 2] {
@@ -6787,3 +6831,51 @@ mod swing_echo_tests {
assert_eq!(swings_back(), 1, "a bed's use is swung back");
}
}
#[cfg(test)]
mod unloaded_chunk_tests {
use super::touching_unloaded_chunk;
use horizon_client::pose::Dimensions;
/// `Entity.touchingUnloadedChunk` (#11312): the box inflated by one block
/// is checked chunk by chunk; a player in the middle of a loaded chunk
/// touches none, one within a block of its edge touches the neighbour.
#[test]
fn a_box_within_a_block_of_a_chunk_edge_touches_the_next_chunk() {
let body = Dimensions {
width: 0.6,
height: 1.8,
eye_height: 1.62,
};
let only_origin = |x: i32, z: i32| (x, z) == (0, 0);
assert!(!touching_unloaded_chunk(
[8.0, 64.0, 8.0],
body,
only_origin
));
// 15.5 + 0.3 + 1 reaches 16.8, in chunk 1.
assert!(touching_unloaded_chunk(
[15.5, 64.0, 8.0],
body,
only_origin
));
// 14.0 + 1.3 = 15.3: Mth.ceil gives 16, the first block of chunk 1.
assert!(touching_unloaded_chunk(
[14.0, 64.0, 8.0],
body,
only_origin
));
// 13.5 + 1.3 = 14.8: ceil 15, still chunk 0.
assert!(!touching_unloaded_chunk(
[13.5, 64.0, 8.0],
body,
only_origin
));
// Everything loaded: nothing is touched anywhere.
assert!(!touching_unloaded_chunk(
[15.9, 64.0, -0.5],
body,
|_, _| true
));
}
}
+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();
+6
View File
@@ -119,12 +119,18 @@ impl Game<'_> {
.play("minecraft:item.llama_carpet.unequip", Some(at), 1.0, 1.0);
self.spawn_item(ItemStack::new(&carpet, 1), at, [0.0, 0.2, 0.0], 10);
self.damage_held_tool();
let id = self.mobs[index].id;
let pos = self.mobs[index].position;
self.mob_equip_event(id, [pos.x, pos.y, pos.z], false);
return true;
}
if carpet(item) && m.extra.body.is_none() {
self.mobs[index].extra.body = Some(item.into());
self.llama_sound(index, "entity.llama.swag", 1.0);
self.consume_held();
let id = self.mobs[index].id;
let pos = self.mobs[index].position;
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
return true;
}
}
@@ -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))
}
+79 -51
View File
@@ -11,19 +11,20 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
};
use horizon_world::nbt::Tag;
use horizon_world::nbt::{JavaString, Tag};
use std::collections::HashMap;
/// What a decoration is filed under in `MapItemSavedData.decorations` (a
/// `LinkedHashMap<String, MapDecoration>`): a player's marker by their
/// connection, the banner at a position (`MapBanner.getId`), a framed map's
/// frame by its entity (`getFrameKey`).
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Key {
Player(u32),
/// An item's `minecraft:map_decorations` (an explorer map's target), or a
/// decoration a server sent (`addClientSideDecorations`).
Item,
/// A named entry of an item's `minecraft:map_decorations`.
Item(JavaString),
/// A decoration a server sent (`addClientSideDecorations`).
Remote,
Banner([i32; 3]),
Frame(i32),
}
@@ -301,8 +302,8 @@ impl MapData {
self.holders.retain(|h| h.holds(now));
let holders = &self.holders;
let before = self.decorations.len();
self.decorations.retain(|d| match d.key {
Key::Player(w) => holders.iter().any(|h| h.who == w),
self.decorations.retain(|d| match &d.key {
Key::Player(w) => holders.iter().any(|h| h.who == *w),
_ => true,
});
if self.decorations.len() != before {
@@ -365,7 +366,11 @@ impl MapData {
name,
key,
};
match self.decorations.iter().position(|d| d.key == key) {
match self
.decorations
.iter()
.position(|d| d.key == decoration.key)
{
Some(i) if self.decorations[i] == decoration => {}
Some(i) => {
self.decorations[i] = decoration;
@@ -377,6 +382,24 @@ impl MapData {
}
}
}
/// `tickCarriedBy` adds only missing component keys. Another carried
/// copy without those entries must not erase an explorer map's target.
fn add_item_decorations(&mut self, targets: Vec<StaticDecoration>, game_time: i64) {
for (name, sprite, x, z, rotation) in targets {
let key = Key::Item(name);
if !self.decorations.iter().any(|d| d.key == key) {
self.add_decoration(
sprite,
Some(game_time),
key,
x,
z,
f64::from(rotation),
None,
);
}
}
}
/// `MapItemSavedData.tickCarriedBy`'s check on every holder but `who`, the
/// player ticking: one wearing map invisibility gear has no marker.
fn hide_invisible(&mut self, who: u32) {
@@ -785,9 +808,11 @@ fn decoration_type(sprite: &str) -> Option<&'static str> {
.find(|(_, s)| *s == sprite)
.map(|(name, _)| *name)
}
/// `minecraft:map_decorations` of a map item: (sprite, x, z, rotation), in
/// the component's order.
fn static_decorations(stack: &ItemStack) -> Vec<(&'static str, f64, f64, f32)> {
/// A named `minecraft:map_decorations` entry: key, sprite, x, z, rotation.
type StaticDecoration = (JavaString, &'static str, f64, f64, f32);
/// `minecraft:map_decorations` of a map item, in the component's order.
fn static_decorations(stack: &ItemStack) -> Vec<StaticDecoration> {
let Some(Tag::Compound(entries)) = stack.data().and_then(|d| {
d.other
.iter()
@@ -803,13 +828,14 @@ fn static_decorations(stack: &ItemStack) -> Vec<(&'static str, f64, f64, f32)> {
_ => None,
};
entries
.values()
.filter_map(|e| {
.iter()
.filter_map(|(key, e)| {
let sprite = match e.get("type") {
Some(Tag::String(s)) => decoration_sprite(&s.to_string_lossy())?,
_ => return None,
};
Some((
key.clone(),
sprite,
number(e.get("x"))?,
number(e.get("z"))?,
@@ -908,7 +934,7 @@ impl Game<'_> {
.name
.as_ref()
.map(|t| horizon_client::entity_nbt::name_text(t).plain()),
key: Key::Item,
key: Key::Remote,
})
})
.collect()
@@ -1337,7 +1363,7 @@ impl Game<'_> {
.flatten()
.filter_map(|i| self.stack_in(i).and_then(map_id))
.collect();
let carried: Vec<(i32, Vec<(&'static str, f64, f64, f32)>)> = self
let carried: Vec<(i32, Vec<StaticDecoration>)> = self
.inventory
.iter()
.chain(std::iter::once(&self.offhand))
@@ -1345,7 +1371,7 @@ impl Game<'_> {
.filter_map(|s| Some((map_id(s)?, static_decorations(s))))
.collect();
let (yaw, dim) = (self.yaw, self.dimension_id());
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
let game_time = self.world.game_time;
let invisible = self.map_invisibility_worn(&self.armor);
// The guest whose turn it is, else the host.
let me = self.lan_acting.unwrap_or(HOST);
@@ -1370,39 +1396,7 @@ impl Game<'_> {
m.remove_decoration(Key::Player(me));
}
m.hide_invisible(me);
// The item's MAP_DECORATIONS (an explorer map's target), added
// after the players as addDecoration would.
let s = (1 << m.scale) as f32;
let mut items: Vec<Decoration> = Vec::new();
for (sprite, x, z, rotation) in targets {
let dx = (x - f64::from(m.center[0])) as f32 / s;
let dz = (z - f64::from(m.center[1])) as f32 / s;
let inside = (-63.0..=63.0).contains(&dx) && (-63.0..=63.0).contains(&dz);
if !inside && !m.unlimited {
continue;
}
let d = Decoration {
sprite,
x: clamp_map_coordinate(dx),
y: clamp_map_coordinate(dz),
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
name: None,
key: Key::Item,
};
if !items.contains(&d) {
items.push(d);
}
}
if !m
.decorations
.iter()
.filter(|d| d.key == Key::Item)
.eq(items.iter())
{
m.decorations.retain(|d| d.key != Key::Item);
m.decorations.extend(items);
m.decorations_changed();
}
m.add_item_decorations(targets, game_time);
}
for id in held {
self.update_map(id);
@@ -1965,7 +1959,7 @@ mod tests {
y: -30,
rot: 4,
name: None,
key: Key::Item,
key: Key::Remote,
};
assert_eq!(
decoration_pose(&vanilla, -0.02),
@@ -2018,13 +2012,47 @@ mod tests {
));
assert_eq!(map_id(&stack), Some(3));
let found = static_decorations(&stack);
assert_eq!(found, vec![("map/decorations/red_x", 120.0, -300.0, 180.0)]);
assert_eq!(
found,
vec![("+".into(), "map/decorations/red_x", 120.0, -300.0, 180.0)]
);
// calculateRotation(180): (byte) (188 * 16 / 360) = 8.
assert_eq!(decoration_rotation(false, 0, 180.0), 8);
assert_eq!(decoration_rotation(false, 0, -90.0), 12);
assert_eq!(clamp_map_coordinate(-70.0), -128);
assert_eq!(clamp_map_coordinate(10.25), 21);
}
#[test]
fn an_undecorated_copy_does_not_erase_the_treasure_target() {
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
let mut treasure = ItemStack::new("minecraft:filled_map", 1);
treasure.data_mut().map_id = Some(7);
treasure.data_mut().other.push((
"minecraft:map_decorations".into(),
target_decoration("minecraft:red_x", [20, 40, 30]),
));
let mut copy = ItemStack::new("minecraft:filled_map", 1);
copy.data_mut().map_id = Some(7);
map.add_item_decorations(static_decorations(&treasure), 0);
map.add_item_decorations(static_decorations(&copy), 0);
assert_eq!(map.decorations.len(), 1);
assert_eq!(map.decorations[0].sprite, "map/decorations/red_x");
assert_eq!((map.decorations[0].x, map.decorations[0].y), (40, 60));
}
#[test]
fn named_map_targets_are_added_once_without_replacing_other_targets() {
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
let target = |key: JavaString, x| (key, "map/decorations/red_x", x, 30.0, 180.0);
let first_key = JavaString(vec![0xd800]);
let second_key = JavaString(vec![0xd801]);
map.add_item_decorations(vec![target(first_key.clone(), 20.0)], 0);
map.add_item_decorations(vec![target(first_key, 40.0), target(second_key, 25.0)], 1);
assert_eq!(map.decorations.len(), 2);
assert_eq!((map.decorations[0].x, map.decorations[1].x), (40, 50));
// Both entries are off the map: neither may remove an existing target.
map.add_item_decorations(vec![target("absent".into(), 200.0)], 2);
assert_eq!(map.decorations.len(), 2);
}
/// `ItemStack.useOn` / `MapItem.useOn`: in adventure mode, a map whose
/// `can_place_on` component includes the banner block toggles that banner's
+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
}
}
@@ -2394,6 +2394,11 @@ impl Game<'_> {
self.carts.insert(i, c);
} else {
let at = [c.position.x, c.position.y, c.position.z];
// VehicleEntity.destroy: `kill` (the cart's own ENTITY_DIE),
// then the drops; a creative hit only discards it.
if !creative {
self.entity_killed_event(at, false);
}
if self.ui.rules.bool("entity_drops") {
for s in c.items.iter().flatten() {
self.spawn_item(s.clone(), at, [0.0, 0.1, 0.0], 10);
@@ -123,9 +123,11 @@ impl Game<'_> {
set_drop_chance(m, slot.index(), 2.0);
m.persistent = true;
let at = [m.position.x, m.position.y, m.position.z];
let id = m.id;
if drop_old && let Some(old) = old {
self.spawn_item(old, at, super::dispenser::new_item_motion(), 10);
}
self.mob_equip_event(id, at, true);
Some(taken)
}
+15 -1
View File
@@ -289,6 +289,15 @@ impl Game<'_> {
})
};
let m = &mut self.mobs[index];
let id = m.id;
let pos = m.position;
let saddle_off = m.extra.saddled && saddle.is_none();
let body_off = body
.as_ref()
.is_some_and(|b| b.is_none() && m.extra.body.is_some());
let body_on = body
.as_ref()
.is_some_and(|b| b.is_some() && m.extra.body.is_none());
m.set_saddle(saddle);
if let Some(body) = body {
m.extra.body = body.map(|a| a.item);
@@ -300,7 +309,12 @@ impl Game<'_> {
let (x, z) = (at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4);
self.world.modified.insert((x, z));
if let Some(sound) = sound {
self.audio.play(&sound, Some(at), 0.5, 1.0);
self.audio.play(&sound, Some(at), 1.0, 1.0);
}
if saddle_put_on || body_on {
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
} else if saddle_off || body_off {
self.mob_equip_event(id, [pos.x, pos.y, pos.z], false);
}
}
pub(super) fn mount_slots(&self) -> Option<Vec<usize>> {
+13 -2
View File
@@ -170,6 +170,7 @@ impl Game<'_> {
self.mobs[index].set_saddle(None);
let context = self.who_context(Some(Who::Player), None);
self.post_game_event("minecraft:shear", [at.x, at.y, at.z], context);
self.mob_equip_event(id, [at.x, at.y, at.z], false);
self.spawn_at_location(at, offset, saddle.clone());
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_sheared_equipment", &saddle, &target);
@@ -190,8 +191,11 @@ impl Game<'_> {
} else {
"minecraft:entity.pig.saddle"
};
self.audio.play(sound, Some(at), 0.5, 1.0);
self.audio.play(sound, Some(at), 1.0, 1.0);
self.consume_held();
let id = self.mobs[index].id;
let pos = self.mobs[index].position;
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
self.world
.modified
.insert((at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4));
@@ -361,8 +365,15 @@ impl Game<'_> {
if let Some(sound) = sound {
let pitch =
(self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio.play(sound, Some(at), 0.5, pitch);
let chest = item == "minecraft:chest";
self.audio
.play(sound, Some(at), if chest { 0.5 } else { 1.0 }, pitch);
self.consume_held();
if !chest {
let id = self.mobs[index].id;
let pos = self.mobs[index].position;
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
}
self.world
.modified
.insert((at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4));
Loaded 100 of 431 files, more files were not shown because too many files have changed in this diff. Show more