Compare commits

...
1057 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
Ludovic Marie 0000cd3e7d Merge pull request #10538 from arslanarm/shaderpacks/end-fog-sky-fan
🤖 Agent: Requeued at the author's main merge 552f2c1b4: same net patch as d8676fff0 (identical patch-id), whose parity failed only command_exec's 26 /say lines that #11687 has since fixed on main. The change: shader packs in the End draw the sky fan under the dragon fight's fog like the rest of the pack's sky.
2026-11-07 12:37:21 +01:00
Ludovic Marie 2303a445fe Merge pull request #11482 from DUzzL/fix/11298-elytra-glide-refused
🤖 Agent: a refused START_FALL_FLYING now flips the glide bit of sent_shared so a flags update goes out, as LivingEntity.stopFallFlying's set(true)+set(false) dirties the flag in vanilla (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:34:18 +01:00
Ludovic Marie 6c53f16dbf Merge pull request #11591 from rdbtCVS/launcher-notes-in-versions
🤖 Agent: the Patch notes tab's three orphan strings are gone from the 86 lang files, so the launcher's i18n tests pass (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:31:28 +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
Ludovic Marie ee8a79e80e Merge pull request #11706 from Bioneisme/fix/damage-type-command-ids
🤖 Agent: /damage ids now come from the world's damage catalog and are replaced on each world open, so a pack's type doesn't leak into the next world (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:30:08 +01:00
Ludovic Marie f98c9b1f74 Merge pull request #11697 from mvanhorn/fix/6763-memory-first-view-chunk-batch
🤖 Agent: the single-player memory connection sends every ready view column in one batch before PlayerLoaded (about 0.5 s saved at render distance 12), then the 64-per-tick cap comes back; socket guests keep vanilla pacing. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:30:04 +01:00
Ludovic Marie 7524ec7664 Merge pull request #11667 from Cx188/fix/11212-chat-suggestion-callback
🤖 Agent: new client-only, read-only native hook chat_suggestion_applied fires after Tab or a click applies a suggestion, with the replaced range in UTF-16 units of the list's original input; fragment stamps cleanly on main. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:30:01 +01:00
Ludovic Marie 1bb63d1e2a Merge pull request #11647 from Hiuoy/feat/port-pack-events-data
🤖 Agent: port-pack events answers VillagerTradesEvent/WandererTradesEvent with the trade_set and villager_trade registries 26.x moved trades to. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:57 +01:00
Ludovic Marie b85e0f4f67 Merge pull request #11635 from MSedgeMC/port_map/dead-row-verdicts
🤖 Agent: Three ledger rows corrected (destroyAndAck's ack, RateKickingConnection via PacketRate::received, shouldRun as skip:other); checked PacketRate::received exists on main. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:53 +01:00
Ludovic Marie 35b963c3b8 Merge pull request #11547 from abit-foggy/codex/studio-b1-playback
🤖 Agent: Studio playback of .mcpr recordings through the normal client handlers (B1 slice of #9712, +2120), only active when a recording is opened; F1 now hides chat and tab list as vanilla Hud.extractRenderState does. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:50 +01:00
Ludovic Marie e43c075351 Merge pull request #11531 from vnsmoreira/packs/polytone-reader
🤖 Agent: new horizon-polytone crate reads Polytone targets and biome/dimension attribute layers from the public wiki only (Polytone is GPL, its source not read); no caller yet, first step of #11486. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:46 +01:00
Ludovic Marie da21244ed0 Merge pull request #11504 from voidsipp/fix/wind-burst-block-hook
🤖 Agent: the hook runs after the vanilla shuffle so RNG draws are unchanged and a no-mod dispatch is an empty loop; fragment wind_burst passes tests.test_stamp_native_hooks and --check --only on main merged in (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:42 +01:00
Ludovic Marie 2617902218 Merge pull request #11499 from martipops/controller/studio-reach
🤖 Agent: Studio rows and buttons now register as focus targets and the wheel moves one whole 40 px row per notch, which also stops part-scrolled rows drawing over the title and makes the last recording reachable. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:39 +01:00
Ludovic Marie bd5dc3ac4a Merge pull request #11496 from RINNECODER/up/port-chain-models
🤖 Agent: only block/ and item/ paths equal to the old id are renamed, so a mod's own pulley:block/chain and chain_command_block_* stay, and files are rewritten only when something changed (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:36 +01:00
Ludovic Marie 471cdcf2db Merge pull request #11495 from RINNECODER/up/recipe-suggest
🤖 Agent: recipe suggestions reuse json_ids_of_every_namespace like /loot, and the dead ids_under goes (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:32 +01:00
Ludovic Marie 6ffbbfc44c Merge pull request #11479 from k3yh/log-unrunnable-features
🤖 Agent: The unsupported placed features are now named in the log (capped at 20), which is what a pack author needs to fix them. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:29 +01:00
Ludovic Marie c0e74b2b53 Merge pull request #11475 from rison1337/perf/cull-offscreen-flames-taa
🤖 Agent: under TAA the flame gate now uses a frustum widened by one rendered pixel per side (jitter is at most half a pixel: 2× margin), one extra frustum per frame, shader packs still bypass. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:25 +01:00
Ludovic Marie d7cc28f952 Merge pull request #11468 from rison1337/perf/lazy-dropped-item-bounds
🤖 Agent: with entity culling off the decision is Bypass before any bounds are read, so the posed-envelope walk over every model point of every copy is now skipped (TAA condition dropped), RNG order of cluster_poses pinned by a test. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:22 +01:00
Ludovic Marie 16b511ed65 Merge pull request #11464 from TaquitoSuabe/perf/net-keepalive-pooling
🤖 Agent: TCP keepalive (10 s + 3×3 s) now ends dead friends tunnels and relay bridges in ~19 s instead of holding them until the 120 s idle; the stack buffers replace one Vec per connection, not per read. (union of 74 heads on main: fmt, clippy -D warnings, tests green).
2026-11-07 12:29:18 +01: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
Ludovic Marie 98b8827123 Merge pull request #10553 from ilguerro/perf/9996-sections
🤖 Agent: Chunk-section block writes now do one position lookup instead of two on the worldgen hot path; output unchanged, and CI parity on main+head (worldgen, structures, light and the save harnesses it selects) passes in full.
2026-11-07 12:07:04 +01: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 a5129be064 Studio playback: its Remote takes main's recipe_transfer (off)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:55:01 +01:00
Horizon agentandClaude Opus 5.5 7b03ce49c7 Merge origin/main into codex/studio-b1-playback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:54:51 +01: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
Ludovic Marie fd06d1c265 Merge pull request #11716 from horizon-rust/agent/cg70-one-rust-version
🤖 Agent: fixes main's red clippy since #11561 (1.95 lints on interpolation.rs: is_multiple_of on non-zero unsigned divisors, as_chunks_mut::<4> quads); workspace clippy clean on main + this.
2026-11-07 11:34:53 +01:00
Horizon agentandClaude Opus 5.5 484a9d0ae8 Apply the 1.95 lints to main's new interpolation code
is_multiple_of on a usize cell size, and as_chunks_mut::<4> for the four
columns' quads (each quad is now a [f32; 4]).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:32:14 +01:00
Ludovic Marie d3319194cc Merge pull request #11118 from Delete228/native-model-material-stack-upstream
🤖 Agent: Native mods can draw terrain models with an explicit material (shade, UV shift); the shading follows Flywheel's (MIT, credited). Main merged in after #11117 landed; its test now builds faces with #11356's BakedFace::new (clippy -D warnings passes).
2026-11-07 11:18: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 7cb47be9fe The terrain model test builds its face with main's BakedFace::new
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:13:43 +01:00
Ludovic Marie d14e14539b Merge pull request #11419 from voidsipp/fix/round5-4-11335
🤖 Agent: The brewing stand's fuel slot and quick-move both read the brewing_fuel component of the stack, as BrewingStandMenu.FuelSlot and quickMoveStack do.
2026-11-07 11:13:32 +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 b5d3de5ea4 Studio: log why a recording failed to open
Both entry points dropped open_studio_replay's error, so a bad archive did
nothing with no trace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:08:54 +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
Ludovic Marie d8ae358b69 Merge pull request #11699 from Detimes/feat-10404-older-clients-3
🤖 Agent: older clients 26.1→1.20.5 behind accept_older_clients (off by default); a 26.3 client never reaches ClientSession or the components_before_1_21_6 rewrites; per-step unit tests + lan_bot joins one bot per release; doc of older_client fixed.
2026-11-07 11:07:55 +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
Ludovic Marie 2535dd9715 Merge pull request #11584 from rdbtCVS/agent/rdbtcvs/dead-code-review
🤖 Agent: attributes only, no code removed; the 30 kept allow(dead_code) now carry a reason (19 native mod API). Windows/macOS-only code is the risk, CI's other platforms cover it.
2026-11-07 11:04:25 +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
Ludovic Marie e9bd8be99b Merge pull request #11569 from rdbtCVS/agent/rdbtcvs/fewer-duplicate-deps
🤖 Agent: socket2 0.6 keeps every call direct.rs makes (set_reuse_address, the unix-only set_reuse_port, set_only_v6, connect_timeout), so the friends socket code needs no change; lock loses 11 packages.
2026-11-07 11:03:33 +01:00
Ludovic Marie 4972ede23a Merge pull request #11561 from rdbtCVS/agent/rdbtcvs/one-rust-version
🤖 Agent: one workspace rust-version (1.95, which horizon-mods already needed); the second commit is clippy's let-chain/as_chunks/is_multiple_of rewrites, all on unsigned values with non-zero divisors, so no behaviour moves.
2026-11-07 11:02:43 +01:00
Ludovic Marie e86cb3276b Merge pull request #11680 from kacpherek/fix/near-chunks-no-xray
🤖 Agent: near sections no longer dither over far terrain (fade() is 0 or lod::HELD), and the replaced tile still stays behind for lod::FADE through previous, so no hole opens; the test bound lowered to 1,000 matches the half that now come in whole.
2026-11-07 11:01:47 +01:00
Ludovic Marie 3e80662fbb Merge pull request #11621 from horizon-rust/fix/harness-reuse-finished-world
🤖 Agent: respawn_world_point and disconnect_crafting reuse generate/world only after a generate/finished marker, written after server.stop(); an old half-made world regenerates once.
2026-11-07 11:01:04 +01:00
Ludovic Marie a9e99c3385 Merge pull request #11620 from horizon-rust/fix/lunge-jab-tick-diagnostic
🤖 Agent: LocatorBarClient.arc now flags a catch-up tick only before the jab (tick < stabTick); a catch-up right before it still changes the jab's motion, which the harness compares with vanilla, so lunge_motion keeps its signal.
2026-11-07 11:00:58 +01:00
Ludovic Marie 31908392d0 Merge pull request #11527 from rdbtCVS/agent/rdbtcvs/agents-md-no-counts
🤖 Agent: drops the harness/probe/field counts from AGENTS.md and CONTRIBUTING.md; the commands and paths around them are unchanged, so nothing a reader runs moves.
2026-11-07 11:00:53 +01:00
Ludovic Marie 35e72b8da9 Merge pull request #11239 from NikitaMGrimm/screenshot-sequence
🤖 Agent: horizon-ctl screenshot-sequence takes repeatable frames: each waits for the GPU (30 s at most) and for the pack's centre-depth copy, and F2 is refused while a sequence runs. Lands after #11238.
2026-11-07 11:00:45 +01:00
Horizon agentandClaude Opus 5.5 3787a6b53c older_client: its doc names every release it now lets join
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:59:36 +01:00
Horizon agentandClaude Opus 5.5 ee026a522e Replace the damage type ids instead of extending them
by_registry outlives a world, so a pack's type stayed accepted by /damage in
the next world opened. Same as sync_enchantment_registries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:59:32 +01:00
Horizon agentandClaude Opus 5.5 3933ed160b Flame culling under TAA: keep the doc to one paragraph, drop the run samples
The flame culling doc now says TAA uses the widened frustum; the run
notes and samples stay in the PR body (run logs are not docs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:58:34 +01:00
Horizon agentandClaude Opus 5.5 8a83859133 Launcher: drop the Patch notes page's three strings from every language
The page is gone, so i18n's every_language_has_every_string_the_code_asks_for
failed on them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:58:02 +01:00
Horizon agentandClaude Opus 5.5 36d9983718 Merge origin/main into launcher-notes-in-versions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:58:01 +01:00
Horizon agentandClaude Opus 5.5 d306a4c171 Dropped items: skip the posed envelope whenever entity culling is off
entity_cull_decision_in_bounds returns Bypass for EntityCullingMode::Off
before reading the bounds, so the TAA condition only kept an unused walk.
Drop the docs/performance run notes (run logs are not docs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:57:19 +01:00
Horizon agentandClaude Opus 5.5 b06f5f36b9 Merge origin/main into #11118
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:56:15 +01:00
Ludovic Marie 9319ad4bf1 Merge pull request #11356 from ilguerro/perf/mesh-padded-index
🤖 Agent: The mesher bakes face data once and reuses padded cell indices; run-parity on meshing passed, so the output is the same.
2026-11-07 10:55:19 +01:00
Ludovic Marie 411d1ca42c Merge pull request #11515 from horizon-rust/fix/mob-entity-collisions-10445
🤖 Agent: mobs path-stop at shulkers and a held-still happy ghast; earlier parity failures were env + main's /say regression (#11687 fixed it); CI parity reruns with run-parity before landing.
2026-11-07 10:50:43 +01:00
Arslan Armanuly 552f2c1b4b Merge main into #10538 (brings #11687's command_exec /say fix for parity) 2026-11-07 14:46:42 +05: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
Ludovic Marie 59aa0e53df Merge pull request #11690 from horizon-rust/fix/raid-wave-ticking-11374
🤖 Agent: raid wave positions (new and cached) now need an entity-ticking chunk in entity ticking range (ServerLevel.isPositionEntityTicking), not just a loaded one; raid, raid_village and raider_celebration harnesses passed.
2026-11-07 10:38:47 +01:00
Ludovic Marie 6920ab1e45 Merge pull request #11688 from horizon-rust/fix/hidden-effect-save-11384
🤖 Agent: the player save now writes and reads the recursive hidden_effect chain (Poison I under Poison II resumes after a reload); SavedEffect became a struct, guest data uses the same path; active_effects, effect_sync, player_effect_amplifier and status_effect harnesses passed.
2026-11-07 10:38:44 +01:00
Ludovic Marie 9bd77676c8 Merge branch 'main' into fix/mob-entity-collisions-10445 2026-11-07 10:38:40 +01:00
Ludovic Marie c3135f0196 Merge pull request #11274 from NikitaMGrimm/still-weather-at-level
🤖 Agent: HORIZON_STILL now settles a frozen world's rain, thunder and rain fog at the level instead of easing them; it only runs with that dev variable set.
2026-11-07 10:33:20 +01:00
Ludovic Marie 44abc057eb Merge pull request #11238 from NikitaMGrimm/pack-history-reset
🤖 Agent: The shader pack reset (both kinds) lands the in-flight frame before replacing targets, so the next frame draws as a fresh pack's first; dev tooling, no change without the reset command. #11239 follows it.
2026-11-07 10:33:15 +01:00
Ludovic Marie 111ba4fd45 Merge pull request #11197 from arslanarm/shaderpacks/shadow-settings-from-fragment-shaders
🤖 Agent: Shader pack shadow settings are now read from the stage that declares them (fragment const settings), with main's noiseTextureResolution kept on the formats stage; only shaders/ packs see a change.
2026-11-07 10:33:11 +01:00
Ludovic Marie 8a50b63daa Merge pull request #11117 from Delete228/native-still-fluid-render-upstream
🤖 Agent: Native mods can draw a still-fluid volume in a block; the fluid shading follows Ponder's FluidRenderHelper (MIT, credited in THIRD_PARTY_NOTICES) and nothing changes without a mod calling it.
2026-11-07 10:33:08 +01:00
Ludovic Marie afdeb72891 Merge pull request #11689 from horizon-rust/fix/raid-command-11372
🤖 Agent: /raid check now finds a raid still counting down (getRaidAt has no started filter), and /raid sound plays the horn only for "local" at volume 2 with the level random's seed; raid and raid_village harnesses passed.
2026-11-07 10:28:42 +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
bioneismeandClaude Opus 5.5 6cc37bdcef /damage takes content mods' and world packs' damage types
The commands' minecraft:damage_type ids came from the vanilla export
alone, so /damage refused a type from a content mod or a world's data
pack with argument.resource.not_found. Vanilla's ResourceArgument looks
the id up in the server's registries, packs included.

build_registries now adds the damage catalog's types to the registry's
ids after the export is applied, keeping the export's tags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:24:35 +05:00
Ludovic Marie e7bc612275 Merge pull request #11442 from GabrielNichols/codex/recipes-viewer-transfer
🤖 Agent: The viewer's transfer is a separate server-side path: it only moves ingredients the player already has, leaves grid and inventory untouched when one is missing or the inventory is full, and never teaches the recipe; the vanilla recipe book keeps its own path. Second of the Recipes series, after #10523.
2026-11-07 10:24:08 +01:00
Ludovic Marie 7ee4860304 Merge pull request #11111 from voidsipp/fix/round4-1-focused-text-hook
🤖 Agent: The focused text field is exposed to native mods before the character is inserted, so a hook can veto it; the hook fragments stamp cleanly on main. Main merged in (its ChatScreen is_draft argument added to the test).
2026-11-07 10:21:27 +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
Matt Van Horn 5c7efbc427 Send the single-player first view faster than 64 chunks per tick
A memory connection that has not reported the view loaded now sends
every ready view column in one batch, and the recorded quota is raised
to cover that send so the following tick is capped again. A socket
guest, a player who has already loaded, and a respawn stay on the
64-column quota. Unlit columns stay withheld, the far ring still spends
only the leftover vanilla quota, and the gates that send nothing are
unchanged.

Fixes #6763
2026-11-07 01:18:24 -08:00
Ludovic Marie 67902962fd Merge pull request #11115 from Delete228/native-client-update-fields-upstream
🤖 Agent: Transient block entity fields from the update tag reach the native API without being saved; only mods that ask for them see anything new. Main merged in (its new Joined field added to the test).
2026-11-07 10:16:43 +01:00
Ludovic Marie 8aa99b4f64 Merge pull request #11692 from horizon-rust/perf/apple-pcore-workers
🤖 Agent: only the automatic count changes, and only where hw.perflevel0.physicalcpu answers (Apple silicon): M4 Pro 13 → 9 workers with no loading or pacing regression over 3 alternating rounds; off Apple the count is unchanged (tested on Linux), and a manual Worker threads setting still wins.
2026-11-07 10:16:39 +01:00
Ludovic Marie a93c0e1f11 Merge pull request #11687 from horizon-rust/fix/say-capture-broadcast
🤖 Agent: #10015's early return dropped /say from chat_capture, so command_exec lost 26 announcement lines; the broadcast now skips only the acting guest (lan_acting), whose copy stays in the capture in order. Console and command-block paths are unchanged. command_exec PASS fresh on the desktop; the control on main 004a39be0 FAILs 16320/16346.
2026-11-07 10:16:35 +01:00
Ludovic Marie 7615a429d3 Merge pull request #11684 from horizon-rust/fix/tnt-cart-impact-11380
🤖 Agent: a head-on stop on a straight rail no longer blows a TNT cart up (Entity.move zeroes the blocked axis and OldMinecartBehavior never re-applies it), and a glancing hit off its rails now does; tnt_saved_power, tnt_data_adoption and both vehicle harnesses passed.
2026-11-07 10:16:31 +01:00
Horizon agentandClaude Opus 5.5 e533502260 Merge origin/main into #11111; the test's ChatScreen takes main's is_draft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:16:27 +01:00
Ludovic Marie b8206893b2 Merge remote-tracking branch 'origin/main' into w-11111 2026-11-07 10:16:27 +01:00
Detimes 81c1cd8b33 Merge remote-tracking branch 'upstream/main' into via-3 2026-11-07 09:15:38 +00:00
Ludovic Marie f4858e8a25 Merge pull request #11186 from flexeykinDev/install-ps1-signature
🤖 Agent: install.ps1 now verifies the release signature with the release-keys.txt key, the same check install.sh already does.
2026-11-07 10:12:13 +01:00
Ludovic Marie 261a343e75 Merge pull request #11428 from Akirabane/feat/transfer-refuse
🤖 Agent: The player-expect move now refuses a full server like a normal join would, and operators still bypass it as vanilla's PlayerList.canBypassPlayerLimit does.
2026-11-07 10:12:09 +01:00
Ludovic Marie 5798723345 Merge branch 'w-11238' into w-11239 2026-11-07 10:12:03 +01:00
Horizon agentandClaude Opus 5.5 37f3271472 Merge origin/main into #11238; the reset tests use main's NativeFrame api and NativePost tonemap arguments
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:11:37 +01:00
Ludovic Marie d887b1331d Merge pull request #11358 from NikitaMGrimm/hdr-step3-float-frame
🤖 Agent: The float HDR frame is only used with HORIZON_HDR_FRAME=float, so the default frame format and its cost are unchanged.
2026-11-07 10:11:24 +01:00
Ludovic Marie 2fcdb9f7af Merge remote-tracking branch 'origin/main' into w-11238 2026-11-07 10:11:21 +01:00
Horizon agentandClaude Opus 5.5 99809b5a55 Merge origin/main into #11115; the new test's Joined takes main's configured field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:09:44 +01:00
Ludovic Marie bc472639ed Merge remote-tracking branch 'origin/main' into w-11115 2026-11-07 10:09:36 +01:00
Ludovic Marie 7ebad255b0 Merge pull request #11107 from Detimes/feat-10404-older-clients-2
🤖 Agent: 26.2 packet layouts are only used inside a session that negotiated V26_2, and accept_older_clients stays off by default, so a 26.3 client's path is unchanged; the lan_bot tests cover both.
2026-11-07 10:08:01 +01:00
Ludovic Marie 1faa3281c2 Merge pull request #10523 from GabrielNichols/codex/recipes-inventory-settings
🤖 Agent: The Recipes settings button opens from the inventory browser; the JEI-derived icon is MIT and credited in THIRD_PARTY_NOTICES. First of the Recipes series (#11442 → #11453 follow).
2026-11-07 10:07:20 +01:00
Horizon agentandClaude Opus 5.5 9d1c979e91 Merge #11117 (with main) into #11118
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:00:02 +01:00
Horizon agentandClaude Opus 5.5 b00d753c62 Merge origin/main into #11117
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:59:20 +01:00
Ludovic Marie 7ab92f9eb7 Merge pull request #11683 from horizon-rust/fix/command-oracle-light-wait
🤖 Agent: only reinforcement_room uses the oracle's light op, so the wait (ThreadedLevelLightEngine.waitForPendingTasks, ticking like settle in a frozen world) changes no other harness's records; command_reinforcements --fresh --repeat 3 PASS 3/3 at desktop load 40-48, where main failed 7 of 10.
2026-11-07 09:58:28 +01: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 278ffc3b96 Merge #11238 (with main) into #11239
take_screenshot returns main's Option<PathBuf>; a running sequence still
refuses F2 (None).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:57:30 +01:00
Horizon agentandClaude Opus 5.5 7cfee48c36 Merge origin/main into #11238
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:56:45 +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 8667fab8c9 Merge origin/main into #11274
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:54:04 +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 bbe57b6b19 Merge origin/main into #11419
Keep the brewing fuel test beside main's new merchant test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:51:56 +01:00
Ludovic Marie 0876de55cc Merge pull request #9974 from daniliammo/fix/launcher-prune-nightlies
🤖 Agent: code-owner approval given; pruning only touches marker-carrying Nightlies, manual/legacy ones are outside the quota (tests cover all four cases).
2026-11-07 09:47:56 +01:00
Ludovic Marie 6f01443e55 Merge pull request #11551 from horizon-rust/fix/11519-checkerboard-negative-scale
🤖 Agent: vanilla's CheckerboardColumnBiomeSource sets bitShift = size + 2 with no clamp, and Java's >> masks the distance; wrapping_add(2) as u32 with wrapping_shr does the same. biome_source PASS with fresh Java on the desktop (main + this head).
2026-11-07 09:39:35 +01:00
Horizon agentandClaude Opus 5.5 2e41b259c2 Merge main into #11197 (noiseTextureResolution kept beside the stage-aware reads)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:33:55 +01:00
Ludovic Marie e255c1b2fa Merge pull request #9967 from TheMrZZ/fix/h324-craft-map-swap
🤖 Agent: a map crafted from the grid gets Item.onCraftedBy on the taken stack and the scaling recipe reads the level's maps; merged with main's merchant on_take; craft_map_take and merchant_crafted pass.
2026-11-07 09:33:52 +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
Ludovic Marie 1e35443f6e Merge pull request #11532 from horizon-rust/fix/11520-harness-plain-run
🤖 Agent: dropping the --reference-only assertion makes a plain run go through runner.main()'s full Java/native comparison, and then the strict tags/pot report, so CI now compares instead of exiting in 0 s. mob_discard's default --resources is lab.RESOURCES (the prepared 26.3 folder) and it gets the missing import os. The two sound harnesses take main's #11555 version.
2026-11-07 09:23:03 +01:00
Ludovic Marie 5075231ffa Merge pull request #10031 from TheMrZZ/fix/h326-boat-live-leash
🤖 Agent: data get on a leashed boat now reads its leash from the same save path the world writes (write_boat_leash); merged main twice, boat_leash_view harness passes and the u/3 union builds with clippy -D warnings.
2026-11-07 09:22:59 +01:00
Ludovic Marie 004a39be07 Merge pull request #11670 from horizon-rust/fix/breeding-bottle-xp-11388
🤖 Agent: breeding now adds one fresh orb of the whole 1-7 roll (no award split, no merge), and XP bottle orbs start 0.25 along the hit face's normal with their motion turned away from it; orb_lava/orb_water/orb_saved_uuid/bed_explosion harnesses passed.
2026-11-07 09:17:36 +01:00
Ludovic Marie d4dc032645 Merge pull request #10024 from some-du6e/optifine/sky-draw
🤖 Agent: OptiFine custom skies draw their layers over the vanilla sky, only when a pack ships optifine/sky and the Horizon sky option is on (a frame without one only clears an empty list); merged main, clippy and the sky tests pass.
2026-11-07 09:17:32 +01:00
Horizon agentandClaude Opus 5.5 81261cc98d Apple silicon: size the chunk worker pool from the performance cores
The automatic pool size was every core but one. On Apple silicon the
efficiency cores count too, so a base M4 (4 performance + 6 efficiency
cores) ran 9 workers that kept the render and server threads waiting for a
performance core: fast flight's p99 frame went from 20 ms to 7 ms with 3
workers (#11294). The automatic count now leaves one of the performance
cores (hw.perflevel0.physicalcpu) instead; elsewhere it is unchanged, and
Graphics > Worker threads still overrides it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:14:33 +01:00
Ludovic Marie 36fc4fa187 Merge pull request #9991 from MSedgeMC/fix/can-be-a-baby
🤖 Agent: a negative Age makes a baby only where AgeableMob.canBeABaby allows (frog, parrot, zombie nautilus, camel husk, slime, magma cube stay adults: box, voice, loot); ageable_integer_load, entity_death_loot and baby_pig_attachment pass.
2026-11-07 09:13:51 +01:00
Ludovic Marie 9ab09b8ed4 Merge pull request #10032 from masa-masa-0314/feat/realms-reset-world
🤖 Agent: Realms reset-world, templates and upload, stacked on #9982 (land after it); upload goes only to the endpoint the service returns; realms tests pass.
2026-11-07 09:13:47 +01:00
Ludovic Marie 92b2dbfbc7 Merge pull request #9982 from masa-masa-0314/feat/realms-backups
🤖 Agent: Realms backup list and backup info screens on main's Realms code (only the backups commit kept; the carried settings/invite copies already landed); restore asks the service and re-reads the list; realms tests pass.
2026-11-07 09:13:43 +01:00
Ludovic Marie b3eee8adef Merge pull request #10130 from TheMrZZ/fix/guest-advancement-selectors
🤖 Agent: advancements= in a selector reads the selected player's own progress, a guest's included (EntitySelectorOptions on the ServerPlayer); guest_advancement_selector passes on the merge, now runnable by parity.py.
2026-11-07 09:10:49 +01:00
Horizon agentandClaude Opus 5.5 542f14f3bf Merge the rebuilt backups branch: reset, templates and upload on top
The two reset-world commits rebuilt on the backups branch (both PRs extend the
same configure screen): the scroll wheel covers the product page, template and
upload lists, and the worlds tab has Options and Backup side by side with
Reset below.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:10:28 +01:00
Horizon agentandClaude Opus 5.5 bdb927a7c4 Merge main: the backups commit rebuilt on main
This branch also carried early copies of the settings-tab and invite-code
commits, which landed on main as #9926 and #9968 and changed since. The tree
is main with only the backups commit on it, plus the configure screen's
tier fields that main added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:10:27 +01:00
Horizon agentandClaude Opus 5.5 3788883346 /say in a captured command: the acting player gets it with the output
#10015 broadcast /say from cmd_say to every guest and dropped the line
from chat_capture. The acting guest still saw it, but the command
harness reads the capture, so command_exec lost every /say line
("[Player] relative dimension", the h154 function's "[Pig] H154
fallthrough") and failed parity-main at ebcfa2263a.

When a guest's command is captured, the broadcast now skips that guest
and the line stays in the capture, in order with the other output, which
is delivered to them as before. The console path is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:09:00 +01:00
Ludovic Marie 62a480890c Merge pull request #11679 from horizon-rust/docs/roadmap-drop-alt-accounts
🤖 Agent: roadmap.toml is the source; ROADMAP.md and roadmap.json regenerated (build --check passes). older-servers kept on purpose (1.8 PvP servers), only the sign-in item goes.
2026-11-07 09:08:00 +01:00
Horizon agentandClaude Opus 5.5 c4a240cb63 Roadmap: drop alt-accounts (other sign-in providers)
Mojang's approval covers Microsoft sign-in only; other account providers
(Ely.by, Yggdrasil servers) are against the rules Horizon plays by, so the
item can't ship. #1270 stays closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:05:10 +01:00
Horizon agentandClaude Opus 5.5 7ea951a07e Merge main: mob_local_sound and ominous_warning_sound already run plainly (#11555)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:04:53 +01:00
Horizon agentandClaude Opus 5.5 2f548e8b23 Run the raid command test with an operator's permission
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:04:27 +01:00
Horizon agentandClaude Opus 5.5 64d97f7db8 Command oracle: read light once the queued light updates ran
ThreadedLevelLightEngine runs the checkBlock tasks a /fill queues at most
1000 per update. command_reinforcements fills ~100k blocks around its room
and then requires block and sky light 0 there; on a loaded runner the
vanilla side still read the old light (7, 9, 10 at different positions),
which failed parity-main and 7 of its bisect steps back to 11-05 merges.
The light op now waits for waitForPendingTasks on that chunk, ticking as
settle does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:03:33 +01:00
Ludovic Marie 39e13b9f70 Merge pull request #11556 from horizon-rust/fix/remote-server-errors-i18n
🤖 Agent: follow-up to #10166: the remote server's SSH errors go through tr/trf with named placeholders, English stand-ins in all 86 lang files.
2026-11-07 09:02:23 +01:00
Ludovic Marie 90bdc031fe Merge pull request #11557 from horizon-rust/fix/macos-titlebar-without-glass
🤖 Agent: follow-up to #10129: with glass off, macOS still gets its 32 px title bar (plain side colour), so content no longer sits under the traffic lights.
2026-11-07 09:02:20 +01:00
Ludovic Marie b66a4683f1 Merge pull request #11617 from horizon-rust/fix/fullscreen-unmaximize-windows-only
🤖 Agent: follow-up to #10177: the unmaximize-before-fullscreen step runs on Windows only, and apply() no longer re-requests fullscreen each call while the window reports maximized.
2026-11-07 09:02:17 +01:00
Horizon agent cdd4332ae4 Merge remote-tracking branch 'origin/main' into n/11556
# 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 08:59:28 +01:00
Horizon agent 4835c74731 Merge remote-tracking branch 'origin/main' into n/9967
# Conflicts:
#	crates/horizon-client/src/menu.rs
#	tools/test_command_exec_parity.py
2026-11-07 08:58:08 +01:00
Horizon agent de976257da Merge remote-tracking branch 'origin/main' into n/10031
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-07 08:57:25 +01:00
Horizon agentandClaude Opus 5.5 33f6584496 guest_advancement_selector harness: run both sides by default
parity.py runs a harness with no arguments; --side was required, so every
automatic selection failed it at once. The default now runs Java, then native
against Java's facts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:56:10 +01:00
Kacpherek 7386e3968f Draw near sections whole over far terrain: their dither showed the caves inside 2026-11-07 08:50:34 +01:00
Horizon agentandClaude Opus 5.5 9478067990 Spawn raid waves only where entities tick
Raid.findRandomSpawnPos and the cached wave position both ask
ServerLevel.isPositionEntityTicking: an entity-ticking chunk in entity
ticking range. Horizon accepted any loaded chunk, so a wave could spawn
past the simulation distance, where its raiders would never tick.

Part of #11374 (the spawn positions; the raid/spawner order stays open).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:48:52 +01:00
Ludovic Marie 37d8dc0e56 Merge pull request #11661 from horizon-rust/fix/copper-chest-viewers-11378
🤖 Agent: a copper chest someone has open now skips its weathering step (WeatheringCopperChestBlock.randomTick checks getEntitiesWithContainerOpen); random_tick and random_tick_sections harnesses passed.
2026-11-07 08:48:46 +01:00
Horizon agentandClaude Opus 5.5 d12e6637f0 Let the raid command test reach the raids
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:47:14 +01:00
Ludovic Marie 1f205c93df Merge pull request #11022 from ilguerro/fix/9776-block-vibrations
🤖 Agent: using items (fishing rod, ender eye…) now posts vanilla's item-use game events through causeUseVibration, so sculk sensors react as in Java; sculk, server_player_sculk, server_wool_vibration and use_item_on pass against the 26.3 oracle in the gate's harness run (242 selected; failures were host setup only plus the lunge_motion timing flake).
2026-11-07 08:42:05 +01:00
Ludovic Marie 66b8b9e69c Merge pull request #11017 from TheMrZZ/fix/h101-merchant-crafted-stat
🤖 Agent: crafting with a merchant result slot now fires vanilla's merchant crafted trigger and updates the trade, checked by the new --merchant-crafted-fixture in command_exec; merchant_crafted, command_exec and trading pass against the 26.3 oracle in the gate's harness run (242 selected; failures were host setup only plus the lunge_motion timing flake).
2026-11-07 08:42:02 +01:00
Ludovic Marie 17995c544f Merge pull request #10837 from TheMrZZ/fix/non-mob-saved-mob-fields
🤖 Agent: item entities, armor stands and boats now save the shared Entity flags (Invulnerable, CustomNameVisible, Silent, NoGravity, Glowing, HasVisualFire, Tags) as vanilla's saveWithoutId does. Its 242 selected harnesses ran on main + this head + #11017 + #11022: save_roundtrip, world_reopen, entity_nbt_boolean, the armor stand and boat ones pass; the only failures are setup (no sound/lang assets on the host, harness args) and the known lunge_motion tick-timing flake under software rendering.
2026-11-07 08:41:57 +01:00
Horizon agentandClaude Opus 5.5 31338299d6 Format the orb tests; give the orb merge fixture merge_new_orb
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:36:45 +01:00
Horizon agentandClaude Opus 5.5 e2d24c60a4 Detonate a TNT minecart from the speed left after its move
MinecartTNT.tick reads the delta after AbstractMinecart.tick. The move
zeroes the blocked axes and no track step puts them back, so a cart
stopped head-on by a wall on a straight rail does not blow up, while one
glancing off a wall keeps its free axis and does, on or off its rails.
Horizon kept the speed the cart asked for before the move on the rail
paths and none off them.

Part of #11380 (the impact detonation; the team push points stay open).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:28:43 +01:00
Ludovic Marie 648f935466 Merge pull request #11657 from horizon-rust/fix/bee-hive-11377
🤖 Agent: overflow honey now leaves through Player.drop (eye height − 0.3, thrown along the view, pickup delay 40) instead of popping at the feet; held_drop harness passed, plus Game tests for Peaceful anger and event 2011.
2026-11-07 08:26:33 +01:00
Horizon agentandClaude Opus 5.5 23d08068aa Keep a player's hidden effects in the save
MobEffectInstance.Details nests the instance an effect hides under
hidden_effect, and the player save writes the whole chain. Horizon wrote
only the top instance, so a weaker, longer effect (Poison I under
Poison II) was gone after a reload instead of resuming. SavedEffect is
now a struct carrying that chain; guest player data goes through the
same code.

Part of #11384 (the player side; mobs still drop weaker instances).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:24:54 +01:00
Cx188andCodex 4f0045d2d0 Notify native mods when chat suggestions are applied
Let native mods keep shared-item text aligned when a player accepts or
cycles chat suggestions. Report the original input, UTF-16 replacement
range, selected suggestion and resulting text. Cover keyboard and mouse
handling, server suggestions, emoji and length limits.

Part of #11212.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 10:24:13 +03:00
Ludovic Marie 6ab56ba9f1 Merge pull request #11600 from horizon-rust/fix/audio-probe-off-render-thread-11490
🤖 Agent: the device list is now read on a worker thread, so a slow Windows audio driver no longer stalls a frame; the render thread only swaps in the result. No vanilla behaviour changes, no harness covers it.
2026-11-07 08:23:53 +01:00
Horizon agentandClaude Opus 5.5 d193cb15ca Make /raid check find a raid counting down; horn only for "local"
RaidCommand.getRaid is getRaidAt, which returns a raid that has not
started its first wave yet. playSound plays the horn only for the text
"local", at volume 2 with the level random's seed. The status line
concatenates its floats the Java way (20.0, not 20).

Fixes #11372.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:21:25 +01:00
Ludovic Marie cc7f3b4c22 Merge pull request #11648 from horizon-rust/fix/effect-show-icon-11385
🤖 Agent: effects now carry vanilla's showIcon apart from the particles (5-arg constructor, update, save codec default, packet bit 4), so warden Darkness and /effect hideParticles no longer show a HUD icon; 8 effect/HUD/sculk harnesses pass.
2026-11-07 08:18:46 +01:00
Horizon agentandClaude Opus 5.5 b1eaf67925 One fresh orb from breeding; bottle orbs start away from the hit
- Animal.finalizeSpawnChildFromBreeding adds one new ExperienceOrb of the
  parent's 1-7 roll: breeding no longer splits it (4 into 3 + 1) or merges.
- ThrownExperienceBottle.onHit awards with direction: away from the block
  face it hit, or back along its motion after an entity hit; the new orb
  starts 0.25 that way and a random motion against it is turned round
  (ExperienceOrb(level, pos, roughly, value)) (#11388).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:06:24 +01:00
Ludovic Marie ebcfa2263a Merge pull request #11637 from horizon-rust/fix/lightning-fire-radius-11376
🤖 Agent: lightning now places fire (and draws its extra-source offsets) only when canSpreadFireAround passes at the bolt, so fire_spread_radius_around_player holds for strikes far from every player.
2026-11-07 07:40:30 +01:00
Horizon agentandClaude Opus 5.5 185187e7e2 Don't weather a copper chest someone has open
WeatheringCopperChestBlock.randomTick runs changeOverTime only when the
chest's getEntitiesWithContainerOpen is empty, so an open chest neither
oxidizes nor draws its odds (part of #11378).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 07:30:45 +01:00
HiuoyandClaude Opus 5.5 383f733f9e Port pack: villager and wandering trader trade events are data in 26.x
`horizon-port-pack events` gives a fifth answer, data: NeoForge's
`VillagerTradesEvent` and `WandererTradesEvent` are gone since vanilla 26.1
made trades the `trade_set` and `villager_trade` registries (NeoForge's 26.1
primer), so a port writes trades as data instead of waiting for a hook.
On ATM10 that answers 33 uses (20 and 13 mods).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 10:29:37 +04: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
Ludovic Marie 7127fb8ed1 Merge pull request #10065 from ilguerro/fix/thorns-victim
🤖 Agent: merged main again (#10015's lan_e2e check landed beside this one's); the Thorns change itself is the head the mace harness passed on.
2026-11-07 07:19:55 +01:00
Horizon agent 9d35a0c0ad Merge remote-tracking branch 'origin/main' into n/10065
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 07:18:08 +01:00
Ludovic Marie b6aa962829 Merge pull request #10035 from IsT3RiK/traduction-messages-mods
🤖 Agent: the Mods page's install and port messages go through tr/trf with named arguments, so they translate; lang files merged key by key onto main, i18n tests pass.
2026-11-07 07:05:28 +01:00
vegetableandOpenAI Codex 3baa4f56ae Cover chunk reload invariants
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-07 11:03:01 +05:00
Horizon agentandClaude Opus 5.5 a03c91e630 guest_advancement_tests: Joined gained configured on main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:59:51 +01: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
Ludovic Marie c1102d9227 Merge pull request #11628 from horizon-rust/fix/absorption-instant-shift-11386
🤖 Agent: a mob's Absorption now refills on every accepted add (AbsorptionMobEffect.onEffectStarted), and heal/harm and effect intervals shift like Java's int << / >> (distance mod 32, wrapping) instead of clamping or panicking at amplifier 32+.
2026-11-07 06:58:08 +01:00
vegetableandOpenAI Codex 2b9cb6ba26 Fix chunk reload invalidation
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-07 10:56:42 +05:00
MSedgeMC a4951f7377 port_map: three rows the tree has moved past
Guest break refusal is the sequence ack every PlayerAction arm sends, the packet rate limit is PacketRate::received,
and MinecraftServer.shouldRun has no haveTime budget to defer against in Horizon's loop.
2026-11-07 05:54:17 +00: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
abit-foggyandCodex 3ec6d2426f fix(studio): restore title and hand state after replay
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:47:05 -10:00
abit-foggyandCodex 34c4cd3322 fix(studio): wait for world resources before replay entry
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:47:05 -10:00
abit-foggyandCodex 2c142a1d49 fix(studio): drop stale HUD height state
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:47:05 -10:00
abit-foggyandCodex b83cbac5c4 chore(studio): merge latest main into playback
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:47:05 -10:00
abit-foggyandCodex 49471d6449 fix(studio): initialize replay save folder
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:46:53 -10:00
abit-foggyandCodex 2e57c30ab2 chore(studio): merge main into playback
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:46:53 -10:00
abit-foggyandCodex 745dc168bc fix(studio): rebuild replay state on seek
Co-Authored-By: Codex <[email protected]>
2026-11-06 19:46:21 -10:00
abit-foggyandCodex 698b836e2e fix(studio): route world exit through replay cleanup
Send pause-menu and death-screen exits through close_studio_replay when playback is active. Clear the replay timer and restore the prior camera so the title screen and the next world use normal player and hand rendering.

Co-Authored-By: Codex <[email protected]>
2026-11-06 19:46:21 -10:00
abit-foggyandCodex c8388a50f6 fix(studio): recover failed replay opens and return to title
Open and validate a replay archive before replacing the current screen. Track standalone replay sessions so closing one disconnects its synthetic remote and restores the title screen. Start playback in first person after applying timestamp-zero world packets.

Co-Authored-By: Codex <[email protected]>
2026-11-06 19:46:20 -10:00
abit-foggyandCodex c8d6221624 feat(studio): add MCPR streaming reader and archive options
Inflate recording streams to a bounded temporary file, verify the stream CRC during the copy, and index a sparse set of seek anchors instead of retaining every packet in memory. Preserve whole-frame recovery and timestamp-group seeking.

Co-Authored-By: Codex <[email protected]>
2026-11-06 19:46:20 -10:00
abit-foggy 44a7fa4dda fix(studio): smooth replay limb animations with virtual tick pacing 2026-11-06 19:46:20 -10:00
abit-foggy 53304d1bb6 fix(studio): advance walk limb animations during replay playback 2026-11-06 19:46:20 -10:00
abit-foggy 4dfa1dc31f feat(studio): record local player swing and pose animations into stream 2026-11-06 19:46:20 -10:00
abit-foggy 06e83b2f80 feat(studio): hide first-person hand and player hotbar in studio mode 2026-11-06 19:46:19 -10:00
abit-foggy 17c146a7be feat(studio): record local player movement into replay stream 2026-11-06 19:46:19 -10:00
abit-foggy 438b47c86b fix(studio): dump initial world snapshot on record start and return to main menu on close
- Dump resident chunk columns, player pose, center chunk, and dimension login into .mcpr stream at timestamp 0ms on recording start
- Allow all chunks in remote_chunk_in_range during studio replay and freecam so distant chunks are never discarded
- Reset title mode to Mode::Main when closing studio replay so the user returns to the main menu instead of multiplayer
2026-11-06 19:46:19 -10:00
abit-foggy bd9883050e fix(studio): bypass remote loading overlay and panorama in studio replay 2026-11-06 19:46:19 -10:00
abit-foggy 0faacc6a85 fix(studio): launch 3D replay editor from studio browser and title screen
- Add interactive glass button for '▶ Edit' on recording cards in StudioUi
- Allow single-clicking '▶ Edit' on any card to immediately load and open the editor
- Allow clicking the bottom 'Edit Replay' button to open the selected or first replay
- Handle Event::OpenStudioReplay in Loading::menu_option_events by queueing PendingWorldEntry::StudioReplay
- Handle PendingWorldEntry::StudioReplay in Game::poll_pending_world_entry to enter replay
- Add Session::replay and init_remote_for_replay to initialize client state when entering replay from title screen
- Dismiss title screen and uncapture cursor so timeline and studio HUD controls are interactive
- Release mouse cursor on Escape key in studio replay mode before opening pause menu
2026-11-06 19:46:19 -10:00
abit-foggy 6a9f30fd40 feat(studio): packet recording and replay playback (.mcpr container format)
- Implement standard .mcpr container reader/writer (metaData.json + VarInt timestamped recording.tmcpr stream)
- Add asynchronous StudioRecorder worker to record clientbound packets with zero impact on gameplay FPS
- Add ReplayDriver with timeline seeking, variable speed control, pausing, and looping
- Wire zero-alloc packet recording tap into Session for singleplayer, LAN, and remote multiplayer
- Add in-game /studio chat commands and control socket commands for recording and replay management
2026-11-06 19:46:18 -10:00
Horizon agentandClaude Opus 5.5 097dd3a63f Bees: Peaceful ends player anger, crop sparks, thrown overflow honey
- NeutralMob.isValidPlayerTarget also fails on Peaceful: a bee's anger at
  the player ends there.
- BeeGrowCropGoal sends level event 2011 (data 15) before the crop grows;
  the client draws it like 2012 (spawnParticleInBlock, happy villager).
- A honey bottle that no longer fits is player.drop(stack, false): from
  below the eyes, thrown along the view, no thrower (#11377).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:45:56 +01: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
Horizon agent 057869bdad Merge remote-tracking branch 'origin/main' into n/10035
# 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 06:43:57 +01: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
Horizon agent ef3856a408 Merge remote-tracking branch 'origin/main' into n/10024
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
#	crates/horizon-client/src/game.rs
2026-11-07 06:42:20 +01: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
Horizon agent 06ca1059f8 Merge remote-tracking branch 'origin/main' into n/9991
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
#	crates/horizon-client/src/mobs/living.rs
#	tools/port_map/game-004.tsv
2026-11-07 06:40:22 +01:00
Ludovic Marie 84ec8516c6 Merge pull request #11622 from horizon-rust/fix/frame-map-limit-11383
🤖 Agent: an empty frame now refuses a map tracking more than 256 counted markers (ItemFrame.interact's isTrackedCountOverLimit FAIL): the map stays held, no swing; exactly 256 still goes in.
2026-11-07 06:40:06 +01:00
Horizon agentandClaude Opus 5.5 4a951a3199 Drop the harness run log from docs/parity
The harness and its tests hold what the log recorded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:38:27 +01:00
Horizon agent b09ef7084f Merge remote-tracking branch 'origin/main' into n/10130
# Conflicts:
#	crates/horizon-client/src/game/scoreboard.rs
2026-11-07 06:37:08 +01:00
Horizon agent b4c2f89660 Merge remote-tracking branch 'origin/main' into n/9967
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 06:36:08 +01:00
Ludovic Marie 4500b9e72b Merge pull request #10015 from repositorykreml1n/codex/fix-issue-9377
🤖 Agent: /say from a command block reaches every player under the block's name, and a captured guest command no longer echoes it back as feedback (kacpherek's point); lan_e2e command-block-chat not run here.
2026-11-07 06:35:42 +01:00
Horizon agent b0ae048383 Merge remote-tracking branch 'origin/main' into n/10031
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-07 06:34:52 +01:00
GabrielNichols 4b753f5956 Report Recipes warnings at their visible tooltip bounds 2026-11-07 02:33:12 -03:00
Horizon agentandClaude Opus 5.5 2710eae7cf Start a bolt's fire only near a player
LightningBolt.spawnFire places fire, and draws its extra sources, only when
ServerLevel.canSpreadFireAround passes at the bolt: the
fire_spread_radius_around_player rule now holds for lightning (#11376).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:30:22 +01:00
Ludovic Marie ac40181853 Merge pull request #11613 from horizon-rust/fix/omen-added-sound-11373
🤖 Agent: the omen sound now plays from the player's addEffect for any newly added omen (command, potion, mod) at the feet, as MobEffect.onEffectAdded does; the three special paths moved from the eye to the feet too.
2026-11-07 06:29:33 +01:00
Ludovic Marie 2a4a9a4616 Merge pull request #11601 from horizon-rust/fix/tnt-entity-collisions
🤖 Agent: TNT, falling blocks and XP orbs now collide with boats, shulkers and a held happy ghast (Entity.collide's canBeCollidedWith); sand landing on a boat over air breaks into an item like vanilla's wouldContinueFalling.
2026-11-07 06:29:29 +01:00
Horizon agent bec6e40518 Merge remote-tracking branch 'origin/main' into n/10065
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 06:26:44 +01:00
Horizon agent 702adabb25 Merge remote-tracking branch 'origin/main' into n/10015
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 06:24:27 +01:00
Ludovic Marie eb2d90fb10 Merge pull request #10040 from NikitaMGrimm/shader-api-02-serve
🤖 Agent: 0.2 shader packs get the float HDR frame only under HORIZON_HDR_FRAME=float; the default and 0.1 paths keep their formats (frame_format tests cover both), so the vanilla look is unchanged.
2026-11-07 06:18:07 +01:00
Horizon agentandClaude Opus 5.5 880f8c6b56 Keep an effect's show_icon apart from its particles
MobEffectInstance.showIcon was not modelled: every effect showed its HUD
icon and every UpdateMobEffect carried the icon flag. Warden Darkness
(no particles, no icon) showed an icon. The flag now follows vanilla's
constructors, update, saves (show_icon defaults to show_particles),
consume effects and the packet flags (#11385).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:16:04 +01:00
Ludovic Marie 5a83184c7e Merge pull request #10170 from TheMrZZ/fix/furnace-loaded-cooking-total
🤖 Agent: a loaded furnace keeps its saved cooking total until the item finishes, and a hopper or dropper filling the empty input slot restarts the cooking as setItem does (kacpherek's point); furnace_loaded_total and furnace_output_components pass.
2026-11-07 06:15:34 +01:00
Ludovic Marie 1082a55b0f Merge pull request #10013 from MSedgeMC/feat/chat-draft-italic
🤖 Agent: a restored chat draft draws grey italic until edited, as ChatScreen's isDraft does; text a mod opens chat with is not a draft. Merge with main builds and the chat tests pass.
2026-11-07 06:15:30 +01:00
Horizon agentandClaude Opus 5.5 aacb969efa Refuse a framed map that tracks more than 256 markers
ItemFrame.interact returns FAIL when MapItemSavedData.isTrackedCountOverLimit(256):
the map stays in the hand and the frame stays empty (part of #11383).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:15:02 +01:00
Horizon agentandClaude Opus 5.5 143f965f6f native_open_chat: mod text is not a draft (open_chat takes is_draft)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:12:51 +01:00
Ludovic Marie 318e263959 Merge pull request #10119 from DUzzL/fix/10049-frost-walker-equip
🤖 Agent: Frost Walker's speed modifier now follows a boots change made by a guest too (the guest's armor is swapped in before the comparison); armor_changes copies only changed slots. armor_stand_frost passes on a union with main.
2026-11-07 06:12:42 +01:00
rdbtandClaude Opus 5.5 d2b41ed400 Parity harnesses: a generate step that died is not reused
respawn_world_point and disconnect_crafting reused generate/world whenever the
folder existed, so one crash left them failing until someone deleted it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:09:02 +00:00
rdbtandClaude Opus 5.5 7c5466e763 lunge_motion: the tick after a jab is not a catch-up before it
Since #11223 the server takes what follows a jab on the next tick, so the
harness's time query comes back a tick later and every jump failed as a
catch-up. The motions are identical to vanilla's and stay compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:06:46 +00:00
Horizon agent 955cd0b289 Merge remote-tracking branch 'origin/main' into n/10013 2026-11-07 05:59:01 +01:00
Ludovic Marie 29082ca5d1 Merge pull request #10009 from DUzzL/fix/9959-spectator-portals
🤖 Agent: a spectator using a portal block travels to its destination, never builds a Nether exit, and gets spectator.cannot_teleport without one (ServerPlayerGameMode.useItemOn); nether_portal harness passes on a union with main.
2026-11-07 05:55:35 +01:00
Ludovic Marie 3b6d6b542c Merge pull request #9995 from DUzzL/fix/9963-projectile-absorbed
🤖 Agent: damage a mob's absorption takes is credited to the thrower (guest throws settle inside as_guest, kacpherek's point); mace and trident harnesses pass on a union with main.
2026-11-07 05:55:32 +01:00
rdbtandClaude Opus 5.5 f670d71c41 Parity harnesses: mob_discard, mob_local_sound and ominous_warning_sound run on a plain run
tools/parity.py runs a harness with no arguments, and these three required
--binary, --work-dir or --resources (mob_discard also used os without importing it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 04:52:13 +00: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
Ludovic Marie 7a2f824043 Merge pull request #10701 from Detimes/perf-reuse-packet-deflater
🤖 Agent: region saves reuse one deflater per writer instead of building one per chunk; the test checks the compressed bytes are identical to a fresh deflater's. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green; verify green after the runner's full-disk rerun.
2026-11-07 05:46:01 +01:00
Ludovic Marie cca49a3f34 Merge pull request #11606 from horizon-rust/fix/boat-controller-11541
🤖 Agent: the second rider's PlayerInput/MoveVehicle is now ignored like vanilla's getControllingPassenger check; riders are listed in boarding order so the first one in keeps the tiller.
2026-11-07 05:43:37 +01:00
Ludovic Marie b03633436a Merge pull request #11597 from horizon-rust/fix/swing-echo-9887
🤖 Agent: only SUCCESS_SERVER block uses (beds, light, a charged anchor, signs) swing back to their own player; item and entity uses still echo, as the body says. use_item_on, flat_place and dedicated_server pass locally.
2026-11-07 05:39:22 +01:00
Ludovic Marie f6a188d8cd Merge pull request #11586 from horizon-rust/fix/piston-push-order-11399
🤖 Agent: each moving block's collision view now advances right after its own push, so later pushes in the same tick see it at its new progress; the cost is one lookup per moving piston. piston_push and piston_movement pass locally.
2026-11-07 05:39:19 +01:00
GabrielNichols 6fbbfdde1c Include the Recipes transfer translations 2026-11-07 01:38:12 -03:00
Horizon agentandClaude Opus 5.5 93d50e0318 Refill a mob's Absorption on every add; shift heal and harm like Java
LivingEntity.addEffect runs onEffectStarted for every effect it accepts,
so Absorption given again refills a mob's shield to 4 per level of the
effect given. Horizon refilled it only for a new or stronger effect.
HealOrHarmMobEffect and the periodic effects use Java's shifts, which take
the distance modulo 32 and wrap: amplifier 32 is amplifier 0 again, and a
ticked heal is at least 0. Horizon clamped the distance to 30, and an
amplifier of 32 or more panicked on Regeneration's interval in a debug
build (#11386).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:36:50 +01: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
Ludovic Marie 507e0ae6f4 Merge pull request #10180 from lightcodegit/fix/brush-horn-use-vibration
🤖 Agent: horn and brush uses end with stopUsingItem (the interaction vibration), and a mob in front of the brushed block stops the brush out to the block reach; item_use_catalog harness passes on a union with main.
2026-11-07 05:16:14 +01:00
Horizon agentandClaude Opus 5.5 1d0b591927 Play an omen's sound when it is added to an entity
MobEffect.onEffectAdded plays Bad Omen's, Trial Omen's and Raid Omen's
sound (withSoundOnAdded) whenever LivingEntity.addEffect adds one that
is new, at the entity's feet. Horizon played them only on the raid, bottle
and trial spawner paths, at the eye, so /effect give, a potion's custom
contents or a mod adding one was silent (#11373). The generic add now
plays it for players and, from /effect, for mobs; the three special paths
play at the feet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:16:06 +01:00
Ludovic Marie 348e296ffa Merge pull request #10039 from MSedgeMC/fix/hud-text-height
🤖 Agent: the item name and the action bar take fixed heights above the hotbar (Hud.extractSelectedItemName 59, extractOverlayMessage 68) instead of following the bars; hud harness passes on a union with main.
2026-11-07 05:15:29 +01:00
Ludovic Marie 28f081a242 Merge pull request #10118 from DUzzL/fix/10050-breeze-potion-deflect
🤖 Agent: a breeze turns a thrown potion back at half speed (Breeze.deflection) once in a row, stopping at the hit point that tick; the REVERSE draw uses the potion's own random.
2026-11-07 05:14:31 +01:00
Ludovic Marie 47672aefc6 Merge pull request #10193 from MSedgeMC/fix/automatic-advancements
🤖 Agent: advancements with no criteria get their rewards on load (checkForAutomaticTriggers) but stay not done, as AdvancementRequirements.test answers false; checked on a union with main.
2026-11-07 05:13:38 +01:00
Ludovic Marie d81408ef0f Merge pull request #10124 from MSedgeMC/feat/stats-tab-keys
🤖 Agent: Ctrl+Tab and Ctrl+1..0 switch the statistics tabs from the raw key, as TabNavigationBar.keyPressed does; checked on a union with main.
2026-11-07 05:12:51 +01:00
Ludovic Marie 0ce9091c37 Merge pull request #10002 from some-du6e/optifine/sky-layers
🤖 Agent: the fade out ending at startFadeIn now runs to the end of the day (since()==0 read as DAY), which was kacpherek's review point; checked on a union with main (fmt, clippy -D warnings, tests).
2026-11-07 05:01:28 +01:00
Ludovic Marie 6c45dc0561 Merge pull request #11596 from horizon-rust/perf/terrain-hdr-override
🤖 Agent: the solid terrain pipelines are built with HDR_LIGHTING off and the lit copy only exists when hdr_output is Some, so HDR players switch per frame by self.hdr; frame-identity label makes CI check HDR off, offscreen HDR and HDR toggled off in a running game. −16 to −22% GPU on M4 Pro, image identical in-process.
2026-11-07 04:57:22 +01: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
bulkandClaude Sonnet 5.5 596a11fb08 Launcher: show patch notes in a dropdown under each version, drop the Patch notes tab
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 03:42:56 +00:00
Ludovic Marie 7775a14526 Merge pull request #11562 from horizon-rust/perf/11024-parallel-world-convert
🤖 Agent: merging. Empty region files (13 303 of 13 620 in the test world) are no longer rewritten and synced on world open, and region files convert on all cores but one with byte-identical output; datafix harness 6/6.
2026-11-07 04:40:56 +01:00
Horizon agentandClaude Opus 5.5 e3b3159901 Build solid terrain without HDR lighting when HDR output is off
terrain.wgsl's solid shaders carry HDR output's lighting behind a runtime
`hdr_light.z` test. The branch is never taken without HDR, yet on Apple GPUs its
code alone costs ~15% of the solid pass (register pressure). A pipeline
constant `HDR_LIGHTING` now guards it: the solid pipelines (per-section, pulled
and flat) are built without it, and a copy with it only where the display
offers HDR output, picked per frame by whether HDR is on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 04:35:44 +01:00
Ludovic Marie 77936bf4be Merge pull request #10196 from IanWorley/native-api/session-state
🤖 Agent: the session state a client HUD reads (world id, screen kind) comes from fields the window already keeps; merged with #10195 and checked with clippy, the native_client tests and the hook stamp test.
2026-11-07 04:33:57 +01:00
Horizon agent f47ac2cbad Merge remote-tracking branch 'origin/main' into conv-par 2026-11-07 04:24:38 +01: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
Horizon agentandClaude Opus 5.5 7f0778e4da Merge origin/main into #10196
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 04:12:16 +01:00
Ludovic Marie b0a8cbf6fb Merge pull request #11579 from horizon-rust/mods/client-combat-hooks
🤖 Agent: client_hurt reports the packet's causing entity (the shooter, not the arrow) and treats an untracked entity as not a player, so a mod never gets a false PvP signal. Main merged in (test conflict with #11512's open-chat test, both kept); verify green on this head.
2026-11-07 04:12:11 +01:00
voidsip f71a05a062 Keep the HUD mode export separate from client snapshots 2026-11-06 21:11:23 -06:00
Horizon agentandClaude Opus 5.5 6bcbf52250 Merge origin/main into mods/client-combat-hooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:59:52 +01: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 745b95ace9 Look at the audio devices off the render thread
Audio::should_change_device listed the output devices on the render
thread once a second. Windows has no cheap fingerprint to skip the list
(Linux reads /proc/asound/cards, macOS names the devices), so every
second the render thread waited ~90-120 ms on WASAPI's endpoints, even in
the main menu (#11490). The look now runs on a short-lived thread every 20
ticks; a later tick applies what it found with the same rules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:54:49 +01:00
Horizon agentandClaude Opus 5.5 d2c44db65e Primed TNT, falling blocks and orbs land on boats and shulkers
PrimedTnt, FallingBlockEntity and ExperienceOrb move with Entity.move,
whose Entity.collide includes EntityGetter.getEntityCollisions: a boat,
a living shulker and a happy ghast held still stop them, as they stop a
dropped item. Horizon resolved their moves against blocks and the world
border only (#11518). The ordered tick gathers those boxes once per run
(again after an explosion) and passes the nearby ones to each move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:54:41 +01:00
Horizon agentandClaude Opus 5.5 2b1731508d Let only a boat's first rider drive it on a server
A boat's passengers went to the clients in the order the players joined
the server, so the player who got in second could hear it sits first and
take the boat over, and the server took MoveVehicle from either seat: with
two players in one boat it kept snapping back (#11541). The passengers now
go in the order they boarded, and handleMoveVehicle ignores a player who
isn't the vehicle's controlling passenger, as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:54:07 +01:00
Ludovic Marie 026f014ff6 Merge pull request #11538 from horizon-rust/mods/chat-input-helpers
🤖 Agent: native_set_chat_input filters text to what typing allows (no control chars, DEL or §) and clamps the cursor, so a mod can't inject formatting codes into the chat box.
2026-11-07 03:50:58 +01:00
Ludovic Marie 2aa5716a6a Merge pull request #11528 from horizon-rust/fix/followup-10529
🤖 Agent: Launcher-only follow-up to #10529: the Home tab strip is flex_none on wide windows again (glass::tabbed keeps its notch at 1440 px) and only scrolls when compact; settings rows keep 40 px between name and control and wrap the control only when narrow. No new strings; fmt/clippy/tests green on the union of this wave's heads.
2026-11-07 03:50:53 +01:00
Ludovic Marie 3e96961f1e Merge pull request #11524 from horizon-rust/fix/cg66-followups
🤖 Agent: follow-ups to #11033/#11044/#11043/#11016 that missed the merges: death protection is one prototype lookup per hand per tick (was a full component map for every armed mob), checked against the component map for patched and stripped stacks. Union check: fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:48 +01:00
Ludovic Marie ed38625015 Merge pull request #11036 from ilguerro/perf/9996-interp2
🤖 Agent: the interpolation change is checked bit for bit against the old formula over the test's whole range, so no position moves; one multiply fewer per entity per frame. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:43 +01:00
Ludovic Marie 31d0888da7 Merge pull request #10703 from Detimes/perf-region-inflate-in-place
🤖 Agent: inflates each region chunk in place instead of through a second buffer; the differential test decodes the same chunks with the old block decoder and compares every section. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:39 +01:00
Ludovic Marie a5ea46ce3e Merge pull request #11049 from DUzzL/fix/11024-parallel-world-upgrade
🤖 Agent: Optimize World now upgrades region files in parallel with one sync per file instead of one per chunk (F_FULLFSYNC on macOS was the cost); the 1-vs-4-thread test checks the counts against the fixture's own layout, and a canceled recreate leaves every file untouched. Union check: fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:46:43 +01:00
Ludovic Marie 5088011241 Merge pull request #9566 from hildlabs/panel/glass-look
🤖 Agent: the server panel moves its dialogs into the page and follows the Glass look; launcher only, strings through tr().
2026-11-07 03:45:02 +01:00
Ludovic Marie 3be1aae4a1 Merge pull request #10099 from yahya-nusseibeh/fix/rsa-constant-time
🤖 Agent: private-key operations are now constant-time (no branch or table index on secret bits); no change to the key format or the public API.
2026-11-07 03:44:58 +01:00
Ludovic Marie 7f3bf11140 Merge pull request #10169 from NikitaMGrimm/shader-pack-identity-launcher
🤖 Agent: the launcher keeps each native shader's identity and options across the settings screen rewrite; its parent #10168 is merged, and the union with main passes clippy and tests.
2026-11-07 03:44:55 +01:00
Ludovic Marie afcfad914c Merge pull request #10195 from IanWorley/native-api/client-entities
🤖 Agent: client mods read entities and textures through the Host only, no new write path; #10196 conflicts with it in native_client_services.rs and gets main merged after this lands.
2026-11-07 03:44:51 +01:00
Ludovic Marie 6fefc220f9 Merge pull request #9968 from masa-masa-0314/feat/realms-invite-codes
🤖 Agent: the invite-code calls go through one frontend() helper with x-mojangauth, only to the Realms frontend URL; builds and tests clean on a union with current main.
2026-11-07 03:44:47 +01:00
Horizon agentandClaude Opus 5.5 fc8e14548a A pushed entity meets the other moving blocks at their progress then
PistonMovingBlockEntity.tick pushes entities from each block entity's own
tick, and getCollisionShape reads its progress: the moving blocks that
ticked before it have advanced, the ones after it have not. Horizon
pushed every entity against last tick's progress of all of them, so a
TNT duper's TNT was pushed through a block that was about to move
(#11399). Each piston's collision view now advances right after its own
push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:42:55 +01:00
Horizon agentandClaude Opus 5.5 ae8bfba8c6 Fill the swing test's Joined like main's, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:40:33 +01:00
Horizon agent 8703ad614c Merge remote-tracking branch 'origin/main' into fix/swing-echo-9887 2026-11-07 03:39:58 +01:00
Ludovic Marie fce784f752 Merge pull request #11575 from horizon-rust/mods/screenshot-helper
🤖 Agent: native_take_screenshot refuses while another screenshot waits for the frame, so a mod's call can't overwrite F2's or the world icon's pending grab.
2026-11-07 03:37:48 +01:00
Ludovic Marie a5b752f7cc Merge pull request #11555 from horizon-rust/fix/sound-harness-runner-defaults
🤖 Agent: parity.py starts harnesses with no arguments; these two required --binary/--work-dir and failed parity-main in 0 s. On the desktop both now reach their servers (the Java sides ran); the full runs there timed out only because the desktop's swap was full.
2026-11-07 03:37:45 +01:00
bulkandClaude Opus 5.5 b9e891bf3e Remove the dead_code allows nothing needs, give the rest a reason
Removing each one and asking the compiler (the workspace's targets and the
server build): most guarded nothing, since `pub` methods of `pub` types already
count as used. The ones native mods need stay, now saying so, as do the fixtures
that share another fixture's source and code whose caller has not landed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:37:35 +00:00
Horizon agentandClaude Opus 5.5 9834143df1 Fix clippy: is_player needs no mut; keep player_voice_pitch's doc on it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:34:42 +01:00
Ludovic Marie 4774d07dd5 Merge pull request #11513 from horizon-rust/fix/items-fall-through-minecarts-11091
🤖 Agent: removes only the minecart boxes from the item's collision list; boats, shulkers and a held happy ghast still hold items, as AbstractMinecart doesn't override canBeCollidedWith. entity_collision, held_drop, shulker_space, boat_item_hit pass locally.
2026-11-07 03:22:31 +01:00
Horizon agent b9693b5716 Merge remote-tracking branch 'origin/main' into fix/swing-echo-9887 2026-11-07 03:20:04 +01:00
Ludovic Marie 1647c1b466 Merge pull request #11028 from Detimes/hooks-b-all
🤖 Agent: merged after #11019 and main (hook and doc blocks kept whole, quinn kept beside the new optional crates). Without a mod, PlayerSender::send pays one relaxed WATCHED load and sends the bytes as they were, and worldgen, LOD columns and redstone keep vanilla's values.
2026-11-07 03:19:45 +01: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 ac31802b58 Give #11019's guest test a Joined with its configuration
The zero-health test that #11019 added builds a Joined, which this branch
gave a configured field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:01:53 +01:00
Horizon agentandClaude Opus 5.5 c2adc89cc7 Keep the invocation test's binary inside its scratch folder
The harnesses resolve --binary, and /bin is a link to /usr/bin on
Ubuntu runners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:59:37 +01:00
bulkandClaude Opus 5.5 898d7a0b4a Drop the duplicate dependencies an upgrade can remove
arboard 3.7 moves to windows-sys 0.61 (removing 0.60, windows-targets 0.53 and
its eight platform crates). socket2 0.6 and if-addrs 0.15 in horizon-friends
remove socket2 0.5; the launcher's lock also loses windows-sys 0.59.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:55:58 +00:00
Horizon agent e82a08ca4d Merge remote-tracking branch 'origin/main' into cg68/hooks-b-all
# Conflicts:
#	crates/horizon-mod-deps/Cargo.toml
#	docs/native-mods.md
2026-11-07 02:55:11 +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
Ludovic Marie 41944ed914 Merge pull request #11553 from horizon-rust/pvp-2/legacy-pvp-check
🤖 Agent: the check, not the 1.8 rules, was broken: the switch row sat half under the list's edge so the click missed it; verified 7/7 legacy, 9/3.6 modern on the Mac.
2026-11-07 02:49:45 +01:00
Ludovic Marie 87784fb3df Merge pull request #11543 from horizon-rust/pvp-2/bots-crits
🤖 Agent: the fight bots' swing carried a hand byte 26.3's swing packet doesn't have, so every server kicked them on the first hit; it now sends the same Punch as --attack.
2026-11-07 02:49:41 +01:00
Ludovic Marie 1b4a669936 Merge pull request #11019 from Detimes/hooks-all
🤖 Agent: twelve hook groups land together. Without a mod, the move, teleport and trample hooks are fn pointers over an empty mods() slice, so a player's move costs one indirect call and no allocation; a mod command's permission node falls back to its own level when no provider answers.
2026-11-07 02:49:38 +01:00
DetimesandClaude Opus 5.5 fa9d06a48f Older clients and servers: 1.21.4, 1.21.3, 1.21.1 and 1.20.6 (#10404)
Id tables from the vanilla data reports (1.20.6's packet list rebuilt from
the archived wiki page, which names no resources, via 1.21.1's report), with
the renames game_profile, set_carried_item and horse_screen_open. Packet
layouts from minecraft.wiki's archived pages for protocols 766 to 769 and the
release notes: 1.21.5's chunk format and per-entity data, hashed container
clicks, 1.21.2's window ids, teleports, movement flags, input and sea level,
1.21's particles, explosion sound and trades, 1.20.5's strict error handling.
1.21.5 item components (attribute modifier display, equippable shearing)
behind a quirk. A 1.21.1 client gets no recipe book.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:46:59 +00:00
Horizon agent 230d414d62 Merge commit '97e3a74179cf3e6137908ea2c8ce36a489605a84' into cg68/hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan_actions.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/native_net.rs
#	docs/native-mods.md
2026-11-07 02:45:45 +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
Ludovic Marie 0ad4f59af2 Merge pull request #11537 from horizon-rust/multiplayer/drag-servers
🤖 Agent: a press on the already-selected server still joins without holding it, and servers.dat is written only on a release after a real move.
2026-11-07 02:37:14 +01:00
Ludovic Marie 3e6e75d8f2 Merge pull request #11511 from horizon-rust/settings/finer-lod-steps
🤖 Agent: only the slider's step table changes; saved values between steps snap to the nearest step (test covers 3000 → 13/23), so existing options.txt files still load.
2026-11-07 02:37:10 +01:00
Ludovic Marie cef80f4fd5 Merge pull request #11510 from horizon-rust/mods/quinn-dep
🤖 Agent: quinn is optional behind the quic feature with default features off (runtime-tokio + rustls-ring only), so no extra TLS backend lands in Cargo.lock beyond the 7 packages.
2026-11-07 02:37:06 +01:00
Ludovic Marie 52a7c28daf Merge pull request #11512 from horizon-rust/mods/open-chat-helper
🤖 Agent: native_open_chat refuses unless the screen is Playing, so a mod can't push the chat over an open container or menu.
2026-11-07 02:37:02 +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 agent 97e3a74179 Merge remote-tracking branch 'origin/main' into cg68/hooks-all 2026-11-07 02:33:34 +01:00
Horizon agentandClaude Opus 5.5 2ba9bcf90d Fix the build: the path goes to mods as a string
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:30:55 +01:00
Horizon agentandClaude Opus 5.5 dd338cd642 Fix the build: FnMut lookup for the attacker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:29:11 +01:00
bulkandClaude Opus 5.5 a2727a069e Apply the lints the raised rust-version turns on
Clippy follows rust-version, so crates that move from 1.85 now get its
collapsible let chains, as_chunks and is_multiple_of suggestions. Almost all
from `cargo clippy --fix`, none changes what the code does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:29:01 +00:00
Horizon agentandClaude Opus 5.5 17ee8c5f13 lan_e2e legacy-pvp: flip the switch once it is whole in view, and swap across ticks
The check failed on main with 1.8 rules that work: the switch row now sits
under the PvP page's HUD rows, the scroll stopped as soon as the row showed
at the list's lower edge, and the click at its centre landed outside the
list, so the rules never turned on (legacy hit 9, not 7). It now scrolls
until the row stops moving and asserts the click flipped it.

The swap and back went out in one tick: since the client sends a slot
change from its next tick, as vanilla does (#10084), that sends nothing,
and the modern hit kept its full charge. The stick is now held 3 ticks.

Mac mini: legacy (7, 7), modern (9, 3.6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:27:08 +01:00
rdbtandClaude Opus 5.5 ac3436923a CheckerboardBiomeSource: a negative scale reaches the shift unclamped, as in vanilla
#11226 clamped scale to 0 before the + 2. Vanilla has no clamp and Java masks the shift
distance, so tools/test_biome_source_parity.py failed at scale -1 and below.

Fixes #11519

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:25:45 +00:00
Ludovic Marie 77db38ec8c Merge pull request #11523 from horizon-rust/platforms/ci-arm64-launcher-version
🤖 Agent: .github only; the by-hand ARM64 job's launcher --version step now runs from bash as launcher.yml does, and passes on the ARM64 runner (run 42320852405), where the pwsh version failed on any code.
2026-11-07 02:23:08 +01:00
Horizon agentandClaude Opus 5.5 6858867f2a Convert older worlds on all cores and skip empty region files
Opening a world saved by an older version runs the converter in
datafix/files.rs (upgrade_world), which rewrote every region file on one
thread. Now:

- upgrade_regions spreads a folder's region files over worker_threads()
  workers (all cores but one). Each file is still read, fixed and written
  whole by one worker through the same staging folder, so the bytes do not
  depend on the thread count. A cancel or an error stops every worker
  before its next file; the error returned is the first file's in order.
- A region file without chunks is left as it is instead of being rewritten
  as an empty 8 KiB file and synced. The game leaves many of them: 13 303
  of the 13 620 overworld files of a 1.21.1 ATM10 test world.

Tests: 4 workers give byte-identical files to 1 and every chunk at the
current version, an empty file stays empty, and a cancel from the first
chunk converts at most one file per worker and leaves the rest old.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:17:31 +01:00
rdbtandClaude Opus 5.5 a3c17924ca Parity harnesses: loaded_pig_tags and loot_mine_pot_components run on a plain run
Both asserted --reference-only when no --reference-dir was given, so tools/parity.py,
which passes no arguments, failed them before they ran anything.

Fixes #11520

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:15:56 +00:00
bulkandClaude Opus 5.5 fab81be9ac Build every workspace crate against one rust-version
Members declared 1.85 to 1.95, or nothing. horizon-mods needs 1.95 and sits
under horizon-client-lib, cargo-horizon-mod and the launcher, so 1.95 is the
version the game really needs; the lower numbers promised builds that could
not happen. Set it once in [workspace.package] and inherit it everywhere.

The separately built workspaces (launcher, telemetry server, mod registry,
third_party) cannot inherit it and keep their own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:14:44 +00:00
Ludovic Marie 00f6358e7a Merge pull request #11081 from horizon-rust/platforms/docs-intel-arm64
🤖 Agent: one line in docs/hosting.md (Intel Macs have had server builds since the 10-31 Nightly; install.sh already maps Darwin-x86_64); the README half is already on main via bf4657da26.
2026-11-07 02:10:26 +01:00
Ludovic Marie b33917f89b Merge pull request #11080 from horizon-rust/platforms/arm64-emulated-dx12
🤖 Agent: Auto's backend order now reads the machine's arch (IsWow64Process2), so the x64 build on a Snapdragon PC starts on DX12 like the ARM64 one; the ARM64 runner built and started the game on this head. Unverified on real Adreno hardware (asked #7513's reporter).
2026-11-07 02:10:21 +01:00
Ludovic Marie 3fd9db1568 Merge pull request #10501 from arslanarm/shaderpacks/same-size-buffer-declarations
🤖 Agent: merged main in again (only the test module list in pack_post.rs collided, with #11196's sky_disc_blend_tests). The last parity failure on 4fa3bf699 was command_exec's '/advancement grant @s everything' count (116 vs 115), which fails identically without this PR; this head reruns parity. sizes_differ is a few array reads per pass per frame.
2026-11-07 02:10:17 +01:00
Horizon agentandClaude Opus 5.5 f31f4d179a horizon-bots: fight bots swing as 26.3 does, and --crits for PvP benches
The fight behaviour's swing went out as a raw Punch with a hand byte
26.3's ServerboundSwingPacket doesn't have: every server kicked the bots
on their first hit ("packet 46 was larger than expected: 1 extra bytes").
It now sends the same Punch as --attack.

--crits makes fight bots swing at a sword's full strength (13 ticks) and
jump into every other swing, landing it on the way down: a critical hit,
then a sweep from the ground. tools/perf PvP scenes get crit and sweep
particles from real hits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:08:33 +01:00
Horizon agentandClaude Opus 5.5 88a372f808 Let mob_local_sound and ominous_warning_sound run with parity.py's defaults
tools/parity.py starts every harness with no arguments, and both scripts
required --binary and --work-dir, so parity-main failed them in 0 s
(argparse usage error) since #10524 and #10266. Like
hostile_sound_categories, they now build the game through net.sides and
pick a work folder under .local/ when none is given.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:06:56 +01:00
bulkandClaude Opus 5.5 71d809e40c AGENTS.md: drop the counts that go stale
The harness, probe, Game field, impl file and module counts had drifted
(324 harnesses against 502 on disk). Nothing regenerates prose, so name the
things and let the tree give the numbers. CONTRIBUTING.md's "about 290"
harnesses goes with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:05:27 +00:00
Horizon agentandClaude Opus 5.5 1f7515f5ee Item move comment: a minecart does not stop an item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:04:23 +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 84f77ee9a5 Launcher: keep the Home tab notch and the settings rows on wide windows
Follow-up to #10529 (atsauban's responsive wrapping). The Home tab strip
took the full width at every size, so glass::tabbed drew one plate and
lost its notch even at 1440 px: it is full width and scrolls only when
compact. setting_row went from 40 px to 16 px with justify_between for
every row; it keeps 40 px between name and control and wraps the
control under the name (12 px) when the window is too narrow. The
breakpoints are named constants beside WIDE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:01:34 +01:00
Horizon agentandClaude Opus 5.5 213cfce453 Launcher: the macOS title bar keeps its height without glass
#10129 made the native title bar transparent on macOS but drew the bar only with
glass on, so with glass off the page started under the traffic lights. The bar
is always there: glass, or the side colour as on Windows and Linux.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:00:38 +01:00
Horizon agentandClaude Opus 5.5 8b7f726609 Fullscreen: unmaximize first on Windows only, in one helper
#10177 unmaximized before every fullscreen request on every system, and apply()
asked for fullscreen again on each call while the window still reported
maximized. enter_fullscreen keeps the #9984 fix where it applies (winit keeps a
maximized window inside the work area on Windows); the resumed() checks share
one helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:00:24 +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 18309e4967 Remote servers: translate the SSH errors
#10166 landed with its messages as raw strings. Every message a player reads
goes through tr/trf with named placeholders, and every language file gets the
English stand-ins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:25 +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 agentandClaude Opus 5.5 8ce49872e7 Follow-ups to #11033, #11044, #11043 and #11016
- Death protection: one prototype lookup per hand instead of a component
  map rebuilt from the stack's patch. update_totem_ready runs for both of
  the player's hands and sync_mob_death_protection for every armed mob,
  every tick; only a stack that patches the component takes the old path.
- Remote projectiles: the swept bubble-column cells are deduplicated
  without a HashSet per projectile per tick.
- Ident::read hands a namespaced identifier back without a second String.
- parked_worlds keeps its doc comment (#11016 put parked_border under it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:14 +01:00
Horizon agentandClaude Opus 5.5 280ef828d6 A placed block's swing is no longer sent back to the player
handleUseItemOn sends the use's swing to the player's own client only when
the result is not SwingSource.PREDICTED. Horizon sent every swing back, so
on a slow connection the echo of a placement's swing arrived after the first
half of the client's own swing and swung the arm again (#9887). A placement
and a block's own SUCCESS now count as predicted; the beds, light block,
respawn anchor and sign stay SUCCESS_SERVER and are still sent back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:07 +01:00
Horizon agentandClaude Opus 5.5 3a6410b63b Location enchantments: copy only the armor slots that changed
The check runs every tick for every player; cloning all four stacks each
time allocated their components for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:52 +01:00
Horizon agentandClaude Opus 5.5 482cc7707c Merge origin/main into #10119
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:49 +01:00
Horizon agent d8676fff04 Merge main into #10538
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_post.rs
2026-11-07 01:57:46 +01:00
Horizon agentandClaude Opus 5.5 e409b37078 Brush: a mob blocks the brush out to the block interaction range
BrushItem.calculateHitResult picks entities with blockInteractionRange, not
the entity range: a mob 3 to 4.5 blocks away in front of the block stops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:43 +01:00
Horizon agentandClaude Opus 5.5 64c68064f6 Merge origin/main into #10180
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:37 +01:00
Horizon agent 34d078e57b Merge main into #10208
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 01:56:19 +01:00
Ludovic Marie 80947472c1 Merge pull request #11087 from horizon-rust/hosting/amp-template
🤖 Agent: the AMP update stage runs the egg's own install script byte for byte (test pins it), so both panels install and SHA-256-check the same way; not imported into a live AMP.
2026-11-07 01:55:30 +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
Horizon agent 1d63e80fc1 Merge main into #10553 2026-11-07 01:53:49 +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
Horizon agent cf93b66d14 Merge remote-tracking branch 'origin/main' into fix/items-fall-through-minecarts-11091 2026-11-07 01:53:29 +01:00
Horizon agent 5f2f0e689a Merge remote-tracking branch 'origin/main' into fix/mob-entity-collisions-10445 2026-11-07 01:53:25 +01:00
Horizon agentandClaude Opus 5.5 1c42629d64 mob_ai probe: fill MobWorld's others_solid
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:52:08 +01:00
Horizon agentandClaude Opus 5.5 486dd3f058 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:52:03 +01:00
Horizon agentandClaude Opus 5.5 53d0ea1320 Native mods: read and write the open chat box (native_chat_input, native_set_chat_input)
A client mod that converts text as it's typed (Emoji Type's :code: to emoji)
reads the chat box's text and cursor and writes the result back as an edit,
so completions refresh as for typing. Only what typing lets in reaches the
box. Stacked on native_open_chat.

Part of #10107

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:52:03 +01:00
Horizon agentandClaude Opus 5.5 403ecdbadb CI: the by-hand Windows ARM64 job reads the launcher's --version from bash
Its first runs failed at "horizon-launcher --version failed": pwsh read no
output from the GUI-subsystem launcher, while launcher.yml's same check, from
bash on the same runner, passes on every main push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:51:38 +01:00
Horizon agentandClaude Opus 5.5 5d91256713 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:50:47 +01:00
Horizon agent a892d2d1eb Merge remote-tracking branch 'origin/main' into fix-11022
# Conflicts:
#	crates/horizon-client/src/game/sculk.rs
2026-11-07 01:50:41 +01:00
Horizon agent 74643df024 Merge remote-tracking branch 'origin/main' into fix-11017
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-07 01:50:40 +01:00
Horizon agent 07c0d4e8fb Merge remote-tracking branch 'origin/main' into fix-10837
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-07 01:50:40 +01:00
Horizon agentandClaude Opus 5.5 5a24802d6a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:50:16 +01: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
Horizon agentandClaude Opus 5.5 ac8a09ef0a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:49:41 +01:00
Horizon agentandClaude Opus 5.5 0a639d598e Merge main into #10501 (test module list in pack_post.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:49:38 +01:00
TaquitoSuabe 8bfcf86e6d net: configure TCP keepalive and zero-alloc stream pumps 2026-11-06 17:34:49 -07: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
voidsip 001f3d008d Clarify the wind hook preserves shuffle random draws 2026-11-06 18:19:07 -06:00
Vinícius MoreiraandClaude Opus 5.5 c80eac9506 Polytone: read targets and biome/dimension attribute modifiers
A new crate, horizon-polytone, reads the Polytone resource pack format
clean-room from the public wiki: modifier targets (implicit path id, ids,
tags, regex, required) and the attributes_modifiers layers of biome and
dimension modifiers (groups, REMOVE, values, modifier objects, colors,
colormap references, expressions kept as source). Nothing uses it yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:17:25 -03:00
DetimesandClaude Opus 5.5 edeee4d656 Older clients and servers: 1.21.8, 1.21.6 and 1.21.5 (#10404)
Packet and registry id tables generated from the vanilla data reports of the
three releases; packet layouts from the public protocol documentation
(minecraft.wiki's archived packet pages for protocols 770, 771 and 772).
Each step has a reader (older server) and a writer (older client) with round
trips; entity data serializers without 1.21.9's additions. The end-to-end
bot test now joins as every release down to 1.21.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 00:15:18 +00: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
RINNECODERandClaude Opus 5.5 4ed6650b88 Port pack: carried models name 26.3's iron chain
A 1.21.1 model's minecraft:block/chain or item/chain (parent or texture) became
iron_chain in 26.3, so a mod block drawing vanilla's chain baked with the missing
texture. Carried models and block states get the RENAMED_IDS rename data files
already had.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:02:48 -06:00
RINNECODERandClaude Opus 5.5 a124aba31a /recipe suggests content mods' recipes
The recipe index reads every content namespace since 31756ebfe, but /recipe's
suggestions still listed only minecraft: ids. List data/<ns>/recipe of every
namespace, as /loot's loot tables already are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:02:48 -06:00
voidsip 7397241fb6 Give debug wind fixtures an explicit stack budget 2026-11-06 17:43:29 -06: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
martipopsandClaude Opus 5.5 f8a7a59085 Controllers: Replay Studio's recordings and buttons take the focus
Its rows and buttons only painted, so a pad reached them through the crosshair alone. The D-pad now
walks the recordings to the buttons, the right stick scrolls them, and Done declares its button.

A notch is now one whole row, since nothing clips a row that's partly scrolled off, and the scroll
limit counts the rows that fit, so the last recording can be reached with the mouse too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:39:17 -05:00
DUzzL 330c59d954 Resend the glide flag when the server refuses a glide start 2026-11-07 00:30:33 +01: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
DetimesandClaude Opus 5.5 9d4cecee14 Older clients: serve 26.1, 1.21.11 and 1.21.10 clients too (#10404)
Mirror translate's per-release readers as writers (client_session/step_*),
keep sent entities for entity data and 26.1's spectate by UUID, drop the
world_clock registry and split 1.21.11's interact into interact and attack.
End-to-end test joins a bot of each release through the adapter's Session.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:29:51 +00:00
k3yhandClaude Opus 5.5 a456a1476c Worldgen: name the placed features that cannot run
The warning only gave their count, so a data pack or content mod author
could not tell which features were skipped or why. Log each one with its
reason, up to 20.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:22:02 -05: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
rison1337andCodex d15d01fbc2 perf(render): keep offscreen flame culling active with TAA
Use a conservative input-pixel guard for the billboard camera frustum
without changing the ordinary model frustum, flame envelope or shader
pack fallback. Keep this PR independent of experience-orb culling.

Retain the separate flame measurements and image controls, with their
original binary provenance and an explicit note about the PR split.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 04:04:56 +05: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
rison1337andCodex 539c847e65 perf(items): avoid unused dropped-item bounds work
Reuse resolved local model depth when placing dropped-item stack copies.
Skip the unused posed occlusion envelope with entity culling and TAA off,
and retain the original preparation under TAA after measured tail regressions.

Keep the existing cluster API and random sequence, with a regression test,
paired production measurements and explicit rendering-validation limits.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 03:58:08 +05: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
voidsip eab98fdd88 Allow slow debug fixtures and retain wind hook evidence 2026-11-06 16:18:35 -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 07b8984a5b Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/wind-burst-block-hook 2026-11-06 16:12:38 -06:00
voidsip 476bb33916 Expose native HUD mode with independent hand visibility 2026-11-06 16:12:32 -06:00
voidsip cf69ad16ef Add a native wind burst block filter hook 2026-11-06 16:12:23 -06:00
GabrielNichols c7d27a84a1 Fill Recipes grids without teaching the recipe 2026-11-06 19:10:19 -03:00
rdbt 13f70ad482 Merge pull request #11250 from TheMrZZ/fix/h252-loaded-pig-tags
Normalize Mob Tags when adopting NBT
2026-11-06 22:09:57 +00:00
rdbt 6590656a9a Merge pull request #11393 from ilguerro/fix/10548-villager-memories
Save villager brain memories like vanilla
2026-11-06 22:09:51 +00:00
rdbt f2e7c1865f Merge pull request #11405 from ilguerro/fix/w076
Count collidable entities in auto-jump like vanilla
2026-11-06 22:09:45 +00:00
rdbt 876c817f7d Merge pull request #11397 from ilguerro/fix/w029
Keep wandering trader homes like vanilla
2026-11-06 22:09:40 +00:00
rdbt b465f24309 Merge pull request #11395 from ilguerro/fix/w024
Match vanilla hanging entity coexistence checks
2026-11-06 22:09:04 +00: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
GabrielNichols 7351b11d1a Sync Recipes settings with main 2026-11-06 18:52:17 -03:00
Delete228andCodex 8c13c07e40 Merge the fluid renderer fixture update
Co-authored-by: Codex <[email protected]>
2026-11-07 00:49:10 +03:00
Delete228andCodex a661a2262d Pass empty motion data in the fluid renderer fixture
Co-authored-by: Codex <[email protected]>
2026-11-07 00:48:50 +03:00
Delete228andCodex 00a77c1e1f Merge the updated fluid renderer into the model renderer
Co-authored-by: Codex <[email protected]>
2026-11-07 00:46:25 +03: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 129e00b6b1 Merge Horizon main into the PR branch
Co-authored-by: Codex <[email protected]>
2026-11-07 00:45:54 +03:00
Delete228andCodex 39f7f1bf42 Merge Horizon main into the PR branch
Co-authored-by: Codex <[email protected]>
2026-11-07 00:45:54 +03:00
Delete228andCodex 63ac8a4adf 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
DetimesandClaude Opus 5.5 611dfc244f Merge upstream/main into hooks-all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:38:48 +00:00
Nikita Maximilian Grimm 3b64b39c5b Merge branch 'shader-api-02-serve' into hdr-step3-float-frame
# Conflicts:
#	crates/horizon-client/src/game/shader_packs.rs
2026-11-06 22:38:21 +01: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
DetimesandClaude Opus 5.5 2e01586b5e Merge upstream/main into hooks-b-all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:35:45 +00:00
voidsip 113cd40efb Respect per-stack brewing fuel components 2026-11-06 15:33:46 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 d36a2c9e21 fix(shaders): give the scripted-clock test its shader API line
main's test predates NativeFrame::new's api argument; it runs a 0.1 frame, as the other tests do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 22:33:28 +01:00
Nikita Maximilian Grimm 1ade310792 Merge remote-tracking branch 'origin/main' into shader-api-02-serve
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
#	crates/horizon-client/src/game/shader_packs.rs
2026-11-06 22:29:20 +01:00
rdbt 9d11d0c710 Merge pull request #11369 from Skr1zzzz/ride-any-tracked-entity
Multiplayer: the player rides any tracked entity, sitting where vanilla seats it
2026-11-06 21:27:13 +00: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
rdbt c90689fd74 Merge pull request #10168 from NikitaMGrimm/shader-pack-identity
Keep native shader selections and settings across updates
2026-11-06 21:27:07 +00:00
rdbt 22628d794c Merge pull request #10194 from IanWorley/native-api/block-appearance
Native mods: block state facts and the client's resource files, for maps and radars
2026-11-06 21:27:01 +00: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
rdbt a94b615c99 Merge pull request #11014 from senjinthedragon/entity-at-velocity
Shader packs: at_velocity for entities
2026-11-06 21:26:55 +00:00
rdbt 63c8c5bce2 Merge pull request #11181 from Slavik-85/f3-overlay-light
F3 overlay: paint at 20 Hz, no per-frame OS calls, less GPU timing
2026-11-06 21:26:49 +00: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
AkirabaneandClaude Opus 5.5 8e033a0480 dedicated: no move onto a server that is full or stopping
player-expect is refused as "the server is full" once the places the
player limit counts and those of the other expected players reach
max-players: each expectation holds a place, so two proxies sending
players at once cannot fill a server past its limit. As at login, an op
who bypasses the limit is never refused, and a renewal holds no second
place.

Once a stop has begun (/stop from the console, a player or the control
endpoint, or a stop signal), player-expect, player-import and
player-confirm are refused as "the server is stopping": a player must not
arrive on a server that is about to close. A retry after the commit is
still answered, and export, release and drop stay open, since they are how
players leave. transfer-status says whether the server is stopping.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 22:23:35 +01:00
Nikita Maximilian Grimm 89bfc9ab79 Merge remote-tracking branch 'fork/shader-api-02-serve' into hdr-step3-float-frame 2026-11-06 22:21:44 +01: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
Nikita Maximilian Grimm 1d1f36972c Merge remote-tracking branch 'origin/main' into shader-api-02-serve
# Conflicts:
#	docs/api/shaders.md
2026-11-06 22:14:42 +01: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
rdbtCVSandClaude Opus 5.5 e70f5347ba Realms: finish the merge of main (frontend helper, tier fields)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:33:13 +00:00
Skr1zzzzandClaude Opus 5.5 ad8a713d95 Multiplayer: the player rides any tracked entity, sitting where vanilla seats it
A server seating the player on a block display (sit mods, NPC plugins) took the vehicle for a minecart; none was
found, so riding was dropped and the player fell into the slab under the seat (y -61.0 where the server keeps
-60.1). The seat is the vehicle's riding position less the player's vehicle attachment (0.6, scaled).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:31:46 +03:00
rdbtCVSandClaude Opus 5.5 f17d86aa79 Keep #[cfg(test)] on the test module the merge of main took it from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:31:36 +00:00
rdbtCVSandClaude Opus 5.5 59ef682c9c Keep #[cfg(test)] on the test module the merge of main took it from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:31:10 +00: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 b25dbdab4e 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:53 +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
rdbtCVSandClaude Opus 5.5 dc175fe838 Hoppers restart a furnace's cooking when they fill its empty input
AbstractFurnaceBlockEntity.setItem resets the time; without the early reset a hopper-fed furnace would smelt its first item in one tick. Review feedback on #10170.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:23:55 +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 d447274d79 Merge origin/main into PR #10170
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:22:15 +00:00
rdbtCVSandClaude Opus 5.5 c99811b0a7 /say: no captured copy of the broadcast line
A guest's /execute run say and the dedicated console saw it twice; review feedback on #10015.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:21:48 +00:00
rdbtCVSandClaude Opus 5.5 883d53f647 Merge origin/main into PR #10015
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:21:48 +00:00
rdbtCVSandClaude Opus 5.5 dcc21109ac Say what the automatic grant does for an advancement with no criteria
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:18:41 +00:00
rdbtCVSandClaude Opus 5.5 daf2f4debc Statistics tabs read the raw key, as Screen.keyPressed does
Review feedback on #10124.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:18:16 +00:00
rdbtCVSandClaude Opus 5.5 c612f19826 Merge origin/main into PR #10124
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:17:59 +00: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 8c820a5994 Chat: clearing a draft runs onEdited, so its old command info goes too
EditBox.setValue fires the responder; review feedback on #10013.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:17 +00:00
rdbtCVSandClaude Opus 5.5 377650f3ee Merge origin/main into PR #10013
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:16 +00:00
rdbtCVSandClaude Opus 5.5 7fbe88924e Keep AdvancementProgress.isDone false for an advancement with no criteria
AdvancementRequirements.test returns false when its list is empty; review feedback on #10193.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:15:51 +00:00
rdbtCVSandClaude Opus 5.5 42948e3f17 Merge origin/main into PR #10193
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:15:26 +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
rdbtCVSandClaude Opus 5.5 8761658a18 Merge origin/main into PR #10180
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:10 +00:00
rdbtCVSandClaude Opus 5.5 fe0a3ac905 Merge origin/main into PR #10538
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:09 +00:00
rdbtCVSandClaude Opus 5.5 06a0f118e2 Merge origin/main into PR #10119
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:07 +00:00
rdbtCVSandClaude Opus 5.5 6218257d19 Merge origin/main into PR #10118
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:06 +00:00
rdbtCVSandClaude Opus 5.5 41d3a0cbb7 Merge origin/main into PR #10009
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:05 +00:00
rdbtCVSandClaude Opus 5.5 5393956e8a Merge origin/main into PR #9995
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:04 +00:00
rdbtCVSandClaude Opus 5.5 439c074655 rustfmt the test modules after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:10:38 +00:00
rdbtCVSandClaude Opus 5.5 57a7b16d5a Merge origin/main into PR #9968
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:08:41 +00:00
rdbtCVSandClaude Opus 5.5 03340f679c Merge origin/main into PR #10130
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:08:28 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 005530f4de style: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:08:18 +01:00
rdbtCVSandClaude Opus 5.5 76c095290f Custom skies: a fade out ending at the fade in's start ramps down over the rest of the day
Review feedback on #10002.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:07:41 +00:00
rdbtCVSandClaude Opus 5.5 7581d1fbe3 Merge origin/main into PR #10002
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:06:52 +00:00
rdbtCVSandClaude Opus 5.5 c25f5a1f49 Merge origin/main into PR #11081
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:05:45 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 295d815ec2 fix(render): give unmarked light no HDR-lighting knee in the HDR pass
The knee eases HDR lighting's gain into the peak; light written as light (alpha 1) has no
gain, and on a float frame the knee would compress it before the clamp to the peak. On a
unorm frame such pixels never reached the knee, so nothing changes there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:04:18 +01:00
rdbtCVS 1b1f240ab1 Merge origin/main into PR #9967
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:54 +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
Nikita Maximilian GrimmandClaude Opus 5.5 44753cefe0 fix(render): keep today's HDR look on the float frame, and draw world text in the frame's format
Marked pixels (light sources, the sun, HDR lighting) clip at white before their lift, as on
a unorm frame; only unmarked ones show light over 1, which is how shaders will write light.
Without that, the float frame tinted the sun and its halo instead of lifting them white.

The native world-text pipeline targeted the swapchain's 8-bit format, which a 16-bit or
float frame's pass refuses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:00:16 +01:00
rdbtCVS 7b21af9519 Merge origin/main into PR #10196
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_client_services.rs
2026-11-06 19:59:43 +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
rdbtCVS 67fa3d1bb3 Merge origin/main into PR #10065
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-11-06 19:59:37 +00:00
6e09e5035b mesher: bake face data and reuse padded cell indices
Cache each baked face's shape for both collision-fullness states and pack
its own UVs once, preserving the original floating-point operation order.
Address neighboring blocks, AO samples and fluid samples from padded cell
indices instead of recomputing coordinate indices for each lookup.

Add asset-independent checks against coordinate lookups for padded steps
and all AO sample tables, plus literal shape bounds and packed UV values.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8KX3vDMZy53y1vXqeT
2026-11-06 20:49:08 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b376a835f6 feat(render): draw the frame in float with HDR on, under HORIZON_HDR_FRAME=float
HDR design step 3, the frame and the display pass (docs/design/hdr-linear-light.md):
with the switch, HDR on and no shader pack, the renderer draws into Rgba16Float where
the surface offers HDR output and the device draws it, whatever Smooth Gradients is.
0.1 and shaders/ packs keep today's frame; HDR off is untouched.

The HDR pass keeps light over 1 where the world shows, up to the peak, and clips it at
white under the HUD and menus and with HDR Lighting off, as a unorm frame does. On
today's frames, which hold nothing over 1, the pass computes the same values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:40:17 +01:00
Nikita Maximilian Grimm 1f31d3d23a Merge remote-tracking branch 'origin/main' into hdr-step3-float-frame 2026-11-06 20:18:33 +01: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
ed0780a03a Count collidable entities in auto-jump like vanilla
LocalPlayer.updateAutoJump reads Level.getCollisions, which returns the
collidable entities' boxes (EntityGetter.getEntityCollisions, query
inflated by 1e-7) before the block shapes. Horizon's auto-jump only saw
blocks, so walking into a boat, a shulker or a happy ghast never
auto-jumped onto it.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:48:41 +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
Ian WorleyandClaude Opus 5.5 cd86e8f45d docs(native-mods): entities a client sees
Document native_client_entities with its new fields,
native_client_items, native_client_texture and skins by UUID, which
entities the list leaves out, and an example loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:15:15 -06:00
Ian WorleyandClaude Opus 5.5 eecfdf2f7b feat(native-mods): list the client's entities and dropped items
Client mods such as radars, name tags and spectator ESP need the
entities the client shows, with what they look like, without reaching
into Game's internals. API 411's native_client_entities had ids,
positions and player profiles, but no type, babyhood or texture, and no
dropped items.

- NativeClientEntity gains kind, yaw, baby, spectator and the texture
  its body draws with (mobs::render::body), plus position_at, the
  renderer's interpolation. The struct is non_exhaustive, so existing
  mods keep building.
- native_client_items lists the dropped items the client shows.
  native_item_entities stays server-only, so server logic never acts on
  a client's copies.
- native_client_texture returns TexturePixels by the name texture and
  NativeClientProfile::skin give: a downloaded skin from memory, else
  native_client_resources().texture(name).

Pending fragment client_entity_list. Tests: position_at between two
ticks, and a client fed the server's play packets reading a zombie, a
calf, a spectating player with its default skin and a dropped item
through Host, plus texture pixels and a refused path out of the packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:15:15 -06:00
Ian WorleyandClaude Opus 5.5 6d9ed21235 feat(native-api): read the client's resource files and texture pixels
Add the `native_client_resources()` helper. It returns `ClientResources`
over the resource tree the game loaded, which is vanilla's with the
resource packs turned on and the content mods' files over it. A client
mod fetches it on the game thread and can then use it on any thread.

`read(path)` returns a file's bytes for a plain path under `assets/`,
such as `assets/minecraft/textures/entity/cow/cow_temperate.png`.
`texture(name)` decodes a texture by name (`block/stone`,
`entity/cow/cow_temperate`) into `TexturePixels`. Paths outside
`assets/` return none, so Horizon's own prepared tables stay private.

Mods stop reading `options.resources` with `atlas_sources::texture_path`
and `prepared_files::read`. The tests build a folder resource pack over
a fake vanilla tree and check that the pack's texture wins, and that
paths outside `assets/` are refused.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-06 12:15:01 -06:00
Ian WorleyandClaude Opus 5.5 908903a58e feat(native-api): block states' facts and looks for client mods
Add the `native_block_states()` helper. It returns `BlockStates`, a copy
of every block state keyed by its `BlockStateId`. A client mod fetches it
on the game thread and can then use it on any thread.

Each state's `BlockStateFacts` holds the block name, air, invisible,
`FluidState`, waterlogged, light emission, outline boxes, map colour and
whether the state can collide at all. `sprites(id)` returns what the
state's model draws on each side, as `native_block_sprites` does for a
block name. `texture(sprite)` names the texture behind a sprite, the
inverse of `native_texture_sprites`. `mod_api` now also exports
`FluidKind`, the type `FluidState::kind` returns.

The table copies the game's internal facts into stable public types.
Client mods that draw blocks their own way, such as minimaps, world maps
and radars, no longer need `res.facts()`, `res.collision()` or
`block_sprites::Lookup`, which change from commit to commit.

Tests run on real prepared resources (ignored without
HORIZON_TEST_RESOURCES): grass block facts, air and light, slab
outlines, and a grass block's face texture names.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-06 12:15:00 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 1e95d86f56 HORIZON_STILL: a frozen world's rain fog sits at its level too
Without a shader pack the rain also pulls the fog in
(AtmosphericFogEnvironment's rain fog), which eases toward the rain only in
tick_particles, and a frozen client skips that. With the rain at its level,
a frozen scene showed full rain but no rain fog. Under HORIZON_STILL a frozen
client now puts the rain fog at its target, from the same inputs the eased
path reads; the easing itself is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:39:22 +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
Nikita Maximilian GrimmandClaude Opus 5.5 c50b3ef699 HORIZON_STILL: a frozen world's rain and thunder sit at their levels
The rain and thunder levels fade 0.01 a tick and stood still in a frozen
world, so a scene that wanted rain had to step the world about a hundred
ticks, and its mobs walked differently in each launch. Under HORIZON_STILL a
frozen world now holds both levels at their targets, as STILL already does
for a shader pack's smoothed values: /weather rain shows full rain with no
step. A running or stepping world, and normal play, fade them as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:05:51 +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
TheMrZZandCodex gpt-6.1-sol 686d897f59 Normalize Mob Tags when adopting NBT
Adopt the bounded String input before duplicate removal in the two Mob NBT load paths. Keep the canonical retained state visible to real tag commands and saves, and pin cap, duplicate, scalar and live rewrite behavior against Java.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 17:38:11 +01:00
oxi-does-dev 147c6bceef Launcher: keep the partner card's corners in the glass look 2026-11-06 18:39:41 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 94a66c81d4 screenshot-sequence: STEP_MS in decimal milliseconds
16.666667 steps a 60 fps clock. STEP_MS is a finite number more than 0,
and the reply gives it back as given.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:33:15 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8650e51ddb screenshot-sequence: no pack reload or loading screen during a sequence
A Horizon pack's hot reload could rebuild the pack under the same name
mid-sequence, unseen by the scene check: it now waits for the reply. A
sequence doesn't start, or ends, while the level-loading screen shows,
which a new world keeps a moment after it is ready. The 30 s limit is on
the frames before the last: the last frame's PNG waits as screenshot does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:33:15 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 0be2751890 horizon-ctl screenshot-sequence: a shader pack's first frames on a set clock
screenshot-sequence FRAMES STEP_MS PATH, under HORIZON_STILL with the world
frozen, makes the running pack start over and draws FRAMES frames on a
scripted clock: frame n is n frames and (n - 1) x STEP_MS milliseconds in,
STEP_MS after the frame before (0 on frame 1). The last is written as
screenshot PATH writes it, its -hdr.png too under HDR output, and the reply
names the pack and the last frame's clock. The same build, GPU and driver
write the same bytes every run, for shaders/ packs, Horizon's own and none.

Sequence frames are submitted inline and waited for on the GPU, so each
reads the centre depth the one before left, or the sequence fails; a frame
the surface refused is drawn again. It refuses to start with far terrain,
TAA, frame generation, chunk fade or the shadow cache on, refuses commands
that would change the scene while it runs, and ends with an error when the
camera, world, settings, pack or window size change. cancel stops it, quit
still quits. Documented in agent-control.md's STILL section, shaders.md and
api/shaders.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:33:15 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 bb8be7d516 Shader packs: the reset forgets the world's frames for smooth() too
Since smooth() keeps going through teleports, a pack remembers whether it
has drawn a frame of the world. A reset pack now forgets that too, as a
pack just loaded has seen none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:33:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4d025d5e93 Shader packs: the reset also blanks a Horizon pack's own post targets
A pass that reads one of the pack's targets ([[post]] output) before the
pass that writes it got last frame's picture after a reset; a pack just
made reads it blank. The reset now drops the targets and their bind
groups, made again blank on the next frame; the pipelines stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8744511d74 Shader packs: a reset that makes the running pack start over
Renderer::reset_shader_pack_history makes the next frame draw as a freshly
loaded pack's first, for either kind. A shaders/ pack's buffers, images,
storage buffers and shadow maps are made again when next drawn, its passes
get their declared uniform values back, and the frame uniforms, custom
uniforms and centre depth forget earlier frames; its programs stay. A
Horizon pack's post history goes black and its eased values, wetness and
frame count restart. Without a pack it does nothing. Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:14 +01: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
flexeykinDevandClaude Opus 5.5 4ac75c281a install.ps1: verify the release signature when OpenSSL 3 is found
The first install on Windows now checks SHA256SUMS.sig (a Nightly's
SHA256SUMS.v2.sig, for the version its title names) against the release
key, then the archive against SHA256SUMS, as install.sh does. OpenSSL is
looked for on the PATH and in Git for Windows. Without OpenSSL 3 it says
so and keeps the GitHub SHA-256 check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 15:53:25 +01:00
Slavik-85andClaude Opus 5.5 791e721843 F3 overlay: paint at 20 Hz, no per-frame OS calls, less GPU timing
The overlay formatted its lines and laid out its glyphs every frame. It now
paints at most 20 times a second and copies those vertices in between, and
repaints at once for another window size, GUI scale or font epoch.

- The refresh rate is read at most once a second: on Windows, winit calls
  EnumDisplaySettingsExW each time. The Window line uses the surface size.
- Passes are timed only while the overlay lists their times, at most 10 frames
  a second. gpu-passes and HORIZON_SHADER_PACK_TIMING keep every sample.
- The CPU name is read in the background when the window opens: on Windows it
  starts reg.exe, which the first F3 frame waited for.
- DebugEntries::any_enabled and status no longer build a String per entry; the
  game asks every frame, F3 closed or not.
- Closing the overlay stops the server thread computing its lines each tick.
- The Mem, Allocation rate and Allocated lines now show on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:44 +02:00
Arslan ArmanulyandClaude Opus 5.5 401693ba65 Shader packs: shadow settings come from the fragment shaders, as Iris reads them
Horizon took shadowDistance, sunPathRotation, shadowIntervalSize,
shadowNearPlane and shadowFarPlane from a program's vertex shader too, and
read nothing of shadow.fsh but its formats when the pack has no shadow
pass. Iris 1.11.7, with probe packs of ours, ignored those five declared
only in final.vsh, gbuffers_textured.vsh or shadow.vsh (its shadow
projection, shadow camera and sun stayed at the defaults), and applied
shadowDistance and sunPathRotation declared only in shadow.fsh of a pack
whose shadow buffers nothing read.

Part of #10490

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:20:46 +05:00
60742e9c49 Save villager brain memories like vanilla
A villager's Brain.memories now keep golem_detected_recently, last_woken
and potential_job_site, and each memory's remaining ttl, through a save
and load (Brain.pack, MemoryMap.CODEC, ExpirableValue.codec). The brain
built at the first tick starts from the loaded memories.
last_worked_at_poi stays unsaved: the villager brain does not register it.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:37 +01:00
0e0fa342b8 Keep wandering trader homes like vanilla
Use the shared mob home for spawn, movement restrictions and persistence.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:37 +01:00
fa76ed4d94 Match vanilla hanging entity coexistence checks
Reject conflicting overlaps between paintings and item frames.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:36 +01: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
Delete228andCodex a3b1e9dd08 Keep native compatibility checks runnable in the upstream test harness
Co-authored-by: Codex <[email protected]>
2026-11-06 16:11:31 +03:00
Delete228andCodex d705f96e06 Keep native compatibility checks runnable in the upstream test harness
Co-authored-by: Codex <[email protected]>
2026-11-06 16:11:31 +03:00
Delete228andCodex ea750ddf7b Format the native compatibility changes
Co-authored-by: Codex <[email protected]>
2026-11-06 16:05:49 +03:00
Delete228andCodex 5bea1f4226 Format the native compatibility changes
Co-authored-by: Codex <[email protected]>
2026-11-06 16:05:48 +03: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
Delete228 3ba55cc9fa Supply the standard LAN join expectation in the fanout test 2026-11-06 15:46:01 +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
Delete228 c8cdfd117a Draw native terrain models with explicit materials and lighting 2026-11-06 15:39:28 +03:00
Delete228 218449af0c Render native still fluid volumes with an explicit vanilla material 2026-11-06 15:39:28 +03:00
Horizon agentandClaude Opus 5.5 34af7009f5 Multiplayer: drag a saved server by its row to move it in the list
Reordering a long server list took many clicks on the icon's small arrows or
Shift+arrow presses. The press that selects a saved server now also holds it:
moving the pointer puts it on the saved server's row underneath, the others
close up, and the release saves the order. A press on the selected server
still joins, as in vanilla, and holds nothing.

Fixes #10060

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:38:53 +01:00
voidsip 87386f72d9 Expose focused text fields to native character hooks 2026-11-06 06:35:13 -06:00
Delete228andCodex e1604cc9c0 Support transient fields consumed at mod client update construction
Mod block entities can replace, clear and atomically take an owner-controlled batch of client update fields without saving it or changing retained contents and revisions. The lazy queue is empty for new, decoded and cloned entities; normal loaded-chunk and block broadcast paths remain unchanged.

Validated with 3 real entity lifecycle/concurrency/poison tests and 3 reusable save/chunk/two-viewer Hub tests. Strict all-target Clippy passes for horizon-client-render and horizon-client-lib. No new dependency, third-party implementation or resource files.

Co-authored-by: Codex <[email protected]>
2026-11-06 15:32:56 +03:00
Horizon agentandClaude Opus 5.5 3365d72927 Native mods: client_attack and client_hurt hooks for the local player's combat
A client mod (battle music, a combat HUD) couldn't hear its own player's
fights on a remote server: the attack and damage hooks run where Horizon is
the server. native_client_attack(target, player) fires when the attack packet
leaves; native_client_hurt(attacker, player) when the server's damage event
for this player arrives, with the causing entity it names and whether this
client knows it as a player.

Fixes #10064

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:31:31 +01:00
Horizon agentandClaude Opus 5.5 ee55bb1e75 Dropped items fall through minecarts, as in vanilla
An item's move takes getEntityCollisions: the entities whose
canBeCollidedWith(item) holds. AbstractMinecart overrides only
canCollideWith (for its own moves), so a cart keeps Entity's false and
an item dropped on it falls through; the item list chained the carts in.
Boats, living shulkers and a held happy ghast still hold items (#11091).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:28:51 +01:00
Horizon agentandClaude Opus 5.5 1b221b01da Native mods: native_take_screenshot takes a screenshot as F2 does
A client mod (timed or repeated screenshots, as fabrishot does) asks for the
next frame's screenshot through F2's own path: screenshots/, vanilla's file
name, the chat line. It returns the file it will write; an error without a
window, or while another screenshot waits for the frame, which it would
otherwise replace.

Part of #9606

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:27:06 +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
Horizon agentandClaude Opus 5.5 3815013235 Native mods: native_open_chat opens the chat with text typed in
A client mod (a command macro such as CommandKeys) can open the chat screen
with text pre-filled for the player to finish, as T and / open it. Nothing is
sent. An error with no player in a world or while another screen is open.

Part of #10120

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:23:56 +01: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 c960a329ce Detect the machine's architecture in horizon-telemetry
horizon-client-render forbids unsafe code, which the Windows ARM64 build
reported (IsWow64Process2 and sysctlbyname are FFI). The detection lives with
the other system probes in horizon_telemetry::system now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:20:50 +01: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 0443141598 Mobs stop at shulkers and at a happy ghast held still
Entity.collide adds getEntityCollisions: the boxes of the entities whose
canBeCollidedWith(mover) holds. Mob movement only had block collisions,
so a zombie or a dropped-on mob fell through a living shulker and
through a happy ghast a player holds still by standing on it (#10445).

Seen carries the happy ghast's still timeout and whether it carries
someone; the mob tick lists the slots that may block (shulkers, happy
ghasts) so other mobs pay nothing when there are none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:16:47 +01:00
Horizon agentandClaude Opus 5.5 594552fec4 Hosting: an AMP template for horizon-server, beside the Pterodactyl egg
packaging/server/amp/ holds a template for CubeCoders AMP's generic module.
Its update stage runs the egg's own install script (the release asset checked
against its SHA-256), the start runs the same horizon-start.sh, and its
settings are the egg's variables with the same defaults. Tests keep both in
step and check the console patterns against the lines the server logs.

Fixes #8238

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:13:17 +01:00
Horizon agentandClaude Opus 5.5 d3d24aca55 Optimize World: cancel within a chunk, one sync per region file
- Each worker looks at the cancel flag before every chunk, not only before
  every file, so Cancel stops within a chunk on large region files. A
  canceled file is never swapped in by recreate_region_files.
- The writes of a region file reach the disk once, when the file is done,
  instead of a sync_data after every chunk. On the ThinkPad (NVMe, ext4) the
  per-chunk sync was half of a 1.21.1 world upgrade (187 s -> 89 s, one
  thread); on macOS sync_data is F_FULLFSYNC and costs far more.
- The calling thread is worker 0 and helpers are named World Upgrader #N;
  a helper that fails to start leaves its files to the others.
- Tag modifiers are no longer cloned per chunk; the recreate folder is made
  once per file.
- Tests: the parallel/one-thread comparison also checks vanilla counts
  computed from the fixture; a new test cancels from the first chunk with 4
  workers and checks at most 4 chunks were looked at and that a canceled
  recreate leaves the world unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:08:06 +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 4f7fadfd9e Merge remote-tracking branch 'origin/main' into pr-11049 2026-11-06 13:06:39 +01:00
Detimes 8dad7f0ac0 Merge remote-tracking branch 'upstream/main' into via-2 2026-11-06 12:04:54 +00:00
Horizon agentandClaude Opus 5.5 11d40ff1b9 Native mods: quinn 0.11 (QUIC) in Cargo.lock through horizon-mod-deps
Mods that speak QUIC (e4mc's relay) couldn't build: their dependencies must
already be in Horizon's lock. quinn joins horizon-mod-deps as an optional
dependency (feature quic: runtime-tokio + rustls-ring, so TLS stays on the
rustls + ring already in the lock). Nothing in the game builds it.

Fixes #9602

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:04:20 +01:00
Horizon agentandClaude Opus 5.5 33c431e3ae Docs: Intel Macs and Windows ARM64 have builds now
The README and the hosting guide still said Intel Macs come "from the next
Nightly" and listed no Windows ARM64 launcher; both ship in every Nightly and
launcher release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:02:53 +01:00
Horizon agentandClaude Opus 5.5 c19d97699d Windows ARM64: the x64 build emulated there also starts on DirectX 12
Auto already tries DirectX 12 before Vulkan on Windows ARM64, where Vulkan lost
the device (#7513, #6320 on Adreno), but it read the build's architecture: the
x64 game, which the x64 launcher installs on a Snapdragon PC, still started on
Vulkan. The order now follows the machine's architecture (IsWow64Process2), and
crash reports name the emulation ("x86_64 on aarch64"), on Windows and under
Rosetta 2 on macOS (sysctl.proc_translated).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:00:51 +01:00
Horizon agent 35e7942094 Merge main into #10208
# Conflicts:
#	tools/lan_e2e.py
2026-11-06 13:00:21 +01:00
Horizon agent e9b071e19e Merge main into #10538
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_post.rs
2026-11-06 13:00:01 +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
Horizon agentandClaude Opus 5.5 904e1d5597 Video settings: LOD Distance gets fine stops from 16 to 256 chunks
A small render distance (8 chunks on a phone or an integrated GPU) could only
add far terrain out to 64 chunks, the slider's first stop. The slider now stops
at 16, 24, 32, 48, 64, 96, 128, 192 and 256 before doubling as before.

Fixes #9694

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:55:06 +01:00
Horizon agentandClaude Opus 5.5 d4a985e599 Drop the run-log note and the non-standard parity script
tools/test_non_mob_saved_flags_parity.py needs a fresh --work-dir that
parity.py never passes, so every selected run would fail; the unit test
covers the four writers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:48:38 +01:00
DUzzL 1fc4fa2529 Upgrade region files in parallel in Optimize World 2026-11-06 12:21:55 +01:00
DetimesandClaude Opus 5.5 964454e1f8 Test that a mod's health 0 kills a guest, in and out of their turn
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:18:32 +00: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
DetimesandClaude Opus 5.5 a3abc60adf lan_bot: skip 26.2-layout packets in the 26.2 bots, walk in one move per tick (#10404)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:14:30 +00: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
DetimesandClaude Opus 5.5 270d067afa Keep the feedback of a captured vanilla command
A capturing server source is not silent: it takes every sendSuccess and
sendFailure line and informs no operator, so run it that way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:13:31 +00: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
Detimes a8dc253fe9 Merge via-2c 2026-11-06 11:06:44 +00:00
Detimes ac7f3dbcf9 Merge via-2b 2026-11-06 11:06:20 +00:00
Detimes 4ae9769ab7 Merge via-2a 2026-11-06 11:06:19 +00:00
DetimesandClaude Opus 5.5 962d7c6a22 Native helpers: per-player feedback, SNBT and map helpers move out of the hook points
They sat in the hook-point section, so the catalog test took them for hooks and hook_points_stay_hookable saw world_items!; now in the helpers' impl block of the same file (targets unchanged), the macro written out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:57:17 +00:00
DetimesandClaude Opus 5.5 304340fa9f native_boss_bar: check the bar's ranges without a negated call in the hook points
hook_points_stay_hookable reads `!(` as a macro.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:50:36 +00:00
DetimesandClaude Opus 5.5 830ee7c7f7 Catalog test: chunk_generate and lod_column live in horizon_world::worldgen::native_generate
The test maps each free hook to its module; the two world generation hooks are free functions in horizon-world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:50:36 +00: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
TheMrZZandCodex gpt-6.1-sol 39c12c2542 Award crafted-item statistics for merchant result takes
Track MerchantResultSlot removal and quick-move counts, reset them at the achievement callback, and award through the actual trading player's stat API without a recipe ID.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 11:34:26 +01:00
DetimesandClaude Opus 5.5 bafc55bbe3 older clients: say which particle stand-ins keep their options
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:33:15 +00:00
DetimesandClaude Opus 5.5 a8c6fb9d35 lan_bot: a 26.2 client's world packets come in 26.2's layout
The join test's bot reads packets with the pinned readers; the world
packets the adapter now writes in 26.2's layout are counted (chunks) or
skipped instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:32:42 +00:00
DetimesandClaude Opus 5.5 bf60148c4f older clients: write 26.2's world packet layouts for 26.2 clients (#10404)
Chunk columns (states down with stand-ins, repacked at 26.2's 15-bit
global palette, block entity types down, light masks as longs),
light_update, level_particles (26.2 field order, one speed, Int count),
explode (no trailing sound flag, silent blasts name intentionally_empty),
move_entity_pos/_pos_rot/_rot, entity_position_sync and animate in
26.2's encodings, swing_animation as 26.2's animate.

Tests write each in 26.2's layout and read it back with translate's
26.2 readers, made pub(super) for that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:31:25 +00:00
DetimesandClaude Opus 5.5 ec0c273cf2 item, recipes: write pot decorations, item-holding component values and tag slot displays in an older version's layout and ids
While ids are renumbered down, the values kept as pinned bytes that hold items
(pot_decorations, container, use_remainder, sulfur_cube_content) are read again
and written with that version's ids, pot sides as 26.2's four item ids, and a
tag slot display as 26.2's bare tag id (a composite of item displays for a list
of items). Quirks::{pot_decorations_are_items, tag_slot_display_is_id} written
the other way; ids::quirks_written gives the quirks of the version written for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:30:52 +00:00
DetimesandClaude Opus 5.5 3571849861 lan_bot: a 26.2 client's walk, dig, swing and teleport confirmations reach the game as 26.3's
The test server keeps every packet the hub hands to the game; a 26.2 bot
confirms its teleports by id alone, walks, starts and stops destroying the
block below in 26.2's action numbers, and swings with each hand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:30:18 +00:00
DetimesandClaude Opus 5.5 cf358cdd4e adapter: write login, respawn, advancements, commands, registry data, maps and stats in 26.2's layout for 26.2 clients (off by default)
layout_26_2 mirrors translate's 26.2 readers for a ClientSession: spawn info game
types back to bytes, the advancement tab position back inside its display,
argument types renumbered down with a string stand-in for 26.3-only parsers,
26.3-only synchronized registries not sent, map decorations and stats in 26.2's
numbering. Enabled with layout_26_2::set_enabled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:29:30 +00:00
DetimesandClaude Opus 5.5 4745f91af6 older-clients: read 26.2's accept_teleportation, player_action and swing as 26.3's
A 26.2 client confirms a teleport by its id alone: the session follows the
player_position packets it is sent and the moves it reports, and fills in
26.3's position and rotation from the teleport it accepts. Its digging
actions past 0 are one up (26.3 inserted CHANGE_DESTROY_DIRECTION at 1), and
its main-hand swing becomes 26.3's punch (an off-hand swing has no packet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:29:07 +00:00
voidsip edc6751798 Keep new lava and fire ignition silent in rain 2026-11-06 04:28:43 -06:00
SenjinandClaude Opus 5.5 d8160a3ded Shader packs: at_velocity for entities
Each entity's vertices are matched with last frame's by entity (Frame::entity_motion, now with
the GPU-posed start at each mark): last frame's pack buffer stays bound at set 1, binding 2, and
binding 3 gives each vertex its index there. at_velocity is this frame's view position less last
frame's (gbufferPreviousModelView joins the block when a pack reads it); a vertex with no match
reads zero, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:26:13 +01:00
Detimes fce1fb23d6 Merge remote-tracking branch 'upstream/main' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/raids.rs
2026-11-06 10:26:00 +00:00
Detimes ef66a2db57 Merge remote-tracking branch 'upstream/main' into hooks-b-all 2026-11-06 10:26:00 +00:00
DetimesandClaude Opus 5.5 4198a4de7f Tests: dispatch-test stubs for the new hook types (EntityDataValue, NativeGenerate, NativeProtoChunk, NativeLodColumn, TickSpan)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:21:12 +00:00
Detimes d1288fc34b Catalog test: log_line's target is horizon_client_lib::native_client_hooks
The test maps each free hook to its module; log_line (hooks-10461-a) lives in native_client_hooks.rs.
2026-11-06 10:18:21 +00: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
DetimesandClaude Opus 5.5 7750a91768 Tests: the respawn guest test from main fills Joined.configured (player_channels)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:14:00 +00:00
DetimesandClaude Opus 5.5 0580048c9d Clippy: then_some for the closing native store's slots (menu_input)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:14:00 +00:00
DetimesandClaude Opus 5.5 82f9fbf1ad Clippy: no explicit deref and sort_by_key in the generation hooks (chunk_generate, lod_column)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:13:59 +00:00
Florian ERNSTandCodex 3122ae98c0 Omit Mob-only flags from modeled non-Mob saves
Co-Authored-By: Codex <[email protected]>
2026-11-06 11:13:36 +01: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
Horizon agentandClaude Opus 5.5 4fa3bf699b Merge main into #10501 (main builds again after #10544)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:09:00 +01:00
fac11b3d6a Cauldrons, beehives, snow golems, farming villagers and lead knots make the vibrations vanilla's do
- LayeredCauldronBlock.lowerFillLevel posts a block change.
- BeehiveBlockEntity.addOccupant posts a block change.
- SnowGolem.aiStep posts snow placement.
- HarvestFarmland.tick posts crop placement.
- LeadItem.bindPlayerMobs and LeashFenceKnotEntity.interact post attachment.

Part of #9776.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:07:40 +01: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
Detimes 624ac54cdc Dispatch tests: stub horizon_client_core::text and horizon_protocol::packets::login
player_pre_login and player_profile name those paths in their hook signatures; the generated
dispatch code compiled in the test crate needs them, as the existing horizon_client stubs.
2026-11-06 09:56:23 +00:00
aba1b1eca4 Interpolate whole-cell volumes four columns at a time; skip air runs at once
Part of #9996.

A chunk's noise volume is made of whole cells, and InterpolatedSampler
filled it one cell at a time: per cell eight corner loads and four Z
lerps, per column two X lerps and a bounds-checked index, then a scalar
loop of stores and additions (fill_cell alone was 6.4% of the Overworld
proto phase's instructions). For a volume aligned to its cells,
fill_whole_cells now walks the four columns of one Z row of a cell from
the bottom of the volume to the top. The X lerps at a level of corners
are computed once (they are the top of one cell and the bottom of the
next: the same lerps of the same samples), the four columns' repeated
additions run as four lanes, and four Ys of the four columns are stored
as four vectors. Each value comes from the same operations on the same
operands in the same order as vanilla's InterpolatedFunction fillCell;
only the order between columns changes. The top cell may be cut short
(the 16x114x16 volumes); a volume that is not aligned, or whose top
corners would clamp, keeps the general path.

fill_noise_inner spent about ten instructions on each air block above
the aquifer's skip height. A downward run of non-positive densities that
stays above it is now skipped in one step: nothing was written for those
blocks and note_skipped only clears a flag, so one call covers the run.

Exact: chunkgen_bench --snapshot files (terrain and decorated) are
byte-identical to main in 16 cases: the Overworld at seeds 12345, -1,
987654321 and -7, the Nether at seeds 12345 and 42, the End at seeds
12345 and 7, sizes 3 to 12. A new test compares sample_volume and the
new path with a transcription of vanilla's sampleWithBlockStep and
fillCell, on an input with NaNs, infinities, signed zeros and
subnormals, for 4x8, 8x4, 4x4 and 8x8 cells, and checks which volumes
the new path takes.

Proto-phase instructions (callgrind, 64 chunks, one thread):
Overworld 5.970 G -> 5.615 G (-5.9%), the Nether 2.868 G -> 2.853 G
(-0.5%).

A first draft by GLM-5.3 and DeepSeek V4.1 Flash, via pi, reviewed and
reworked (the air-run skip).

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: pi coding agent (GLM-5.3) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:56:01 +01:00
Detimes bce9527c43 player_client, server_player_state fragments: mod_api.rs is not a comment path
The stamp tool fills mod_api.rs's markers itself and refuses it in "comments".
2026-11-06 09:55:22 +00:00
Detimes 402534f462 run_command_as: run_guest_command_told takes the hub with ? (clippy::question_mark) 2026-11-06 09:55:15 +00:00
Detimes fb9ce454ed Build fix: one more entity_nbt test call of mob_to_nbt gets uuid_of
The same #10304 argument as in the earlier build fix; this call sits in a test only pr_ready's test run compiles.
2026-11-06 09:55:15 +00:00
Detimes f830672067 Merge remote-tracking branch 'upstream/main' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/raids.rs
2026-11-06 09:50:14 +00:00
Detimes 0df8f65eda Merge remote-tracking branch 'upstream/main' into perf-region-inflate-in-place
# Conflicts:
#	crates/horizon-world/src/compression.rs
2026-11-06 09:49:51 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol c667277fcd chore: merge the updated shader identity core
Preserve the launcher layer while carrying main and the replacement shader screen from the game PR.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-06 10:49:49 +01:00
makscoder b7dbeda41a A brush stops on a mob in the crosshair
BrushItem.onUseTick aims with getHitResultOnViewVector, so a mob between the
player and the block is the nearer hit and the use releases without a stroke.
tick_brush used the block-only pick and brushed through the mob.
2026-11-06 12:45:11 +03:00
makscoder c1de12ab86 A brush or goat horn that uses itself up sends the interaction vibration
LivingEntity.stopUsingItem, reached when a use runs out or when the brush hits
no block, causes the ITEM_INTERACT_FINISH vibration. The brush and instrument
paths cleared the use directly instead, so only letting go of the button made a
sculk sensor hear them.
2026-11-06 12:42:48 +03:00
Nikita Maximilian GrimmandGPT 6.1 Sol 1295602deb fix(shaders): integrate the new settings screen
Merge main at 0873f24d2, retain its replacement shader screen, and route native selections and options through stable manifest IDs.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-06 10:40:49 +01:00
DetimesandClaude Opus 5.5 f416696038 cargo fmt: native_npcs before native_packets in game.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:38:41 +00: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
Riccardo Guerriero 82ac14c4ad Merge remote-tracking branch 'origin/main' into perf/9996-sections 2026-11-06 10:27:23 +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
DetimesandClaude Opus 5.5 fd2ec97a17 Native hooks: chunk_generate and lod_column reach mods through a callback table
horizon-world cannot call horizon-client's dispatch, so the two generation hooks
call a NativeGenerateHooks table that entry.rs installs (as native_load_hooks
does for the loot hooks). mod_api re-exports NativeGenerate, NativeProtoChunk
and NativeLodColumn for the generated events. Without the table, and without
mods, both hooks do nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:16:06 +00:00
DetimesandClaude Opus 5.5 9b08e99433 Native hooks: mod_api contracts for the 16 new hook fragments, dispatch calls filled
Since #6253 the mod API generator needs a mod_api entry for every pending hook
fragment. Cancel flags get {cancel, out: cancel}, menu_input_closed collects its
slots, every other hook listens. The placeholder bodies are filled by
`cargo run -p xtask -- mod-api`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:16:06 +00:00
Detimes a93e07da89 Merge branch 'b-bundle-18' into hooks-b-all 2026-11-06 09:12:35 +00:00
Detimes 37dfe7d880 Merge branch 'b-bundle-17' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/mod_api.rs
#	docs/native-mods.md
2026-11-06 09:12:34 +00:00
Detimes 58ba651c7b Merge branch 'b-bundle-16' into hooks-b-all
# Conflicts:
#	docs/native-mods.md
2026-11-06 09:12:15 +00:00
Detimes 950cd1058f Merge branch 'b-bundle-15' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:12:08 +00:00
Detimes 93ff841913 Merge branch 'b-bundle-14' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/commands/world_cmds.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:11:56 +00:00
Arslan ArmanulyandClaude Opus 5.5 6199a21cbe Merge origin/main into shaderpacks/same-size-buffer-declarations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:11:26 +05:00
Detimes 4cd44b1bfa Merge branch 'b-bundle-13' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 09:11:11 +00:00
Detimes 3dc1eac9a1 Merge branch 'b-bundle-12' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/lan/hub.rs
#	docs/native-mods.md
2026-11-06 09:10:59 +00:00
Detimes 1065005412 Merge branch 'b-bundle-11' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:10:36 +00:00
Detimes 71e2f6b0f5 Merge branch 'b-bundle-10' into hooks-b-all 2026-11-06 09:10:07 +00:00
Detimes 702055471b Build fix: mob_to_nbt's uuid_of argument in raids.rs and three entity_nbt tests
#10304 added the argument while these calls were merged; upstream/main does not compile without it.
2026-11-06 09:10:01 +00: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
Detimes bf7e616ebf hooks-all: integration fixes for the merged hook branches
- NativeEffect (player_effects) and NativeInventoryKind (set_inventory) are pub and re-exported in
  mod_api: the generated mod API refused both fragments.
- native_teleport_player's cross-level teleports fill LevelTeleport's new cause (hooks-move) with
  "unknown"; player_teleport's docs name that case.
- lan_vehicles: the vehicle_moved_v2 call closure is FnMut.
- cargo fmt (game.rs mod lines in order).
2026-11-06 09:00:30 +00:00
Detimes 8887870ec3 Build fix: mob_to_nbt's uuid_of argument in raids.rs and three entity_nbt tests
#10304 added the argument while these calls were merged; upstream/main does not compile without it.
2026-11-06 08:58:30 +00:00
Detimes 21d7853cc9 Merge branch 'hooks-pread' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-06 08:51:10 +00:00
Detimes b2ec69a247 Merge branch 'hooks-vehicle' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:09 +00:00
Detimes 23bb236cfc Merge branch 'hooks-pstate' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 08:51:08 +00:00
Detimes af12d6d443 Merge branch 'hooks-login' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 08:51:07 +00:00
Detimes b74f04855d Merge branch 'hooks-inv' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:06 +00:00
Detimes 010504d207 Merge branch 'hooks-move' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:06 +00:00
Detimes 24876b536b Merge branch 'hooks-cmds' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-06 08:51:04 +00:00
Detimes 5699b862d6 Merge branch 'hooks-perm' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:49:52 +00:00
Detimes 90a7a7d47b Native mods: a compound mods keep on a player (player_persistent_data)
native_player_persistent_data(uuid) reads and
native_set_player_persistent_data(uuid, data) replaces the compound mods
keep on a player, as NeoForge's Entity.getPersistentData() and Bukkit's
PersistentDataContainer. It is saved in the player's file as
NeoForgeData, beside the keys Horizon keeps without modelling them, and
kept over a death and a level change; an empty compound takes the key
away. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 08:49:26 +00:00
Detimes 66b3059251 Merge branch 'hooks-10461-d' into hooks-all
# Conflicts:
#	docs/native-mods.md
2026-11-06 08:49:13 +00:00
Detimes 341896762f Merge branch 'hooks-10461-c' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:49:11 +00:00
Detimes 513b1fe43d Merge branch 'hooks-10461-b' into hooks-all
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-client/src/game/mods.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/menu.rs
#	crates/horizon-client/src/native_client_hooks.rs
#	docs/native-mods.md
2026-11-06 08:48:54 +00:00
Detimes b99b9e13f7 Native hooks: item_dropped_v2 can cancel a throw (unproven on a server)
native_item_dropped_v2(player, stack, cancel) is now cancellable, as
NeoForge's ItemTossEvent is: a canceled throw puts the stack back into
the player's inventory (Inventory.add, the held slot first); only what
does not fit is thrown, unheard, and native_item_dropped hears nothing.
The drop statistics and the swing still happen, as in NeoForge. Wanted
by the combat log, freeze and inventory-lock ports.

Unproven: this commit was neither compiled nor run (builds were stopped for
the integration test); `cargo run -p xtask -- mod-api` filled the dispatch.
The listen-only v2 drop hook before it was proven on a server.

Part of #10461.
2026-11-06 08:48:11 +00:00
Detimes c2ec210fea Merge branch 'hooks-10461-a' into hooks-all 2026-11-06 08:47:51 +00:00
Detimes 9edbea0a6d Native hooks: thrown projectiles at the world border (projectile_world_border)
native_projectile_world_border(dimension, entity, kind, pos, velocity,
collide) is heard each tick a thrown projectile (pearl, snowball, egg,
potion...) inside the world border would end its next step outside it,
before 26.3 stops it on the border like a wall (MC-310476). A mod may
change its position and velocity, or set `collide` false: that tick's ray
is then clipped without the border, so it flies through as before 26.3.
Arrows and tridents, which Horizon does not stop at the border, are not
heard. Static (no self), called from tick_projectiles.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes c6ed7f75a0 Native hooks: farmland trampling, cancellable (trample)
native_trample(dimension, entity, kind, pos, fall_distance, cancel) is
heard when a player or mob that fell onto farmland is about to turn it to
dirt, once FarmlandBlock.fallOn's chance and checks have let it (NeoForge's
FarmlandTrampleEvent, Bukkit's PHYSICAL PlayerInteractEvent and
EntityInteractEvent). Canceling keeps the farmland and its crop; the fall
damage is taken either way. The landing rules ask through a new
BlockLandingWorld::trample_allowed (true by default), which the player's
and the mobs' landing worlds answer from a function the game sets once
(block_contact::TRAMPLE); there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 5caff4ecb4 Native hooks: where a dead player respawns (player_respawn_position)
native_player_respawn_position(player, name, dimension, pos, yaw, pitch)
is heard once per respawn, after the Respawn click and before the player
is placed (NeoForge's PlayerRespawnPositionEvent, Bukkit's
PlayerRespawnEvent.setRespawnLocation), with their respawn point: a bed
or anchor block's top, a forced point, else the world spawn. Left alone,
the point is resolved as before. A changed dimension or position places
the player exactly there, with no bed or anchor used and no free column
looked for; yaw and pitch set their look, which Hub::respawn now takes
instead of always 0, 0. An unknown dimension or a non-finite value
leaves that part unchanged.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 893eb8d636 Native hooks: a player's teleport before it reaches their client (player_teleport)
native_player_teleport(player, name, cause, from_dimension, from,
to_dimension, to, relative, cancel) is heard before a server player's
teleport is sent (Bukkit's PlayerTeleportEvent, NeoForge's
EntityTeleportEvent): commands (/tp within a level and into another),
ender pearls, chorus fruit, the spectator teleport menu, a dismount and
other server moves of the player. A mod may move `to`, cancel (the
player and their body stay where they are), or set the look and motion
bits of the position packet's relative flags, so a pearl can keep the
player's speed (0x1f8, what 26.2 sent); the position bits are ignored.

The call sites name the cause in a thread-local (lan::hub::teleport_cause)
that Hub::teleport and Hub::teleport_keeping_motion read; a teleport
without a named cause (respawns, portals, the End gateway, vehicles
carrying riders, waking up, refused moves) is not heard. Command and
spectator teleports into another level carry their cause in
LevelTeleport. The hub reaches the hook through a function the game sets
once (lan::hub::PLAYER_TELEPORT); there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 8e83a2c898 Native hooks: a player's move before it is applied (player_move)
native_player_move(player, name, from, to, yaw, pitch, on_ground,
cancel) is heard for each position or look packet that changed either
and passed the server's checks (moved too quickly, moved wrongly, the
anticheat), before Hub::position_change applies it (Bukkit's
PlayerMoveEvent). Canceling sends the player back to `from`, as a
refused move does; a changed `to` puts them there and their client gets
a teleport. Riders, sleepers and moves while a teleport waits are not
heard. The hub reaches the hook through a function the game sets once
(lan::hub::PLAYER_MOVE), as the damage hooks do; there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 0c8fb31992 Native mods: respawn a dead server player without the Respawn click (respawn_player)
The helper native_respawn_player(uuid) respawns a dead server player as
their client's PERFORM_RESPAWN does (Spigot's player.spigot().respawn()),
after the players' packets of the server tick, through the same
guest_respawn. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:13 +00:00
Detimes d117673fe9 Native mods: let a server player fly in any game mode (set_flight)
The helper native_set_flight(uuid, allow, flying) lets a server player fly
in any game mode or not and sets whether they fly now, as Bukkit's
Player.setAllowFlight and setFlying: the guest keeps the mod's mayfly
(allow_flight) until their game mode changes or they respawn, their
abilities packet and the server's body carry it, and the flying check does
not kick them. Without a mod allow_flight stays false and every check is
the game mode's, as before.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:13 +00:00
Detimes 2efd4b8346 Native mods: seat an entity on a vehicle, take it off, empty a vehicle (mount)
The helpers native_mount(entity, vehicle), native_dismount(entity) and
native_eject_passengers(vehicle) seat a server player or mob on a boat,
minecart or mob, take it off, or take every rider off, as /ride does with
its checks and native_entity_mount (Bukkit's addPassenger, leaveVehicle,
eject). /ride's mount checks move into ride_mount, which both use; the
command's answers are unchanged. Nothing calls the helpers without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes a8c67d77aa Native hooks: a ridden vehicle's move, cancellable and with its id, mounts included (vehicle_moved_v2)
native_vehicle_moved_v2(player, dimension, vehicle, kind, from, to, cancel)
is heard right after native_vehicle_moved, with the same state swapped in,
and also for a mob a server player rides. A canceled move puts the vehicle
back at from with its riders, a changed to puts it there
(native_teleport_vehicle), as Bukkit's VehicleMoveEvent with a teleport.
v1 keeps its signature, firing point and vehicles. Without mods nothing
moves the vehicle.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes 052443e2a5 Native mods: teleport a vehicle with its riders (teleport_vehicle)
The helper native_teleport_vehicle(dimension, id, pos) teleports a boat,
minecart or ridden mob of the level in place with its riders, as /tp does
a vehicle (Bukkit's Vehicle.teleport): the riders stay seated and a server
player riding it is told, as their client drives it. Only where nobody's
state is swapped in. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes 38a9866a1f Native mods: teleport a server player from any server hook (teleport_player)
The helper native_teleport_player(uuid, dimension, pos, yaw, pitch)
teleports a server player as /tp does (Bukkit's Player.teleport): at once
when they are the acting player or nobody's state is swapped in, else
after the players' packets of the server tick, so a port no longer runs
execute in .. run tp from the console, which waits for the next tick and
does nothing inside a player's hook. Into another level it is a command's
level teleport. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes aa8f5fc12a Native mods: send a player's command tree again (resend_commands)
The helper native_resend_commands(player) sends a player their command
tree again, with native_player_commands_sent, its v2 and the permission
nodes of the mods' commands asked anew, as Bukkit's Player.updateCommands:
after a permissions mod changed what they may use. The tree goes out in
the next server tick, with no player's state swapped in, so the helper
works from any server hook, the player's own included. Nothing calls it
without a mod.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 329acc6c53 Native mods: the level one typed command runs at (player_command_level)
native_player_command_level(player, name, command, level) is heard in
Game::guest_command after native_player_command, before the server checks
the command: level comes in as the player's own and becomes the level this
one command runs at (GuestCommand.permission), so a permissions mod can
grant /gamemode to a player of level 0 or refuse a command to an operator,
as Paper's VanillaCommandWrapper does with minecraft.command nodes. A mod
command with a permission node falls back to this level. Unchanged, the
command runs at the player's level, as before.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 0bd927b7c1 Native mods: a permission node on a mod's command (NativeCommand::permission)
NativeCommand::new(usage).permission(node) gives a mod's command a node, as
Bukkit's plugin.yml permission: does. The server then asks
native_has_permission(player, node, level), the command's level as the
default, both before it puts the command in a player's command tree and
before a player's typed command reaches native_perform_command, so a
permissions mod can grant it below that level or refuse it to an operator.
Without a permissions mod the level decides, as for a command without a
node; a dedicated server's console runs every registered command as before.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 3cc7e3c822 Native mods: put a command the level hides back into a player's tree (player_commands_sent_v2)
native_player_commands_sent_v2(player, name, names, names_hidden) is heard
right after native_player_commands_sent, at the same times, with the root
commands the player's level hides (those a level 4 operator would get) in
names_hidden. A root a mod moves into names is sent with its subtree, as
for an operator of its level, so a command a permissions mod grants
completes and highlights in the player's chat box (Paper's
PlayerCommandSendEvent after a passed permission check). v1 keeps its
signature and firing point. Without mods nothing is granted and the tree
is unchanged.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:04 +00:00
Detimes 25ac7a9841 Native mods: permission nodes, answered by a permissions mod (permission_check)
The hook native_permission_check(player, node, default_level, result) lets a
permissions mod (a LuckPerms port) say whether a player has a permission
node; the first mod to set result decides. The helper
native_has_permission(player, node, default_level) asks it, for every mod
and for the server, and falls back to the player's operator level against
default_level when no mod answers, as Bukkit's Permissible.hasPermission and
NeoForge's PermissionAPI.getPermission do. Without a permissions mod every
answer is the operator level, so nothing changes for unmodded servers.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:04 +00:00
DUzzL 379850ac5c Never build an exit portal for a spectator using a nether portal 2026-11-06 09:41:40 +01:00
DUzzL 99b969686e Stop a deflected potion at the breeze and use its own random 2026-11-06 09:41:05 +01:00
Detimes fe7635e545 Cargo.lock: the 12 packages of the new horizon-mod-deps entries (unbuilt)
Written by hand from a scratch resolution seeded with this lock: rusqlite,
libsqlite3-sys, fallible-iterator, fallible-streaming-iterator, hashlink,
vcpkg, yaml-rust2, arraydeque, gif, weezl, color_quant, snap. Existing
entries keep their versions; chrono gains serde and mysql_common gains chrono.
To confirm: cargo metadata --locked must not want to change it.
2026-11-06 08:38:14 +00:00
Detimes 71b36cca26 Native mods: a store menu the hub closes on its own gives the cursor back too (removed) (unbuilt) 2026-11-06 08:37:33 +00:00
DUzzL ac4045d83f Keep the worn-armor snapshot per LAN guest 2026-11-06 09:37:03 +01:00
Detimes ca623aae34 Native mods: another player's inventory in a server menu with vanilla click rules, live or on their file (native_open_inventory_menu) (unbuilt) 2026-11-06 08:35:30 +00: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
Detimes 8c7807e832 Native helpers: the name over a player's head (set_name_tag) (unbuilt) 2026-11-06 08:34:22 +00:00
DUzzL 98e1e2d67f Credit trident damage_dealt_absorbed to the throwing LAN guest 2026-11-06 09:33:17 +01:00
Detimes 296d765d78 Native helpers: a player's styled name in chat (set_display_name) (unbuilt) 2026-11-06 08:33:08 +00:00
Detimes ca660e8d1f Native mods: server menus with input slots, their contents, the click's cursor and the slots on close (native_open_menu_with_input, native_menu_input_closed) (unbuilt) 2026-11-06 08:32:15 +00:00
Detimes 450b6c8548 Native hooks: leave mod_api to the stamp step; cancel flags named cancel (unbuilt)
The bundle's fragments carry no mod_api entry: session A adds them in one
step (add_mod_api.py, which takes a cancel: &mut bool parameter for the
cancel contract). So interact_entity_v2's claimed and
right_click_block_v2's canceled are named cancel; they act as before.

Not built yet.
2026-11-06 08:32:11 +00:00
Detimes 4cf75b20d9 Native helpers: a player's styled Tab list name (set_list_name) (unbuilt) 2026-11-06 08:32:07 +00:00
Detimes 873480bddc Native hooks: damage with the players' UUIDs and the attacker (living_incoming_damage_v3) (unbuilt)
native_living_incoming_damage_v3(victim, victim_uuid, kind, attacker,
attacker_uuid, direct, damage_type, amount, cancel) fires everywhere v1
fires (health::HURT_SERVER), right after it with the amount v1 left and
the same cancel flag. It adds a player victim's UUID, the causing entity
where v1 leaves 0 (a mob's attacker, a player's melee, the synchronous
attack in progress as living_knockback_v2 falls back to), a player
attacker's UUID and the direct entity. Players are noted by entity id as
each guest's turn starts (game/native_damage.rs, only while a native mod
is loaded), since the hit carries ids only and no Game.

Not done here, as the plan asked: a &mut self host (the victim is
borrowed out of the game inside Mob/Player/Dragon hurtServer, behind the
HURT_SERVER fn pointer) and Bukkit's modifier breakdown (the reductions
run after the hook in horizon-world's living_hurt and Mob::hurt_server).

Sentinel (attacker matched within 500 ms), Wanted, HuskSync and
CombatLog (an id-to-player map each tick) need the names.

Not built yet.
2026-11-06 08:31:35 +00:00
Detimes 0072925aef Native mods: kick a player with a whole text as the reason (kick) (unbuilt)
native_kick(player, reason) disconnects a player with a Text reason in its
colours and translations (Adventure's Player.kick(Component)), through
Hub::kick as /kick does, so the log line and native_player_kicked follow.
It is queued and runs at the tick's end with the mod messages
(flush_native_net), so it works from any server hook, the kicked player's
own included; today a port sends a raw Disconnect and then runs /kick with
one plain line.

Part of #10461.
2026-11-06 08:31:11 +00:00
Detimes 342a45b57a Native helpers: entities shown to one player's client alone (client_entities) (unbuilt) 2026-11-06 08:31:04 +00:00
Detimes ca9437b79f Native hooks: resource pack answers with the pack's id, configuration included (resource_pack_status_v2) (unbuilt)
native_resource_pack_status_v2(player, name, pack, status) is an additive
version of native_resource_pack_status with the answered pack's id
(Bukkit's PlayerResourcePackStatusEvent.getID), so a mod's own pack's
answers are told apart from the server's. In play it is heard just before
v1, which keeps its signature and firing point. The answers to the
server's own pack given during configuration, which the connection thread
handled alone, are kept (lan::Configured::pack_answers) and heard once the
player has logged in, right after native_player_logged_in. The connection
answers the packets exactly as before.

Part of #10461.
2026-11-06 08:30:06 +00:00
Detimes 0c10886ec0 Native mods: horizon-mod-deps adds rusqlite, yaml-rust2, gif, snap and mysql/chrono (unbuilt)
rusqlite 0.41 (bundled + cache, no default features) reads SQLite files such
as a Bukkit plugin's .db; yaml-rust2 0.13 (no default features, safe Rust)
reads and writes config.yml; gif 0.14 and snap 1 are small and pure Rust;
mysql gains chrono for DATETIME columns. All optional, nothing builds them.
Cargo.lock not yet updated.
2026-11-06 08:29:39 +00:00
Detimes d7c1e71d45 Native mods: each server tick's time and spans, a shared span switch and mob costs a mod samples (native_server_tick_timed, native_spans, native_mob_cost_sampling, native_take_mob_costs) (unbuilt)
tick_server_spans calls native_server_tick_timed with the pass's wall and CPU time and merged spans (TickSpan, re-exported from spans::Span) before handing them to the report. Spans are switched through tick_profile::want_spans by who wants them (HORIZON_SPIKES, horizon-report, a mod), so a mod and a report no longer turn each other's spans off. tick_profile keeps a mod's mob cost sums apart from the report's (two bits, two maps), so both can sample at once; calibration stays the report's. Fragment server_tick_timed.
2026-11-06 08:29:28 +00:00
Detimes 36323e05ae Native mods: each player's play packets once a tick (native_server_packets, native_count_packets) (unbuilt)
While a mod turns counting on, the network threads keep each framed and read play packet's id and body size in the connection's Outbox (io_pool Conn::frame and Conn::read, behind one relaxed load of a static switch, capped at 65536 each way); the server tick's end hands them to native_server_packets per player and way. Off (the default) nothing is kept and the hook never fires. Fragment server_packets.
2026-11-06 08:29:28 +00:00
Detimes 8abf5298fc Native mods: hurt a mob or a player from an attacker (native_hurt_entity) (unbuilt)
native_hurt_entity(dimension, target, amount, damage_type, attacker)
hurts a mob or a player of the level as LivingEntity.hurtServer with
damageSources().mobAttack(attacker) does, the way /damage <target>
<amount> <type> by <attacker> goes: cmd_damage's hurt is moved unchanged
into damage_target, which both call. Returns whether it hurt; false for
another dimension, a target that is neither a mob nor a player of the
level, a missing attacker or a non-finite amount.

Sentinel's own damage (damage set, workaround damage, lightning weapon)
today runs a damage command by UUID.

Not built yet.
2026-11-06 08:29:25 +00:00
Detimes 7fbd43b6a9 Native mods: offer and take away a mod's own resource packs (resource_packs) (unbuilt)
native_send_resource_pack(player, id, url, sha1, prompt, required) sends
ClientboundResourcePackPushPacket with the mod's own pack id (Bukkit's
Player.addResourcePack), checked against the packet's limits (hash at most
40, URL at most 32767 characters); native_pop_resource_pack(player, id)
sends ClientboundResourcePackPopPacket for one pack or, with None, all
(removeResourcePack). Both go through native_send. A decline still kicks
only when the server's own pack is required, as vanilla's
handleResourcePackResponse does.

Part of #10461.
2026-11-06 08:29:23 +00:00
Detimes e5acbdde15 Native helpers: hide an entity from one player's client (hide_entity_from) (unbuilt) 2026-11-06 08:28:56 +00:00
Detimes b842df0899 Native hooks: a mob's target changing, cancellable (living_change_target) (unbuilt) 2026-11-06 08:28:56 +00:00
Detimes 7f91f765b2 Native hooks: the plugin channels a player registers, in configuration and play (player_channels) (unbuilt)
native_player_register_channels(player, name, channels, register) hears
the names a client registers (minecraft:register) or unregisters
(minecraft:unregister), NUL-separated as Bukkit's and Fabric's clients send
them; vanilla reads neither (Bukkit's PlayerRegisterChannelEvent). What a
client registered during configuration, which the connection thread
dropped, is kept (lan::Configured on Joined) and heard once right after
native_player_logged_in; a message in play is heard as it is handled, with
the player's state swapped in, and then goes on as before. The helper
native_player_channels(player) lists a guest's channels
(Player.getListeningPluginChannels). At most 128 names of at most 256
bytes are kept per client.

Part of #10461.
2026-11-06 08:28:35 +00:00
Detimes 33e0f27a8d Native hooks: a button's, lever's or plate's signal change (block_redstone) (unbuilt)
native_block_redstone(dimension, pos, old, new) is asked, as Bukkit's
BlockRedstoneEvent, when a button is pressed (hand or arrow) or let go by
its release tick, a lever is pulled, or a pressure plate's weight check
changes its signal. A mod may change new; one equal to old keeps the
component as it is, and a held button or plate is asked again at its
next scheduled check. The seam is LevelAccess::redstone_change in
horizon-world (default: leaves the signal), which GameLevel forwards on
an authoritative level. Wire, torches, diodes, observers and the other
sources are not heard yet.

CoreSMPEssentials part B holds its power rods' component on without the
2-tick re-set that leaks a 1-tick off pulse. Test (not run):
a_level_can_keep_a_lever_as_it_is.

Not built yet.
2026-11-06 08:28:19 +00:00
Detimes ce01d5ffeb Native mods: native_set_compass_target, kept over respawns and level changes (unbuilt)
Bukkit's Player.setCompassTarget: Hub.compass_targets by UUID while the player is on; default_spawn sends the target in place of the world spawn wherever it is sent (join, respawn, level change). Without a target the packet is unchanged. Helper-only fragment compass_target.
2026-11-06 08:28:07 +00:00
Detimes 1ffc88650d Native mods: native_boss_bar, boss bars for one player (unbuilt)
Bukkit's BossBar.addPlayer/removePlayer: a NativeBossBar (mod_api) by player UUID in BossState, offered to that viewer by boss_events_for, so publish_boss_bars sends Add/Update/Remove as for the world's bars. Unmodded servers keep an empty map. Helper-only fragment boss_bar.
2026-11-06 08:28:07 +00:00
Detimes d66f3e02ea Native mods: native_title, a title and subtitle for one player (unbuilt)
Adventure's showTitle and /title: SetTitlesAnimation, SetSubtitleText, SetTitleText through native_send. Helper-only fragment title.
2026-11-06 08:28:06 +00:00
Detimes ebb19e976e Native mods: native_send_particles_to, particles for one player alone (unbuilt)
Vanilla's ServerLevel.sendParticles(player, ...) and Bukkit's Player.spawnParticle: within 32 blocks, 512 with force (overrideLimiter). Replaces the hand-built LevelParticles (dust id hard-coded) of plugin ports; same options and checks as GameLevel::native_send_particles. Helper-only fragment send_particles_to.
2026-11-06 08:28:06 +00:00
Detimes 6ad344bec9 Native mods: native_play_sound_to, a sound for one player alone (unbuilt)
Bukkit's Player.playSound for plugin ports, which sent a raw ClientboundSoundPacket through native_send. Queued in game/native_player_fx.rs and sent at the tick's end with the hub back, so it works from any server hook; helper-only fragment play_sound_to.
2026-11-06 08:27:51 +00:00
Detimes fe2ef8529c Native mods: native_mob_attack, a mob's own attack on a mob or player (unbuilt)
Sentinel's native attacks (its default) call Mob.doHurtTarget for the
body. Horizon's do_hurt_target needs the goal's world, and its player is
whichever guest the mob sees, so the port injected a goal and wrote
LanHost.mob_players to bind the body to its target. native_mob_attack
runs do_hurt_target from any server hook with the target guest's view as
the mob's player (a new lan_guest_view, the single-guest form of
lan_guest_views), then hands the hits on that player to the guest and the
rest to handle_mob_events, as a mob's tick does. Helper only: unmodded
servers never call it. Part of #10461.
2026-11-06 08:27:49 +00:00
Detimes 21da267e5b Native mods: native_remove_entity takes projectiles and fishing bobbers (remove_projectiles) (unbuilt)
native_remove_entity(dimension, id) also removes projectiles (arrows,
tridents, snowballs, eggs, pearls, fireballs, wind charges, llama spit,
shulker bullets, firework rockets) as /kill does (kill_target), and a
server player's fishing bobber, as Entity.discard: the bobber of the
player in place, or outside a guest's turn any player's of the level
(remove_server_hook in fishing.rs). Displays, markers and interactions go
as before. Sentinel's arrow cleanup and CoreSMPEssentials part B's power
rods need it.

Not built yet.
2026-11-06 08:26:32 +00:00
Detimes 182345dc5e Native mods: mob navigation and look helpers for NPC ports (unbuilt)
Citizens' Navigator and Sentinel walk their bodies to points and
entities, stop them, ask whether they still walk and aim their heads.
Horizon offered this only to a goal (Mob::nav_move_to with the goal's
world), so ports injected a goal that read intents from a global map, or
moved bodies in straight /tp steps through walls. native_mob_navigate,
native_mob_follow, native_mob_stop, native_mob_navigating and
native_mob_look_at call the mob's own navigation and look control
(PathNavigation.moveTo, stop, isInProgress, LookControl.setLookAt) from
any server hook, for a mob of any loaded level, with the world its goals
see. Helpers only: unmodded servers never call them. Part of #10461.
2026-11-06 08:26:27 +00:00
Detimes a59c7468d1 Native mods: maps a server mod draws, kept by the world (native_new_map, native_paint_map) (unbuilt) 2026-11-06 08:26:00 +00:00
Detimes 07acadab21 Native mods: native_set_entity_rotation for mobs and mannequins (unbuilt)
native_move_entity turns only the entities Horizon keeps without
simulating them, and native_teleport_mob takes no rotation, so NPC ports
turned their bodies with execute .. rotate or tp .. facing every few
ticks. The helper is /rotate (Entity.forceSetRotation) for an entity of
any loaded level, with an optional head yaw (setYHeadRot) for a mob.
Helper only: unmodded servers never call it. Part of #10461.
2026-11-06 08:25:28 +00:00
Detimes 4855ac0767 Native hooks: the entity data one viewer is sent, changeable for them alone (entity_data_to) (unbuilt)
native_entity_data_to(viewer, entity, values) is a typed shortcut of
native_server_packet for ClientboundSetEntityDataPacket: the entries one
guest is about to get for one entity, changeable for that viewer alone, so
a mod can show one player glowing to one viewer without touching the
server's own flags (TAB's and Essentials' per-viewer glow, which resend
their own packets on a timer today). Heard just before native_server_packet
for the same packet. Switched on by native_watch_entity_data, which decodes
that packet only. EntityDataValue is horizon_protocol's entity_data::Value.

Part of #10461.
2026-11-06 08:25:27 +00:00
Detimes aad1954499 Native hooks: a server player's bobber on a block (fishing_hook_landed) (unbuilt)
native_fishing_hook_landed(player, hook, dimension, block, face) hears a
server player's flying fishing bobber meeting a block on its way, and
each tick it lies on one (on the ground as the tick began or ends: the
block under its centre, its top), as Bukkit's ProjectileHitEvent for a
FishHook. It is heard at the end of FishingHook.tick with the bobber back
in place, so a mod can remove it there. hook_block_hit keeps its result;
a sibling hook_block_hit_at also gives the block and face.

CoreSMPEssentials part B starts its power rods from the plate, button or
lever the bobber lands on; native_projectile_impact leaves bobbers out.

Not built yet.
2026-11-06 08:25:18 +00:00
Detimes e55c423833 Native mods: what each open server level holds (native_level_stats, native_level_entities, native_level_block_entities) (unbuilt)
NativeLevelStats counts a level's loaded and forced chunks, block entities and ticking ones (BlockEntities::ticking_len), and its entities (players included) and block entities by type. The entity list reuses the selector walk (level_candidates, now pub(in crate::game)) with a parked level swapped in as /execute in does; block entity types come from the block (lan::chunks::block_entity_type), which the enum's variants cannot tell apart, so this replaces the planned BlockEntity::type_id(). Fragment level_stats.
2026-11-06 08:25:07 +00:00
Detimes db26ac1679 Native mods: native_entity_by_uuid and native_merge_entity_nbt for any loaded level (unbuilt)
A stage plugin keeps its actors by UUID and moves, changes or removes them
in whatever level they are in; Horizon's entity handles reach the level in
place only, so ports ran tp/data merge entity by UUID on the console.
native_entity_by_uuid finds the entity (players included) in any loaded
level and returns its dimension, id and type; native_merge_entity_nbt is
native_entity_merge_nbt for a named loaded level. Helpers only: unmodded
servers never call them. Part of #10461.
2026-11-06 08:24:56 +00:00
Detimes 9bf6fb8323 Native mods: an item stack from SNBT (native_item_from_snbt, ItemStack::from_nbt, mod_api::parse_snbt) (unbuilt) 2026-11-06 08:24:47 +00:00
Detimes 4b1319096e Native hooks: a mod's terrain in the far terrain (lod_column) (unbuilt)
native_lod_column(dimension, column, seed, flat, column_out) is asked
for each column GeneratorWorker::lod_grid, surface_sample and
surface_column sample. A mod whose native_chunk_generate builds that
chunk answers the column's blocks (and its biome) with
column_out.replace, and the far terrain shows them, with the top, the
water over it and the spans derived as vanilla's sample derives them,
instead of vanilla's noise until the chunk generates.

Vanilla's far sample is computed first and only replaced when a mod
answers: without a mod, one empty call per column and the same columns
(#105).
2026-11-06 08:24:15 +00:00
Detimes 6c3dd1247f Native hooks: a player's fishing rod with its hand (player_fish_v2) (unbuilt)
native_player_fish_v2(player, name, state, hand, cancel) is
native_player_fish with the rod's hand: the hand the cast or reel-in uses
(Bukkit's PlayerFishEvent.getHand), and for a bite the hand holding the
rod, main hand first as FishingHook.shouldStopFishing looks. Asked right
after the unchanged v1 hook with the same cancel flag. CoreSMPEssentials
part B sees off-hand stasis and power rod casts.

Not built yet.
2026-11-06 08:24:02 +00: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
Detimes f4ccc08720 Native hooks: every play packet a player is sent, changeable and cancellable (server_packet) (unbuilt)
native_server_packet(player, packet, cancel) hears each play packet the
server is about to send one guest, decoded, a broadcast once for each
player, where PlayerSender::send hands it over (ProtocolLib's and
PacketEvents' outgoing listeners; NeoForge and Bukkit have no event).
A changed packet is encoded again, a canceled one is not sent. Nothing is
decoded or copied until a mod calls the helper native_watch_server_packets:
until then each packet costs one relaxed atomic load. The hook is a free
function the game sets once (lan::conn::SERVER_PACKET), as player_move is.

Part of #10461.
2026-11-06 08:23:51 +00:00
Detimes 91a7c31b12 Native mods: native_spawn_mob and native_summon reach any loaded level (unbuilt)
Both helpers were refused for any level but the one in place, so a mod
spawning from native_server_tick (where no level is defined) or into
the Nether from the Overworld's tick could not, and Stage-style ports ran
`execute in <dimension> run summon` instead. The named level is now put
in place for the call and put back afterwards, through the same
enter_command_level that `execute in` uses (npc_in_level in the new
native_npcs.rs). A level not loaded is still refused. Unmodded servers
never call these helpers, so nothing changes for them. Part of #10461.
2026-11-06 08:23:46 +00:00
Detimes 14e39641c2 Native hooks: a right click on a block with the hand (right_click_block_v2) (unbuilt)
native_right_click_block_v2(pos, face, hand, canceled) is
native_right_click_block with the hand that clicks (NeoForge's
PlayerInteractEvent.RightClickBlock.getHand), asked right after the
unchanged v1 hook with the same flag, once per hand tried. A cancel does
what a v1 cancel does. CoreWG's guard refuses an off-hand flint and steel
or bone meal; today it sees the main hand only.

Not built yet.
2026-11-06 08:23:26 +00:00
Horizon agentandClaude Opus 5.5 0a1a3c6ffe Merge #10502 into #10501 (both edit pack_post.rs)
Keeps both test modules (mixed_size_tests, undrawn_terrain_tests) and
has_terrain with #10502's doc and undrawn flag after sizes_differ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:23:24 +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
Detimes ff07b57b27 Native mods: the server's tick times and ticks a second (native_tick_times, native_tps) (unbuilt)
native_tick_times() returns the last 100 tick durations at vanilla's getTickTimesNanos indices (tick n at n % 100); native_tps() the ticks a second over one, five and fifteen minutes (Bukkit's getTPS), measured between recorded tick ends in FixedStep::record. Helpers only, fragment tick_times.
2026-11-06 08:22:55 +00:00
Detimes 9553a05bb8 Native hooks: a right click on an entity with the hand (interact_entity_v2) (unbuilt)
native_interact_entity_v2(entity, kind, hand, stack, claimed) is
native_interact_entity with the hand that clicks (NeoForge's
PlayerInteractEvent.EntityInteract.getHand), asked right after the
unchanged v1 hook with the same claimed flag: a click v1 claimed is not
asked again. Once per hand vanilla tries, so a mod claiming the main hand
hears one event per click (Citizens' click events and commandtrait
hands, CoreWG, CoreSMPEssentials part B).

Not built yet.
2026-11-06 08:22:47 +00: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
Detimes 6847ec0b7b Native hooks: a chunk's terrain from a mod (chunk_generate) (unbuilt)
native_chunk_generate(dimension, chunk, seed, flat, chunk_out,
generate) is asked on the generation workers for every chunk, after its
biomes and structure starts and before the noise (Bukkit's
ChunkGenerator.generateNoise). A mod sets the chunk's blocks through
NativeProtoChunk::set_block and answers NativeGenerate::Replaced with
the shouldGenerate* flags: vanilla then runs only the surface rule, the
carvers (on a superflat level too), the structures and the features it
asks for. set_biomes and clear_structures work with either answer.

Left at Vanilla, nothing changes: the hook is one empty call per chunk
and every vanilla step runs as before. Replaces the cancellable HEAD on
VanillaTerrain::proto that generated the vanilla chunk and overwrote it,
and the coregen_glue module (#104).
2026-11-06 08:22:18 +00:00
Detimes a58cb67f76 Native world seed read helper (native_world_seed) (unbuilt)
The world's seed as /seed prints it (Bukkit's World.getSeed), for a
generator mod's preview that must draw the terrain the chunks get.
None off a server, where only a hash of the seed is known. No
behaviour change. Part of the world generation bundle (#98).
2026-11-06 08:19:15 +00: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
Arslan ArmanulyandClaude Opus 5.5 e1a9161f7e Shader packs: the End under the dragon fight's fog has gbuffers_skybasic's fan
While the dragon fight's world fog leaves out the End's sky, Iris 1.11.7 still
runs a pack's gbuffers_skybasic (render stage SKY, vertex colour fogColor), and
the gl_Vertex values our probe packs read there (render distances 12 and 8) fit
a fan around the horizon: from (0, -16, 0) under the eye to eight rim points 16
above it, every 45 degrees, at the render distance. Horizon ran no sky program
in the End, so packs that paint their sky there left that band empty.

Frame carries the boss bar's world fog; PackSky draws only the fan then, in
the fog colour.

Part of #10491

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:06:31 +05:00
Detimes 304276d8dc Region chunks: inflate zlib straight into the chunk's buffer
A saved chunk's zlib stream was inflated by a new Decompress, 8 KiB at a
time into a stack block, each block then copied onto the output, which
grew by doubling from empty. Now each thread keeps one inflater, reset
for every chunk, and a whole stream that fits is inflated straight into
the output, reserved at about eight times the stream (a chunk's usual
ratio) and never past one byte over the limit. Any stream that fails
(damaged, cut short, over the limit) is decoded again the old way, so the
error and what the output keeps are exactly as before. A new test
compares both on whole, cut and damaged streams around every limit.
2026-11-06 08:03:25 +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
Detimes 3dd74dcc64 Native read-only chunk snapshots (native_chunk_snapshot)
A NativeChunkSnapshot of a loaded chunk of an open level (block states,
WORLD_SURFACE heights, noise biomes, the fuzzy biome zoom's cell) that
shares the chunk's sections copy-on-write and can be read on another
thread, with native_block_map_color, native_biome_name and
native_biome_tint to draw it. Replaces the engine internals a web map
reads today. No behaviour change. Part of #10461.
2026-11-06 07:57:06 +00: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
GabrielNichols 3dd2820398 Open Recipes settings from the inventory browser 2026-11-06 04:38:32 -03:00
Detimes 3a20f23ee6 Native mods: read and set a player's attributes (player_attributes)
native_attribute(uuid, attribute) gives an attribute's base value and its
value with every modifier (Bukkit's AttributeInstance.getBaseValue and
getValue); native_set_attribute_base sets the base as
/attribute .. base set does; native_set_attributes loads a saved
attributes list onto the player as vanilla's AttributeMap.load reads a
player's file. A lower max_health caps the health, and the client gets
UpdateAttributes from the tick's sync. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:33 +00:00
Detimes 865e30ecb6 Native mods: read, give, take and replace a player's effects (player_effects)
native_effects(uuid) reads a player's effects as NativeEffect { id,
amplifier, duration, ambient, particles }; native_add_effect gives one as
vanilla's LivingEntity.addEffect (Bukkit's addPotionEffect), so a weaker
or shorter one waits under it; native_remove_effect takes one off as
/effect clear does; native_set_effects replaces them all, what a mod
restoring a saved player needs instead of writing PlayerEffects into the
hub's guests. The client gets UpdateMobEffect with the ambient and
particle flags from the tick's sync. Horizon keeps no hidden-icon flag on
an effect, so none is offered. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:33 +00:00
Detimes 3e6a1990ce Native mods: read a player's food, set health, food and experience (player_status)
native_player_food(uuid) reads a player's food level, saturation and
exhaustion; native_set_player_status(uuid, health, food, xp) sets each
that is given, as Bukkit's setHealth, setFoodLevel, setSaturation,
setExhaustion, setLevel, setExp and setTotalExperience. The write goes
through the player's turn as /kill and /effect reach a player
(for_player), so their client gets SetHealth and SetExperience with the
tick's sync. Health is capped at the maximum; 0 or less kills as /kill
does, with "<player> died" (Bukkit's setHealth(0), LivingEntity.die with
the generic source). The mod's food is not heard as the player's own
change (native_food_level_changed) outside their turn. Nothing calls it
without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:32 +00:00
Detimes 56b7dcbe62 Native mods: a player's compound as the server saves it (player_compound)
The helper native_player_compound(uuid) gives a player's compound as the
server would save it now (players/data/<uuid>.dat, playerdata::player_tag),
vanilla's ServerPlayer.saveWithoutId: what a mod keeping players in step
across servers (a HuskSync port) stores, instead of calling player_tag on
the hub's guests itself. None inside a player's turn, where their live
state is out of their record, and for a player not on. The new module
native_player_state.rs holds this group's helpers. Nothing calls it
without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:32 +00:00
Detimes 29a0f2dc72 Native mods: players the server knows, by name or id (profile_cache, #10461)
native_profile_by_name(name) gives the id and name, and
native_profile_by_uuid(uuid) the name, of a player who is on, else one
in a dedicated server's profile cache (usercache.json): vanilla's
GameProfileCache, Bukkit's getOfflinePlayer without its network
lookup. They ask no profile service and make up no offline player;
UserCache::find_by_name reads without changing the cache. native_player_head
names a player who is off from the cache too. Ports parse
usercache.json or ask Mojang themselves today.
2026-11-06 07:25:24 +00:00
Detimes bb4f25c694 Native mods: any player's held stack and equipment from any server hook (player_equipment)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes 2a86196e83 Native mods: the server's player limit, folder and log file (server_files)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes ea6382f2a1 Native mods: a player's statistics and advancements, read and replaced (player_records)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes 2f4294aba1 Native mods: what a player's client told the server (player_client)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes eacaa4db69 Native mods: more of a player on NativeServerPlayer (server_player_state)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:14 +00:00
Detimes ca41bb4780 Packets: one zlib compressor per thread, reset for each packet
frame::contents made a new ZlibEncoder for every packet above the
compression threshold, which allocates and clears zlib's window and hash
tables (some 300 KiB) each time. A thread-local Compress, reset before each
packet and driven with the encoder's own calls (the input unflushed, then
finish), writes the same bytes. A new test checks them against a fresh
encoder for bodies from 256 bytes to 300 KiB, in both orders.
2026-11-06 07:19:47 +00: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
Detimes f1586af649 Native hooks: set one checked inventory slot, take exact stacks (helpers)
native_set_inventory_slot(uuid, kind, slot, expected, new) puts a stack
in any slot of a player's inventory or ender chest, armour and off hand
included, if it still holds exactly `expected`, and returns what was
there (Bukkit's setItem). native_take_stacks(uuid, slots) takes exact
stacks out of the given slots, all or none. Both are checked like
native_move_stack and work live or on the player's file. Wanted by the
InvSee, AdminKit and Wanted ports (armour slots, splitting, stakes);
Wanted edits LanHost guests directly for this today.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes a7c6eff569 Native hooks: replace a player's whole inventory (set_inventory helper)
native_set_inventory(uuid, kind, slots, selected) replaces a player's 41
inventory slots or 27 ender chest slots (Bukkit's
PlayerInventory.setContents), live or in their file like
native_inventory. For the inventory it sets the held hotbar slot, empties
the cursor and the 2x2 crafting grid, and sends a player on the server the
whole inventory at once. The HuskSync port writes Guest fields directly
for this today.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 8e61d452e0 Native hooks: a guest's block placement before it happens (block_place)
native_block_place(player, name, stack, pos, state, cancel) is heard
where BlockItem.place has found the position and the state and checked
survival, before anything is set (Bukkit's BlockPlaceEvent, NeoForge's
BlockEvent.EntityPlaceEvent). A canceled placement is a failed one: no
block, no use, no swing, and the client gets its blocks and inventory
back. The position and state come from the existing dry run
(Behaviours::placement_state). Ports cancel placements today by claiming
native_use_item_on, which also stops doors, chests and the item's other
uses.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 531a026a14 Native hooks: a guest's drag over slots, cancellable (inventory_drag)
native_inventory_drag(player, name, window, kind, slots, cancel) hears a
guest's quick craft in their own inventory or a vanilla menu once, at its
end, with the kind and the window slots the client dragged over (Bukkit's
InventoryDragEvent). A canceled drag's end is handled like a canceled
click: the drag ends empty and the client's prediction is answered with
the server's slots and cursor.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 96d477b41b Native hooks: a guest's inventory and menu clicks, cancellable (inventory_click)
native_inventory_click(player, name, window, slot, button, mode, cancel)
hears a guest's click in their own inventory or a vanilla menu they have
open before any handler applies it (Bukkit's InventoryClickEvent). A
canceled click becomes a drag start of no type, which every menu takes as
resetQuickCraft and nothing more, so nothing moves and the client's
predicted slots and cursor are answered with the server's, as for any
click the server refused. Drags and a mod's own menus are not heard.
Wanted by the HuskSync and Essentials ports (data lock, freeze).

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 2e5d156644 Native hooks: a guest's throw, cancellable (player_launch_projectile_v2)
native_player_launch_projectile_v2(player, name, stack, cancel) is heard
where ProjectileItem.use creates a snowball, egg, ender pearl or a mod's
thrown item, before the projectile exists (Bukkit's ProjectileLaunchEvent
for a player). A canceled throw is a failed use: nothing is thrown, used
or put on cooldown, v1 hears nothing, and the guest's client, which took
the item at once, gets its inventory back. Wanted by the HuskSync port's
data lock (no ender pearls while locked).

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 157208790d Native mods: an online player's textures and a head stack of a player (player_textures, #10461)
native_player_textures(uuid) gives the textures property (value and
signature) of a player who is on, as their player list entry carries
it (Paper's Player.getPlayerProfile). native_player_head(uuid) gives a
minecraft:player_head whose profile holds the player's id, and their
name and skin when they are on (Bukkit's SkullMeta.setOwningPlayer).
Ports look the current skin up at the session server today, or build
heads with a name-only profile.
2026-11-06 07:03:55 +00:00
Detimes 16750b8c90 Native hooks: a player logging in, held for the mods' answers (player_pre_login, #10461)
native_player_pre_login(player, name, address, defer) runs on the
connection's own thread once the session service or the proxy has named
the player and before the server's own checks (Bukkit's
AsyncPlayerPreLoginEvent). A mod that needs time, a permissions or
inventory load, or may refuse, pushes a Receiver<Result<(), Text>> onto
defer and answers from any thread: Ok lets the player on, Err refuses
them with the reason. The login waits for every answer, 20 s at most in
all; a missing or dropped answer refuses with a plain Disconnected.
Today such loads start in native_player_login, where a database failure
cannot refuse the player.
2026-11-06 07:02:40 +00:00
Detimes 6564832337 Native mods: set a profile property and show the player again (profile_refresh, #10461)
native_set_profile_property(player, name, value) sets or removes a
property of an online player's profile (Paper's Player.setPlayerProfile);
native_refresh_player(player) shows them again at the end of their
level's pass: everyone who sees them gets their list entry anew and
their entity again, and the player is respawned in place into the same
level with all data kept (Paper's hidePlayer/showPlayer plus the
respawn), so their own client draws a new skin too. Both work from any
server hook, native_server_tick included. They replace the raw HEAD
injection on Game::flush_native_net that edits Guest.properties and
sends the player info itself.
2026-11-06 07:01:16 +00:00
Detimes 413c2b1593 Native mods: command arguments that ask the server for completions, and the request split into label and arguments (tab_complete_v2) (unproven) 2026-11-06 07:00:51 +00:00
Detimes 78f3b6fca9 Native mods: look up a server command by name, aliases resolved (command_info) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 7495f4c56b Native mods: run a console command that reaches the mods' commands (run_mod_command) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 90a091b024 Native mods: run a command as a player, at their level, with its answer (run_command_as) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 41413878d0 Native mods: run a console command and keep its result and answer (run_command_captured) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 9b670c7cfc Native hooks: a joining player's profile on the connection thread (player_profile, #10461)
native_player_profile(player, name, properties) runs on the
connection's own thread once the server has let a player in and before
it sends LoginFinished. A mod may change, add or remove the profile's
properties (a skin's textures): the player's own client and every
player list get the new ones (Velocity's GameProfileRequestEvent,
Paper's PlayerProfile at AsyncPlayerPreLoginEvent). It replaces the raw
HEAD injections on lan::conn::login_and_configure and
Game::lan_guest_joined that skin and Floodgate ports use today.
2026-11-06 07:00:34 +00:00
Detimes 0da0001f38 Native mods: a player's permission level, also at login (player_permission, #10461)
The helper native_player_permission(player) gives the permission level of a
player on the server or joining it (0 to 4, ops.json's for an operator,
PlayerList.getProfilePermissions). It works inside native_player_login for the
player that hook hears, whose level the hub set on admitting them, so a login
gate that lets operators through needs no ops.json of its own. The hook's
contract is unchanged.
2026-11-06 06:51:55 +00:00
Detimes a4a64c560d Native mods: send a plugin channel message to a player (send_custom_payload_to_player, #10461)
The helper native_send_custom_payload_to_player(player, channel, data) queues
a ClientboundCustomPayloadPacket on any namespace:path channel outside
horizon: (at most ClientboundCustomPayloadPacket's 1 MiB) for one player's
client, vanilla or Horizon, sent at the tick's end with the mod messages, so
it works from any server hook. Bukkit's Player.sendPluginMessage.
2026-11-06 06:51:55 +00:00
Detimes 27200f64cd Native hooks: a player's plugin channel message (custom_payload_from_client, #10461)
native_custom_payload_from_client(player, channel, data) hears a guest's
ServerboundCustomPayloadPacket in play on any channel that isn't Horizon's own
(horizon:mod_net, horizon:quiet_commands, horizon:lod), which the server drops
today. Like mod messages it waits for the tick's end, with the hub back, so
every helper works; it is heard from a dead player too, as vanilla's
handleCustomPayload doesn't look at health. Horizon's own channels are handled
as before. The client side is #6774.
2026-11-06 06:51:55 +00:00
Detimes 01fec61d2d Handshake: a host of up to 32767 characters behind a proxy (#10461)
With proxy forwarding on (forwarding.secret, offline mode, not the integrated
server's own player) the handshake's host is read with Short.MAX_VALUE as its
limit, as Paper does behind a proxy, so a proxy's data after the host (a
Floodgate player's, often longer than 255 characters) no longer drops the
login. Without forwarding the limit stays vanilla's 255; it is the only string
read with that limit.
2026-11-06 06:51:55 +00:00
Detimes ac6ff90c34 Native hooks: a client's handshake on the connection thread (handshake, #10461)
native_handshake(address, host, intent, cancel, reason) is a free function in
horizon_client_lib::lan::conn, called on the connection's own thread for each
network connection's handshake, before the server acts on the intent. A mod may
rewrite the host (a proxy's data after it, Floodgate's, included) or refuse the
connection: a login or transfer is disconnected with the reason, a status ping
is closed unanswered. Wired through NativeClientHooks like player_chat. Nothing
changes without a mod.
2026-11-06 06:51:55 +00: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
Detimes c87a60d013 Native hooks: creative slots with the whole stack (creative_slot_v2)
native_creative_slot_v2(player, name, slot, stack, cancel) is an
additive version of native_creative_slot with the whole ItemStack put in
the slot (none for an emptied one), components included. It is heard
just before v1, which keeps its signature and firing point and hears
nothing of a packet canceled here.

Part of #10461.
2026-11-06 06:30:14 +00:00
Detimes 960634a9ad Native hooks: throws with the whole stack (item_dropped_v2)
native_item_dropped_v2(player, stack) is an additive version of
native_item_dropped with the whole ItemStack thrown, components included
(enchantments, unbreakable, attribute modifiers...), heard just before
v1, which keeps its signature and firing point.

Part of #10461.
2026-11-06 06:30:13 +00:00
Detimes a425fe4152 Native hooks: pickups with the whole stack, thrower and position (item_picked_up_v2)
native_item_picked_up_v2(player, stack, thrower, position, canceled) is
an additive version of native_item_picked_up: the whole ItemStack with
its components, the item entity's thrower UUID (none for block and mob
drops) and its position. It is heard just before v1, which keeps its
signature and firing point and hears nothing of a pickup canceled here.

Part of #10461.
2026-11-06 06:30:11 +00:00
Detimes 192898e730 Native hooks: where a joining player is placed (player_spawn_location)
native_player_spawn_location(player, name, dimension, position,
rotation) is heard once per join after the saved data is loaded (or a
newcomer's spawn found) and before the level is chosen and the world is
sent (Spigot's PlayerSpawnLocationEvent). A mod that changes them places
the player there, in that level, with no teleport afterwards. An unknown
dimension or a non-finite value leaves that part unchanged.

Part of #10461.
2026-11-06 06:30:10 +00:00
Detimes 14af676a89 Native hooks: player-versus-player damage with both players (player_hurt_by_player)
native_player_hurt_by_player(attacker, attacker_entity, victim,
victim_entity, source, damage_type, amount, canceled) names both players
by UUID and network entity id, and how the hit came: melee, sweep, or the
projectile's entity type (arrow, spectral arrow, snowball, egg, a mod's
thrown type). It is heard where native_living_incoming_damage is, at the
victim's hurtServer past its guards (pvp rule and teams allow it, victim
not invulnerable or dead), and just before it: changing the amount
reaches v1, canceling skips the hurt and v1 alike. The PvP call sites
name the hit in a thread-local scope that hurt_server reads.

A snowball or egg does zero damage, where vanilla's Player.hurtServer
stops: it is heard there with amount 0 and nothing the mods set applies.
Thrown tridents hurt no players in Horizon yet and are not heard.

Part of #10461.
2026-11-06 06:30:09 +00:00
Detimes 3515e99feb Native hooks: a player logging out while still present (player_logging_out)
native_player_logging_out(player, name) is heard when a placed player's
connection is gone (lost, kicked, quit) but before their menu closes and
they are saved and removed (Bukkit's PlayerQuitEvent): they are still in
their level and listed in native_server_players. No state is swapped in;
the hub is back in self.lan for the call, so the UUID helpers and
native_run_command reach them. A death a mod causes there (`kill <name>`)
is died at once (death message, drops, native_player_died), since the
leaving player gets no further tick to die in; they are then saved dead.
Not heard when the server stops, where native_player_logged_out already
tells with them still listed. Unmodded servers are unchanged: the death
step only runs when the hook took the player from alive to zero health.

Part of #10461.
2026-11-06 06:30:08 +00: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
Detimes da8c2cfc55 Native hooks: each line written to the log (log_line)
native_log_line(level, thread, message, line) hears each message
written to logs/latest.log, on the log's writer thread after the file
has it, so the thread that logged never waits on a mod and a Discord
console bridge needs no tailing of latest.log. line is the text as the
file got it. The logger calls it through logging::set_line_hook,
installed before the log opens; trait mods are reached through
NativeClientHooks. A callback that panics is not called again, so its
own panic line can't loop.

Refs #10461
2026-11-06 05:42:47 +00:00
Detimes 2f2eecf40d Native hooks: every console and RCON line before it runs (console_line)
native_console_line(source, line, cancel, replies) hears each line the
dedicated server's console is about to run, before vanilla's commands,
the dedicated-only ones and mods' registered ones: source is console
(typed on the console, or sent by horizon-server as the console) or
rcon. Set cancel and it does not run; replies are logged as the
console's feedback, so they also reach the RCON or horizon-server
caller. Until now native_perform_console_command only heard commands
a mod registered, so an audit mod could not see ordinary console
commands (Bukkit's ServerCommandEvent / RemoteServerCommandEvent).

Refs #10461
2026-11-06 05:42:46 +00:00
Detimes 6e4fbfa79f Native per-player view distance (native_set_player_view_distance)
A mod limits one player's view distance below the server's: only that
player is told its radius (SetChunkCacheRadius) and is sent and tracks
chunks within it; native_player_view_distance reads it. The guest's
distance is ChunkMap.getPlayerViewDistance under the limit; with no limit
(every unmodded server) it is exactly as before. Part of #10461.
2026-11-06 05:40:48 +00:00
Detimes 9c23922432 Native world border read helper (native_world_border)
A level's world border bounds as the server enforces it, for a web map's
border line and a pre-generator's border selection. Vanilla 26.3 keeps a
border per level (ServerLevel.getWorldBorder, each level's
data/minecraft/world_border.dat); Horizon keeps one for every level, so
the helper answers the same bounds for each level until that is fixed.
No behaviour change. Part of #10461.
2026-11-06 05:40:07 +00:00
Detimes 2d2936b9df Native chunk tickets in a level nobody is in (native_force_chunk_in)
native_force_chunk only reaches the level in place, so a mod could not hold
a chunk of the Nether or the End from a server-wide hook, and a level not
open yet never ticked: a pre-generator for those levels never advanced.
native_force_chunk_in takes the dimension; a level held this way opens at
the next server tick and ticks while held. Unmodded servers hold no mod
tickets, so nothing changes for them. Part of #10461.
2026-11-06 05:39:58 +00:00
Arslan ArmanulyandClaude Opus 5.5 b1574b9721 Shader packs: a program writing buffers sized relative and in pixels draws while they match
A composite program writing two colortex buffers was skipped whenever their
`size.buffer` lines differed, even when one relative size and one pixel size
came to the same size (`0.25 0.25` and `320 180` at 1280x720). Iris 1.11.7
draws it (a probe pack of ours: texel (100, 50) of both reads 100.5, 50.5 and
texcoord 0.314, 0.281).

Sizes of one kind that differ still skip the program as the pack loads. A mix
of the two kinds is kept, and the pass is skipped only at render sizes where
the buffers it writes resolve to different sizes.

Fixes #10454

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:26:01 +05:00
masa-masa-0314andZCode 42849387d1 Realms: the template rows' website and trailer buttons
`RealmsSelectWorldTemplateScreen.Entry`'s 15x15 `icon/link` and
`icon/video_link` buttons beside each row's version, opening the
template's page or trailer; the drawn rectangles are kept for the click.

Co-Authored-By: ZCode <[email protected]>
2026-11-06 13:05:17 +08:00
repositorykreml1nandCodex f4b9bc1471 Merge upstream main to resolve PR #10015 conflicts
Keep the command-block /say LAN regression and upstream legacy PvP checks,
with both registrations intact. Preserve upstream function bodies and the
original say regression unchanged.

Validation: Python syntax/AST and registrations checked; public-tree check
passed; Python unittest suite passed (41 skipped). pr_ready could not start
Rust checks because Cargo is unavailable in this restored environment.

Co-Authored-By: Codex <[email protected]>
2026-11-06 07:37:01 +03: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
b2423d6d62 Write chunk-section blocks with one position lookup and walked words
Part of #9996.

BlockSection::set and BlockSection::fill_run are 7.0% of the proto phase
in the overworld and 9.4% in the Nether (callgrind on generate_proto).
Each write located its packed word twice, and fill_run rebuilt the word
mask for every block.

PackedStorage now hands out a Slot (word and bit shift) that read_at and
write_at take, so set locates the entry once; the palette-growth branch,
which re-packs the words, looks it up again. A run in one column moves
256 entries per Y, so while a word holds a whole number of those steps
(every width generation produces) the bit shift stays the same along the
run and WordRun writes each block with one load, mask and store. Widths
that do not divide the stride go one set per block, with the same palette
edits in the same order.

Live palette slots hold distinct states (sections loaded from disk are
rebuilt through set), so "already this state" is old_id == id and "was
air" is old_id == air_id, both resolved once per call. set on a uniform
section has its own small path.

Palettes, their order, the free list, counts, the chosen width and the
packed words are unchanged: the 8 chunkgen_bench snapshot files (terrain
and decorated; overworld seeds 12345 and -1, the Nether, the End) are
byte-identical to main, and a new unit test replays random set/fill_run
sequences over every palette width against main's fill_run.

Proto-phase instructions: overworld 6.497 G -> 6.340 G (-2.41%), Nether
2.974 G -> 2.859 G (-3.84%).

A first draft by DeepSeek V4.1 Flash (pi coding agent), reviewed by Claude.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd3xkgubE8VfXrALh2td
2026-11-06 02:52:28 +01: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
masa-masa-0314andZCode 8db0ec744e Realms: the reset-world screen, world templates and world upload
RealmsResetWorldScreen's tiles, RealmsSelectWorldTemplateScreen's paged
list, the local-world picker and RealmsUploadScreen with the tar.gz
packer and the upload body. Follows the 26.3 bytecode of those screens,
client.worldupload.RealmsWorldUpload/RealmsUploadWorldPacker/FileUpload
and RealmsClient.fetchWorldTemplates/resetWorldWithTemplate/
requestUploadInfo/putIntoMinigameMode.

Co-Authored-By: ZCode <[email protected]>
2026-11-06 07:42:06 +08:00
masa-masa-0314andZCode f221ae33ef Realms: the invite-code list and its edit screen
The Players tab's second button now opens RealmsInviteCodesScreen: the count in the
title, the subtitle, a row per code with its expiration (or Never), Copy / Edit / Delete
per row, and Create. RealmsEditInviteCodeScreen edits one code: its active flag, the
seven InviteCodeExpiration choices (a click on the slider track picks the nearest — this
screen has no drag event yet), Copy to the clipboard (a new Outcome::Copy the title
turns into Action::CopyText), Delete, Done and Back.

RealmsClient gains inviteCodes (GET api/v1.0/invite/codes?realmId={id}),
createInviteCode (POST .../invite/codes), updateInviteCode (POST .../update) and
deleteInviteCode (DELETE .../delete/{code}), all on the invite-code service with the
token in x-mojangauth, with InviteCode's DTO. The existing redeemInviteCode now goes
through the same helper. An existing expiration reads back as the closest of the seven
choices (InviteCodeExpiration.closestTo).

Co-Authored-By: ZCode <[email protected]>
2026-11-06 07:16:04 +08: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
Ian WorleyandClaude Opus 5.5 dfa637e389 fix(native-mods): a world is known once logged in, and a pack reload covers it
A connection reported its world while still connecting, before a failed
login could drop it, and a resource-pack reload's loading screen still
counted as playing.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 897f17114a fix(native-mods): keep a friend's relay in its world identity
Round-two review finding: a long invite's identity kept only the host's
addresses when it had any. Two friends on other networks behind the
same private address and the fixed direct port (25580), each reached
through a relay, shared one NativeWorldId. The relay's address and
nameplate now stay beside the addresses; neither secret takes part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 8f75db7d5c fix(native-mods): portal loading covers the world; friends' worlds stay apart
Review findings on the session state helpers:

- NativeClientState.screen read remote_loading(), which leaves out the
  portal loading screens because they draw no panorama blur, so a
  minimap drew over them. A connection's screen is now one predicate,
  remote_screen_up(), which remote_overlay draws by too.
- Every long friend invite showed as the same "Friend's world", so two
  friends' worlds shared one NativeWorldId and a mod's per-world data.
  A friend's world is now friend: and the invite's public part (the
  host's addresses, else its relay nameplate), never its secret.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 6975556e3d feat(native-mods): session state a client HUD reads
A minimap or waypoint mod can now read what it needs through stable
helpers instead of Game's fields, on a server's client and in single
player alike:

- native_client_state() gains screen (NativeScreenKind: playing, chat,
  death or another screen), debug_screen (F3), world (NativeWorldId:
  single player's save folder or the server's address, for per-world
  data) and sleeping_pos (the bed, LivingEntity.getSleepingPos).
- native_biome(pos): the biome at a block as Level.getBiome picks it,
  through BiomeManager's zoom.
- native_data_dir(mod_id) joins the helper catalog.

The window's connection remembers the save it joined through memory,
its own server thread's or P1's for a splitscreen seat, so the world
identity needs no mixin on open_integrated. A friend's world is named
as players see it, never by its invite secret.

Helper-only API fragment session_state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
MSedgeMC eb649762c3 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-05 21:03:30 +00:00
TheMrZZandCodex gpt-6.1-sol 62d0687fea Merge main and retain the crafting map regression group
Keep the map-crafting, transmute, mob-effect and frame-map groups and their
isolation sets. The merged game passes the focused 62-record Java replay.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 20:51:24 +01:00
MSedgeMC a266ac1c43 test: an empty-criteria advancement is done and is the one auto-granted
`Progress::done` on an advancement parsed from `{"criteria": {}}` (true, no
requirement groups to wait for) against one with a criterion (false), and
`Tree::automatic` over a tree holding both: only the empty one is listed, which
is what the load pass grants the rewards of.

Without the `done` change the first assertion fails at once
(`assertion failed: Progress::default().done(&empty)`), and `Tree::automatic`
does not exist to compile against.
2026-11-05 19:25:02 +00:00
MSedgeMC 349ec13a9b fix: an advancement with no criteria is done and pays out
`AdvancementProgress.isDone` loops over the requirement groups, so with no
groups at all it answers true: an advancement whose `criteria` map is empty is
finished from the moment the player's progress loads. `PlayerAdvancements.
checkForAutomaticTriggers` walks every advancement, and for those with no
criteria calls `award(holder, "")` - which finds no `""` criterion to grant, so
it neither toasts nor announces - and then `AdvancementRewards.grant`s it. The
rewards are granted again on the next load, because there is no criterion to
record them under.

Horizon required `!requirements.is_empty()`, so such an advancement was never
done and never paid out. `Progress::done` now keeps vanilla's shape (every group
satisfied, vacuously true with no groups), `Tree::automatic` lists the
advancements nothing can grant, and the progress load grants their rewards
through the new `grant_rewards`, which is the `AdvancementRewards.grant` half of
`award` split out so the toast and announcement stay with a real completion.

Scope, plainly: **no vanilla advancement has an empty criteria map** - all 1224
in the shipped data have at least one criterion - so nothing in the game's own
data changes. What changes is a datapack's: before this, an advancement it
declared with `"criteria": {}` could never be obtained and its rewards never
arrived, where vanilla hands it over on load.
2026-11-05 19:24:58 +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
Florian ERNSTandCodex 7c171a543c Preserve loaded furnace cooking totals until completion
Co-Authored-By: Codex <[email protected]>
2026-11-05 19:53:51 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol e1e307cbb5 fix(launcher): preserve native shader selection and options
Fixes #10123

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:48:31 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol 64297b8e8b fix(shaders): preserve native pack identity across updates
Part of #10123

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:45:17 +01:00
Florian ERNSTandCodex ff0a8b31f8 Read selected guest progress for advancement selectors
Co-Authored-By: Codex <[email protected]>
2026-11-05 19:11:09 +01:00
MSedgeMC 3ef1d5a7aa port_map: StatsScreen.keyPressed is the statistics tab bar's keys 2026-11-05 18:04:22 +00:00
MSedgeMC fadcfcd6d0 test: the statistics tab bar takes Ctrl+Tab, Ctrl+Shift+Tab and Ctrl+number
`StatsUi::key` as `Game::stats_key` calls it: the cycle wraps both ways, a
number picks its tab and is clamped to the last one, a key without Control is
left alone, an empty Items or Mobs tab is skipped (and not switched to by its
number), and a switch scrolls the new list back to the top.
2026-11-05 18:04:22 +00:00
MSedgeMC c679dff99e feat: Ctrl+Tab switches the statistics screen's tabs
`StatsScreen.keyPressed` runs `MenuTabBar.keyPressed` first: with Control
held, `TabNavigationBar` cycles the tabs on Tab and Shift+Tab (skipping a tab
that cannot be chosen) and picks one on the number keys, where
`InputConstants.Key.getNumericKeyValue` reads the main row's digits and the
keypad's. Horizon's statistics screen had no keyboard handling at all, so only
the mouse could change tabs.

`StatsUi::key` is that tab-bar key press: it reports whether the bar took the
key, moves to the chosen tab when that tab has rows, and starts that list at
the top as a tab press does. `tab_key` ports `getNextTabIndex` (a digit is
`floorMod(digit - 1, 10)` clamped to the last tab; Tab walks to the next tab
that can be chosen, one full turn at most) and `numeric_key_value` ports
`getNumericKeyValue`. `Game::stats_key` gives it the tabs that have rows
(`stats_tab_active`) and plays the tab's click when the tab changes, and
`Game::key_pressed` hands the statistics screen its keys.
2026-11-05 18:04:22 +00:00
MSedgeMC d0a71973e8 The health HUD state's doc drops the removed heights
native_health_hud_state described left_height and right_height as the ones
that place the selected item's name and the action bar. They are gone, and
the text no longer reads them.
2026-11-05 18:04:12 +00:00
MSedgeMC 15a6831a33 The HUD probe fixture drops the bars' height with the argument
The two calls in hud_fixture/kinds.rs still passed the 39 the old signatures
took. The probe compares against the vanilla client, which reads neither, so
nothing else changes.

cargo clippy --examples -p horizon-client-lib -p horizon-client -- -D
warnings is clean.
2026-11-05 18:03:21 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol ffd9b8f475 chore: remove agent markers from shader code comments
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:00:20 +01:00
Nikita Maximilian Grimm ceeb7841e2 Merge remote-tracking branch 'origin/main' into shader-api-02-serve 2026-11-05 18:59:42 +01:00
DUzzL 9cf77dcdd7 Run location effects of newly worn armor at once 2026-11-05 18:54:19 +01:00
DUzzL 7858858b94 Make thrown potions bounce off a breeze 2026-11-05 18:54:16 +01:00
Nikita Maximilian Grimm 276919beae Merge remote-tracking branch 'fork/shader-api-02' into shader-api-02-serve 2026-11-05 18:47:17 +01:00
yahya-nusseibehandClaude Opus 5.5 e0b19571dd RSA: constant-time private-key operations
The dedicated server and a LAN host decrypt each client's
ServerboundKey with the server's private exponent. That used
square-and-multiply, which multiplies only on the exponent's set bits,
with a data-dependent final subtraction in each Montgomery product, and
no blinding: a client controls the ciphertext and can time the reply,
the classic extra-reduction channel. The key is regenerated every start
(as in vanilla) and a recovered one still needs an on-path position,
but vanilla's JCE blinds this operation.

- Mont::mul subtracts n through a mask chosen from the overflow and
  the borrow of t − n, never a branch (Big::sub_with_borrow,
  Big::select). Every product takes the same steps.
- Mont::pow_secret: a square and a multiply for every bit of the
  exponent's limbs, the product kept by a mask. RsaPublicKey::
  apply_secret uses it; RsaPrivateKey::decrypt and ::sign call that.
  Public-key verification and encryption stay on the faster pow.
- Big::from_be_bytes no longer skips zero bytes: it parses the private
  exponent on every call, and the result is the same.
- Test: pow and pow_secret agree with a reference modular power, for
  moduli near a limb boundary (the overflow case) and exponents with
  leading zero limbs. The 11 existing RSA tests (OpenSSL signature,
  known-key decryption, generated keys) pass unchanged.

No wire or parity change. Multiplicative blinding would be the more
complete fix once a modular inverse exists.

Found in yahya-nusseibeh's review of Horizon (L1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:40:43 +02:00
MSedgeMC 51a4b98992 port_map: the item name and the action bar rows name their layout fns
Hud.extractSelectedItemName is hud_overlays::ToolHighlight::draw and
Hud.extractOverlayMessage is hud_overlays::Titles::overlay_message; neither
row existed. map-check: 151 rows, no problems.
2026-11-05 16:54:42 +00:00
MSedgeMC 0e97601d25 test: the item name and the action bar sit at vanilla's heights
text_position_tests asks a ToolHighlight holding a stack and a Titles
holding an action bar for their draw calls at 320x240: the name's text top
is 240 - 59 (and 240 - 45 in a mode that cannot hurt the player), the action
bar translates to 240 - 68 with its text four pixels under that, and a
zeroed timer draws nothing. The font is built from no resources, as
chat_tests does, so the test needs no game assets.
2026-11-05 16:54:42 +00:00
MSedgeMC d48f37c2a4 fix: the selected item name and the action bar keep vanilla's height
Hud.extractSelectedItemName draws the name at guiHeight - 59, plus 14 in a
mode that cannot hurt the player, and Hud.extractOverlayMessage puts the
action bar at guiHeight - 68: both constants, which the health, food, air and
mount rows above the hotbar never move.

Horizon moved both up by max(leftHeight, rightHeight). That is 59 for a
survival player with armour, so the common case agreed by accident, but it is
69 with a second row of hearts and 81 for a player underwater on a mount, and
the two texts floated ten or twenty pixels above where vanilla draws them.

Both use the constants now, and HealthHud no longer keeps the heights: 26.3's
Hud has no such fields, only the locals of extractItemHotbar that the
contextual-info bar reads.
2026-11-05 16:54:42 +00:00
Riccardo GuerrieroandClaude Opus 5.5 a88fab01a5 A mob's Thorns hurts the player who hits it
EnchantmentHelper.doPostAttackEffectsWithItemSource runs the victim's
equipment before the attacker's weapon: the player's melee hit and sweep,
arrow, trident and wind charge now meet the mob's own post_attack
effects. Thorns rolls from the level's random, hurts the player from
the wearer by 1-5 and wears the piece by 2 (Unbreaking, breaking with
its entity event); the player's own Thorns doesn't answer a thorns hit,
and a thorns hit that lands whole plays enchant.thorns.hit.

The mace harness gets armored targets with Thorns and Unbreaking; new
lan_e2e check mob-thorns.

Part of #9954

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:49:52 +01:00
IsT3RiK 44b980def2 Merge remote-tracking branch 'upstream/main' into traduction-messages-mods
# 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-05 17:46:05 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 12daf9d13f Golden Hour 0.2: 0.1's look in the grade, Horizon's tone curve
The port ran 0.1's grade on linear values, which darkened midtones by about
nine 8-bit steps. The soft shoulder and the saturation are a look written for
display values, so the grade now runs them on the encoded picture, while the
golden tint, the glow and the vignette stay on light. The pack drops its own
[tonemap]: Horizon's curve clips at white in SDR as 0.1's clamp did, and light
above white survives the grade for HDR. The captures now match 0.1 within
about 0.3 steps on average; what's left is the glow around light sources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:44:38 +01:00
TheMrZZandCodex gpt-6.1-sol 9365b979ac Expose live boat leash data to entity commands
Apply the existing leash writer after the boat's base command tag so live
holders and pending links override stale input data. Keep holder policy
and boat behavior in their existing implementations.

Add an actual Game getter regression and a guarded Java/Game fixture
covering owner clicks, repeated reads, pending UUIDs and detached links.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 17:32:38 +01:00
some-du6eandClaude Opus 5.5 a99b380175 Format the client lib's re-exports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:17:37 -06:00
MSedgeMC dd6a0b938d port_map: ChatScreen.formatChat is the input's draft formatter 2026-11-05 16:16:26 +00:00
MSedgeMC 4077c5e01c test: a restored chat draft draws grey italic and one Backspace clears it
Through `ChatScreen::new` with `is_draft`, `key` and `draw` - the calls
`Game::open_chat` and `Game::chat_key_code` make: the draft's drawn text is
grey and italic, an edited draft and a box opened on ordinary text are not,
and the first Backspace on an untouched draft empties the box where an
ordinary box loses one character.
2026-11-05 16:16:26 +00:00
MSedgeMC 6a51ac58a7 feat: a restored chat draft draws grey italic and clears on the first Backspace
`ChatScreen` keeps the `isDraft` its constructor argument sets: `formatChat`
draws the text grey italic until `onEdited` runs, and `keyPressed` clears the
whole box on the first Backspace while it is still set. Horizon tracked the
draft (`Game::chat_draft`, `chat_draft_shown`) but opened the box as plain
text: `open_chat_method` knew whether the text was the draft and did not pass
it.

`ChatScreen::new` takes `is_draft` (`ChatScreen.init`'s `isDraft`), `edited`
(`onEdited`) clears it, `key` (`keyPressed`) clears the input on a Backspace
while it is set, `format` applies the draft formatter (`formatChat`, which
`ChatScreen.init` registers before `CommandSuggestions.formatText`) and
`resize` keeps it (`ChatScreen.resize` keeps the field).

`Game::open_chat` threads the flag from `open_chat_method`
(`ChatComponent.createScreen`); the in-bed wake-up
(`InBedChatScreen.onPlayerWokeUp`) and `Gui.openChatAndAddText` open plain
text, as vanilla does.
2026-11-05 16:16:26 +00:00
repositorykreml1nandCodex 6d9e1191a2 fix: broadcast command-block say announcements to connected players
Broadcast unsigned say chat independently of feedback capture and keep the
command source display name. Add a two-client LAN regression for both
TrackOutput states and conditional-chain success.

Co-Authored-By: Codex <[email protected]>
2026-11-05 19:16:22 +03:00
some-du6eandClaude Opus 5.5 6d7496f62a OptiFine custom skies: draw a pack's sky layers over the sky
Each frame picks the layers of the Overworld's or End's sky whose day,
weather, biome and height hold at the camera, eases them over their
transition, and draws them as 3x2 skyboxes with their blend mode, over the
vanilla sky's disc and sunrise (the End's box) and under the sun, moon and
stars. Packs without optifine/sky draw nothing new.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:10:20 -06:00
DUzzL fb828fda35 Teleport spectators that use a nether portal, end portal or end gateway 2026-11-05 17:08:32 +01:00
some-du6eandClaude Opus 5.5 a1a052c35c OptiFine custom skies: read the sky layers of optifine/sky
Parse optifine/sky/world<N>/sky<M>.properties into layers (source, fade
times, blend, rotation, days, weather, biomes, heights, transition), the
sun and moon_phases replacements, and the 3x2 skybox grid's faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 09:59:41 -06:00
DUzzL 37fed7a88e Credit damage_dealt_absorbed for a player's projectiles 2026-11-05 16:47:46 +01:00
MSedgeMC e28eb5363a port_map: isBaby and canBeABaby name the species table
AgeableMob.isBaby is now a row of its own pointing at Mob::is_baby, which is
`can_be_a_baby && getAge() < 0`; canBeABaby points at
species::can_be_a_baby, and the six classes that override it to false carry a
row each so the false arms are not read as unported.
2026-11-05 15:40:15 +00:00
MSedgeMC 24267c893b test: pin the species a negative age cannot make a baby
living.rs::a_negative_age_is_a_baby_only_where_can_be_a_baby_allows_it drives
Mob::new on the loading table's own types and sets Age to -24000, the
BABY_START_AGE a save or a /summon carries, then asks is_baby, dims and
voice_pitch - the three the game draws and hears from.

A parrot, a tadpole, a frog, a zombie nautilus, a camel husk, a slime, a magma
cube, a piglin brute and a creeper keep their type's box (the tadpole 0.4 by
0.3, the camel husk 1.7 by 2.375, the piglin brute 0.6 by 1.95) and voice at
1.0 ± 0.2. The control is a cow at the same age: is_baby true, Cow's
BABY_DIMENSIONS (0.45 by 0.7, eyes at 0.69) and 1.5 ± 0.2, and an adult box and
voice again at age 0. With is_baby back to `age < 0` the test stops at the
parrot.

entity_nbt's save test now runs a frog and a camel husk beside the slime, the
magma cube and the pig: the first four write no Age and keep none of a raw
compound's Age, the pig writes its modeled -37.
2026-11-05 15:40:15 +00:00
MSedgeMC 3c7bba9bdb fix: a mob that cannot be a baby is not one at a negative age
AgeableMob.isBaby is `canBeABaby() && getAge() < 0`, and canBeABaby is true
across the ageable tree but for the six classes that override it to false:
Frog, Parrot, ZombieNautilus, CamelHusk, Slime and MagmaCube (an
AbstractCubeMob is an AgeableMob). Everything outside that tree inherits
LivingEntity.isBaby's false, so a Giant, a Creeper, a PiglinBrute (it extends
AbstractPiglin and declares no isBaby of its own) and a Tadpole, whose own Age
runs up from -24000, are never babies either; only the zombies, the piglins and
the zoglin answer a synced DATA_BABY_ID instead of an age.

Mob::is_baby read `extra.age < 0` alone. The list of who can be a baby existed
already, but only as mobs::drops::can_be_baby, which gates the metadata baby
flag and the loot table: the box, the voice, breeding, growth and the goals
read the age and so called a frog, a parrot, a creeper or a tadpole with a
negative Age a baby. The list is now species::can_be_a_baby, on the species
table, and is_baby is `can_be_a_baby && age < 0`.

What that changes: a tadpole (Age -24000, its own DEFAULT_AGE) is drawn at
getAgeScale's 1.0 instead of 0.5 and speaks at 1.0 ± 0.2 instead of 1.5 ± 0.2,
as vanilla does; a parrot, a frog, a zombie nautilus, a camel husk, a slime, a
magma cube, a piglin brute, a creeper or anything else the table cannot make a
baby keeps an adult's box, voice, breeding, growth and drops. The Parrot arm of
voice_pitch was there for the same reason and is dead now: Parrot.getVoicePitch
is `getPitch(random)`, the plain range an adult already gets.

AgeableMob.addAdditionalSaveData writes Age only when canBeABaby, and the
branch that drops the values a raw compound carried named four classes by hand;
it asks the ageable table now, which also covers the frog and the camel husk
that were re-saving an Age vanilla writes for neither.
2026-11-05 15:40:15 +00:00
DANILIAMMOandCodex GPT-6 86c0211c35 launcher: preserve manually installed Nightlies during pruning
Record a marker only for Nightlies installed by automatic updates. Keep
manual and legacy Nightlies out of GUI and CLI cleanup, while retaining
the newest managed Nightlies separately from game releases.

Add regression coverage for manual, legacy, selected and pinned builds.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 20:34:10 +05:00
masa-masa-0314andZCode dd35df0f18 Realms: the backup list and backup info screens
The Worlds tab's Backup button now opens RealmsBackupScreen: a row per automatic backup
with its date (mco.backup.entry), Restore per row, and Back. A row opens
RealmsBackupInfoScreen: the backup's metadata as translated key/value rows
(mco.backup.entry.<key> when the language has it). Restoring asks nothing (vanilla's
Restore button restores at once) and the list reads again when it is done.

RealmsClient gains backupsFor (GET worlds/{id}/backups) and restoreWorld
(PUT worlds/{id}/backups?backupId={id}, vanilla's 40 s/600 s timeouts), with Backup's DTO
(backupId, lastModified, size, metadata, changeList). The backup id goes into the query
percent-encoded, so it cannot add a parameter.

Download Latest still needs the download-and-unpack flow and is left for its own PR; the
rows show the date and the metadata screen the details (size included).

Co-Authored-By: ZCode <[email protected]>
2026-11-05 23:18:13 +08:00
IsT3RiK f3e8afcb03 Launcher: add Glass, Blur and the server modes' notes to every table
These labels reach tr through a variable (the Launcher settings' slider rows, MODE_NOTES in
the server dialog), so the scan for tr("...") never asked for them and no table had them: they
showed in English in every language. Added to every table, in English, and translated in
fr.json.
2026-11-05 16:05:40 +01:00
DANILIAMMOandCodex GPT-6 921a31e8a1 launcher: include dated Nightlies in automatic version pruning
The GUI runs the shared pruning path after saving its selection, but its
release-only filter excludes every dated Nightly. Include those builds
so the existing retention, selection, instance-pin and in-use protections
also apply to GUI Nightly updates.

Add regression coverage for following updates, selected and pinned
Nightlies, and retention across Nightly/release/local builds.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 20:03:57 +05:00
TheMrZZandCodex gpt-6.1-sol 038365e19b Post-process maps taken from crafting results
Use the level's backed maps for crafting recipe matching and run map
post-processing on the actual stacks taken by swap, pickup and quick-move.
Keep the original-copy callback used by quick-move and preserve cartography.

Add a real Game/Menu regression and a guarded connected-owner Java/Game
fixture covering previews, consumed inputs, transferred maps and saved backing.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 15:52:08 +01:00
masa-masa-0314andZCode 3b29a9fbc5 Realms: the invite-code list and its edit screen
The Players tab's second button now opens RealmsInviteCodesScreen: the count in the
title, the subtitle, a row per code with its expiration (or Never), Copy / Edit / Delete
per row, and Create. RealmsEditInviteCodeScreen edits one code: its active flag, the
seven InviteCodeExpiration choices (a click on the slider track picks the nearest — this
screen has no drag event yet), Copy to the clipboard (a new Outcome::Copy the title
turns into Action::CopyText), Delete, Done and Back.

RealmsClient gains inviteCodes (GET api/v1.0/invite/codes?realmId={id}),
createInviteCode (POST .../invite/codes), updateInviteCode (POST .../update) and
deleteInviteCode (DELETE .../delete/{code}), all on the invite-code service with the
token in x-mojangauth, with InviteCode's DTO. The existing redeemInviteCode now goes
through the same helper. An existing expiration reads back as the closest of the seven
choices (InviteCodeExpiration.closestTo).

Co-Authored-By: ZCode <[email protected]>
2026-11-05 22:51:09 +08:00
IsT3RiK 66beeb539d Launcher: the Mods page's install messages go through tr
The messages the Mods page shows after an install (a mod, a native mod, a data pack, a
modpack, a resource pack) and its "isn't set up" errors were built with format!, so they
stayed in English in every language: "Installed JEI for Horizon 0.1.0 into Horizon. Build to
play with it." on a French launcher. They now use trf with named holes. The 11 strings are
added to every table, in English, and translated in fr.json.
2026-11-05 15:47:58 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2bc92b6348 Serve shader API 0.2 behind HORIZON_HDR_FRAME=float
With the switch, the game draws 0.2 packs ([frame] blend = "linear" is
still refused). A renderer made with a 0.2 pack picked draws a float frame
where the device can, else the 8-bit one; a pack picked or dropped later
runs on the frame there is, logged once. The renderer's material, sky,
weather, vertex and shadow entries decode a 0.2 stage's colours and encode
its results; scene_color, opaque_color and the water mirror are decoded
copies, shadow_color is float, and Frame's sky and fog colours are
decoded. The post chain ends in a window-size picture, and the pack's
[tonemap] (or Horizon's clip) draws it onto the frame, encoded at alpha 1.
With HDR on, HDR output lifts nothing of a 0.2 pack's world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:37:14 +01:00
masa-masa-0314andZCode c3d63d1e98 Realms: the settings tab and the region preference screen
The configuration screen's Settings tab (RealmsSettingsTab): the world's name and
description fields (the click moves the caret between them), the region preference line
and its Region Preference button, and the open/close toggle moved here from the footer
(vanilla keeps it on this tab). Leaving the tab — switching tabs or Back/Escape — saves
the changed name, description and preference through RealmsClient.updateConfiguration
(POST worlds/{id}/configuration with RealmsConfigurationDto's JSON: the active slot, its
settings, the preference and the description).

RealmsPreferredRegionSelectionScreen: the automatic choices and every manual region, a
click selects, Done writes the choice back to the tab and Cancel drops it.

RealmsClient gains getPreferredRegionSelections (GET regions/preferredRegions) and
updateConfiguration; RealmServer reads regionSelectionPreference; RegionSelectionPreferenceDto,
RegionDataDto and RealmSetting serialization join the DTOs.

The tier's render/simulation distance options and the region quality icons are the next
PRs (the icons need sprites the GUI atlas does not carry yet); the region rows say their
name for now.

Co-Authored-By: ZCode <[email protected]>
2026-11-05 22:04:46 +08:00
Nikita Maximilian GrimmandClaude Opus 5.5 36021e2559 Golden Hour's shader API 0.2 port, as a test pack of the SDK
The example pack written for 0.2: its sky and post passes in linear light,
with the 0.1 pack's gamma constants decoded, and a [tonemap] stage of its
own that eases colours above three quarters of the headroom toward it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:59:12 +01:00
Claude 82f601072b Server panel: the launcher's Glass look, a page for each part, in-page dialogs
The panel takes the Glass look from docs/design-system.md: the sunset with pixel ridges, frosted
plates, Geist and Bricolage Grotesque Heading, the theme's Hugeicons. What it already did gets a page
each in a sidebar (overview, players, console, backups, crash reports), and confirm()/prompt() become
in-page dialogs. The supervisor serves the theme's fonts and the mark; the CSP gains font-src 'self'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jh5A2PzdusBChqE38VZ3zD
2026-11-04 23:29:16 +00: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
1035 changed files with 87449 additions and 15243 deletions

No files matched your search

+8 -7
View File
@@ -234,14 +234,15 @@ jobs:
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
- name: Check launcher DLL imports and command-line startup
working-directory: launcher
shell: pwsh
# bash, as launcher.yml's check on the same runner: the launcher is a GUI-subsystem program, and a pwsh
# `$output = & launcher --version` read no output from it (run 42217014588).
shell: bash
run: |
$ErrorActionPreference = 'Stop'
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
python ../tools/check_imports.py $launcher
$output = & $launcher --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
Write-Output ($output -join "`n")
launcher=target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
python ../tools/check_imports.py "$launcher"
out=$("$launcher" --version)
echo "$out"
[[ "$out" == "horizon-launcher "* ]] || { echo "::error::horizon-launcher --version printed '$out'"; exit 1; }
- name: Build a native-mod game with the launcher's managed toolchain
working-directory: launcher
shell: pwsh
+17 -16
View File
@@ -51,7 +51,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings), modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -65,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
@@ -92,8 +93,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -105,8 +106,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
harnesses for CI.
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
- A harness = `tools/test_<name>_parity.py` + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (mostly horizon-world and horizon-client),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
@@ -120,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`
@@ -151,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)) |
@@ -176,7 +177,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
## How `Game` is organised
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
- `struct Game` (game.rs) holds everything. Its methods are spread over many files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
@@ -190,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
+5 -1
View File
@@ -33,7 +33,7 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
and compares their outputs. There are about 290 of them.
and compares their outputs.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
@@ -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
+254 -105
View File
@@ -131,9 +131,9 @@ dependencies = [
[[package]]
name = "arboard"
version = "3.6.1"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"log",
@@ -142,17 +142,32 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -237,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"
@@ -485,6 +506,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
@@ -540,6 +562,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.8"
@@ -946,6 +974,18 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1105,8 +1145,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1135,6 +1177,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.2"
@@ -1264,6 +1316,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1365,6 +1426,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"crc32fast",
"dunce",
"flate2",
"fs4",
@@ -1447,6 +1509,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"crc32fast",
"flate2",
"fs4",
"getrandom 0.3.4",
@@ -1506,7 +1569,7 @@ name = "horizon-client-render"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"base64 0.22.1",
"bytemuck",
"chrono",
"cpal",
@@ -1575,7 +1638,7 @@ dependencies = [
name = "horizon-cosmetics"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"glam",
"png",
"serde",
@@ -1603,7 +1666,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2 0.5.10",
"socket2 0.6.5",
"spake2",
]
@@ -1660,10 +1723,15 @@ dependencies = [
name = "horizon-mod-deps"
version = "0.1.0"
dependencies = [
"gif",
"mysql",
"quinn",
"redis",
"rusqlite",
"rusty-opus",
"snap",
"tungstenite",
"yaml-rust2",
]
[[package]]
@@ -1703,6 +1771,14 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-polytone"
version = "0.1.0"
dependencies = [
"regex",
"serde_json",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1742,6 +1818,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"socket2 0.5.10",
]
[[package]]
@@ -1757,7 +1834,7 @@ dependencies = [
name = "horizon-release-sign"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"ring",
]
@@ -1766,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",
@@ -1797,7 +1874,7 @@ dependencies = [
name = "horizon-server-files"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"horizon-core",
"horizon-protocol",
"horizon-world",
@@ -2001,12 +2078,12 @@ dependencies = [
[[package]]
name = "if-addrs"
version = "0.13.4"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2268,6 +2345,17 @@ dependencies = [
"redox_syscall 0.9.4",
]
[[package]]
name = "libsqlite3-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
@@ -2319,9 +2407,15 @@ 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"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "lz4_flex"
@@ -2414,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",
@@ -2441,15 +2536,16 @@ 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",
"bytes",
"chrono",
"crc32fast",
"flate2",
"getrandom 0.3.4",
@@ -2484,7 +2580,7 @@ dependencies = [
"num-traits",
"once_cell",
"pp-rs",
"rustc-hash",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.21",
"unicode-ident",
@@ -2498,7 +2594,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown 0.17.1",
"indexmap",
"rustc-hash",
"rustc-hash 1.1.0",
"thiserror 2.0.21",
]
@@ -3060,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",
@@ -3112,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",
]
@@ -3354,6 +3450,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"socket2 0.6.5",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -3401,6 +3553,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3539,12 +3700,32 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.41.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3619,6 +3800,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
@@ -3883,6 +4065,12 @@ dependencies = [
"serde",
]
[[package]]
name = "snap"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4199,12 +4387,22 @@ dependencies = [
]
[[package]]
name = "tokio"
version = "1.53.2"
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2 0.6.5",
"windows-sys 0.61.2",
]
[[package]]
@@ -4274,6 +4472,9 @@ name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
@@ -4374,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",
@@ -4412,6 +4613,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
@@ -4675,6 +4882,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -4728,7 +4941,7 @@ dependencies = [
"portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 2.0.21",
"wgpu-core-deps-apple",
@@ -5051,15 +5264,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5093,30 +5297,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
@@ -5138,12 +5325,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5156,12 +5337,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5174,24 +5349,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5204,12 +5367,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5222,12 +5379,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5240,12 +5391,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5258,12 +5403,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
@@ -5471,6 +5610,16 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yaml-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
dependencies = [
"arraydeque",
"hashlink",
]
[[package]]
name = "yasna"
version = "0.6.0"
+17 -1
View File
@@ -37,6 +37,7 @@ members = [
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-deps",
@@ -47,7 +48,7 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
rust-version = "1.85"
rust-version = "1.95"
# Ports mirror vanilla's Java signatures, index loops and nested generic types
# one to one so they can be checked against the decompiled sources.
@@ -81,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):
-10
View File
@@ -410,16 +410,6 @@ The panel's later features (bans, worlds, server.properties import, logs and cra
</details>
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
<details><summary>Technical</summary>
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
#### Join servers on older versions
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
+94
View File
@@ -1,5 +1,46 @@
# 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
UV rules of Catnip's `FluidRenderHelper` from Ponder 1.0.82 for Minecraft 1.21.1
(`net.createmod.ponder:ponder-neoforge:1.0.82+mc1.21.1`). Copyright (c) 2022 The Create Team,
licensed under MIT (`LICENSE_Ponder` in that artifact); the full license heads the Rust file.
The native renderer uses Horizon's own block-sprite arrays, pose and light packing. The
development-only oracle calls the unchanged helper from the player's local artifact. No
original classes, game shaders, textures or prepared resources are distributed here.
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
@@ -212,3 +253,56 @@ 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.
```
## Just Enough Items
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
The two-light diffuse expression and transformed-normal policy in
`terrain.wgsl`'s native model entries follow Flywheel 1.0.6
(`internal/diffuse.glsl`, `instance/transformed.vert`, `internal/common.vert`).
No Flywheel classes, compiled shaders or original model/texture assets are bundled.
Copyright (c) 2021-2024 Jozufozu
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
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.
```
+1 -1
View File
@@ -3,7 +3,7 @@ name = "cargo-horizon-mod"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
rust-version.workspace = true
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-anticheat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.85"
rust-version.workspace = true
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
[dependencies]
@@ -34,7 +34,10 @@ pub fn element(v: &Element) -> Value {
.collect();
json!({"from":bits(v.from),"to":bits(v.to),"faces":faces,"rotation":v.rotation.as_ref().map(rotation),"shade":v.shade_direction_override.map(Direction::name),"light":v.light_emission})
}
#[allow(dead_code)] // Shared by fixtures which normalize different model stages.
#[allow(
dead_code,
reason = "shared by fixtures which normalize different model stages"
)]
pub fn model(v: &CuboidModel) -> Value {
let textures: BTreeMap<_, _> = v
.textures
+10 -2
View File
@@ -34,6 +34,8 @@ pub struct ModelQuad {
/// NeoForge's `render_type` names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderType {
/// A declared render type unknown to this baker; distinct from an absent default.
Unsupported,
Solid,
Cutout,
CutoutMipped,
@@ -234,7 +236,10 @@ fn bake_into(
skip_flat: bool,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
let render_type = model
.render_type
.as_deref()
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
for element in model.elements() {
let element = element
.as_ref()
@@ -292,7 +297,10 @@ fn bake_obj(
transform: BlockModelTransform,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
let render_type = model
.render_type
.as_deref()
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
let slot = match obj.materials.get(&face.material) {
Some(MtlTexture::Sprite(sprite)) => {
+9 -9
View File
@@ -179,19 +179,19 @@ impl Condition {
let fields = object(raw)?;
if fields.len() == 1 {
for name in ["OR", "AND"] {
if let Some(raw) = fields.get(name) {
if let Ok(values) = array(raw).and_then(|values| {
if let Some(raw) = fields.get(name)
&& let Ok(values) = array(raw).and_then(|values| {
values
.into_iter()
.map(Self::decode)
.collect::<Result<Vec<_>>>()
}) {
return Ok(if name == "OR" {
Self::Or(values)
} else {
Self::And(values)
});
}
})
{
return Ok(if name == "OR" {
Self::Or(values)
} else {
Self::And(values)
});
}
}
}
+17
View File
@@ -777,6 +777,23 @@ mod tests {
);
}
#[test]
fn an_unknown_declared_render_type_remains_distinct_from_absent() {
let json = LIGHTS.replace("minecraft:cutout", "fixture:custom_material");
let library = library(&[("block/custom", &json)]);
let model = library.resolve("block/custom").unwrap();
let quads = crate::bake::bake_block_model(
&model,
crate::bake::BlockModelTransform::new([0; 3], false),
)
.unwrap();
assert!(!quads.is_empty());
assert!(
quads
.iter()
.all(|q| q.render_type == Some(crate::bake::RenderType::Unsupported))
);
}
#[test]
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
let library = library(&[
("block/base", r#"{"render_type":"translucent"}"#),
+1
View File
@@ -2,6 +2,7 @@
name = "horizon-bench"
version = "0.1.0"
edition = "2021"
rust-version.workspace = true
license = "MIT"
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
+142 -18
View File
@@ -90,6 +90,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--pvp B fight bots also attack each other (true or false); default true
--crits fight bots swing at a sword's full strength (every 13 ticks) and jump into every
other swing, landing it while falling: a critical hit, then a sweep from the
ground (give them swords); for PvP benches (crit and sweep particles)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
@@ -165,6 +168,8 @@ struct Options {
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
no_read: bool,
no_loaded: bool,
@@ -202,6 +207,7 @@ fn options() -> Result<Options, String> {
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
crits: false,
flood: 100,
no_read: false,
no_loaded: false,
@@ -230,6 +236,10 @@ fn options() -> Result<Options, String> {
o.no_swing = true;
continue;
}
if arg == "--crits" {
o.crits = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
@@ -532,11 +542,84 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// A sword's attack speed is 1.6 a second: full strength 20 / 1.6 = 12.5 ticks after a swing.
const SWORD_COOLDOWN_TICKS: u32 = 13;
/// `LivingEntity.getJumpPower`: a jump's first upward motion, in blocks a tick.
const JUMP_POWER: f64 = 0.42;
/// `--crits`: a fight bot that swings at a sword's full strength and jumps into every other swing, so
/// it lands while falling (`Player.attack`'s critical hit: falling, not on the ground, full strength),
/// and the next one from the ground (a sweep: on the ground, full strength, a sword).
#[derive(Default)]
struct Crits {
/// Vertical motion while in the air (`LivingEntity.travel`: minus 0.08 a tick, times 0.98).
jump: Option<f64>,
ground: f64,
/// The next swing is the jumped one.
crit_next: bool,
}
impl Crits {
fn airborne(&self) -> bool {
self.jump.is_some()
}
/// One tick: walk up to the target and, with a full cooldown, strike; jump ahead of a crit's swing.
fn tick(
&mut self,
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since: &mut u32,
) -> Option<i32> {
if let Some(v) = self.jump {
pos[1] += v;
if pos[1] <= self.ground {
pos[1] = self.ground;
self.jump = None;
} else {
self.jump = Some((v - 0.08) * 0.98);
}
} else {
self.ground = pos[1];
// A jump takes 6 ticks to its top: start it so the cooldown ends on the way down.
if self.crit_next && *since + 8 >= SWORD_COOLDOWN_TICKS {
self.jump = Some(JUMP_POWER);
}
}
let falling = self.jump.is_some_and(|v| v < 0.0);
if self.crit_next && !falling {
*since = since.saturating_add(1);
fight_step(pos, target.1);
return None;
}
let hit = fight_tick(pos, target, since, SWORD_COOLDOWN_TICKS);
if hit.is_some() {
self.crit_next = !self.crit_next;
}
hit
}
}
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
fn fight_tick(
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since_attack: &mut u32,
cooldown: u32,
) -> Option<i32> {
*since_attack = since_attack.saturating_add(1);
let (id, at) = target;
fight_step(pos, at);
if *since_attack >= cooldown && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// Walk toward `at`, stopping `CLOSE_IN` short of it.
fn fight_step(pos: &mut [f64; 3], at: [f64; 3]) {
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
let horizontal = dx.hypot(dz);
if horizontal > CLOSE_IN {
@@ -544,11 +627,6 @@ fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u3
pos[0] += dx / horizontal * step;
pos[2] += dz / horizontal * step;
}
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
@@ -616,7 +694,7 @@ fn costly(
Behaviour::FloodUse => {
// A block within reach: place stone there, or break what is there.
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
if i % 2 == 0 {
if i.is_multiple_of(2) {
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
@@ -645,7 +723,7 @@ fn costly(
carried: None,
}),
Behaviour::FloodSuggest => {
let text = if i % 2 == 0 {
let text = if i.is_multiple_of(2) {
"/"
} else {
LONG_COMMAND.as_str()
@@ -1124,6 +1202,7 @@ fn run_bot(
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let mut crits = Crits::default();
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -1181,11 +1260,16 @@ fn run_bot(
Behaviour::Fight => {
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
if let Some(near) = near {
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
let hit = if o.crits {
crits.tick(&mut pos, near, &mut since_attack)
} else {
fight_tick(&mut pos, near, &mut since_attack, ATTACK_COOLDOWN_TICKS)
};
if let Some(victim) = hit {
// The client sends the attack, then swings the arm.
let sent = send(
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
) && send(raw(sb::PUNCH, |w| w.varint(0)));
) && send(play::Serverbound::Punch(Empty).encode());
if sent {
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
@@ -1300,9 +1384,10 @@ fn run_bot(
x: pos[0],
y: pos[1],
z: pos[2],
// Fight bots stand on the ground; the others fly.
// Fight bots stand on the ground (but in a --crits jump); the others fly.
flags: u8::from(
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
(matches!(behaviour, Behaviour::Fight | Behaviour::Stand) && !crits.airborne())
|| o.ground,
),
})
.encode(),
@@ -1470,10 +1555,10 @@ fn main() {
});
if o.report.as_deref() == Some("-") {
println!("{summary:#}");
} else if let Some(path) = &o.report {
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
eprintln!("horizon-bots: {path}: {e}");
}
} else if let Some(path) = &o.report
&& let Err(e) = std::fs::write(path, format!("{summary:#}\n"))
{
eprintln!("horizon-bots: {path}: {e}");
}
let errors: Vec<String> = bots
.iter()
@@ -1600,7 +1685,9 @@ mod tests {
let target = (3, [1.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let hits: Vec<u32> = (0..40)
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
.filter_map(|tick| {
fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).map(|_| tick)
})
.collect();
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
assert!(
@@ -1617,7 +1704,7 @@ mod tests {
let mut since = ATTACK_COOLDOWN_TICKS;
let mut first = None;
for tick in 0..100 {
if fight_tick(&mut pos, target, &mut since).is_some() {
if fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).is_some() {
first = Some(tick);
break;
}
@@ -1627,6 +1714,43 @@ mod tests {
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
}
#[test]
fn crits_alternate_a_falling_swing_and_a_grounded_one() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [1.0, 64.0, 0.0]);
let (mut since, mut crits) = (SWORD_COOLDOWN_TICKS, Crits::default());
let mut last_y = pos[1];
let mut hits = Vec::new();
for tick in 0..120u32 {
let hit = crits.tick(&mut pos, target, &mut since);
if hit.is_some() {
hits.push((tick, crits_state(pos[1], last_y)));
}
last_y = pos[1];
}
assert!(hits.len() >= 6, "{hits:?}");
for (i, w) in hits.windows(2).enumerate() {
assert!(
w[1].0 - w[0].0 >= SWORD_COOLDOWN_TICKS,
"full strength each time: {hits:?}"
);
assert_ne!(w[0].1, w[1].1, "swing {i} and the next alternate: {hits:?}");
}
// A crit lands in the air, on the way down; a sweep from the ground.
assert!(hits.iter().any(|h| h.1 == "falling") && hits.iter().any(|h| h.1 == "ground"));
assert!(pos[1] >= 64.0);
}
fn crits_state(y: f64, before: f64) -> &'static str {
if y == 64.0 {
"ground"
} else if y < before {
"falling"
} else {
"rising"
}
}
#[test]
fn percentiles_use_nearest_rank() {
assert_eq!(percentile(&[], 50), 0);
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-chat-filter"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-client-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
[dependencies]
@@ -275,6 +275,25 @@ impl Registries {
.clone_from(&e.advancement_criteria);
}
}
/// Add the damage catalog's types ([`Self::damage_types`]: content mods'
/// and the world's packs' with vanilla's) to the commands'
/// `minecraft:damage_type` ids, which the export lists for vanilla alone.
/// `ResourceArgument` looks a type up in the server's registries, so
/// `/damage` takes every type the world has. The export's tags stay. The
/// ids are replaced, not extended: `by_registry` outlives a world, and a
/// pack's type must not stay accepted in the next world opened.
pub fn sync_damage_type_ids(&mut self) {
if self.damage_types.is_empty() {
return;
}
let mut ids = self.damage_types.clone();
ids.sort();
ids.dedup();
self.by_registry
.entry("minecraft:damage_type".into())
.or_default()
.ids = ids;
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
@@ -312,3 +331,49 @@ impl Registries {
}
}
}
#[cfg(test)]
mod tests {
use super::Registries;
/// A content mod's damage type joins vanilla's in the commands' ids, the
/// export's tags stay, and a second world open lists each type once.
#[test]
fn damage_type_ids_take_the_catalogs_types() {
let export = Registries::from_export(&serde_json::json!({
"by_registry": {"minecraft:damage_type": {
"ids": ["minecraft:fall", "minecraft:magic"],
"tags": ["minecraft:is_fall"],
}},
}));
let mut reg = Registries {
damage_types: vec![
"minecraft:magic".into(),
"example:test".into(),
"minecraft:fall".into(),
],
..Registries::default()
};
reg.fill_from_exported(&export);
assert_eq!(
reg.ids("minecraft:damage_type"),
["minecraft:fall", "minecraft:magic"],
"the export alone lists vanilla's types"
);
for _ in 0..2 {
reg.sync_damage_type_ids();
assert_eq!(
reg.ids("minecraft:damage_type"),
["example:test", "minecraft:fall", "minecraft:magic"]
);
assert_eq!(reg.tags("minecraft:damage_type"), ["minecraft:is_fall"]);
}
// The next world, without the pack: its type is no longer accepted.
reg.damage_types = vec!["minecraft:fall".into(), "minecraft:magic".into()];
reg.sync_damage_type_ids();
assert_eq!(
reg.ids("minecraft:damage_type"),
["minecraft:fall", "minecraft:magic"]
);
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-client-input"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
[dependencies]
+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;
+38 -31
View File
@@ -393,38 +393,17 @@ impl FullscreenState {
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
if wanted && window.is_maximized() {
window.set_maximized(false);
window.set_fullscreen(Some(fullscreen_request(
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => enter_fullscreen(
window,
exclusive,
preferred.as_ref(),
)));
} else {
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None if reapply || mode_change => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None => {}
}
fullscreen_request(window, exclusive, preferred.as_ref()),
),
None if reapply || mode_change => enter_fullscreen(
window,
fullscreen_request(window, exclusive, preferred.as_ref()),
),
None => {}
}
}
@@ -602,6 +581,34 @@ fn monitor_current_mode(
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
}
/// `window.set_fullscreen(Some(fullscreen))`. On Windows a maximized window leaves its maximized
/// state first: winit keeps a maximized window's client area inside the work area, so a
/// borderless fullscreen kept the taskbar over its bottom and pushed its top off screen (#9984).
pub fn enter_fullscreen(window: &winit::window::Window, fullscreen: winit::window::Fullscreen) {
#[cfg(target_os = "windows")]
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen));
}
/// Borderless fullscreen on the window's own monitor (the primary one when it has none).
pub fn borderless_fullscreen(window: &winit::window::Window) -> winit::window::Fullscreen {
winit::window::Fullscreen::Borderless(
window
.current_monitor()
.or_else(|| window.primary_monitor()),
)
}
/// A window created fullscreen that the system also marked maximized enters it again
/// unmaximized ([`enter_fullscreen`]); only Windows does that.
pub fn settle_created_fullscreen(window: &winit::window::Window, fullscreen: bool) {
if cfg!(target_os = "windows") && fullscreen && window.is_maximized() {
enter_fullscreen(window, borderless_fullscreen(window));
}
}
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
+13 -5
View File
@@ -6,7 +6,7 @@ autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "Reusable client systems, control tool and parity probes"
[lib]
@@ -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,9 +74,10 @@ 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"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
@@ -112,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.
+2 -2
View File
@@ -3,7 +3,7 @@ name = "horizon-client-render"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
@@ -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" }
@@ -0,0 +1,55 @@
//! Headless production brewing-fuel component fixture.
use horizon_client_render::{
brewing::{Brewing, BrewingStand, brewing_fuel},
providers::Providers,
};
use horizon_items::{
item_data::decode_components,
items::{ItemStack, Items},
};
use horizon_world::loot::PackOverlay;
use std::{
io::{BufRead, Write},
path::Path,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let root = Path::new(&args[1]);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let providers = Providers::load(root);
let brewing = Brewing {
recipes: vec![],
reagents: Default::default(),
inputs: Default::default(),
};
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3])?);
for line in std::io::BufReader::new(std::fs::File::open(&args[2])?).lines() {
let input: serde_json::Value = serde_json::from_str(&line?)?;
let mut stack = ItemStack::new(
input["item"].as_str().unwrap(),
input["count"].as_u64().unwrap() as u32,
);
let components = horizon_client_core::scoreboard::json_to_tag(&input["components"]);
let (_, extra) = decode_components(Some(&components));
stack.extra = extra;
let accepted = brewing_fuel(&items, &stack).is_some();
let mut stand = BrewingStand::new();
// A newly constructed vanilla block entity has no saved total yet.
stand.total_fuel = 0;
stand.items[4] = Some(stack);
for _ in 0..2 {
stand.tick(&brewing, &items, &providers);
writeln!(
out,
"{} {} {} {} {}",
accepted,
stand.fuel,
stand.total_fuel,
stand.speed.to_bits(),
stand.items[4].as_ref().map_or(0, |s| s.count)
)?;
}
}
Ok(())
}
@@ -66,6 +66,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
false,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]).unwrap();
@@ -224,6 +224,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.translucent_batching,
false,
false,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
@@ -126,6 +126,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
assert!(
@@ -348,6 +350,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -368,6 +371,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
+76 -12
View File
@@ -261,6 +261,40 @@ pub struct Audio {
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
/// The look at the output devices running on its own thread, until a tick
/// takes its answer: listing them can take 100 ms (#11490).
#[cfg(feature = "audio")]
device_probe: Option<PendingProbe>,
}
/// What one look at the output devices found: the system default, the
/// fingerprint, and the device list when those say it may have changed.
#[cfg(feature = "audio")]
struct DeviceProbe {
default: Option<String>,
cards: Option<String>,
devices: Option<Vec<String>>,
}
#[cfg(feature = "audio")]
type PendingProbe = Arc<Mutex<Option<DeviceProbe>>>;
/// Looks at the output devices as `should_change_device` did on the render
/// thread: the device list only when the default or the fingerprint moved.
#[cfg(feature = "audio")]
fn probe_devices(default_seen: Option<String>, cards_seen: Option<String>) -> DeviceProbe {
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them; on Windows
// every endpoint, with no fingerprint to skip it); only the fingerprint's
// devices can change the list, and it is cheap.
let devices = (default != default_seen
|| cards_changed(cards_seen.as_deref(), cards.as_deref()))
.then(output_devices);
DeviceProbe {
default,
cards,
devices,
}
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
use symphonia::core::{
@@ -1051,6 +1085,8 @@ impl Audio {
devices_seen: devices,
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
device_probe: None,
}
}
#[cfg(not(feature = "audio"))]
@@ -1211,6 +1247,8 @@ impl Audio {
devices_seen: self.devices_seen.clone(),
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
device_probe: None,
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -1232,7 +1270,8 @@ impl Audio {
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
/// default changed (with no device chosen in the options), or the chosen
/// device came back; the output is then opened again. The devices are looked
/// at once a second.
/// at once a second, on a thread of their own: a later tick reads what it
/// found.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
// A splitscreen share plays through the first player's output: it leaves the device,
@@ -1246,29 +1285,54 @@ impl Audio {
};
if lost {
log::info!("Horizon: audio device was lost");
self.device_probe = None;
self.default_seen = default_output_name();
self.cards_seen = card_fingerprint();
self.devices_seen = output_devices();
return true;
}
if let Some(pending) = &self.device_probe {
let Some(probe) = pending.lock().ok().and_then(|mut found| found.take()) else {
return false;
};
self.device_probe = None;
return self.devices_changed(probe, current);
}
self.device_ticks += 1;
if self._stream.is_none() || self.device_ticks < 20 {
return false;
}
self.device_ticks = 0;
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them); only the
// fingerprint's devices can change the list, and it is cheap.
if default == self.default_seen
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
{
return false;
let pending = PendingProbe::default();
let out = Arc::clone(&pending);
let seen = (self.default_seen.clone(), self.cards_seen.clone());
let started = std::thread::Builder::new()
.name("audio-devices".into())
.spawn(move || {
let probe = probe_devices(seen.0, seen.1);
if let Ok(mut found) = out.lock() {
*found = Some(probe);
}
});
match started {
Ok(_) => self.device_probe = Some(pending),
Err(e) => log::warn!("Horizon: could not look at the audio devices: {e}"),
}
false
}
/// The rest of `shouldChangeDevice` with what a look at the devices found,
/// `current` being the device playing now.
#[cfg(feature = "audio")]
fn devices_changed(&mut self, probe: DeviceProbe, current: String) -> bool {
let DeviceProbe {
default,
cards,
devices,
} = probe;
let Some(devices) = devices else {
return false;
};
self.cards_seen = cards;
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
if default == self.default_seen && devices == self.devices_seen {
@@ -538,6 +538,11 @@ impl BlockEntities {
});
Arc::clone(&self.tickers)
}
/// How many block entities tick (`Level.blockEntityTickers`, the slots still live), as of
/// the last [`Self::tickers`]: what a diagnostics mod counts.
pub fn ticking_len(&self) -> usize {
self.live.len()
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
@@ -0,0 +1,249 @@
//! Every block state's facts and looks by its id, for a client mod drawing blocks or a map off the main thread
//! (`native_block_states`; docs/native-mods.md): what the game knows of each state, what its model draws on each side
//! and the texture behind each of those sprites. A copy, so the game's own tables can change shape under it.
use crate::{
block_sprites::{BlockSprites, Lookup, SpriteRef},
streaming::WorkerContext,
};
use horizon_items::blocks::{BlockFacts, MAP_COLORS, RenderShape};
use horizon_world::{
block_collision::BlockCollisionRegistry, fluid::FluidState, registry::BlockStateId,
};
use std::{collections::HashMap, sync::Arc};
/// What one block state is, as a renderer or a map reads it.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct BlockStateFacts {
/// Its block, without properties (`minecraft:grass_block`).
pub name: Arc<str>,
/// `BlockState.isAir`: air, cave air or void air.
pub air: bool,
/// No model draws it (render shape `INVISIBLE`): air, barriers, light, and water and lava, drawn as fluids.
pub invisible: bool,
/// `BlockState.getFluidState`: the fluid in it, a waterlogged block's and kelp's included; empty for none.
pub fluid: FluidState,
/// Its `waterlogged` property is true.
pub waterlogged: bool,
/// The light it gives, 0 to 15.
pub emission: u8,
/// `BlockState.getShape`, the outline the cursor picks, as block-local boxes
/// `[min_x, min_y, min_z, max_x, max_y, max_z]`: `[0, 0, 0, 1, 1, 1]` for a whole block, none for air. Without
/// the random offset of flowers and grass.
pub outline: Arc<[[f64; 6]]>,
/// `MapColor.col` (`0xRRGGBB`), its colour on a vanilla map; none for `MapColor.NONE`, which maps leave out.
pub map_color: Option<u32>,
/// It can collide at all: false for a state whose collision shape is always empty (air, flowers, water).
pub collides: bool,
}
// A mod builds its tables from these on a worker thread.
const _: () = {
const fn shareable<T: Send + Sync + 'static>() {}
shareable::<BlockStates>()
};
/// Every block state by its id: its facts, and what its model draws with the resources loaded when it was made.
#[derive(Clone)]
pub struct BlockStates {
states: Arc<[BlockStateFacts]>,
context: Arc<WorkerContext>,
/// Texture names by sprite layer, across every array.
textures: Arc<HashMap<u32, String>>,
split: u32,
}
impl BlockStates {
/// The states of `context`'s registry, with their `facts` and `collision`, and the sprites of `context`'s
/// resources at `split` layers to an array (`Renderer::sprite_split`).
pub fn new(
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
context: Arc<WorkerContext>,
split: u32,
) -> Self {
let registry = context.assets.registry;
let states = facts
.states
.iter()
.enumerate()
.map(|(i, f)| {
let id = BlockStateId(i as u32);
BlockStateFacts {
name: f.name.clone(),
air: facts.is_air(id),
invisible: f.render_shape == RenderShape::Invisible,
fluid: registry
.state(id)
.and_then(|state| FluidState::from_block(state).ok())
.unwrap_or(FluidState::EMPTY),
waterlogged: f.waterlogged,
emission: f.emission,
outline: f.outline.clone(),
map_color: MAP_COLORS
.get(usize::from(f.map_color))
.copied()
.filter(|_| f.map_color != 0),
collides: collision.may_collide(id),
}
})
.collect();
// Vanilla's names go without their namespace, as `native_draw_cube` takes them.
let textures = context
.render
.sprite_layers
.iter()
.map(|(name, layer)| (*layer, name.trim_start_matches("minecraft:").to_owned()))
.collect();
Self {
states,
context,
textures: Arc::new(textures),
split: split.max(1),
}
}
/// How many states there are: their ids run from 0 to one less.
pub fn len(&self) -> usize {
self.states.len()
}
pub fn is_empty(&self) -> bool {
self.states.is_empty()
}
/// `state`'s facts; none for an id past the last state.
pub fn get(&self, state: BlockStateId) -> Option<&BlockStateFacts> {
self.states.get(state.0 as usize)
}
/// Every state with its id, in id order.
pub fn iter(&self) -> impl Iterator<Item = (BlockStateId, &BlockStateFacts)> {
self.states
.iter()
.enumerate()
.map(|(i, facts)| (BlockStateId(i as u32), facts))
}
/// What `state` draws on each side, as `native_block_sprites` says for a block by name; none for an id past the
/// last state.
pub fn sprites(&self, state: BlockStateId) -> Option<BlockSprites> {
self.get(state)?;
Some(Lookup::new(&self.context, self.split).state(state))
}
/// The texture `sprite` shows, by the name `native_texture_sprites` takes (`block/grass_block_top`; a mod's with
/// its namespace, `mymod:block/gear`).
pub fn texture(&self, sprite: SpriteRef) -> Option<&str> {
let layer = sprite
.array
.checked_mul(self.split)?
.checked_add(sprite.layer)?;
self.textures.get(&layer).map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
assets::Assets, biome::Biomes, block_sprites::parse_state, light::LightTables,
mesher::MeshTables,
};
use horizon_assets::model::Direction;
use horizon_world::fluid::FluidKind;
use std::{path::PathBuf, sync::OnceLock};
/// Far fewer layers to an array than a GPU takes, so most sprites lie past array 0.
const SMALL_SPLIT: u32 = 64;
/// The game's own states from real prepared resources, loaded once for every test here.
fn states() -> &'static BlockStates {
static STATES: OnceLock<BlockStates> = OnceLock::new();
STATES.get_or_init(|| {
let root = PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root).unwrap());
let render = Arc::new(assets.reload(&root).unwrap());
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let context = Arc::new(WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts).unwrap()),
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility).unwrap()),
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root).unwrap()),
});
BlockStates::new(&facts, &collision, context, SMALL_SPLIT)
})
}
fn facts(text: &str) -> &'static BlockStateFacts {
let states = states();
let id = parse_state(states.context.assets.registry, text).expect(text);
states.get(id).unwrap()
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_grass_block_is_a_whole_solid_grass_coloured_block() {
let grass = facts("grass_block");
assert_eq!(&*grass.name, "minecraft:grass_block");
assert!(!grass.air && !grass.invisible && !grass.waterlogged);
assert_eq!(grass.fluid.kind(), FluidKind::Empty);
assert_eq!(grass.emission, 0);
assert_eq!(&*grass.outline, [[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]]);
assert_eq!(grass.map_color, Some(0x7F_B238), "MapColor.GRASS");
assert!(grass.collides);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn air_and_light_are_not_drawn() {
let air = facts("air");
assert!(air.air && air.invisible && !air.collides);
assert!(air.outline.is_empty());
assert_eq!(air.map_color, None);
let light = facts("light[level=15]");
assert!(!light.air && light.invisible);
assert_eq!(light.emission, 15);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_slabs_outline_is_its_half() {
assert_eq!(
&*facts("stone_slab[type=bottom]").outline,
[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]]
);
assert_eq!(
&*facts("stone_slab[type=top]").outline,
[[0.0, 0.5, 0.0, 1.0, 1.0, 1.0]]
);
let wet = facts("stone_slab[type=bottom,waterlogged=true]");
assert!(wet.waterlogged && wet.collides);
assert_eq!(wet.fluid.kind(), FluidKind::Water);
assert!(wet.fluid.is_source());
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_face_names_its_texture() {
let states = states();
let id = parse_state(states.context.assets.registry, "grass_block").unwrap();
let grass = states.sprites(id).unwrap();
let top = grass.sides[Direction::Up as usize][0].sprite;
assert_eq!(states.texture(top), Some("block/grass_block_top"));
let north = &grass.sides[Direction::North as usize];
assert_eq!(
states.texture(north[0].sprite),
Some("block/grass_block_side")
);
assert_eq!(
states.texture(north[1].sprite),
Some("block/grass_block_side_overlay")
);
}
}
+7 -7
View File
@@ -114,12 +114,8 @@ mod tests {
ambient_occlusion: true,
particle: None,
cube: None,
faces: vec![BakedFace {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
faces: vec![BakedFace::new(
ModelQuad {
positions: [
[0.0, 0.5, 0.0],
[0.0, 0.5, 1.0],
@@ -142,7 +138,11 @@ mod tests {
render_type: None,
shade: true,
},
}],
0,
true,
false,
None,
)],
}
}
#[test]
+58 -1
View File
@@ -387,8 +387,21 @@ impl BrewingStand {
out.brewed = true;
}
}
/// The `minecraft:brewing_fuel` providers of an item: (uses, speed_multiplier).
/// `ItemStack.get(BREWING_FUEL)`: the stack's providers (uses, speed_multiplier),
/// respecting component patches before falling back to the item's defaults.
pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)> {
if let Some(data) = stack.data() {
if data.other("!minecraft:brewing_fuel").is_some() {
return None;
}
if let Some(own) = data.other("minecraft:brewing_fuel") {
let component = horizon_client_core::scoreboard::tag_to_json(own);
return Some((
component["uses"].clone(),
component["speed_multiplier"].clone(),
));
}
}
items.get(&stack.item).and_then(|i| i.brewing_fuel.clone())
}
@@ -396,6 +409,50 @@ pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)>
mod tests {
use super::*;
#[test]
fn stack_fuel_controls_brew_duration_and_consumption() {
let items = Items::for_tests(Default::default());
let brewing = Brewing {
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
inputs: HashSet::new(),
recipes: vec![BrewingRecipe {
id: "awkward".into(),
input: PotionIngredient {
items: ["minecraft:potion".to_owned()].into_iter().collect(),
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
},
reagent: PotionIngredient {
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
potions: None,
},
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
}],
};
let mut fuel = ItemStack::new("minecraft:stone", 2);
fuel.data_mut().set_loot_component(
"minecraft:brewing_fuel",
&serde_json::json!({"uses": 7, "speed_multiplier": 2.5}),
);
let mut stand = BrewingStand::new();
stand.items[0] = Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
stand.items[4] = Some(fuel);
let providers = Providers::empty();
stand.tick(&brewing, &items, &providers);
assert_eq!((stand.brew_time, stand.fuel, stand.total_fuel), (160, 6, 7));
assert_eq!(stand.items[4].as_ref().unwrap().count, 1);
for _ in 1..160 {
assert!(!stand.tick(&brewing, &items, &providers).brewed);
}
assert!(stand.tick(&brewing, &items, &providers).brewed);
assert_eq!(
stand.items[0].as_ref().unwrap().potion(),
Some("minecraft:awkward")
);
assert_eq!(stand.fuel, 6);
assert_eq!(stand.items[4].as_ref().unwrap().count, 1);
}
#[test]
fn potion_colours_and_names() {
// Swiftness: speed colour 3402751 = 0x33EBFF.
+90 -4
View File
@@ -1188,11 +1188,27 @@ impl SuggestLayout {
};
}
/// A suggestion accepted by Tab or a click (`SuggestionsList.useSuggestion`).
#[derive(Debug, PartialEq, Eq)]
pub struct SuggestionCompletion {
/// The suggestion list's original input, also when cycling with Tab.
pub original: String,
/// The replaced range in `original`, as Java UTF-16 start/end offsets.
pub range: (usize, usize),
pub text: String,
/// The resulting input, after the text field's length limit.
pub input: String,
}
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
/// the suggestion list, Tab completion, history).
pub struct ChatScreen {
pub input: TextField,
/// `ChatScreen.isDraft`: the box opened with the draft saved last time and
/// has not been edited since. A draft draws grey italic, and the first
/// Backspace clears the whole box instead of one character.
is_draft: bool,
width: i32,
height: i32,
history_buffer: String,
@@ -1207,6 +1223,7 @@ pub struct ChatScreen {
/// the command up to the cursor.
request: Option<(i32, String)>,
suggestions: Option<SuggestionList>,
completion: Option<SuggestionCompletion>,
usage: Vec<Text>,
usage_position: i32,
usage_width: i32,
@@ -1300,11 +1317,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String {
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`,
/// which is a draft when `is_draft` (`ChatComponent.createScreen`).
pub fn new(
m: &Measure,
c: &Commands,
initial: &str,
is_draft: bool,
history_len: usize,
width: i32,
height: i32,
@@ -1317,6 +1336,7 @@ impl ChatScreen {
input.set_value(m, initial);
let mut s = Self {
input,
is_draft,
width,
height,
history_buffer: String::new(),
@@ -1326,6 +1346,7 @@ impl ChatScreen {
awaiting: None,
request: None,
suggestions: None,
completion: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
@@ -1353,6 +1374,7 @@ impl ChatScreen {
input.set_value(m, command);
let mut s = Self {
input,
is_draft: false,
width,
height,
history_buffer: String::new(),
@@ -1362,6 +1384,7 @@ impl ChatScreen {
awaiting: None,
request: None,
suggestions: None,
completion: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
@@ -1377,12 +1400,18 @@ impl ChatScreen {
pub fn value(&self) -> &str {
&self.input.value
}
/// Take the most recently accepted suggestion, at most once. Call after
/// each key or click to observe every acceptance, including Tab cycling.
pub fn take_suggestion_completion(&mut self) -> Option<SuggestionCompletion> {
self.completion.take()
}
/// Replace the input for a suggest-command link and refresh completions.
pub fn suggest_command(&mut self, m: &Measure, c: &Commands, command: &str) {
self.input.set_value(m, command);
self.edited(m, c);
}
/// `ChatScreen.resize`: a new screen with the current text.
/// `ChatScreen.resize`: a new screen with the current text, still a draft
/// while it has not been edited (`ChatScreen.resize` keeps the field).
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
@@ -1390,7 +1419,16 @@ impl ChatScreen {
let abilities = self.abilities;
let message = std::mem::take(&mut self.input.message);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
Self::new(
m,
c,
&value,
self.is_draft,
self.history_pos,
width,
height,
abilities,
)
} else {
Self::command_block(m, c, &value, width, height)
};
@@ -1402,6 +1440,7 @@ impl ChatScreen {
fn edited(&mut self, m: &Measure, c: &Commands) {
self.allow_suggestions = true;
self.update_command_info(m, c);
self.is_draft = false;
}
/// Ctrl+A/C/V/X in the input box.
pub fn shortcut(
@@ -1415,6 +1454,24 @@ impl ChatScreen {
self.edited(m, c);
}
}
/// Edit the existing input and refresh its command responder when it changes.
pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) {
let before = (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
);
f(&mut self.input);
if before
!= (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
)
{
self.edited(m, c);
}
}
/// Typed text (`charTyped` per character).
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
for ch in text.chars() {
@@ -1714,6 +1771,17 @@ impl ChatScreen {
self.select(l, l.current as isize);
self.keep_suggestions = false;
l.tab_cycles = true;
let units = |byte: usize| {
l.original[..byte.min(l.original.len())]
.encode_utf16()
.count()
};
self.completion = Some(SuggestionCompletion {
original: l.original.clone(),
range: (units(s.range.0), units(s.range.1)),
text: s.text,
input: self.input.value.clone(),
});
}
/// `ChatScreen.keyPressed`.
pub fn key(
@@ -1729,6 +1797,14 @@ impl ChatScreen {
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
// ChatScreen.keyPressed: while the box holds an unedited draft, the
// first Backspace clears all of it rather than one character.
if self.is_draft && key == K::Backspace {
self.input.set_value(m, "");
self.is_draft = false;
self.edited(m, c);
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
@@ -1862,8 +1938,13 @@ impl ChatScreen {
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input.
/// `CommandSuggestions.formatText`: argument colours over the input. The
/// draft formatter (`ChatScreen.formatChat`) is registered before this one
/// and wins while the draft is unedited: the whole text is grey italic.
fn format(&self, value: &str, offset: usize) -> Text {
if self.is_draft {
return Text::literal(value).with_color(text::GRAY).italic();
}
let gray = Style::color(text::GRAY);
let Some(parse) = &self.parse else {
return Text::literal(value);
@@ -1990,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);
+193 -7
View File
@@ -7,6 +7,85 @@ fn full() -> ChatAbilities {
ChatAbilities::new(ChatVisibility::Full)
}
#[test]
fn accepted_suggestions_report_utf16_ranges_and_tab_cycles() {
use winit::keyboard::KeyCode as K;
let font = font();
let m = Measure(&font);
let registries = Registries {
players: vec!["Steve".into(), "Steven".into()],
..Registries::default()
};
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "😀 hi st");
assert!(screen.take_suggestion_completion().is_none());
screen.key(&m, &c, &[], K::Tab, false, false);
assert!(
screen.take_suggestion_completion().is_none(),
"opening is not accepting"
);
for (shift, text) in [(false, "Steve"), (false, "Steven"), (true, "Steve")] {
screen.key(&m, &c, &[], K::Tab, shift, false);
assert_eq!(screen.value(), format!("😀 hi {text}"));
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "😀 hi st".into(),
range: (6, 8),
text: text.into(),
input: format!("😀 hi {text}"),
})
);
assert!(
screen.take_suggestion_completion().is_none(),
"reported once"
);
}
screen.key(&m, &c, &[], K::ArrowDown, false, false);
assert!(
screen.take_suggestion_completion().is_none(),
"selection is not accepting"
);
screen.key(&m, &c, &[], K::Escape, false, false);
assert!(screen.take_suggestion_completion().is_none());
}
#[test]
fn clicked_suggestion_reports_the_actual_length_limited_input() {
use winit::keyboard::KeyCode as K;
let font = font();
let m = Measure(&font);
let registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.input.max_length = 6;
screen.type_text(&m, &c, "hi st");
screen.key(&m, &c, &[], K::Tab, false, false);
let [x, y, _, _] = screen.suggestions.as_ref().unwrap().rect;
assert!(screen.click(&m, &c, f64::from(x + 1), f64::from(y + 1)));
assert_eq!(screen.value(), "hi Ste");
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "hi st".into(),
range: (3, 5),
text: "Steve".into(),
input: "hi Ste".into(),
})
);
assert!(screen.take_suggestion_completion().is_none());
}
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
Font::load(
@@ -201,7 +280,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -242,6 +321,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
&m,
&commands,
"unfinished draft",
false,
history.len(),
640,
360,
@@ -263,6 +343,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
}
}
/// `ChatScreen.isDraft`: the box opens on the draft kept from last time
/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init`
/// stores), so the text draws grey italic through `ChatScreen.formatChat`
/// and the first Backspace clears the whole draft instead of one character.
/// Editing the draft (`onEdited`) ends both.
#[test]
fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() {
use winit::keyboard::KeyCode::Backspace;
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
// The input's own text as the game draws it: x 4, y height - 12.
let input_text = |screen: &mut ChatScreen| -> Text {
screen
.draw(&m, (0, 0), 0, i32::MIN)
.into_iter()
.find_map(|draw| match draw {
Draw::Text {
text, x: 4, y: 348, ..
} => Some(text),
_ => None,
})
.expect("the input draws its text")
};
let draft_style = |t: &Text| {
t.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
};
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(screen.value(), "half-written");
assert!(
draft_style(&input_text(&mut screen)),
"the restored draft draws grey italic"
);
// Typing into the draft (`onEdited`) ends the draft style, and Backspace
// eats one character again.
screen.type_text(&m, &c, "!");
assert_eq!(screen.value(), "half-written!");
let typed = input_text(&mut screen);
assert!(
!typed.spans.iter().any(|s| s.style.is_italic()),
"an edited draft is not grey italic"
);
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-written");
// A draft left alone clears whole on its first Backspace.
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "", "the first Backspace clears the draft");
// The flag is gone with it: typing is ordinary text, and Backspace eats
// one character.
screen.type_text(&m, &c, "hi");
assert_eq!(screen.value(), "hi");
let typed = input_text(&mut screen);
assert!(!typed.spans.iter().any(|s| s.style.is_italic()));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "h");
// A box opened on ordinary text deletes one character at a time.
let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full());
assert!(!draft_style(&input_text(&mut screen)));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-writte");
// A command draft is grey italic as a whole: `formatChat` is registered
// before `CommandSuggestions.formatText`, so it wins over the argument
// colours.
let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full());
assert!(draft_style(&input_text(&mut screen)));
}
#[test]
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
let font = font();
@@ -283,7 +450,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -333,7 +500,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
@@ -381,7 +548,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -395,15 +562,25 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
tab(&mut screen);
assert_eq!(screen.value(), "/bossbar get pt");
screen.server_suggestions(&m, &c, request, (13, 2), reply("pt6:check"));
assert!(screen.take_suggestion_completion().is_none());
tab(&mut screen);
assert_eq!(screen.value(), "/bossbar get pt6:check");
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "/bossbar get pt".into(),
range: (13, 15),
text: "pt6:check".into(),
input: "/bossbar get pt6:check".into(),
})
);
// Without the server's provider the local list is done at once.
let local = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher: local,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -473,7 +650,7 @@ fn the_chat_option_decides_what_may_be_sent() {
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities);
screen.type_text(&m, &c, line);
match enter(&mut screen) {
ChatAction::Submit(sent) => {
@@ -499,7 +676,16 @@ fn restricted_input_says_why_it_will_not_send() {
registries: &registries,
};
let usage = |visibility, line: &str| {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
let mut screen = ChatScreen::new(
&m,
&c,
"",
false,
0,
640,
360,
ChatAbilities::new(visibility),
);
screen.type_text(&m, &c, line);
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
};
@@ -11,7 +11,7 @@ use horizon_world::{
use std::{
collections::HashMap,
sync::{
Arc,
Arc, Mutex, OnceLock, PoisonError,
atomic::{AtomicU64, Ordering},
},
};
@@ -108,6 +108,9 @@ pub enum BlockEntity {
pub struct ModBlockEntity {
fields: ModBlockEntityFields,
revision: ModRevision,
// A client update can consume transient effects without changing saved
// fields. Most entities never use this; do not allocate their queue.
client_update_fields: OnceLock<Mutex<Compound>>,
}
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
@@ -138,11 +141,55 @@ impl ModBlockEntity {
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
changes: 0,
},
client_update_fields: OnceLock::new(),
}
}
pub fn revision(&self) -> ModRevision {
self.revision
}
/// Replace the owner's pending, one-shot client update fields. These
/// fields are not saved, cloned, compared or retained for drawing, and
/// changing them does not change this entity's revision. The owner must
/// explicitly take and merge them into its update tag. Calling this does
/// not schedule a block update.
///
/// An empty batch clears a pending batch without allocating an idle queue.
pub fn set_client_update_fields(&mut self, fields: Compound) {
if fields.is_empty() {
self.clear_client_update_fields();
return;
}
*self
.client_update_fields
.get_or_init(|| Mutex::new(Compound::new()))
.lock()
.unwrap_or_else(PoisonError::into_inner) = fields;
}
/// Discard the owner's pending client update fields, without changing
/// saved data, revision or a queue that has never been used.
pub fn clear_client_update_fields(&mut self) {
if let Some(fields) = self.client_update_fields.get() {
fields
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
}
/// Atomically take the owner's pending fields at update-tag construction.
/// A loaded chunk and a block change use this same boundary. Construct a
/// tag once, then send that tag to every recipient; another construction
/// sees an empty batch. A save or a clone does not consume this batch.
///
/// This is safe for an update-tag hook receiving only a shared reference,
/// including a saved chunk encoded by a worker thread. If a previous lock
/// holder panicked, preserve its pending data instead of dropping it.
pub fn take_client_update_fields(&self) -> Compound {
self.client_update_fields
.get()
.map_or_else(Compound::new, |fields| {
std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner))
})
}
/// A client's copy loads the tag the server sent (`loadWithComponents`
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
/// replace its own of the same names. One it already has as sent is
@@ -706,6 +753,85 @@ pub struct CommandBlock {
mod tests {
use super::*;
#[test]
fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
id: "test:machine".into(),
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
..Default::default()
});
let revision = machine.revision();
let saved = machine.fields.clone();
let debug = format!("{machine:?}");
machine.clear_client_update_fields();
machine.set_client_update_fields(Compound::new());
assert!(machine.take_client_update_fields().is_empty());
assert!(machine.client_update_fields.get().is_none());
machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))]));
let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]);
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
let copy = machine.clone();
assert_eq!(machine, copy, "transient fields are not retained contents");
assert_ne!(machine.revision(), copy.revision());
assert!(copy.client_update_fields.get().is_none());
assert!(copy.take_client_update_fields().is_empty());
assert_eq!(machine.take_client_update_fields(), expected);
machine.set_client_update_fields(expected);
machine.clear_client_update_fields();
assert!(machine.take_client_update_fields().is_empty());
assert_eq!(machine.fields, saved);
assert_eq!(machine.revision(), revision);
assert_eq!(format!("{machine:?}"), debug);
}
#[test]
fn concurrent_client_update_constructions_consume_one_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
let received = std::thread::scope(|scope| {
let handles = (0..16)
.map(|_| scope.spawn(|| machine.take_client_update_fields()))
.collect::<Vec<_>>();
handles
.into_iter()
.map(|h| h.join().unwrap())
.collect::<Vec<_>>()
});
let nonempty = received
.into_iter()
.filter(|c| !c.is_empty())
.collect::<Vec<_>>();
assert_eq!(nonempty, [expected]);
}
#[test]
fn a_poisoned_client_update_lock_preserves_its_pending_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
std::thread::scope(|scope| {
assert!(
scope
.spawn(|| {
let _held = machine.client_update_fields.get().unwrap().lock().unwrap();
panic!("interrupt before consuming the queued batch");
})
.join()
.is_err()
);
});
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
}
#[test]
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
@@ -0,0 +1,143 @@
//! The files of the resources the game loaded, resource packs' over vanilla's, for a client mod reading textures and
//! other resources on any thread (`native_client_resources`; docs/native-mods.md).
use horizon_core::prepared_files;
use std::path::PathBuf;
/// A texture's pixels: `width` by `height` RGBA, rows top first, as its PNG stores them (gamma-encoded, alpha not
/// premultiplied). An animated texture is its frames one under another, as in its file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TexturePixels {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
// A mod reads textures on a worker thread.
const _: () = {
const fn shareable<T: Send + Sync + 'static>() {}
shareable::<ClientResources>()
};
/// The resources the game loaded, as one tree: vanilla's, with the resource packs turned on (a higher pack over a
/// lower one) and the content mods' files over it. A pack turned on or off makes a new tree: ask for it again then.
/// It reads the files on disk at each call, not a copy: while a reload rebuilds them, a read can miss a file or get
/// the new pack's before `native_block_sprites_version()` changes.
#[derive(Clone, Debug)]
pub struct ClientResources {
root: PathBuf,
}
impl ClientResources {
/// The tree at `root` (the game's `options.resources`).
pub fn new(root: PathBuf) -> Self {
Self { root }
}
/// The bytes of the resource at `path` in the tree (`assets/minecraft/textures/entity/cow/cow_temperate.png`).
/// None when there is no such file, or for a path that isn't plain names under `assets/`.
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
let plain = |p: &str| !p.is_empty() && p != "." && p != ".." && !p.contains('\\');
if !path.starts_with("assets/") || !path.split('/').all(plain) {
return None;
}
prepared_files::read(&self.root.join(path)).ok()
}
/// A texture's pixels by name (`block/stone`, `entity/cow/cow_temperate`, a mod's `mymod:item/gear`): the PNG
/// under `assets/<namespace>/textures/`, decoded. None when there is no such PNG or it doesn't decode.
pub fn texture(&self, name: &str) -> Option<TexturePixels> {
let path = crate::atlas_sources::texture_path(&self.root, name, "textures");
let (width, height, rgba) =
crate::hud::decode_bytes(&prepared_files::read(&path).ok()?).ok()?;
Some(TexturePixels {
width,
height,
rgba,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packs::{build_overlay, discover};
use std::{fs, path::Path};
const COW: &str = "assets/minecraft/textures/entity/cow/cow_temperate.png";
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
fn one_pixel_png(rgba: [u8; 4]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&rgba)
.unwrap();
out
}
fn write(path: &Path, bytes: &[u8]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
#[test]
fn a_resource_pack_overrides_a_texture() {
let base =
std::env::temp_dir().join(format!("horizon-client-resources-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
write(&vanilla.join(COW), &one_pixel_png(RED));
let pack = base.join("packs/Blue cows");
write(
&pack.join("pack.mcmeta"),
br#"{"pack":{"description":"blue cows","pack_format":97}}"#,
);
write(&pack.join(COW), &one_pixel_png(BLUE));
let overlay = base.join("overlay");
build_overlay(&vanilla, &discover(&vanilla, &base.join("packs")), &overlay).unwrap();
let (plain, packed) = (ClientResources::new(vanilla), ClientResources::new(overlay));
assert_eq!(plain.read(COW), Some(one_pixel_png(RED)));
assert_eq!(packed.read(COW), Some(one_pixel_png(BLUE)));
assert_eq!(
packed.texture("entity/cow/cow_temperate"),
Some(TexturePixels {
width: 1,
height: 1,
rgba: BLUE.to_vec(),
})
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn only_plain_paths_under_assets_are_read() {
let base = std::env::temp_dir().join(format!(
"horizon-client-resources-paths-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&base);
let root = base.join("root");
write(&root.join(COW), &one_pixel_png(RED));
write(&root.join("horizon-resources.json"), b"{}");
write(&base.join("secret.txt"), b"secret");
let resources = ClientResources::new(root);
assert!(resources.read(COW).is_some());
for refused in [
"horizon-resources.json",
"assets/../horizon-resources.json",
"assets/../../secret.txt",
"assets//minecraft/textures/entity/cow/cow_temperate.png",
"/assets/minecraft/textures/entity/cow/cow_temperate.png",
] {
assert_eq!(resources.read(refused), None, "{refused}");
}
let _ = fs::remove_dir_all(base);
}
}
+14 -1
View File
@@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> {
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[])
}
/// [`Self::resolve_in_border`], also stopped by `entities`: the boxes
/// `EntityGetter.getEntityCollisions` gave the moving entity.
pub fn resolve_in_border_with(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
entities: &[Aabb],
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
entities,
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
+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",
+22 -1
View File
@@ -10,6 +10,7 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Local};
use horizon_telemetry::system::machine_arch;
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
@@ -242,9 +243,18 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
/// The build's architecture, and the machine's after "on" when it emulates the build.
fn arch_label(build: &str, machine: &str) -> String {
if build == machine {
build.to_owned()
} else {
format!("{build} on {machine}")
}
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
let arch = arch_label(std::env::consts::ARCH, machine_arch());
let version = if cfg!(target_os = "linux") {
let name = read("/etc/os-release").and_then(|t| {
t.lines()
@@ -683,6 +693,17 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn an_emulated_build_names_both_architectures() {
// A Windows ARM64 PC playing the x64 build: its reports say so (#7513 read "x86_64").
assert_eq!(arch_label("x86_64", "aarch64"), "x86_64 on aarch64");
assert_eq!(arch_label("aarch64", "aarch64"), "aarch64");
assert_eq!(arch_label("x86_64", "x86_64"), "x86_64");
// On the machine running the tests: native builds name one architecture.
let label = arch_label(std::env::consts::ARCH, machine_arch());
assert!(label.starts_with(std::env::consts::ARCH));
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
@@ -0,0 +1,243 @@
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
use std::path::Path;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
/// A layer's texture, RGBA8.
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Draw {
pub image: u16,
pub blend: Blend,
pub brightness: f32,
/// Camera-relative world from the skybox's space.
pub rotation: Mat4,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
world: Option<i32>,
eased: Vec<Transition>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
&self,
world: i32,
conditions: &Conditions,
sun_turns: f32,
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
+170 -31
View File
@@ -14,6 +14,8 @@ pub struct EffectInstance {
pub amplifier: i32,
pub ambient: bool,
pub visible: bool,
/// `MobEffectInstance.showIcon`: whether the HUD and inventory list it.
pub show_icon: bool,
pub hidden: Option<Box<EffectInstance>>,
}
impl EffectInstance {
@@ -24,9 +26,18 @@ impl EffectInstance {
amplifier: amplifier.clamp(0, 255),
ambient: false,
visible: true,
show_icon: true,
hidden: None,
}
}
/// `new MobEffectInstance(effect, duration, amplifier, ambient, visible)`:
/// the icon shows exactly when the particles do.
pub fn with_flags(mut self, ambient: bool, visible: bool) -> Self {
self.ambient = ambient;
self.visible = visible;
self.show_icon = visible;
self
}
fn infinite(&self) -> bool {
self.duration == -1
}
@@ -71,6 +82,10 @@ impl EffectInstance {
self.visible = take.visible;
changed = true;
}
if take.show_icon != self.show_icon {
self.show_icon = take.show_icon;
changed = true;
}
changed
}
/// `MobEffectInstance.tickClient`: a client counts its effects down
@@ -98,6 +113,7 @@ impl EffectInstance {
self.amplifier = h.amplifier;
self.ambient = h.ambient;
self.visible = h.visible;
self.show_icon = h.show_icon;
self.hidden = h.hidden;
}
}
@@ -116,8 +132,9 @@ pub enum EffectAction {
}
/// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects.
fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
// Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`).
let every = |base: i32| {
let interval = base >> amp;
let interval = base.wrapping_shr(amp as u32);
interval <= 0 || tick % interval == 0
};
match id {
@@ -126,10 +143,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
"wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)),
"hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)),
"saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal(
4i32.wrapping_shl(amp as u32).max(0) as f32,
)),
"instant_damage" if tick >= 1 => Some(EffectAction::Hurt(
"minecraft:magic",
(6 << amp.min(30)) as f32,
6i32.wrapping_shl(amp as u32) as f32,
)),
_ => None,
}
@@ -139,11 +158,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -168,6 +187,57 @@ pub enum OmenTick {
/// It added this effect, then returned false.
Add(EffectInstance),
}
/// A saved player's effect: `MobEffectInstance.Details` under its `id`.
#[derive(Clone, Debug, PartialEq)]
pub struct SavedEffect {
pub id: String,
pub duration: i32,
pub amplifier: i32,
pub ambient: bool,
/// `show_particles`.
pub particles: bool,
/// `show_icon`.
pub icon: bool,
/// `hidden_effect`: the weaker, longer instance of the same effect that
/// comes back when this one ends.
pub hidden: Option<Box<SavedEffect>>,
}
impl SavedEffect {
/// Ambient off, particles and icon shown, nothing hidden: the codec's
/// defaults.
pub fn new(id: impl Into<String>, duration: i32, amplifier: i32) -> Self {
Self {
id: id.into(),
duration,
amplifier,
ambient: false,
particles: true,
icon: true,
hidden: None,
}
}
fn of(e: &EffectInstance) -> Self {
Self {
id: potions::namespaced(e.id).to_owned(),
duration: e.duration,
amplifier: e.amplifier,
ambient: e.ambient,
particles: e.visible,
icon: e.show_icon,
hidden: e.hidden.as_deref().map(|h| Box::new(Self::of(h))),
}
}
/// `new MobEffectInstance(effect, details)`: the hidden chain comes back
/// with it.
fn instance(&self, id: &'static str) -> EffectInstance {
let mut e = EffectInstance::new(id, self.duration, self.amplifier);
e.ambient = self.ambient;
e.visible = self.particles;
e.show_icon = self.icon;
e.hidden = self.hidden.as_deref().map(|h| Box::new(h.instance(id)));
e
}
}
#[derive(Clone, Debug, Default)]
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
@@ -200,39 +270,25 @@ impl PlayerEffects {
pub fn clear(&mut self) {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, particles visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
/// The effects of a saved player (`Player.active_effects`), each with
/// the instances it hides.
pub fn from_saved(saved: &[SavedEffect]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, particles) in saved {
if let Some(def) = potions::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
effects.add(e);
for s in saved {
if let Some(def) = potions::effect(&s.id) {
effects.add(s.instance(def.id));
}
}
effects
}
/// `Player.active_effects` for the save.
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
self.list
.iter()
.map(|e| {
(
potions::namespaced(e.id).to_owned(),
e.duration,
e.amplifier,
e.ambient,
e.visible,
)
})
.collect()
pub fn saved(&self) -> Vec<SavedEffect> {
self.list.iter().map(SavedEffect::of).collect()
}
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
/// particles visible and the icon shown (always, for these effects).
/// particles visible and the icon shown.
pub fn flags(e: &EffectInstance) -> i8 {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
i8::from(e.ambient) | i8::from(e.visible) << 1 | i8::from(e.show_icon) << 2
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
@@ -494,7 +550,75 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
#[cfg(test)]
mod tests {
use super::*;
/// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo
/// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is
/// at least 0 (#11386).
#[test]
fn heal_and_harm_shift_like_java() {
let magic = "minecraft:magic";
assert_eq!(
instant("instant_health", 32, 1.0),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
instant("instant_damage", 33, 1.0),
Some(EffectAction::Hurt(magic, 12.0))
);
assert_eq!(
instant("instant_health", 1, 0.5),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
periodic("instant_health", 1, 32),
Some(EffectAction::Heal(4.0))
);
// 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN.
assert_eq!(
periodic("instant_health", 1, 29),
Some(EffectAction::Heal(0.0))
);
assert_eq!(
periodic("instant_damage", 1, 30),
Some(EffectAction::Hurt(magic, i32::MIN as f32))
);
// Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick).
assert_eq!(periodic("regeneration", 49, 32), None);
assert_eq!(
periodic("regeneration", 50, 32),
Some(EffectAction::Heal(1.0))
);
}
/// Warden darkness (`new MobEffectInstance(DARKNESS, 260, 0, false,
/// false)`) shows neither particles nor its icon; a renewal that shows the
/// icon turns it back on (`MobEffectInstance.update`).
#[test]
fn an_effect_without_particles_hides_its_icon() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("darkness", 260, 0).with_flags(false, false));
let darkness = fx.get("darkness").unwrap();
// ClientboundUpdateMobEffectPacket: no ambient, particles or icon bit.
assert_eq!(PlayerEffects::flags(darkness), 0);
assert!(!crate::hud_overlays::EffectIcon::from(darkness).show_icon);
assert!(fx.add(EffectInstance::new("darkness", 100, 0)));
assert_eq!(PlayerEffects::flags(fx.get("darkness").unwrap()), 2 | 4);
// Details keeps show_icon apart from show_particles.
let mut speed = EffectInstance::new("speed", 100, 0);
speed.show_icon = false;
let mut fx = PlayerEffects::default();
fx.add(speed);
let saved = fx.saved();
assert_eq!(
saved,
[SavedEffect {
icon: false,
..SavedEffect::new("minecraft:speed", 100, 0)
}]
);
let loaded = PlayerEffects::from_saved(&saved);
assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2);
}
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
let mut fx = PlayerEffects::default();
@@ -531,6 +655,21 @@ mod tests {
assert_eq!(fx.get("speed").unwrap().duration, 49);
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
}
#[test]
fn a_reloaded_effect_falls_back_to_the_one_it_hid() {
// /effect give poison 6 s level II over poison 30 s level I, saved
// and loaded: when level II runs out, level I resumes with what is
// left of its 30 s.
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("poison", 600, 0));
fx.add(EffectInstance::new("poison", 120, 1));
let mut loaded = PlayerEffects::from_saved(&fx.saved());
for _ in 0..120 {
loaded.tick();
}
let poison = loaded.get("poison").expect("level I resumed");
assert_eq!((poison.amplifier, poison.duration), (0, 480));
}
/// The only test here that registers mod effects (once per process).
#[test]
fn a_mod_effect_saves_under_its_namespace() {
@@ -546,11 +685,11 @@ mod tests {
1,
));
let saved = fx.saved();
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
assert_eq!(saved, [SavedEffect::new("ruby:glow", 600, 1)]);
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
// Without its mod the saved effect is dropped, as an unknown id is.
assert!(
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
PlayerEffects::from_saved(&[SavedEffect::new("gone:glow", 600, 1)])
.list
.is_empty()
);
@@ -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);
}
}
@@ -0,0 +1,210 @@
// Still-fluid tiling follows Catnip/Ponder 1.0.82 FluidRenderHelper.renderTiledFace.
// Copyright (c) 2022 The Create Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND 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.
use horizon_core::geometry::Direction;
/// Controls a still-fluid box. The tint is stack ARGB, independent of biome tint.
#[derive(Clone, Copy, Debug)]
pub struct NativeFluidRenderOptions {
pub argb: Option<u32>,
pub render_bottom: bool,
/// TextureAtlasSprite.uvShrinkRatio: 4 / the largest stitched atlas dimension.
/// Explicit because Horizon's block sprites occupy arrays rather than a stitched atlas.
pub uv_shrink_ratio: f32,
}
/// A corner before the caller's pose, with UVs within the still sprite, and its outward normal.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub normal: [i8; 3],
}
/// Preserve sky light and increase block light to the fluid's luminosity (native light packing).
pub fn fluid_light(light: u32, luminosity: u8) -> u32 {
(light & 0xf0) | (((light >> 8 & 0xf0).max(u32::from(luminosity.min(15)) * 16)) << 8)
}
/// Emit whole tiled quads, or refuse invalid/unreasonably large geometry before emitting anything.
/// Negative coordinates and exact integer boundaries retain the source's local UV fractions.
pub fn fluid_box(
bounds: [f32; 6],
options: NativeFluidRenderOptions,
mut emit: impl FnMut(FluidVertex),
) -> bool {
if !options.uv_shrink_ratio.is_finite()
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
|| bounds
.iter()
.any(|x| !x.is_finite() || x.abs() > 1_048_576.0)
|| (0..3).any(|i| bounds[i] > bounds[i + 3])
{
return false;
}
let tiles =
std::array::from_fn::<_, 3, _>(|i| (bounds[i + 3].ceil() - bounds[i].floor()) as u64);
let quads = tiles[0] * tiles[1] * 2
+ tiles[1] * tiles[2] * 2
+ tiles[0] * tiles[2] * if options.render_bottom { 2 } else { 1 };
if quads > 65_536 {
return false;
}
let [x0, y0, z0, x1, y1, z1] = bounds;
for (side, face) in [
(Direction::Down, [x0, z0, x1, z1, y0]),
(Direction::Up, [x0, z0, x1, z1, y1]),
(Direction::North, [x0, y0, x1, y1, z0]),
(Direction::South, [x0, y0, x1, y1, z1]),
(Direction::West, [z0, y0, z1, y1, x0]),
(Direction::East, [z0, y0, z1, y1, x1]),
] {
if side != Direction::Down || options.render_bottom {
tiled_face(&mut emit, side, face, options.uv_shrink_ratio);
}
}
true
}
fn tiled_face(out: &mut impl FnMut(FluidVertex), side: Direction, face: [f32; 5], ratio: f32) {
let [a0, b0, a1, b1, plane] = face;
let positive = matches!(side, Direction::Up | Direction::South | Direction::East);
let shrink = ratio * 0.25;
let lerp = |v| v + shrink * (0.5 - v);
let mut a = a0;
while a < a1 {
let end_a = (a.floor() + 1.0).min(a1);
let (u0, u1) = if matches!(side, Direction::North | Direction::East) {
(end_a.ceil() - end_a, end_a.ceil() - a)
} else {
(a - a.floor(), end_a - a.floor())
};
let (u0, u1) = (lerp(u0), lerp(u1));
let mut b = b0;
while b < b1 {
let end_b = (b.floor() + 1.0).min(b1);
let (v0, v1) = if side == Direction::Up {
(b - b.floor(), end_b - b.floor())
} else {
(end_b.ceil() - end_b, end_b.ceil() - b)
};
let (v0, v1) = (lerp(v0), lerp(v1));
let positions = match side {
Direction::West | Direction::East => {
let (near, far) = if positive { (end_a, a) } else { (a, end_a) };
[
[plane, end_b, near],
[plane, b, near],
[plane, b, far],
[plane, end_b, far],
]
}
Direction::North | Direction::South => {
let (near, far) = if positive { (a, end_a) } else { (end_a, a) };
[
[near, end_b, plane],
[near, b, plane],
[far, b, plane],
[far, end_b, plane],
]
}
Direction::Down | Direction::Up => {
let (near, far) = if positive { (b, end_b) } else { (end_b, b) };
[
[a, plane, near],
[a, plane, far],
[end_a, plane, far],
[end_a, plane, near],
]
}
};
let normal = match side {
Direction::Down => [0, -1, 0],
Direction::Up => [0, 1, 0],
Direction::North => [0, 0, -1],
Direction::South => [0, 0, 1],
Direction::West => [-1, 0, 0],
Direction::East => [1, 0, 0],
};
for (pos, uv) in positions
.into_iter()
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
{
out(FluidVertex { pos, uv, normal });
}
b = end_b;
}
a = end_a;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_tiling_is_outward_and_does_not_inset_geometry() {
let bounds = [-1.25, 0.125, -0.25, 1.5, 1.125, 0.75];
let mut result = Vec::new();
assert!(fluid_box(
bounds,
NativeFluidRenderOptions {
argb: None,
render_bottom: false,
uv_shrink_ratio: 4.0 / 2048.0,
},
|v| result.push(v),
));
assert_eq!(result.len(), (4 * 2 * 2 + 2 * 2 * 2 + 4 * 2) * 4);
for quad in result.as_chunks::<4>().0 {
let [a, b, c, _] = quad.map(|v| glam::Vec3::from_array(v.pos));
assert!(
(b - a)
.cross(c - a)
.dot(glam::Vec3::from_array(quad[0].normal.map(f32::from)))
> 0.0
);
assert!(
quad.iter()
.all(|v| (0..3).all(|i| v.pos[i] >= bounds[i] && v.pos[i] <= bounds[i + 3]))
);
}
assert!(
result
.iter()
.any(|v| v.pos == [bounds[0], bounds[4], bounds[2]])
);
assert!(!fluid_box(
[0.0, 0.0, 0.0, 1_000.0, 1_000.0, 1_000.0],
NativeFluidRenderOptions {
argb: None,
render_bottom: true,
uv_shrink_ratio: 0.0,
},
|_| panic!("invalid geometry must fail before emitting"),
));
}
#[test]
fn luminosity_changes_only_block_light() {
assert_eq!(fluid_light(0x20a0, 15), 0xf0a0);
assert_eq!(fluid_light(0x25a5, 15), 0xf0a0);
assert_eq!(fluid_light(0xe030, 3), 0xe030);
}
}
+36 -2
View File
@@ -413,6 +413,12 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
}
/// What `AtmosphericFogEnvironment.updateRainFogState` eases the rain fog toward: the rain, where
/// the sky reaches the eye, halved in a biome without precipitation.
pub fn rain_fog_target(rain: f32, sky_light: u8, biome_rains: bool) -> f32 {
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
rain * sky * if biome_rains { 1.0 } else { 0.5 }
}
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
@@ -428,10 +434,14 @@ pub fn update_rain_fog(
biome_rains: bool,
delta_ticks: f32,
) {
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
let target = rain * sky * if biome_rains { 1.0 } else { 0.5 };
let target = rain_fog_target(rain, sky_light, biome_rains);
state.rain_fog_multiplier += (target - state.rain_fog_multiplier) * delta_ticks * 0.2;
}
/// The rain fog at its target at once: a frozen world under `HORIZON_STILL`, where the rain sits
/// at its level and easing would wait for ticks that do not come.
pub fn settle_rain_fog(state: &mut FogState, rain: f32, sky_light: u8, biome_rains: bool) {
state.rain_fog_multiplier = rain_fog_target(rain, sky_light, biome_rains);
}
fn rgb(c: u32) -> [f32; 3] {
[
(c >> 16 & 255) as f32 / 255.0,
@@ -726,6 +736,30 @@ mod tests {
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
}
/// Settling puts the rain fog at its target exactly, where a tick of easing moves it a fifth
/// of the way, as before.
#[test]
fn settled_rain_fog_is_its_target_and_easing_is_unchanged() {
assert_eq!(
rain_fog_target(1.0, 15, false),
0.5,
"a dry biome halves it"
);
assert_eq!(rain_fog_target(1.0, 8, true), 0.0, "no sky, no rain fog");
let start = FogState {
rain_fog_multiplier: 0.1,
..FogState::default()
};
let mut settled = start;
settle_rain_fog(&mut settled, 0.5, 15, true);
assert_eq!(settled.rain_fog_multiplier, 0.5);
let mut eased = start;
update_rain_fog(&mut eased, 0.5, 15, true, 1.0);
assert_eq!(
eased.rain_fog_multiplier.to_bits(),
(0.1f32 + (0.5f32 - 0.1) * 1.0 * 0.2).to_bits()
);
}
#[test]
fn rain_fog_pulls_fog_in() {
let sky = Sky {
File diff suppressed because it is too large. Load diff
@@ -1040,7 +1040,14 @@ mod tests {
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let pipeline = &pipeline;
let solid = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str| {
super::super::SolidPipeline {
plain: pipeline(layout, label, "", "", None, None, true),
hdr: None,
}
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
@@ -199,6 +199,8 @@ pub struct FrameInputs {
pub skybox: bool,
pub end_sky: bool,
pub end_sky_sprite: Option<[f32; 4]>,
#[serde(default)]
pub boss_world_fog: bool,
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
pub fog: Option<FogData>,
@@ -414,6 +416,7 @@ impl Renderer {
skybox: frame.skybox,
end_sky: frame.end_sky,
end_sky_sprite: frame.end_sky_sprite,
boss_world_fog: frame.boss_world_fog,
environment_fog: frame.environment_fog,
water_fog: frame.water_fog,
fog: frame.fog,
@@ -510,6 +513,7 @@ impl Scene {
skybox: f.skybox,
end_sky: f.end_sky,
end_sky_sprite: f.end_sky_sprite,
boss_world_fog: f.boss_world_fog,
environment_fog: f.environment_fog,
water_fog: f.water_fog,
fog: f.fog,
@@ -530,6 +534,7 @@ impl Scene {
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
@@ -0,0 +1,416 @@
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{CustomSky as Pack, Draw};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
Blend::Add,
Blend::Subtract,
Blend::Multiply,
Blend::Dodge,
Blend::Burn,
Blend::Screen,
Blend::Replace,
Blend::Overlay,
Blend::Alpha,
];
/// Layers drawn in a frame at most; a pack's later ones wait for a frame with fewer.
const MAX_DRAWS: usize = 32;
/// Dynamic uniform offsets are 256-byte aligned on every backend.
const STRIDE: u64 = 256;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
clip_from_sky: [[f32; 4]; 4],
brightness: f32,
mode: u32,
pad: [u32; 2],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 3],
uv: [f32; 2],
cell: [f32; 4],
}
pub(super) struct CustomSky {
pipelines: Vec<wgpu::RenderPipeline>,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
/// One bind group per layer texture of the pack.
groups: Vec<wgpu::BindGroup>,
/// This frame's layers: bind group and pipeline indices.
frame: Vec<(u16, u8)>,
}
/// The fragment's output for each mode is in custom_sky.wgsl; these factors finish it.
fn blend(mode: Blend) -> wgpu::BlendComponent {
use wgpu::BlendFactor as F;
let (src_factor, dst_factor, operation) = match mode {
Blend::Add => (F::One, F::One, wgpu::BlendOperation::Add),
Blend::Subtract => (F::One, F::One, wgpu::BlendOperation::ReverseSubtract),
Blend::Multiply => (F::Zero, F::Src, wgpu::BlendOperation::Add),
// dst + dst * src: lightens.
Blend::Dodge => (F::Dst, F::One, wgpu::BlendOperation::Add),
// dst * (1 - src): darkens.
Blend::Burn => (F::Zero, F::OneMinusSrc, wgpu::BlendOperation::Add),
// src + dst * (1 - src) = 1 - (1 - src)(1 - dst).
Blend::Screen => (F::One, F::OneMinusSrc, wgpu::BlendOperation::Add),
Blend::Replace => (F::One, F::Zero, wgpu::BlendOperation::Add),
// dst * src + src * dst.
Blend::Overlay => (F::Dst, F::Src, wgpu::BlendOperation::Add),
Blend::Alpha => (F::SrcAlpha, F::OneMinusSrcAlpha, wgpu::BlendOperation::Add),
};
wgpu::BlendComponent {
src_factor,
dst_factor,
operation,
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
for face in Face::ALL {
let cell = face.cell();
let [u0, v0, u1, v1] = cell;
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
let corners = face.corners();
for i in [0, 1, 2, 0, 2, 3] {
out.push(Vertex {
position: corners[i],
uv: uv[i],
cell,
});
}
}
out
}
impl CustomSky {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Custom sky bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: wgpu::BufferSize::new(
std::mem::size_of::<Uniform>() as u64
),
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Custom sky shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Custom sky layout"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
let pipelines = MODES
.iter()
.map(|&mode| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Custom sky layer"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_custom_sky"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_custom_sky"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
// under the layers keeps its own.
blend: Some(wgpu::BlendState {
color: blend(mode),
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Custom sky"),
min_filter: wgpu::FilterMode::Linear,
mag_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Custom sky layers"),
size: STRIDE * MAX_DRAWS as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Custom sky box"),
contents: bytemuck::cast_slice(&mesh()),
usage: wgpu::BufferUsages::VERTEX,
});
Self {
pipelines,
layout,
sampler,
uniform,
vertices,
groups: Vec::new(),
frame: Vec::new(),
}
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
let (width, height, rgba) = if image.width <= limit && image.height <= limit {
(image.width, image.height, &image.rgba[..])
} else {
log::warn!(
"custom sky texture of {}x{} too large",
image.width,
image.height
);
(1, 1, &[0u8; 4][..])
};
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Custom sky layer"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
rgba,
);
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Custom sky layer"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.uniform,
offset: 0,
size: wgpu::BufferSize::new(std::mem::size_of::<Uniform>() as u64),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
})
.collect();
}
/// Upload this frame's layers; `clip` is clip space from camera-relative world.
pub(super) fn prepare(
&mut self,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
clip: Mat4,
draws: &[Draw],
) {
self.frame.clear();
for draw in draws.iter().take(MAX_DRAWS) {
if usize::from(draw.image) >= self.groups.len() {
continue;
}
let mode = MODES.iter().position(|m| *m == draw.blend).unwrap_or(0);
let uniform = Uniform {
clip_from_sky: (clip * draw.rotation).to_cols_array_2d(),
brightness: draw.brightness.clamp(0.0, 1.0),
mode: mode as u32,
pad: [0; 2],
};
let offset = STRIDE * self.frame.len() as u64;
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
self.frame.push((draw.image, mode as u8));
}
}
pub(super) fn is_drawing(&self) -> bool {
!self.frame.is_empty()
}
/// The layers [`CustomSky::prepare`] uploaded. Leaves the pass's pipeline and bind group 0
/// changed.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_vertex_buffer(0, self.vertices.slice(..));
for (i, &(image, mode)) in self.frame.iter().enumerate() {
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
pass.set_bind_group(
0,
&self.groups[usize::from(image)],
&[(STRIDE * i as u64) as u32],
);
pass.draw(0..36, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
let k = a * b;
let src = match mode {
Blend::Multiply => 1.0 + (t - 1.0) * k,
Blend::Overlay => 0.5 + (t - 0.5) * k,
Blend::Replace | Blend::Alpha => t,
_ => t * k,
};
let c = blend(mode);
let f = |factor: wgpu::BlendFactor| match factor {
wgpu::BlendFactor::One => 1.0,
wgpu::BlendFactor::Zero => 0.0,
wgpu::BlendFactor::Src => src,
wgpu::BlendFactor::OneMinusSrc => 1.0 - src,
wgpu::BlendFactor::Dst => dst,
wgpu::BlendFactor::SrcAlpha => k,
wgpu::BlendFactor::OneMinusSrcAlpha => 1.0 - k,
other => panic!("{other:?}"),
};
let (s, d) = (src * f(c.src_factor), dst * f(c.dst_factor));
match c.operation {
wgpu::BlendOperation::Add => s + d,
wgpu::BlendOperation::ReverseSubtract => d - s,
other => panic!("{other:?}"),
}
}
#[test]
fn every_mode_gives_its_documented_result() {
let close = |a: f32, b: f32| assert!((a - b).abs() < 1e-6, "{a} != {b}");
// Full brightness and alpha, a sky texel of 0.4 under a layer texel of 0.8.
let (d, t) = (0.4, 0.8);
close(mix(Blend::Add, d, t, 1.0, 1.0), 1.2);
close(mix(Blend::Subtract, d, t, 1.0, 1.0), -0.4);
close(mix(Blend::Multiply, d, t, 1.0, 1.0), 0.32);
close(mix(Blend::Screen, d, t, 1.0, 1.0), 1.0 - 0.6 * 0.2);
close(mix(Blend::Replace, d, t, 1.0, 1.0), 0.8);
close(mix(Blend::Overlay, d, t, 1.0, 1.0), 0.64);
close(mix(Blend::Overlay, d, 0.2, 1.0, 1.0), 0.16);
close(mix(Blend::Alpha, d, t, 0.25, 1.0), 0.4 * 0.75 + 0.8 * 0.25);
assert!(mix(Blend::Dodge, d, t, 1.0, 1.0) > d);
assert!(mix(Blend::Burn, d, t, 1.0, 1.0) < d);
// Faded out, every mode but replace (drawn only when bright) leaves the sky alone.
for mode in MODES {
if mode != Blend::Replace {
close(mix(mode, d, t, 1.0, 0.0), d);
}
}
// Half faded: add adds half.
close(mix(Blend::Add, d, t, 1.0, 0.5), 0.8);
}
#[test]
fn the_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_box_covers_every_face_with_its_cell() {
let mesh = mesh();
assert_eq!(mesh.len(), 36);
for v in &mesh {
let [u0, v0, u1, v1] = v.cell;
assert!((u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1]));
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
}
}
}
@@ -0,0 +1,61 @@
// OptiFine custom sky layers (gfx/custom_sky.rs): one skybox per draw, its six faces cut from a
// 3x2 texture, mixed with the sky under it by the pipeline's blend. The fragment's output is
// chosen so that blend gives the layer's documented mode at its brightness, and a layer at
// brightness 0 leaves the sky as it was.
struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
@group(0) @binding(2) var sky_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
// The face's cell (u0, v0, u1, v1): sampling stays half a texel inside it.
@location(1) @interpolate(flat) cell: vec4<f32>,
}
@vertex fn vs_custom_sky(
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
@location(2) cell: vec4<f32>,
) -> Out {
var out: Out;
out.clip = layer.clip_from_sky * vec4(position * 100.0, 1.0);
out.uv = uv;
out.cell = cell;
return out;
}
const ADD: u32 = 0u;
const SUBTRACT: u32 = 1u;
const MULTIPLY: u32 = 2u;
const DODGE: u32 = 3u;
const BURN: u32 = 4u;
const SCREEN: u32 = 5u;
const REPLACE: u32 = 6u;
const OVERLAY: u32 = 7u;
const ALPHA: u32 = 8u;
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
let t = textureSample(sky_texture, sky_sampler, uv);
let k = t.a * layer.brightness;
switch layer.mode {
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(t.rgb, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(t.rgb, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(t.rgb * k, 1.0); }
}
}
@@ -14,8 +14,24 @@ use super::Vertex;
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
const MATERIALS: usize = 12;
/// Sorted still-fluid geometry follows, then the two native world-canvas text materials,
/// NORMAL and POLYGON_OFFSET. Material storage order is independent of their draw stages.
const MATERIALS: usize = 16;
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
pub fn is_native_terrain(layer: u32) -> bool {
matches!(
layer & 0xffff,
FLUID_OVERLAY
| TERRAIN_MODEL_SOLID_OVERLAY
| TERRAIN_MODEL_CUTOUT_OVERLAY
| TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY
)
}
/// Sorted depth-writing still fluid, sampling terrain sprite arrays rather than the shared atlas.
pub const FLUID_OVERLAY: u32 = 41;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
@@ -31,7 +47,9 @@ pub const DISSOLVE_OVERLAY: u32 = 38;
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
for vertex in quads {
vertex.layer |= OIT_ENTITY;
if !is_native_terrain(vertex.layer) {
vertex.layer |= OIT_ENTITY;
}
}
}
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
@@ -60,12 +78,21 @@ pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
fn material(layer: u32, oit: bool) -> usize {
match layer & 0xffff {
TERRAIN_MODEL_SOLID_OVERLAY => return 13,
TERRAIN_MODEL_CUTOUT_OVERLAY => return 14,
TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY => return 15,
_ => {}
}
if layer & 0xffff == FLUID_OVERLAY {
return 10;
}
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer & 0xffff {
WORLD_TEXT_OVERLAY => 10,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
WORLD_TEXT_OVERLAY => 11,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 12,
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -148,10 +175,10 @@ impl EntityBatch {
}
}
}
// RenderType.text sorts alpha-blended quads from far to near. Keep
// both world-canvas layers after existing materials (including eyes
// moved out of additive); front text uses polygon offset.
for runs in &self.runs[10..] {
// Fluid and RenderType.text sort their own alpha-blended quads far to
// near. Fluid precedes both world-canvas layers, after existing materials
// (including eyes moved out of additive).
for runs in &self.runs[10..13] {
let start = self.combined.len();
for &(first, count) in runs {
self.combined
@@ -169,6 +196,13 @@ impl EntityBatch {
distance(b).total_cmp(&distance(a))
});
}
// Native solid/cutout models are depth writing and keep their submission order.
for runs in &self.runs[13..] {
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
if self.moved_additive == 0 {
self.combined.extend_from_slice(additive);
} else {
@@ -189,7 +223,57 @@ mod tests {
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
fn native_models_keep_opaque_order_and_do_not_move_existing_materials_or_tails() {
let quad = |layer, x| {
[Vertex {
layer,
pos: [x, 0.0, 0.0],
..geometry(1, false)[0]
}; 4]
};
let entities: Vec<_> = [
(FLUID_OVERLAY, 10.0),
(WORLD_TEXT_OVERLAY, 11.0),
(TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16, 3.0),
(TERRAIN_MODEL_SOLID_OVERLAY, 1.0),
(TERRAIN_MODEL_CUTOUT_OVERLAY, 4.0),
(TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, 5.0),
(OIT_ENTITY, 6.0),
(0, 7.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
let counts = batch.update(
&entities,
&quad(0, 8.0),
&quad(0, 9.0),
&quad(0, 12.0),
true,
);
assert_eq!(counts, [1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 2, 1, 1]);
let xs: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
xs,
[7.0, 6.0, 10.0, 11.0, 3.0, 1.0, 4.0, 5.0, 8.0, 9.0, 12.0]
);
assert_eq!(
batch.combined[16].layer,
TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16
);
}
#[test]
fn tagging_oit_does_not_corrupt_a_native_models_sprite_index() {
let mut vertices = [geometry(1, false)[0]; 2];
vertices[0].layer = TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16;
vertices[1].layer = 3;
mark_oit(&mut vertices);
assert_eq!(vertices[0].layer, TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16);
assert_eq!(vertices[1].layer, 3 | OIT_ENTITY);
}
#[test]
fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() {
let quad = |layer, x| {
[Vertex {
layer,
@@ -204,6 +288,8 @@ mod tests {
(WORLD_TEXT_OVERLAY, 4.0),
(WORLD_TEXT_OVERLAY, 5.0),
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
(FLUID_OVERLAY, 11.0),
(FLUID_OVERLAY | OIT_ENTITY, 12.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
@@ -215,12 +301,14 @@ mod tests {
let mut batch = EntityBatch::default();
assert_eq!(
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1, 0, 0, 0]
);
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
positions,
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
[
1.0, 2.0, 3.0, 7.0, 12.0, 11.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0
]
);
}
@@ -320,12 +408,12 @@ mod tests {
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
+10 -16
View File
@@ -17,6 +17,7 @@
//! copied after the test, which tells which ones it saw: each run of a page
//! reserves room for all of its draws, the copy packs the seen ones at its
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
@@ -86,12 +87,12 @@ pub struct Call {
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
pub solid: SolidPipeline,
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
/// then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
pub solid_opaque: SolidPipeline,
pub solid_cutout: SolidPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
@@ -357,7 +358,8 @@ fn plan(
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer, and without
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`);
/// `solid(layout, label, vertex entry, fragment entry)` a pulled solid one.
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
@@ -372,6 +374,7 @@ impl Flat {
Option<wgpu::Face>,
bool,
) -> wgpu::RenderPipeline,
solid: impl Fn(&wgpu::PipelineLayout, &str, &str, &str) -> SolidPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
let compute = wgpu::ShaderStages::COMPUTE;
@@ -432,32 +435,23 @@ impl Flat {
Self {
// The solid draws read either layout of the solid quads (`mesher::packed`) from
// the page; translucent ones keep their vertex buffer.
solid: pipeline(
solid: solid(
&layout,
"Terrain solid (flat)",
"vs_section_flat",
"fs_solid",
None,
back,
true,
),
solid_opaque: pipeline(
solid_opaque: solid(
&layout,
"Terrain solid opaque (flat)",
"vs_section_flat_opaque",
"fs_opaque",
None,
back,
true,
),
solid_cutout: pipeline(
solid_cutout: solid(
&layout,
"Terrain solid cutout (flat)",
"vs_section_flat_cutout",
"fs_solid",
None,
back,
true,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
@@ -0,0 +1,69 @@
//! Sorted still-fluid material: ordinary back culling, alpha blend and color/depth writes.
pub(super) const SAMPLER: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
label: Some("Still fluid (nearest texels, linear mipmaps)"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
lod_min_clamp: 0.0,
lod_max_clamp: 32.0,
compare: None,
anisotropy_clamp: 1,
border_color: None,
};
pub(super) fn pipeline(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> wgpu::RenderPipeline {
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Still fluid"),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_native_fluid"),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_native_fluid"),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
@@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.base, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(in.lightmap),
in.position, in.uv, translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
@@ -349,12 +351,13 @@ fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api),
if vertex {
DISPLACE_PACK
} else {
@@ -378,8 +381,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -403,6 +407,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
frame.api,
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -562,7 +567,7 @@ impl NativeMaterial {
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check};
fn stage(material: &str) -> CheckedStage {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -609,7 +614,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false).unwrap();
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -622,8 +627,8 @@ mod tests {
&stage.source,
&tags,
&materials,
false,
false
(false, false),
Api::V0_1
))
)
});
@@ -647,6 +652,33 @@ mod tests {
);
}
/// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes.
#[test]
fn the_terrain_validates_with_a_0_2_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
[material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n";
let vertex = "fn vertex(input: VertexInput) -> vec3<f32> {\n \
return input.position + input.tint.rgb;\n}\n";
let files = [
(MANIFEST, manifest),
("material.wgsl", material),
("vertex.wgsl", vertex),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_displaced_grid_validates_with_a_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -661,14 +693,14 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true).unwrap();
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, false, true);
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -681,4 +713,116 @@ mod tests {
);
}
}
/// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2,
/// its colours decoded on the way in and encoded on the way out as the entries do, draws
/// what 0.1's does (vanilla's) within an 8-bit step.
#[test]
fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() {
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;
};
// The entries' call of `material`, with a 16×16 grid of colours.
let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let c = floor(p.xy) / 15.0;
let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0);
let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0);
let lightmap = vec3(c.y, 0.8, 1.0 - c.y);
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap),
vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0),
vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
";
let vanilla = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n";
let format = wgpu::TextureFormat::Rgba8Unorm;
let draw = |api: Api| {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\
[material]\nfile = \"material.wgsl\"\n"
);
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let size = wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
};
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: Default::default(),
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
drop(pass);
let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]);
queue.submit([encoder.finish()]);
screenshot
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
screenshot.slice(..).get_mapped_range().unwrap().to_vec()
};
let (old, new) = (draw(Api::V0_1), draw(Api::V0_2));
let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max();
assert!(worst.is_some_and(|w| w <= 1), "{worst:?}");
}
}
@@ -101,7 +101,15 @@ impl NativePack {
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let api = report.manifest.api;
let mut frame = NativeFrame::new(
device,
settings.as_ref(),
textures,
lights,
terrain.pbr,
api,
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
.manifest
@@ -222,8 +230,13 @@ impl NativePack {
)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
})
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
File diff suppressed because it is too large. Load diff
@@ -6,7 +6,7 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
@@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) {
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
@@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> {
pub normals: &'a wgpu::TextureView,
}
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band.
fn shadow_color(api: Api) -> wgpu::TextureFormat {
match api {
Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm,
Api::V0_2 => super::native_post::INTERMEDIATE,
}
}
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
@@ -218,7 +223,7 @@ pub struct ShadowMap {
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -250,7 +255,7 @@ impl ShadowMap {
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
shadow_color(api),
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
@@ -474,6 +479,7 @@ fn shader_source(
vertex: Option<&str>,
materials: &[(u32, u32)],
grid: Option<&str>,
api: Api,
) -> String {
let table = super::native_material::layer_table("horizon_sprite_material", materials);
let (head, displace) = match (grid, vertex) {
@@ -504,11 +510,12 @@ fn shader_source(
),
};
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
),
super::native_post::line_helpers(api)
)
}
@@ -527,6 +534,7 @@ impl NativeShadow {
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
grid.as_ref().map(|g| g.material.source.as_str()),
frame.api,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
@@ -613,7 +621,7 @@ impl NativeShadow {
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
format: shadow_color(frame.api),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
@@ -731,8 +739,9 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
let lines = [Api::V0_1, Api::V0_2];
for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -767,6 +776,7 @@ mod tests {
None,
&materials,
Some(&stage.source),
Api::V0_1,
);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
@@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_";
const PACK_ENTRY: &str = "
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
let d = horizon_sky_view_ray(in.ndc);
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0);
return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0);
}
";
@@ -38,10 +39,11 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str) -> String {
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
vanilla_sky()
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api)
)
}
@@ -99,7 +101,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source);
let text = shader_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -195,7 +197,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source);
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -252,7 +254,14 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -217,6 +217,41 @@ impl CenterDepth {
})
}
/// Once the GPU has finished the frame (`screenshot-sequence`), the depth it copied, as
/// the next frame reads it: an error unless exactly that one sample was read back, never
/// an older one.
pub fn take_completed(&mut self, device: &wgpu::Device) -> Result<f32, String> {
device
.poll(wgpu::PollType::Poll)
.map_err(|e| e.to_string())?;
let state = |s: &Slot| s.state.load(Ordering::Acquire);
let mut mapped = self.slots.iter().filter(|s| state(s) == MAPPED);
let (Some(slot), None) = (mapped.next(), mapped.next()) else {
return Err("the frame's centre depth wasn't read back alone".into());
};
if self
.slots
.iter()
.any(|s| !matches!(state(s), FREE | MAPPED))
{
return Err("the frame's centre depth wasn't read back alone".into());
}
let depth = slot
.buffer
.slice(..4)
.get_mapped_range()
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]))
.map_err(|e| e.to_string());
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
let depth = depth?;
if !depth.is_finite() {
return Err("the centre depth isn't finite".into());
}
self.latest = Some(depth.clamp(0.0, 1.0));
Ok(depth.clamp(0.0, 1.0))
}
/// The newest depth read back so far, `None` before the first arrives.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
@@ -265,6 +300,76 @@ mod tests {
});
}
/// Inline and on the submit thread: each finished frame's depth, then none left to
/// take; several outstanding samples are refused.
#[test]
#[ignore = "requires a host GPU"]
fn a_finished_frame_s_depth_is_taken_alone_never_an_older_one() {
let (device, raw) = test_device().expect("host GPU required");
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 3,
height: 3,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = depth.create_view(&Default::default());
for threaded in [false, true] {
let queue = super::super::submit::Queue::new(raw.clone(), threaded);
if threaded {
assert!(queue.threaded(), "deferred submit unavailable");
}
let mut center = CenterDepth::new(&device);
assert!(center.take_completed(&device).is_err());
for value in [0.125, 0.75, 0.25] {
let mut encoder = device.create_command_encoder(&Default::default());
clear(&mut encoder, &view, value);
center.copy(&device, &mut encoder, &depth);
let after = center.submitted().expect("one copied sample");
if threaded {
let job = super::super::submit::Job {
encoder,
surface: None,
after: Some(Box::new(after)),
};
assert!(queue.submit_later(job).is_ok());
queue.wait();
} else {
queue.submit([encoder.finish()]);
after();
}
device
.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: Some(std::time::Duration::from_secs(30)),
})
.unwrap();
assert_eq!(center.take_completed(&device).unwrap(), value);
assert_eq!(center.collect(&device), Some(value));
assert!(
center.take_completed(&device).is_err(),
"no stale latest fallback"
);
}
// Several outstanding copies are ambiguous, even after they all map.
let mut encoder = device.create_command_encoder(&Default::default());
for _ in 0..SLOTS {
center.copy(&device, &mut encoder, &depth);
}
queue.submit([encoder.finish()]);
center.submitted().unwrap()();
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert!(center.take_completed(&device).is_err());
}
}
/// Frames that go to the submit thread start their readback there: the centre depth
/// follows the frames instead of keeping the first ones' depth.
#[test]
@@ -32,13 +32,28 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
let words = std::mem::size_of::<Vertex>() / 4;
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
layout(set = 1, binding = 0) readonly buffer HzEntityVertices { uint hz_entity_vertices[]; };\n\
vec4 hz_tangent; vec2 hz_mid;\n"
.to_owned();
let words = std::mem::size_of::<Vertex>() / 4;
// `at_velocity`: the vertex's offset from last frame in view space, this frame's view
// position less last frame's. Last frame's vertices stay at binding 2, relative to last
// frame's camera, and binding 3 gives each vertex its index there (none: no velocity).
declarations.push_str(&format!(
"layout(set = 1, binding = 2) readonly buffer HzEntityPrevious {{ uint hz_entity_previous[]; }};\n\
layout(set = 1, binding = 3) readonly buffer HzEntityPreviousIndex {{ uint hz_previous_index[]; }};\n\
uint hz_vi;\n\
vec3 hz_velocity(mat4 view, mat4 previous_view) {{\n \
uint p = hz_previous_index[hz_vi];\n \
if (p == 0xffffffffu) return vec3(0.0);\n \
uint w = p * {words}u;\n \
vec3 then = uintBitsToFloat(uvec3(hz_entity_previous[w], hz_entity_previous[w + 1u], hz_entity_previous[w + 2u]));\n \
return (view * vec4(hz_v_pos, 1.0)).xyz - (previous_view * vec4(then, 1.0)).xyz;\n\
}}\n"
));
let corner = |k: usize, field: &str| {
let at = |o: usize| format!("hz_entity_vertices[hz_q + {}u]", k * words + o);
match field {
@@ -47,7 +62,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}
};
let body = format!(
" uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
" hz_vi = uint(gl_VertexIndex);\n \
uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
vec3 hz_e1 = {p1} - {p0};\n \
vec3 hz_e2 = {p2} - {p0};\n \
vec2 hz_t0 = {t0};\n \
@@ -87,6 +103,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
("vaUV2", "ivec2(hz_MultiTexCoord1.xy)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
(
"at_velocity",
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
),
];
horizon_shaders::GbuffersInputs {
declarations,
@@ -229,7 +249,15 @@ impl Casters {
/// The vertices and per-draw uniforms of the runs a pack draws, rebuilt each frame.
pub(super) struct PackEntities {
vertices: wgpu::Buffer,
/// Set 1: the vertices again, for the quads' corners.
/// Last frame's `vertices` (the two swap each frame), for `at_velocity`.
previous: wgpu::Buffer,
/// For each of `vertices`, its index in `previous` (`NO_PREVIOUS`: none).
previous_index: wgpu::Buffer,
/// This frame's entities (`Frame::entity_motion`) and last frame's.
motion: Motion,
previous_motion: Motion,
index_scratch: Vec<u32>,
/// Set 1: the vertices again, for the quads' corners; last frame's and the indices into them.
pub table: wgpu::BindGroupLayout,
pub table_group: wgpu::BindGroup,
slots: wgpu::Buffer,
@@ -248,23 +276,32 @@ impl PackEntities {
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
let entry = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let table = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack entity vertices"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
entries: &[entry(0), entry(2), entry(3)],
});
let (vertices, table_group) = Self::vertex_buffer(device, &table, 4096);
let vertices = Self::vertex_buffer(device, 4096);
let previous = Self::vertex_buffer(device, 4096);
let previous_index = Self::index_buffer(device, 4096);
let table_group = Self::table_group(device, &table, &vertices, &previous, &previous_index);
let (slots, group) = Self::slot_buffer(device, layout, 64, stride);
Self {
vertices,
previous,
previous_index,
motion: Motion::default(),
previous_motion: Motion::default(),
index_scratch: Vec::new(),
table,
table_group,
slots,
@@ -278,28 +315,51 @@ impl PackEntities {
}
}
fn vertex_buffer(
device: &wgpu::Device,
table: &wgpu::BindGroupLayout,
vertices: u64,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
fn vertex_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity vertices"),
size: vertices * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
})
}
fn index_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity previous indices"),
size: vertices * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn table_group(
device: &wgpu::Device,
table: &wgpu::BindGroupLayout,
vertices: &wgpu::Buffer,
previous: &wgpu::Buffer,
index: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack entity vertices"),
layout: table,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
(buffer, group)
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: vertices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: previous.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: index.as_entire_binding(),
},
],
})
}
fn slot_buffer(
@@ -359,11 +419,17 @@ impl PackEntities {
shadow: Casters,
rest: &mut Vec<Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
motion: (&[(u32, u64)], &[u32]),
) {
self.draws.clear();
self.scratch.clear();
self.uniforms.clear();
rest.clear();
// Last frame's vertices stay for `at_velocity`; this frame's go to the other buffer.
std::mem::swap(&mut self.vertices, &mut self.previous);
std::mem::swap(&mut self.motion, &mut self.previous_motion);
self.motion.clear();
let mut copied = Vec::new();
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
@@ -388,8 +454,31 @@ impl PackEntities {
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
let mut without_fluids = Vec::new();
for (span, range) in runs(entities.len(), spans) {
let run_start = range.start as u32;
let whole = &entities[range];
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
// Keep it in its own material even when a shader pack draws the
// model in the same block-entity run. Do not corrupt either UV space.
let whole: &[Vertex] = if whole
.chunks(4)
.any(|q| super::is_native_terrain(q[0].layer))
{
without_fluids.clear();
for quad in whole.chunks(4) {
if super::is_native_terrain(quad[0].layer) {
if !span.hidden {
rest.extend_from_slice(quad);
}
} else {
without_fluids.extend_from_slice(quad);
}
}
&without_fluids
} else {
whole
};
// A block entity's run can hold End portal faces and beams beside plain parts (an End
// gateway: its cube and its beam): each goes to its own program.
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
@@ -444,6 +533,10 @@ impl PackEntities {
Part::Beam => beam_segments(run, &mut self.scratch),
_ => self.scratch.extend_from_slice(run),
}
// A run copied whole: its vertices keep their order, for `at_velocity`.
if !split {
copied.push((run_start, posed + start, run.len() as u32));
}
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
@@ -468,6 +561,14 @@ impl PackEntities {
if self.draws.is_empty() && posed == 0 {
return;
}
let total = posed + self.scratch.len() as u32;
let posed_marks = if skin.is_some() { motion.1 } else { &[] };
self.motion
.collect(motion.0, posed_marks, entities.len() as u32, posed, &copied);
self.index_scratch.clear();
self.index_scratch.resize(total as usize, NO_PREVIOUS);
self.motion
.link(&self.previous_motion, &mut self.index_scratch);
let vertex_size = std::mem::size_of::<Vertex>() as u64;
let (need_vertices, need_slots) = (
u64::from(posed) + self.scratch.len() as u64,
@@ -476,9 +577,23 @@ impl PackEntities {
let have_vertices = self.vertices.size() / vertex_size;
let have_slots = self.slots.size() / self.stride;
if need_vertices > have_vertices {
(self.vertices, self.table_group) =
Self::vertex_buffer(device, &self.table, need_vertices.next_power_of_two());
self.vertices = Self::vertex_buffer(device, need_vertices.next_power_of_two());
}
if need_vertices * 4 > self.previous_index.size() {
self.previous_index = Self::index_buffer(device, need_vertices.next_power_of_two());
}
queue.write_buffer(
&self.previous_index,
0,
bytemuck::cast_slice(&self.index_scratch),
);
self.table_group = Self::table_group(
device,
&self.table,
&self.vertices,
&self.previous,
&self.previous_index,
);
if need_slots > have_slots {
(self.slots, self.group) =
Self::slot_buffer(device, layout, need_slots.next_power_of_two(), self.stride);
@@ -670,6 +785,87 @@ fn drawn_by_pack(span: &EntitySpan, drawn: [bool; PROGRAMS]) -> Option<usize> {
}
/// The kind of program that draws `span`'s run, when a pack may.
/// A vertex with no copy last frame: `at_velocity` reads zero.
const NO_PREVIOUS: u32 = u32::MAX;
/// Where each entity's vertices are in a frame's pack buffer, to match them with last frame's
/// (`at_velocity`): the indices of each one's GPU-posed vertices, then of its CPU ones.
#[derive(Default)]
struct Motion {
/// Entity id (`Frame::entity_motion`) → (first index in `indices`, posed count, CPU count).
entities: std::collections::HashMap<u64, (u32, u32, u32)>,
indices: Vec<u32>,
}
impl Motion {
fn clear(&mut self) {
self.entities.clear();
self.indices.clear();
}
/// Records each marked entity (`marks`: first CPU vertex and id, each run up to the next;
/// `posed_marks`: the posed count at each mark, empty without posing). A CPU vertex is in
/// the pack buffer where `copied` (first vertex in `Frame::entities`, first index in the
/// buffer, count, in order) has it, else `NO_PREVIOUS`.
fn collect(
&mut self,
marks: &[(u32, u64)],
posed_marks: &[u32],
cpu_total: u32,
posed_total: u32,
copied: &[(u32, u32, u32)],
) {
let at = |e: u32| {
let i = copied.partition_point(|c| c.0 <= e);
match i.checked_sub(1).map(|i| copied[i]) {
Some((first, index, n)) if e < first + n => index + (e - first),
_ => NO_PREVIOUS,
}
};
for (i, &(cpu, id)) in marks.iter().enumerate() {
if id == 0 {
continue;
}
let cpu_end = marks.get(i + 1).map_or(cpu_total, |m| m.0).max(cpu);
let posed = posed_marks.get(i).copied().unwrap_or(0).min(posed_total);
let posed_end = posed_marks
.get(i + 1)
.copied()
.unwrap_or(posed_total)
.clamp(posed, posed_total);
let first = self.indices.len() as u32;
self.indices.extend(posed..posed_end);
self.indices.extend((cpu..cpu_end).map(at));
self.entities
.insert(id, (first, posed_end - posed, cpu_end - cpu));
}
}
/// For each entity drawn last frame with the same posed and CPU counts, each vertex's index
/// last frame, written into `out` (indexed by this frame's buffer).
fn link(&self, previous: &Motion, out: &mut [u32]) {
for (id, &(first, posed, cpu)) in &self.entities {
let Some(&(then, p, c)) = previous.entities.get(id) else {
continue;
};
if (p, c) != (posed, cpu) {
continue;
}
let n = (posed + cpu) as usize;
let now = &self.indices[first as usize..][..n];
let before = &previous.indices[then as usize..][..n];
for (&now, &before) in now.iter().zip(before) {
if now != NO_PREVIOUS
&& before != NO_PREVIOUS
&& let Some(slot) = out.get_mut(now as usize)
{
*slot = before;
}
}
}
}
}
pub fn program_of(span: &EntitySpan) -> Option<usize> {
Some(if span.stage == render_stage("ENTITIES") {
if span.translucent {
@@ -967,6 +1163,83 @@ fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32
mod gpu_span_tests {
use super::*;
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn native_fluid_stays_native_while_model_uses_pack() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
for overlay in [
super::super::FLUID_OVERLAY,
super::super::TERRAIN_MODEL_SOLID_OVERLAY,
super::super::TERRAIN_MODEL_CUTOUT_OVERLAY,
super::super::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
] {
let mut mixed = vec![
Vertex {
layer: 0,
..bytemuck::Zeroable::zeroed()
};
8
];
for v in &mut mixed[4..] {
v.layer = overlay | 279 << 16;
v.uv = [0.25, 0.75];
v.glint_uv = [17.0, 0.0];
}
let mut rest = Vec::new();
for hidden in [false, true] {
geometry.prepare(
&device,
&queue,
&layout.layout,
&mixed,
&[EntitySpan {
start: 0,
id: 77,
stage: render_stage("BLOCK_ENTITIES"),
hidden,
..Default::default()
}],
[true; PROGRAMS],
([0.; 4], [1; 2]),
Casters {
blocks: true,
..Default::default()
},
&mut rest,
None,
(&[], &[]),
);
assert_eq!(
geometry.scratch.len(),
4,
"the model still uses the shader pack"
);
assert_eq!(geometry.draws.len(), 1);
assert_eq!(
(geometry.draws[0].pack, geometry.draws[0].shadow),
(!hidden, true)
);
if hidden {
assert!(
rest.is_empty(),
"hidden native fluid must not leak into the visible pass"
);
} else {
assert_eq!(
bytemuck::cast_slice::<_, u8>(&rest),
bytemuck::cast_slice::<_, u8>(&mixed[4..]),
"fluid UV/layer payload cannot be sampled from the entity atlas"
);
}
}
}
}
#[test]
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
let mut skin = SkinFrame {
@@ -1079,6 +1352,7 @@ mod gpu_span_tests {
},
&mut rest,
None,
(&[], &[]),
);
assert_eq!(
(geometry.draws.iter())
@@ -1160,6 +1434,7 @@ mod gpu_span_tests {
shadows,
&mut rest,
Some(&skin),
(&[], &[]),
);
assert_eq!(rest.len(), 4, "untagged CPU prefix stays in Horizon's draw");
assert_eq!(geometry.scratch.len(), 4, "only CPU geometry is uploaded");
@@ -1203,6 +1478,7 @@ mod gpu_span_tests {
shadows,
&mut rest,
None,
(&[], &[]),
);
assert!(geometry.draws.is_empty());
assert_eq!(
@@ -1230,8 +1506,125 @@ mod gpu_span_tests {
Casters::default(),
&mut rest,
Some(&skin),
(&[], &[]),
);
assert!(geometry.draws.is_empty());
assert!(geometry.skin_target().size() >= 5000 * 44);
}
}
#[cfg(test)]
mod velocity_tests {
use super::{Motion, NO_PREVIOUS};
/// Two mobs (ids 7 and 9) and a run of unmarked geometry (id 0); `copied` places CPU
/// vertices `first..first + n` at buffer index `at`.
fn frame(order: [(u64, u32); 2], copied: &[(u32, u32, u32)]) -> Motion {
let mut marks = Vec::new();
let mut cpu = 0;
for (id, n) in order {
marks.push((cpu, id));
cpu += n;
}
marks.push((cpu, 0));
let mut m = Motion::default();
m.collect(&marks, &[], cpu + 2, 0, copied);
m
}
/// The same mob's vertices find themselves however the frame's order and the buffer's
/// layout moved between frames; geometry of no entity, and vertices not in the buffer,
/// find nothing.
#[test]
fn each_vertex_finds_its_copy_from_last_frame() {
// Last frame: mob 7 (4 vertices) then mob 9 (2), copied whole from buffer index 10.
let before = frame([(7, 4), (9, 2)], &[(0, 10, 8)]);
// This frame: mob 9 first, then mob 7, at buffer index 0; 9's last vertex not drawn.
let now = frame([(9, 2), (7, 4)], &[(0, 0, 1), (2, 1, 6)]);
let mut out = vec![NO_PREVIOUS; 8];
now.link(&before, &mut out);
// 9's first vertex (buffer 0) was 10 + 4; 7's four (buffer 1..5) were 10..14.
assert_eq!(
out,
[14, 10, 11, 12, 13, NO_PREVIOUS, NO_PREVIOUS, NO_PREVIOUS]
);
}
/// A mob whose vertex count changed (armor put on, an item picked up) gets no velocity
/// this frame rather than the wrong vertices'.
#[test]
fn a_changed_model_gets_none() {
let before = frame([(7, 4), (9, 2)], &[(0, 0, 8)]);
let now = frame([(7, 6), (9, 2)], &[(0, 0, 10)]);
let mut out = vec![NO_PREVIOUS; 10];
now.link(&before, &mut out);
assert!(out[..6].iter().all(|&i| i == NO_PREVIOUS));
assert_eq!(out[6..8], [4, 5]);
}
/// GPU-posed vertices come first in the buffer: each mob's posed part is matched by its
/// own posed count, then its CPU part.
#[test]
fn posed_and_cpu_parts_match_separately() {
let marks = [(0, 7), (2, 9), (2, 0)];
let mut before = Motion::default();
// Mob 7: 3 posed (0..3) and 2 CPU; mob 9: 1 posed (3), no CPU.
before.collect(&marks, &[0, 3, 4], 2, 4, &[(0, 4, 2)]);
let mut now = Motion::default();
// Mob 9 posed first this frame (0), then mob 7 (1..4).
now.collect(&[(0, 9), (0, 7), (2, 0)], &[0, 1, 4], 2, 4, &[(0, 4, 2)]);
let mut out = vec![NO_PREVIOUS; 6];
now.link(&before, &mut out);
assert_eq!(out, [3, 0, 1, 2, 4, 5]);
}
}
#[cfg(test)]
mod velocity_translate_tests {
/// A pack that reads `at_velocity` translates with the entity inputs (which read the
/// previous view and last frame's vertices) and with the hand's (which read zero).
#[test]
fn a_pack_reading_at_velocity_translates_for_entities_and_the_hand() {
let dir = std::env::temp_dir().join(format!("hz-at-velocity-{}", std::process::id()));
let shaders = dir.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
std::fs::write(
shaders.join("gbuffers_entities.vsh"),
"#version 120\nattribute vec3 at_velocity;\nvarying vec3 v;\n\
void main() {\n gl_Position = ftransform();\n v = at_velocity;\n}\n",
)
.unwrap();
std::fs::write(
shaders.join("gbuffers_entities.fsh"),
"#version 120\nvarying vec3 v;\nvoid main() {\n gl_FragData[0] = vec4(v, 1.0);\n}\n",
)
.unwrap();
let pack = horizon_shaders::Pack::open(&dir).unwrap();
let m = horizon_shaders::Macros::standard();
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
for (who, inputs) in [
("entities", super::inputs()),
("hand", super::super::pack_hand::inputs()),
] {
let p = horizon_shaders::translate_gbuffers(
&vsh,
&fsh,
horizon_shaders::Target::Buffers,
&inputs,
)
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
let previous = p
.interface
.uniforms
.iter()
.any(|u| u.name == "gbufferPreviousModelView");
assert_eq!(
previous,
who == "entities",
"{who}: previous view in the block"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -19,6 +19,13 @@ const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
// has no previous vertices).
for (name, value) in &mut inputs.attributes {
if name == "at_velocity" {
*value = "vec3(0.0)".to_owned();
}
}
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
inputs.item_id_declared = true;
inputs.declarations.push_str(
@@ -23,6 +23,13 @@ pub(super) struct PackImage {
}
impl PackImage {
pub(super) fn reset_history(&mut self) {
self.view = None;
self.copy = None;
self.before = None;
self.made_for = [0; 2];
}
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
self.format
.sample_type(None, Some(features))
@@ -316,6 +323,11 @@ impl StandIn {
}
}
pub(super) fn reset_stand_ins(device: &wgpu::Device, images: &mut Vec<StandIn>) {
let wanted = images.iter().map(|i| (i.format, i.dimension)).collect();
*images = stand_ins(device, wanted);
}
/// One stand-in for each format and dimension of `wanted`.
pub(super) fn stand_ins(
device: &wgpu::Device,
@@ -392,6 +404,12 @@ pub(super) struct Buffers {
}
impl Buffers {
pub(super) fn reset_history(&mut self) {
self.group = None;
self.buffers.clear();
self.made_for = [0; 2];
}
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
pub(super) fn new(
@@ -149,6 +149,7 @@ void main() {
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
&Default::default(),
)
.expect("the pack runs");
let mut pass = post
+486 -88
View File
@@ -31,6 +31,9 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
mod packed;
#[cfg(test)]
mod history_reset_tests;
#[cfg(test)]
mod world_target_tests;
@@ -43,6 +46,9 @@ mod higher_shadowcolor_tests;
#[cfg(test)]
mod single_side_tests;
#[cfg(test)]
mod mixed_size_tests;
#[cfg(test)]
mod sky_disc_blend_tests;
@@ -70,6 +76,9 @@ mod custom_type_tests;
#[cfg(test)]
mod sky_depth_tests;
#[cfg(test)]
mod end_fan_tests;
#[cfg(test)]
mod runtime_length_tests;
@@ -372,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],
@@ -392,10 +402,12 @@ 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>,
/// Shader-declared defaults must survive a reset of the mutable upload block.
initial_block: Vec<u8>,
/// (index into `pack_uniforms::NAMES`, declared type, offset).
uniforms: Vec<(Option<usize>, String, u32)>,
samplers: Vec<Binding>,
@@ -449,9 +461,19 @@ struct Pass {
}
impl Pass {
/// Back to the uniform values the pass was made with, and no bind groups.
fn reset_history(&mut self) {
self.block.clone_from(&self.initial_block);
self.groups.clear();
self.portal_groups.clear();
for compute in &mut self.compute {
compute.reset_history();
}
}
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"),
}
}
@@ -494,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])>,
@@ -1222,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>,
@@ -1265,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
@@ -1873,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 {
@@ -1935,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,
@@ -1970,6 +2158,7 @@ fn build_pass(
mapped_at_creation: false,
}),
block: interface.initial_values.clone(),
initial_block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -2077,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,
@@ -2105,6 +2291,7 @@ fn build_compute(
mapped_at_creation: false,
}),
block: interface.initial_values.clone(),
initial_block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -2153,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();
@@ -2315,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 {
@@ -2376,7 +2624,16 @@ impl PackPost {
} else {
written.next()
};
if !in_shadow && group != Group::Final && written.any(|s| Some(s) != first) {
// Sizes of one kind that differ never meet. A size relative to the render size and
// one in pixels can: the pass then draws while they resolve to one size
// (`PackPost::sizes_differ`). Iris 1.11.7 drew a composite program writing
// colortex2 at `0.25 0.25` and colortex3 at `320 180` at 1280×720 (#10454).
let apart = |s: horizon_shaders::BufferSize| {
first.is_some_and(|f| {
f != s && (depth || std::mem::discriminant(&f) == std::mem::discriminant(&s))
})
};
if !in_shadow && group != Group::Final && written.any(apart) {
notes.push(format!(
"{}: writes buffers of different sizes (size.buffer); skipped",
pass.name
@@ -2656,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
@@ -2989,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,
@@ -3112,23 +3375,7 @@ impl PackPost {
.create_view(&Default::default()),
textures,
shadow: (
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default()),
super::pack_shadow::stand_in(device),
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack shadow"),
compare: Some(wgpu::CompareFunction::LessEqual),
@@ -3174,7 +3421,76 @@ impl PackPost {
u
},
shadow_cache: Default::default(),
})
};
Ok((built, jobs))
}
/// `Renderer::finish_sequence_frame`: the centre depth the finished frame copied.
pub fn take_center_depth(&mut self, device: &wgpu::Device) -> Result<(), String> {
self.center_depth
.as_mut()
.ok_or("the shader pack copied no centre depth")?
.take_completed(device)
.map(|_| ())
}
/// Forgets what the pack kept from earlier frames, so the next frame draws as a freshly
/// loaded pack's first: buffers, images, storage buffers and shadow maps start over (made
/// again at their sizes when next drawn), the frame uniforms' history, the custom uniforms
/// and the centre depth too. Compiled programs and the pack's textures stay.
pub fn reset_history(&mut self, device: &wgpu::Device) {
self.uniforms.reset_history();
// A parsed pack's custom uniforms start at tick 0's seed, before any frame of the world.
self.custom.restart_at(0);
self.custom.forget_world();
self.center_depth = None;
self.targets = None;
self.main = 0;
self.mips_valid = 0;
self.rgb_unset = true;
self.final_target = None;
self.blit_group = None;
self.page_groups.clear();
for image in &mut self.images {
image.reset_history();
}
self.buffers.reset_history();
pack_images::reset_stand_ins(device, &mut self.stand_ins);
if let Some(shadow) = &mut self.shadow_targets {
shadow.reset_history(device, &self.uniforms.shadow);
}
self.shadow.0 = super::pack_shadow::stand_in(device);
self.shadow_cleared = false;
self.shadow_cache = Default::default();
self.setup_pending = true;
self.portal_texture = None;
self.portal_rect = [0.0; 4];
let sky = self
.sky
.iter_mut()
.flat_map(|s| s.basic.iter_mut().chain(&mut s.textured));
let entities = self
.entities
.iter_mut()
.flat_map(|e| e.passes.iter_mut().chain(&mut e.shadow).flatten());
for pass in self
.passes
.iter_mut()
.chain(&mut self.terrain)
.chain(&mut self.terrain_cutout)
.chain(&mut self.water)
.chain(sky)
.chain(entities)
.chain(&mut self.clouds)
.chain(&mut self.lines)
.chain(self.weather.iter_mut().flatten())
.chain(self.hand.iter_mut().map(|h| &mut h.0))
.chain(&mut self.shadow_terrain)
.chain(&mut self.shadow_water)
.chain(&mut self.shadow_comp)
{
pass.reset_history();
}
}
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
@@ -3434,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]
@@ -3648,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()));
@@ -3853,6 +4170,7 @@ impl PackPost {
portal: [f32; 4],
rest: &mut Vec<super::Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
motion: (&[(u32, u64)], &[u32]),
) {
self.portal_rect = portal;
let shadow = self.shadow_casters();
@@ -3873,6 +4191,7 @@ impl PackPost {
shadow,
rest,
skin,
motion,
);
}
@@ -3926,7 +4245,7 @@ impl PackPost {
frame: &super::Frame,
terrain: &mut TerrainDraw<'_>,
) {
if super::sky_visible(frame) {
if super::sky_visible(frame) || super::end_fog_sky(frame) {
self.draw_sky(device, queue, encoder, size, frame);
}
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
@@ -4286,6 +4605,18 @@ impl PackPost {
})
}
/// Whether the buffers `pass` writes have different sizes at the current render size: then
/// it is skipped, as one whose `size.buffer` sizes never meet is when the pack loads. Only
/// a pass writing buffers sized relative to the render size and in pixels can.
fn sizes_differ(&self, pass: &Pass) -> bool {
let Some(targets) = &self.targets else {
return false;
};
let mut sizes = pass.writes.iter().map(|&(_, b)| targets.sizes[b]);
let first = sizes.next();
!pass.group.shadow() && pass.group != Group::Final && sizes.any(|s| Some(s) != first)
}
/// Whether the pack draws terrain itself, or has nothing draw it.
pub fn has_terrain(&self) -> bool {
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
@@ -4527,6 +4858,14 @@ impl PackPost {
mips,
});
self.forget_groups();
for pass in self.passes.iter().filter(|p| self.sizes_differ(p)) {
log::info!(
"shader pack: {}: writes buffers of different sizes at {}x{} (size.buffer); skipped",
pass.name,
size[0],
size[1]
);
}
}
/// The shadow pass (2.1, passes 2 and 3): the shadow buffers cleared, then the `shadowcomp`
@@ -5073,6 +5412,10 @@ impl PackPost {
terrain_drawn = true;
self.draw_opaque(device, queue, encoder, size, frame, terrain);
}
// As if the pack didn't have it: no compute stages, flips or stage changes.
if self.sizes_differ(pass) {
continue;
}
if !water_drawn && matches!(pass.group, Group::Composite | Group::Final) {
water_drawn = true;
self.draw_translucent(device, queue, encoder, size, terrain);
@@ -5308,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]
@@ -5397,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(
@@ -5491,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(
@@ -5574,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(
@@ -5716,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!(
@@ -593,6 +594,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -613,6 +615,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -244,6 +244,7 @@ impl Fixture {
weather: false,
hand: false,
},
&Default::default(),
)
}
@@ -431,6 +432,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -451,6 +453,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -0,0 +1,151 @@
//! In the End, while the dragon fight's world fog leaves out vanilla's sky, a pack's
//! `gbuffers_skybasic` draws a fan in the fog colour, as our probe packs read it in Iris 1.11.7.
//! Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// `gbuffers_skybasic` writes its vertex colour into colortex0 and `gl_Vertex` into colortex1
/// (32-bit floats). final shows, on the picture's top half, `gl_Vertex.y` of the view's lower
/// half as a 24-bit integer ((y + 64) * 65536), and on its bottom half colortex0 of the view's
/// upper half.
fn files() -> Vec<(String, String)> {
vec![
("shaders.properties".into(), "blend.gbuffers_skybasic=off\n".into()),
(
"gbuffers_skybasic.vsh".into(),
"#version 120\nvarying vec4 color;\nvarying vec3 at;\nvoid main() {\n\
\x20 gl_Position = ftransform();\n color = gl_Color;\n at = gl_Vertex.xyz;\n}\n"
.into(),
),
(
"gbuffers_skybasic.fsh".into(),
"#version 120\nvarying vec4 color;\nvarying vec3 at;\n/* DRAWBUFFERS:01 */\n\
void main() {\n gl_FragData[0] = color;\n gl_FragData[1] = vec4(at, 1.0);\n}\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 130\n/*\nconst int colortex1Format = RGBA32F;\n*/\n\
uniform sampler2D colortex0;\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
void main() {\n\
\x20 float y = texelFetch(colortex1, ivec2(gl_FragCoord.xy) - ivec2(0, 8), 0).y;\n\
\x20 uint n = min(uint((y + 64.0) * 65536.0 + 0.5), 16777215u);\n\
\x20 vec4 at = vec4(uvec4(n >> 16u, (n >> 8u) & 255u, n & 255u, 255u)) / 255.0;\n\
\x20 gl_FragColor = uv.y > 0.5 ? at : texelFetch(colortex0, ivec2(gl_FragCoord.xy) + ivec2(0, 8), 0);\n}\n"
.into(),
),
]
}
/// The pack's picture of the fixture's frame (16 pixels square, 90° field of view, looking level
/// toward +Z) in the End, the world having left the far plane everywhere.
fn picture(gpu: &Gpu, boss_world_fog: bool) -> Vec<[u8; 4]> {
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
let mut post = gpu.load(
files(),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(super::super::pack_sky::inputs()),
..Default::default()
},
);
assert!(post.sky.is_some(), "{:?}", post.notes);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.end_sky = true;
frame.boss_world_fog = boss_world_fog;
frame.detail_distance = 192.0;
frame.fog = Some(crate::fog::FogData {
color: [0.2, 0.4, 0.6, 1.0],
environmental_start: 0.0,
environmental_end: 96.0,
render_distance_start: 172.8,
render_distance_end: 192.0,
sky_end: 96.0,
cloud_end: 96.0,
});
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
}
/// Where the view ray through pixel (`column`, `row`) meets the fan: from (0, -16, 0) to eight
/// points 16 above the eye at radius 192, every 45° from +Z. None when it passes above the rim.
fn fan_height(column: usize, row: usize) -> Option<f64> {
let side = |i: usize| (i as f64 + 0.5) / 8.0 - 1.0;
// The fan is mirrored across x = 0, so either sign of the view's x does.
let ray = glam::DVec3::new(side(column), -side(row), 1.0);
let azimuth = ray.x.atan2(ray.z).to_degrees();
let edge = (azimuth / 45.0).floor();
let rim = |k: f64| {
let a = (k * 45.0).to_radians();
glam::DVec3::new(192.0 * a.sin(), 16.0, 192.0 * a.cos())
};
let apex = glam::DVec3::new(0.0, -16.0, 0.0);
let normal = (rim(edge) - apex).cross(rim(edge + 1.0) - apex);
let along = apex.dot(normal) / ray.dot(normal);
let hit = ray * along;
(along > 0.0 && hit.y <= 16.0).then_some(hit.y)
}
#[test]
fn the_end_under_the_dragons_fog_has_the_packs_fan() {
let Some(gpu) = Gpu::new() else { return };
let pixels = picture(&gpu, true);
let width = SIZE[0] as usize;
// Below the horizon: every pixel sees the fan, at the height the planes give (to 0.02:
// the GPU interpolates `gl_Vertex` across triangles 192 blocks wide; it came within 0.008).
for row in 8..16 {
for column in 0..width {
let p = pixels[(row - 8) * width + column];
let got = f64::from(u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
/ 65536.0
- 64.0;
let expected = fan_height(column, row).expect("below the horizon");
assert!(
(got - expected).abs() < 0.02,
"row {row}, column {column}: gl_Vertex.y {got}, expected {expected}"
);
}
}
// Above it: only the row just over the horizon reaches the fan (the rim stands 4.8° up),
// drawn in the fog colour; above that, the world's grey stays.
for row in 0..8 {
for column in 0..width {
let p = pixels[(row + 8) * width + column];
let expected = match fan_height(column, row) {
Some(_) => [51, 102, 153, 255],
None => [16, 16, 16, 255],
};
assert_eq!(p, expected, "row {row}, column {column}");
}
assert_eq!(fan_height(0, row).is_some(), row == 7, "row {row}");
}
}
/// The End without the fog: vanilla's End sky, which no `gbuffers_skybasic` draws.
#[test]
fn the_end_without_the_fog_has_no_fan() {
let Some(gpu) = Gpu::new() else { return };
let pixels = picture(&gpu, false);
let width = SIZE[0] as usize;
for (i, p) in pixels[8 * width..].iter().enumerate() {
assert_eq!(*p, [16, 16, 16, 255], "pixel {i}");
}
}
@@ -0,0 +1,109 @@
//! `PackPost::reset_history`: a pack reset after some frames draws as a freshly loaded one.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
#[test]
#[ignore = "requires a host GPU with compute and fragment storage"]
fn a_reset_pack_draws_its_buffers_images_storage_and_shadow_from_the_start() {
let gpu = Fixture::new().expect("a GPU is required for this reset regression");
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let vertex = "#version 120\nvarying vec2 uv;\nvoid main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}\n";
let state = "layout(std430,binding=0) buffer State { uint setups; uint frames; } state;\n";
let compute = "#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(1);\n";
add(
"setup.csh",
&format!("{compute}{state}void main(){{state.setups+=1u;}}\n"),
);
add(
"begin.csh",
&format!(
"{compute}{state}layout(r32ui) uniform uimage2D imgCounter;\n\
void main(){{state.frames+=1u;imageStore(imgCounter,ivec2(0),uvec4(imageLoad(imgCounter,ivec2(0)).r+1u));}}\n"
),
);
for name in ["composite", "shadowcomp"] {
add(&format!("{name}.vsh"), vertex);
let target = if name == "composite" { 2 } else { 0 };
let (declarations, value) = if name == "composite" {
("", "vec4(0.125)")
} else {
(
"uniform sampler2D shadowcolor0;varying vec2 uv;",
"texture2D(shadowcolor0,uv)+vec4(0.125)",
)
};
add(
&format!("{name}.fsh"),
&format!(
"#version 120\n/* RENDERTARGETS:{target} */\n\
{declarations}\nvoid main(){{gl_FragData[0]={value};}}\n"
),
);
}
add("final.vsh", vertex);
add(
"final.fsh",
&format!(
"#version 430 compatibility\n{state}\
uniform sampler2D colortex2;uniform sampler2D shadowcolor0;uniform usampler2D texCounter;\n\
const bool colortex2Clear=false;const bool shadowcolor0Clear=false;\n\
const int shadowMapResolution=16;uniform float baseline=0.375;varying vec2 uv;\n\
void main(){{gl_FragColor=gl_FragCoord.x<8.0?\
vec4(texture(colortex2,uv).r,float(texelFetch(texCounter,ivec2(0),0).r)/8.0,float(state.frames)/8.0,1):\
vec4(texture(shadowcolor0,uv).r,float(state.setups)/8.0,baseline,1);}}\n"
),
);
add(
"shaders.properties",
"shadow.enabled=true\nbufferObject.0=8\n\
image.imgCounter=texCounter RED_INTEGER R32UI UNSIGNED_INT false false 1 1\n\
blend.composite=ONE ONE ONE ONE\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("original-reset", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let load = || horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.try_build(load()).expect("reset fixture compiles");
let mut fresh = gpu.try_build(load()).expect("fresh control compiles");
let check = |pixels: &[[u8; 4]], frame: u8| {
let counter = 32 * frame;
for (i, pixel) in pixels.iter().enumerate() {
let expected = if i % 16 < 8 {
[counter, counter, counter, 255]
} else {
[counter, 32, 96, 255]
};
assert!(
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1),
"frame {frame}, pixel {i}: {pixel:?} != {expected:?}"
);
}
};
for frame in 1..=3 {
check(&gpu.render(&mut post, [16, 16]), frame);
}
// At the same size too: the retained colortex2 and shadowcolor0, the image and
// the storage buffer start over, and setup runs once again. The control is a pack never
// reset.
for _ in 0..2 {
post.reset_history(&gpu.device);
for frame in 1..=2 {
let pixels = gpu.render(&mut post, [16, 16]);
check(&pixels, frame);
if frame == 1 {
assert_eq!(pixels, gpu.render(&mut fresh, [16, 16]));
}
}
fresh.reset_history(&gpu.device);
}
}
@@ -0,0 +1,89 @@
//! A composite program writing two buffers whose `size.buffer` lines declare one relative to the
//! render size and one in pixels (#10454). Iris 1.11.7 drew such a program where both came to
//! the same size (our probe pack: `0.25 0.25` and `320 180` at 1280×720). The programs here are
//! original and cut down.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes (0.25, 0.5, 0.75) into colortex2, sized `0.25 0.25`, and colortex3, sized
/// `colortex3` (`size.buffer` words); `final` shows colortex2's red and green and colortex3's
/// blue.
fn pack(colortex3: &str) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add(
"shaders.properties",
&format!("size.buffer.colortex2 = 0.25 0.25\nsize.buffer.colortex3 = {colortex3}\n"),
);
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 2,3 */\nvoid main() {\n\
gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\ngl_FragData[1] = vec4(0.25, 0.5, 0.75, 1.0);\n}\n",
);
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\n\
void main() {\ngl_FragColor = vec4(texture2D(colortex2, uv).rg, texture2D(colortex3, uv).b, 1.0);\n}\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("mixed-sizes", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
/// Every pixel's colour, with how many pixels differ from it by more than one step.
fn one_colour(pixels: &[[u8; 4]]) -> ([u8; 4], usize) {
let first = pixels[0];
let off = pixels
.iter()
.filter(|p| p.iter().zip(first).any(|(a, b)| a.abs_diff(b) > 1))
.count();
(first, off)
}
/// At 16×16 both buffers are 4×4: the program draws. At 32×32 colortex2 is 8×8 and colortex3
/// still 4×4: the program is skipped there, as one whose sizes never meet is (Iris refused
/// the whole pack in that case; Horizon skips only the program).
#[test]
fn a_program_writing_buffers_that_come_to_one_size_draws() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack("4 4"));
let (colour, off) = one_colour(&gpu.render(&mut post, [16, 16]));
assert_eq!(off, 0, "{colour:?}");
for (got, want) in colour.iter().zip([64, 128, 191, 255]) {
assert!(got.abs_diff(want) <= 1, "{colour:?}");
}
let (colour, off) = one_colour(&gpu.render(&mut post, [32, 32]));
assert_eq!((colour, off), ([0, 0, 0, 255], 0));
}
/// Sizes of one kind that differ never meet: the program is skipped as the pack loads.
#[test]
fn a_program_writing_buffers_of_different_relative_sizes_is_skipped() {
let Some(gpu) = Fixture::new() else { return };
let post = pack("0.5 0.5");
let post = gpu
.try_build(post)
.unwrap_or_else(|_| panic!("the pack runs"));
assert!(
post.notes
.iter()
.any(|n| n == "composite: writes buffers of different sizes (size.buffer); skipped"),
"{:?}",
post.notes
);
}
@@ -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);
@@ -400,6 +401,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -420,6 +422,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -76,7 +76,50 @@ fn target(
(texture, view)
}
/// The depth stand-in is initialized to no shadow on the next pack render.
pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default())
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
Some([a, b]) => {
colors |= 1 << n;
if a.texture() != b.texture() {
doubled |= 1 << n;
}
a.texture().format()
}
None => wgpu::TextureFormat::Rgba8Unorm,
});
*self = Self::new(
device,
shadow,
formats,
colors,
doubled,
self.depth1.is_some(),
);
}
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`, `doubled` (bit n: shadowcolor n needs a B texture). The
/// resolution is kept within the device's limit.
@@ -22,6 +22,8 @@ const SUNSET_APEX: u32 = 1;
const SUNSET_RIM: u32 = 2;
const STAR: u32 = 3;
const VOID: u32 = 4;
/// The End's fan (`fan`): `pos.xz` of a rim vertex is its direction, scaled by the radius.
const FAN: u32 = 5;
/// The frame's sky (std140).
#[repr(C)]
@@ -33,7 +35,7 @@ struct Uniform {
color: [f32; 4],
/// Sunset colour and alpha.
sunrise: [f32; 4],
/// cos and sin of the sunset fan's turn, star brightness.
/// cos and sin of the sunset fan's turn, star brightness, the End fan's radius.
sun: [f32; 4],
}
@@ -77,6 +79,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}} else if (hz_v_kind == {STAR}u) {{\n \
hz_p = (hz_sky_stars * vec4(hz_v_pos, 1.0)).xyz;\n \
hz_Color = vec4(hz_sky_sun.z);\n \
}} else if (hz_v_kind == {FAN}u) {{\n \
hz_p = vec3(hz_v_pos.x * hz_sky_sun.w, hz_v_pos.y, hz_v_pos.z * hz_sky_sun.w);\n \
}} else if (hz_v_kind == {VOID}u) {{\n \
hz_Color = vec4(0.0, 0.0, 0.0, 1.0);\n \
}}\n \
@@ -188,14 +192,37 @@ fn horizon(out: &mut Vec<Vertex>) {
}
}
/// The fan a pack's `gbuffers_skybasic` draws in the End while the dragon fight's fog leaves out
/// vanilla's sky (`super::end_fog_sky`): from (0, -16, 0) under the eye to eight rim vertices 16
/// above it, every 45° from +Z, at the render distance (the `far` uniform; scaled in the vertex
/// stage). Fitted to the `gl_Vertex` our probe packs' fragments read in Iris 1.11.7 at render
/// distances 12 and 8: 289 of 291 samples lie within 0.05 block of these eight planes (one of
/// the other two, checked, sat on a terrain edge), and nothing drew above the rim.
fn fan(out: &mut Vec<Vertex>) {
let rim = |i: i32| {
let a = (i as f32 * 45.0).to_radians();
Vertex {
pos: [a.sin(), 16.0, a.cos()],
kind: FAN,
}
};
let apex = Vertex {
pos: [0.0, -16.0, 0.0],
kind: FAN,
};
for i in 0..8 {
out.extend([apex, rim(i), rim(i + 1)]);
}
}
pub(super) struct PackSky {
vertices: wgpu::Buffer,
celestial: wgpu::Buffer,
uniform: wgpu::Buffer,
pub layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
/// Vertex ranges of the sky disc, the sunset fan, the stars and the void.
ranges: [Range<u32>; 4],
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
ranges: [Range<u32>; 5],
}
impl PackSky {
@@ -227,6 +254,8 @@ impl PackSky {
let stars = sunset.end..v.len() as u32;
disc(-4.0, VOID, &mut v);
let void = stars.end..v.len() as u32;
fan(&mut v);
let end_fan = void.end..v.len() as u32;
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack sky"),
contents: bytemuck::cast_slice(&v),
@@ -271,7 +300,7 @@ impl PackSky {
uniform,
layout,
group,
ranges: [sky, sunset, stars, void],
ranges: [sky, sunset, stars, void, end_fan],
}
}
@@ -294,7 +323,8 @@ impl PackSky {
/// Writes the frame's sky and returns its draws in vanilla's order: the disc, the sunset
/// fan while the sun is near the horizon, the sun and the moon, the stars and, with the
/// camera low, the void. `tilt` is the pack's `sunPathRotation` (degrees): the sun, moon
/// and stars turn with it (7.1).
/// and stars turn with it (7.1). In the End under the dragon fight's fog, only the End's fan,
/// in the fog colour (`fogColor`), as stage SKY.
pub fn prepare(&self, queue: &wgpu::Queue, frame: &super::Frame, tilt: f32) -> Vec<Part> {
let turn = super::pack_uniforms::tilt_matrix(tilt);
let sun_angle = frame.sky.sun_angle.to_radians();
@@ -302,12 +332,22 @@ impl PackSky {
let flip = if sun_angle.sin() < 0.0 { 180f32 } else { 0.0 };
let theta = (flip + 90.0).to_radians();
let (rotation, brightness) = frame.stars.unwrap_or_default();
let [r, g, b] = frame.sky.sky_color;
let end_fan = super::end_fog_sky(frame);
let [r, g, b] = match &frame.fog {
Some(f) if end_fan => [f.color[0], f.color[1], f.color[2]],
None if end_fan => frame.sky.fog_color,
_ => frame.sky.sky_color,
};
let uniform = Uniform {
stars: (turn * rotation).to_cols_array_2d(),
color: [r, g, b, 1.0],
sunrise: frame.sky.sunrise_color,
sun: [theta.cos(), theta.sin(), brightness.clamp(0.0, 1.0), 0.0],
sun: [
theta.cos(),
theta.sin(),
brightness.clamp(0.0, 1.0),
frame.detail_distance,
],
};
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
// The sun's quad, then the moon's (Game::celestial_geometry), as triangles.
@@ -327,12 +367,15 @@ impl PackSky {
})
.collect();
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
let [sky, sunset, stars, void] = self.ranges.clone();
let [sky, sunset, stars, void, fan] = self.ranges.clone();
let part = |stage, textured, range| Part {
stage,
textured,
range,
};
if end_fan {
return vec![part("SKY", false, fan)];
}
let mut parts = vec![part("SKY", false, sky)];
if frame.sky.sunrise_color[3] > 0.001 {
parts.push(part("SUNSET", false, sunset));
@@ -3,13 +3,15 @@
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
//! write them), then read back when mapped. The next frame after that is timed again, once `gap`
//! has passed since the last.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::{Duration, Instant};
const REPORT: usize = 30;
/// Passes timed per frame at most (two timestamps each).
@@ -43,6 +45,10 @@ pub struct PassTiming {
/// `mark`'s pipeline, for the target format it was made for.
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
reports: usize,
/// Least time between timed frames.
pub gap: Duration,
/// When the last timed frame began.
timed_at: Option<Instant>,
}
impl PassTiming {
@@ -81,6 +87,8 @@ impl PassTiming {
log: Self::logging(),
marker: None,
reports: 0,
gap: Duration::ZERO,
timed_at: None,
})
}
@@ -123,9 +131,12 @@ impl PassTiming {
}
_ => {}
}
if matches!(self.stage, Stage::Idle) {
if matches!(self.stage, Stage::Idle)
&& self.timed_at.is_none_or(|at| at.elapsed() >= self.gap)
{
self.names.clear();
self.stage = Stage::Recording;
self.timed_at = Some(Instant::now());
}
}
@@ -709,6 +709,16 @@ fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl FrameUniforms {
/// Forgets the frames before (the previous camera, smoothed values, the clock and the
/// centre depth), keeping the pack's atlas size and shadow settings.
pub(super) fn reset_history(&mut self) {
*self = Self {
atlas: self.atlas,
shadow: std::mem::take(&mut self.shadow),
..Self::default()
};
}
/// This frame's values for a render size of `size` pixels.
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
let now = std::time::Instant::now();
@@ -2367,6 +2377,61 @@ pub(super) mod initial_history_tests {
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
}
/// A reset pack draws its next frame as its first: no previous camera, smoothed values
/// at their targets, the clock from 0; the atlas size it was given stays.
#[test]
fn a_reset_forgets_the_uniforms_history_and_keeps_the_pack_s_settings() {
let hud = HudBatch::new([16, 16]);
let mut f = frame(&hud);
let mut uniforms = FrameUniforms {
atlas: [256, 128],
..Default::default()
};
let mut inputs = PackInputs {
rain: 1.0,
eye_light: [15, 15],
frame_timing: Some(FrameTiming {
counter: 800,
elapsed: 99.0,
delta: 1.0,
}),
..Default::default()
};
uniforms.center_depth_raw = Some(0.25);
uniforms.update(&f, &inputs, [16, 16]);
f.camera = glam::DVec3::new(7.0, 12.0, -30.0);
f.yaw = 77.0;
inputs.rain = 0.25;
inputs.eye_light = [3, 8];
inputs.frame_timing = None;
uniforms.reset_history();
uniforms.update(&f, &inputs, [16, 16]);
let float = |name| match uniforms.values[index(name).unwrap()] {
Value::F(v) => v,
_ => panic!("float uniform expected"),
};
let int = |name| match uniforms.values[index(name).unwrap()] {
Value::I(v) => v,
_ => panic!("integer uniform expected"),
};
assert_eq!(int("frameCounter")[0], 0);
assert_eq!(float("frameTime")[0], 0.0);
assert_eq!(int("atlasSize")[..2], [256, 128]);
assert_eq!(float("wetness")[0], 0.25);
assert_eq!(int("eyeBrightnessSmooth")[..2], [48, 128]);
assert_eq!(float("centerDepthSmooth")[0], 1.0);
for (previous, current) in [
("previousCameraPosition", "cameraPosition"),
("gbufferPreviousModelView", "gbufferModelView"),
("gbufferPreviousProjection", "gbufferProjection"),
] {
assert_eq!(
float(previous).map(f32::to_bits),
float(current).map(f32::to_bits)
);
}
}
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
#[test]
@@ -2411,6 +2476,7 @@ pub(super) mod initial_history_tests {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -2431,6 +2497,7 @@ pub(super) mod initial_history_tests {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -323,4 +323,27 @@ mod tests {
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
/// `Rgba16Float`, the frame of a shader API 0.2 pack, or of HDR on without a pack, under
/// `HORIZON_HDR_FRAME=float` (`Renderer::frame_format`): what is left over 1 is clipped on
/// the way.
#[test]
fn a_float_frame_reaches_an_8_bit_swapchain() {
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 color = wgpu::Color {
r: 1.5,
g: 0.2,
b: -0.5,
a: 1.0,
};
let float = wgpu::TextureFormat::Rgba16Float;
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
}
}
@@ -0,0 +1,66 @@
//! Opaque/cutout terrain-sheet native models, with ordinary culling and depth writes.
pub(super) fn pipelines(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
sprite_constants: &[(&str, f64)],
) -> [wgpu::RenderPipeline; 3] {
std::array::from_fn(|i| {
let mut constants = sprite_constants.to_vec();
constants.push(("NATIVE_MODEL_ALPHA_CUTOFF", [0.0, 0.1, 0.5][i]));
// The ordinary Vertex ABI is unchanged. This material's private payload uses
// normal's four bytes for a float z, and glint UV for float normal x/y.
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Uint32,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(
[
"Native model solid",
"Native model cutout",
"Native model cutout mipmapped",
][i],
),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_native_model"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_native_model"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
}
+23 -17
View File
@@ -217,15 +217,19 @@ struct HorizonWeatherOut {
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
let input = WeatherInput(vec4(horizon_line_in(color.rgb), color.a), in.light, in.position,
in.snow != 0u);
let out = weather(input);
return vec4(horizon_line_out(out.rgb), out.a);
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
super::native_post::line_helpers(api)
)
}
@@ -242,7 +246,7 @@ pub(super) fn native_pipeline(
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
@@ -318,19 +322,21 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
use horizon_shader_api::{Api, stages::StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source, api);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
@@ -257,14 +257,23 @@ impl Post {
/// The world's rendered size and the size it is shown at (TAA's history).
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
let upscale = if self.taa_on { self.upscale } else { 1.0 };
self.sizes_with_upscale(window, upscale)
}
fn sizes_with_upscale(&self, window: [u32; 2], upscale: f32) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
(
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
output,
)
}
/// The next TAA frame's input size, even before its first jitter or resize.
pub(super) fn taa_render_size(&self, window: [u32; 2]) -> [u32; 2] {
self.sizes_with_upscale(window, self.upscale).0
}
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
@@ -604,7 +613,7 @@ mod tests {
#[test]
fn jitter_stays_inside_the_pixel() {
for i in 1..=8 {
for i in 1..=128 {
let (x, y) = (halton(i, 2), halton(i, 3));
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
}
@@ -614,6 +623,30 @@ mod tests {
);
}
#[test]
fn culling_uses_the_next_taa_size_before_the_first_jitter_or_resize() {
let Some((device, _)) = test_device() else {
return;
};
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
assert!(!post.taa_on);
assert!(post.size().is_none());
for (window, scale, upscale, expected) in [
([1280, 720], 1.0, 1.0, [1280, 720]),
([1281, 721], 0.5, 2.0, [321, 181]),
([3840, 2160], 0.25, 4.0, [240, 135]),
([10000, 10000], 2.0, 4.0, [2048, 2048]),
([0, 0], 0.5, 4.0, [1, 1]),
] {
post.scale = scale;
post.upscale = upscale;
post.taa_on = false;
assert_eq!(post.taa_render_size(window), expected);
post.taa_on = true;
assert_eq!(post.sizes(window).0, expected);
}
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
+8 -4
View File
@@ -771,7 +771,7 @@ pub struct Graphics {
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
/// (`smooth_gradients_offered`).
pub hdr_smooth_gradients: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// The native `horizon:<manifest-id>` or classic filename picked in `shaderpacks/`; empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
pub shader_pack: String,
@@ -1092,8 +1092,9 @@ impl Graphics {
// (horizon_threads: niced on Linux; on macOS and Windows the server
// and render threads are raised above four cores, where vanilla
// raises its server thread). Leaving two idled a core of a 4-thread
// laptop while a world loaded: a teleport there took ~15% longer.
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
// laptop while a world loaded: a teleport there took ~15% longer. On Apple
// silicon only the performance cores count (horizon_threads::default_workers).
let auto = horizon_threads::jobs::default_threads();
if self.battery { auto.div_ceil(2) } else { auto }
}
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
@@ -1476,7 +1477,10 @@ mod tests {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = horizon_threads::worker_count();
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
if !cfg!(target_vendor = "apple") {
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
}
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
assert_eq!(manual.worker_threads(), 3);
@@ -639,12 +639,20 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
/// BlueMap download radii, in chunks.
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
/// What the LOD Distance slider stops at, in chunks: off, fine steps up to 256
/// for a short ring of far terrain past a small render distance (a phone or an
/// integrated GPU at 8 chunks wants LOD to 12 or 16, not 64), then each doubling
/// up to the whole world.
const LOD_STEPS: [u32; 18] = [
const LOD_STEPS: [u32; 24] = [
0,
16,
24,
32,
48,
64,
96,
128,
192,
256,
512,
1024,
@@ -1655,6 +1663,7 @@ impl GraphicsUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, cols, y, &view);
}
@@ -2692,12 +2701,21 @@ mod tests {
item.label(&s).plain(),
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
);
item.set_slider(&mut s, 5.0 / 17.0);
item.set_slider(&mut s, 11.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 1024);
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
// The fine stops: a ring of far terrain just past a small render
// distance, which the doubling steps skipped (#9694).
item.set_slider(&mut s, 1.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 16);
assert_eq!(item.label(&s).plain(), "LOD Distance: 16 chunks");
item.set_slider(&mut s, 2.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 24);
// A distance between stops (from the file) sits on the next one.
s.horizon.lod_distance = 3000;
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
s.horizon.lod_distance = 12;
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
format!("Full Detail Distance: {near} chunks")
+15 -8
View File
@@ -61,9 +61,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
// would read differently (an alpha of 0.001 would count as the hand); its colour
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
// keeps the gradients 8 bits would band, and a float frame's (LINEAR_FRAME) the
// light over 1 that blends added past white. On an 8-bit frame nothing changes.
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
return vec4(max(v.rgb, vec3(0.0)), round(saturate(v.a) * 255.0) / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -161,15 +162,12 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
return vec4(nits / 80.0, 1.0);
}
let f = frame_texel(textureLoad(frame, at, 0));
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
@@ -179,11 +177,19 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && w.a >= 0.75 && w.a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
@@ -214,6 +220,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
light = min(light, vec3(params.peak));
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
let scrgb = light * params.paper;
+35
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.
@@ -1354,6 +1384,11 @@ impl HudAtlas {
bordered.insert(image.0.clone());
}
}
if full {
let (width, height, rgba) =
decode_bytes(include_bytes!("jei_recipes/config_button.png"))?;
images.push(("horizon/recipes/settings".to_owned(), width, height, rgba));
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
@@ -485,11 +485,6 @@ pub struct HealthHud {
/// `Util.getMillis()` of the last health change.
pub last_health_time: i64,
pub health_blink_time: i64,
/// `Hud.leftHeight` and `rightHeight` once the health, armor, food, mount
/// and air layers have run; the selected item name and the action bar sit
/// above them.
pub left_height: i32,
pub right_height: i32,
/// `Hud.lastBubblePopSoundPlayed`.
pub last_bubble_pop_sound: i32,
/// The `ui.hud.bubble_pop` volume and pitch `playAirBubblePoppedSound`
@@ -655,9 +650,6 @@ impl HealthHud {
let rows = ceil((max_health + absorption as f32) / 2.0 / 10.0);
let row_height = (10 - (rows - 2)).max(3);
let mut y_air = y_base - 10;
self.left_height = 39 + (rows - 1) * row_height + 10 + if i.armor > 0 { 10 } else { 0 };
// The mount's heart rows (`extractVehicleHealth`); food adds its row below.
self.right_height = 39 + 10 * ((i.vehicle_hearts + 9) / 10);
let offset_index = if i.regeneration {
i.tick % ceil(max_health + 5.0)
} else {
@@ -752,13 +744,11 @@ impl HealthHud {
}
}
y_air -= 10;
self.right_height += 10;
}
// Hud.extractAirBubbles.
let max_air = i.max_air.max(1);
let air = i.air.clamp(0, max_air);
if i.under_water || air < max_air {
self.right_height += 10;
let rows = (f64::from(i.vehicle_hearts) / 10.0).ceil() as i32;
let y = y_air - (rows - 1) * 10;
let bubble = |offset: i32| ceil(((air + offset) * 10) as f32 / max_air as f32);
@@ -115,8 +115,7 @@ impl From<&crate::effects::EffectInstance> for EffectIcon {
color: def.map_or(0, |d| d.color as i32),
ambient: effect.ambient,
duration: effect.duration,
// Horizon keeps no showIcon flag: every effect shows its icon.
show_icon: true,
show_icon: effect.show_icon,
}
}
}
@@ -323,13 +322,12 @@ impl Titles {
}
}
}
/// `Hud.extractOverlayMessage`; `bar_height` is
/// `max(leftHeight, rightHeight)`.
/// `Hud.extractOverlayMessage`: the action bar keeps its place however
/// tall the health, food, air and mount rows above the hotbar are.
pub fn overlay_message(
&self,
w: i32,
h: i32,
bar_height: i32,
partial: f32,
font: &Font,
background: u32,
@@ -345,12 +343,11 @@ impl Titles {
let alpha = ((t * 255.0 / 20.0) as i32).min(255);
if alpha > 0 {
let message = message.resolve_legacy();
// The bars' height plus the gap between the item name (59) and the
// overlay (68), at least the default level.
let y_shift = bar_height + (68 - 59);
// `Hud.extractOverlayMessage` translates to `guiHeight - 68`, a
// constant the bars above the hotbar never move.
out.push(GuiDraw::Push(vec![PoseOp::Translate(
(w / 2) as f32,
(h - y_shift.max(68)) as f32,
(h - 68) as f32,
)]));
let color = if self.overlay_animated {
hsv_to_argb(t / 50.0, 0.7, 0.6, alpha as u32)
@@ -468,13 +465,13 @@ impl ToolHighlight {
self.last = selected;
}
/// `Hud.extractSelectedItemName`; `can_hurt` is
/// `gameMode.canHurtPlayer()` (survival and adventure) and `bar_height`
/// is `max(leftHeight, rightHeight)`.
/// `gameMode.canHurtPlayer()` (survival and adventure). The name sits 59
/// pixels above the bottom edge whatever the bars above the hotbar do,
/// and 14 lower when the mode cannot hurt the player.
pub fn draw(
&self,
w: i32,
h: i32,
bar_height: i32,
can_hurt: bool,
font: &Font,
background: u32,
@@ -495,7 +492,7 @@ impl ToolHighlight {
}
let str_width = width(font, &name);
let x = (w - str_width) / 2;
let mut y = h - bar_height.max(59);
let mut y = h - 59;
if !can_hurt {
y += 14;
}
@@ -1295,3 +1292,92 @@ mod subtitle_tests {
assert!(!o.listening());
}
}
#[cfg(test)]
mod text_position_tests {
use super::*;
use crate::{font::Font, hud::Rect};
use std::{collections::HashMap, path::Path};
/// The missing-glyph metrics let these tests run without game assets.
fn font() -> Font {
Font::load(
Path::new("/nonexistent-horizon-hud-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
)
}
fn held() -> HeldName {
HeldName {
item: "minecraft:stone".into(),
hover_name: Text::literal("Stone"),
rarity_color: 0xFFFF_FFFF,
custom_name: false,
}
}
fn text_top(draws: &[GuiDraw]) -> Option<i32> {
draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
})
}
/// `Hud.extractSelectedItemName`: `guiHeight - 59`, then `+ 14` when the
/// game mode cannot hurt the player. The health, food, air and mount rows
/// above the hotbar never move it: with armor and a second row of hearts
/// they reach 69 pixels, and the name stays where vanilla draws it.
#[test]
fn the_selected_item_name_sits_fifty_nine_above_the_bottom_edge() {
let mut highlight = ToolHighlight {
last: Some(held()),
timer: 40,
};
let f = font();
let draws = highlight.draw(320, 240, true, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59));
// Creative and spectator add the fourteen pixels the experience bar
// and the food row would have taken.
let draws = highlight.draw(320, 240, false, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59 + 14));
// No timer, no name.
highlight.timer = 0;
assert!(highlight.draw(320, 240, true, &f, 0).is_empty());
}
/// `Hud.extractOverlayMessage`: the action bar and its text translate to
/// `guiHeight - 68` and `-4`, again wherever the bars above the hotbar
/// reach.
#[test]
fn the_action_bar_sits_sixty_eight_above_the_bottom_edge() {
let mut titles = Titles::default();
titles.set_overlay(Text::literal("Action bar"), false);
let f = font();
let draws = titles.overlay_message(320, 240, 0.0, &f, 0);
let translate = draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(_, y) => Some(*y),
_ => None,
}),
_ => None,
});
assert_eq!(translate, Some((240 - 68) as f32));
// The text is drawn four pixels below that origin, as vanilla's is.
let relative = draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
});
assert_eq!(relative, Some(-4));
}
}
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2015 mezz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND 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.
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: 169 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

+6
View File
@@ -14,6 +14,7 @@ pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod block_sprites;
pub mod block_states;
pub mod bluemap;
pub mod bluemap_detect;
pub mod border_render;
@@ -27,6 +28,7 @@ pub mod cit;
pub mod cit_armor;
pub mod cit_assets;
pub mod cit_glint;
pub mod client_resources;
pub mod clouds;
pub mod collisions;
pub mod command_block;
@@ -34,12 +36,15 @@ pub mod connected;
pub mod containers;
pub mod controller_ui;
pub mod crash;
pub mod custom_sky;
pub mod datapack;
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;
pub mod font;
pub mod fusion;
@@ -54,6 +59,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod terrain_model;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
+37 -1
View File
@@ -274,6 +274,8 @@ enum Out {
date: NaiveDate,
lines: Vec<String>,
stderr: Option<String>,
/// The message's level, thread and text, for the [`set_line_hook`] callback.
record: (log::Level, String, String),
},
/// Answer once everything before is written.
Flush(mpsc::SyncSender<()>),
@@ -293,12 +295,14 @@ struct Writer {
impl Writer {
fn run(mut self, out: mpsc::Receiver<Out>) {
let stderr = io::stderr();
let mut hook_alive = true;
for msg in out {
match msg {
Out::Write {
date,
lines,
stderr: message,
record: (level, thread, text),
} => {
if let Some(message) = message {
let _ = writeln!(stderr.lock(), "{message}");
@@ -319,6 +323,12 @@ impl Writer {
text.push('\n');
let _ = latest.file.write_all(text.as_bytes());
}
// After the file has it. A callback that panics is not called again: the
// panic's own log line would reach it next.
if let Some(hook) = LINE_HOOK.get().filter(|_| hook_alive) {
let call = || hook(level.as_str(), &thread, &text, &lines.join("\n"));
hook_alive = std::panic::catch_unwind(call).is_ok();
}
}
Out::Flush(done) => {
if let Some(latest) = &mut self.file {
@@ -395,6 +405,18 @@ impl Floods {
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
/// What [`set_line_hook`] installed: level (`INFO`, `WARN`, `ERROR`), thread, message, and the
/// lines as `latest.log` got them.
type LineHook = fn(&str, &str, &str, &str);
static LINE_HOOK: std::sync::OnceLock<LineHook> = std::sync::OnceLock::new();
/// Calls `hook` on the log's writer thread for each message written to the log, once the file
/// has it, so the thread that logged never waits on it. Once per process; a second call keeps
/// the first.
pub fn set_line_hook(hook: LineHook) {
let _ = LINE_HOOK.set(hook);
}
fn ours(target: &str) -> bool {
target.starts_with("horizon")
}
@@ -490,6 +512,7 @@ impl Logger {
date: now.date_naive(),
lines,
stderr,
record: (level, thread.to_owned(), message.to_owned()),
};
if let Err(mpsc::SendError(Out::Write {
stderr: Some(message),
@@ -502,12 +525,25 @@ impl Logger {
}
}
/// The `latest.log` this run writes, once `init` opened it.
static LATEST_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// The `latest.log` this run writes, absolute; None before `init` and when it logs to the
/// terminal only.
pub fn latest_path() -> Option<PathBuf> {
LATEST_PATH.get().cloned()
}
/// Starts the log in `home/logs`. The main thread shows as `main_thread` in it. Messages are
/// still printed when the file cannot be opened.
pub fn init(home: &Path, main_thread: &'static str) {
let dir = logs_dir(home);
let file = match open_latest(&dir) {
Ok(Some(latest)) => Some(latest),
Ok(Some(latest)) => {
let path = dir.join("latest.log");
let _ = LATEST_PATH.set(std::path::absolute(&path).unwrap_or(path));
Some(latest)
}
Ok(None) => {
eprintln!(
"Horizon: another game is writing {}; this one logs to the terminal only",
+141 -39
View File
@@ -90,6 +90,11 @@ fn round_to_u16(v: f32) -> u16 {
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// A model quad's own UVs in `TerrainVertex` fixed point, the packing
/// `TerrainVertex::new` does, baked once per face (`BakedFace::uv_fixed`).
pub(crate) fn baked_uv(uv: [[f32; 2]; 4]) -> [[u16; 2]; 4] {
uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)))
}
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
/// which span `[0, 2048)`: merged faces repeat their sprite once per block.
pub const TILED_UV_STEPS: f32 = 32.0;
@@ -103,6 +108,23 @@ impl TerrainVertex {
color,
}
}
/// A vertex whose UV is already in `UV_STEPS` fixed point (a model face's
/// own UVs, baked in `BakedFace::uv_fixed`).
pub(crate) fn packed_uv(
pos: [f32; 3],
uv: [u16; 2],
layer: u16,
color: u32,
light: u16,
) -> Self {
Self {
pos,
uv,
layer,
light,
color,
}
}
/// A vertex of a merged distant-terrain face, its UV in blocks from the
/// face's corner. Drawn with an instance UV scale of `1 / TILED_UV_STEPS`.
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
@@ -122,6 +144,63 @@ impl TerrainVertex {
#[cfg(test)]
mod vertex_tests {
use super::TerrainVertex;
#[test]
fn baked_face_keeps_shape_bounds_and_fixed_point_uvs() {
use crate::models::catalog::BakedFace;
use horizon_assets::{
bake::ModelQuad,
model::{Direction, Material},
};
let face = BakedFace::new(
ModelQuad {
positions: [
[-0.25, 0.5, 0.125],
[-0.25, 0.5, 0.875],
[0.625, 0.5, 0.875],
[0.625, 0.5, 0.125],
],
uv: [
[-0.25, f32::NAN],
[1.0 / 65536.0, 3.0 / 65536.0],
[1.5, 2.0],
[f32::INFINITY, f32::NEG_INFINITY],
],
facing: Direction::Up,
cull: None,
material: Material {
sprite: "test:face".into(),
force_translucent: false,
},
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
},
0,
true,
false,
None,
);
// Literal expectations: axis-ordered extrema and complements, plus
// clamping and half-step rounding in 1/32768 UV units.
let bounds: [f32; 12] = [
0.5, 0.5, 0.125, 0.875, -0.25, 0.625, 0.5, 0.5, 0.875, 0.125, 1.25, 0.375,
];
for shape in face.shapes {
assert_eq!(shape.bounds.map(f32::to_bits), bounds.map(f32::to_bits));
assert!(shape.partial);
}
assert!(!face.shapes[0].cubic);
assert!(face.shapes[1].cubic);
assert_eq!(face.uv_fixed, [[0, 0], [1, 2], [49152, 65535], [65535, 0]]);
}
#[test]
fn model_uvs_pack_exactly() {
for i in 0..=32 {
@@ -957,6 +1036,12 @@ const P: usize = 16 + 2 * B as usize;
fn pi(x: i32, y: i32, z: i32) -> usize {
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
}
/// `pi`'s index offset of a step `n`: `pi(p + n) - pi(p)`, so the samples
/// around a cell are `base + step_index(n)` rather than another `pi`.
#[inline]
const fn step_index(n: [i32; 3]) -> isize {
(n[0] + P as i32 * n[2] + (P * P) as i32 * n[1]) as isize
}
/// Blocks and light of one section plus a `B`-block border.
#[derive(Default)]
struct Padded {
@@ -1079,6 +1164,17 @@ const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
(d, d.normal(), d.opposite() as u8)
}
/// `step_index` of each direction's normal, indexed like `DIRECTIONS`: the
/// padded index of a block's neighbour is its own plus one of these.
const OFFSETS: [isize; 6] = {
let mut out = [0isize; 6];
let mut i = 0;
while i < 6 {
out[i] = step_index(DIRECTIONS[i].1);
i += 1;
}
out
};
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -1540,8 +1636,12 @@ impl Mesher<'_> {
let mut models = Vec::new();
for y in 0..16 {
for z in 0..16 {
// The padded index of the row's x = 0 cell: `pi(x, y, z)` is
// `row + x` and a neighbour adds `OFFSETS[d]`.
let row = pi(0, y, z);
for x in 0..16 {
let id = pad.blocks[pi(x, y, z)];
let cell = row + x as usize;
let id = pad.blocks[cell];
if id == 0 {
continue;
}
@@ -1596,7 +1696,7 @@ impl Mesher<'_> {
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let neighbor = pad.blocks[(cell as isize + OFFSETS[d as usize]) as usize];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> opposite & 1 != 0 {
@@ -1656,7 +1756,8 @@ impl Mesher<'_> {
let on_edge = p.iter().all(|v| (v[1] - plane).abs() < 1e-4);
let centre =
[0, 2].map(|k| p.iter().map(|v| v[k]).sum::<f32>() / 4.0);
let other = pad.blocks[pi(x, y + dy, z)];
let other =
pad.blocks[(cell as isize + step_index([0, dy, 0])) as usize];
if on_edge && connected.stacked_pane_hides(id, other, centre) {
continue;
}
@@ -1666,6 +1767,7 @@ impl Mesher<'_> {
&mut tints,
&mut out,
[x, y, z],
cell,
origin,
face,
ao,
@@ -1706,6 +1808,7 @@ impl Mesher<'_> {
tints: &mut Tints,
out: &mut SectionMesh,
pos: [i32; 3],
cell: usize,
origin: [f32; 3],
face: &BakedFace,
ao: bool,
@@ -1730,11 +1833,14 @@ impl Mesher<'_> {
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// The quad's shape (bounds, partial, cubic), baked per face for full and
// non-full blocks: a pure function of the quad and the block's `full`.
let shape = &face.shapes[usize::from(state.full)];
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
let (gray, coords, ao_factor) = if ao {
let info = &self.tables.info;
let (brightness, coords) =
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
lighting::ambient_occlusion(pad, info, state, pos, shape, q.facing);
let gray = if self.separate_ao {
[lighting::gray(directional); 4]
} else {
@@ -1744,7 +1850,7 @@ impl Mesher<'_> {
} else {
let coords = match q.cull {
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
None => lighting::flat_shaped(pad, state, pos, q.facing, shape),
};
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
@@ -1768,8 +1874,7 @@ impl Mesher<'_> {
count += 1;
}
};
let neighbour =
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
let neighbour = |n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize];
let fused = face.fusion.and_then(|rule| {
fusion.pick(
rule,
@@ -1810,44 +1915,41 @@ impl Mesher<'_> {
&mut push,
)
};
let vertex = |tint: Option<u32>, i: usize, uv: [u16; 2]| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
TerrainVertex::packed_uv(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv,
0,
color,
pack_coords(lighting::with_min(coords[i], q.light)),
)
};
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
std::array::from_fn::<_, 4, _>(|i| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv[i],
0,
color,
pack_coords(lighting::with_min(coords[i], q.light)),
),
uv[i],
)
})
let fixed = uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)));
std::array::from_fn::<_, 4, _>(|i| (vertex(tint, i, fixed[i]), uv[i]))
};
// Vanilla's pane models mirror some faces' textures; OptiFine's don't, so a tile's borders
// land where the neighbours are: on a pane, tiles take the cube's own UVs.
let standard = || q.positions.map(|p| standard_uv(q.facing, p));
let tiled = !matches!(pick, Pick::Keep);
let corners = vertices(
tint,
if state.pane && tiled {
standard()
} else {
q.uv
},
);
let corners = if state.pane && tiled {
vertices(tint, standard())
} else {
// The face's own UVs, already packed: no per-vertex rounding.
std::array::from_fn(|i| (vertex(tint, i, face.uv_fixed[i]), q.uv[i]))
};
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
@@ -19,6 +19,11 @@ use std::{convert::Infallible, sync::OnceLock};
/// `FluidRenderer.MAX_FLUID_HEIGHT`.
const MAX_HEIGHT: f32 = 0.888_888_9;
const EPSILON: f32 = 0.001;
/// `super::step_index` of the unit steps of the padded index: one block east
/// (+x), one south (+z), one up (+y).
const X_STEP: isize = 1;
const P_STEP: isize = super::P as isize;
const UP: isize = super::step_index([0, 1, 0]);
/// For the occlusion face toward `direction`: the height from the bottom over
/// which its slice flush with the `direction` side covers the whole width
@@ -224,14 +229,14 @@ pub(super) fn tesselate(
}
};
let info = &tables.info;
let at = |p: [i32; 3]| &info[pad.blocks[pi(p[0], p[1], p[2])] as usize];
let me = at([x, y, z]);
// The cell's own padded index: every other cell sampled here is a constant
// step from it (`super::step_index`), so no `pi` per sample.
let cell = pi(x, y, z) as isize;
let at = |step: isize| &info[pad.blocks[(cell + step) as usize] as usize];
let me = at(0);
let kind = me.fluid.kind();
let same = |s: &Info| s.fluid.kind().same_family(kind);
let neighbor = |d: Direction| {
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
at([x + dx, y + dy, z + dz])
};
let neighbor = |d: Direction| at(super::OFFSETS[d as usize]);
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
let should_render = |d: Direction| !same(neighbor(d)) && !occluded_by_self(me, d);
let render_up = !same(up);
@@ -265,10 +270,10 @@ pub(super) fn tesselate(
crate::mesher::emitting(pack_rgb(rgb), emission)
}
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
let height = |step: isize| -> f32 {
let s = at(step);
if same(s) {
if same(at([p[0], p[1] + 1, p[2]])) {
if same(at(step + UP)) {
1.0
} else {
s.fluid.own_height()
@@ -279,15 +284,15 @@ pub(super) fn tesselate(
-1.0
}
};
let own = height([x, y, z]);
let own = height(0);
let (mut ne, mut nw, mut se, mut sw) = (1.0f32, 1.0f32, 1.0f32, 1.0f32);
if own < 1.0 {
let n = height([x, y, z - 1]);
let s = height([x, y, z + 1]);
let e = height([x + 1, y, z]);
let w = height([x - 1, y, z]);
let n = height(-P_STEP);
let s = height(P_STEP);
let e = height(X_STEP);
let w = height(-X_STEP);
// calculateAverageHeight(self, height2, height1, corner)
let average = |h2: f32, h1: f32, corner: [i32; 3]| -> f32 {
let average = |h2: f32, h1: f32, corner: isize| -> f32 {
if h1 >= 1.0 || h2 >= 1.0 {
return 1.0;
}
@@ -313,16 +318,17 @@ pub(super) fn tesselate(
add(h2);
sum[0] / sum[1]
};
ne = average(n, e, [x + 1, y, z - 1]);
nw = average(n, w, [x - 1, y, z - 1]);
se = average(s, e, [x + 1, y, z + 1]);
sw = average(s, w, [x - 1, y, z + 1]);
ne = average(n, e, X_STEP - P_STEP);
nw = average(n, w, -X_STEP - P_STEP);
se = average(s, e, X_STEP + P_STEP);
sw = average(s, w, -X_STEP + P_STEP);
}
let light = |p: [i32; 3]| {
let above = [p[0], p[1] + 1, p[2]];
let light = |step: isize| {
let i = (cell + step) as usize;
let above = (cell + step + UP) as usize;
pack_coords(lighting::max_coords(
lighting::coords(pad, at(p), p),
lighting::coords(pad, at(above), above),
lighting::coords_at(pad, lighting::state_at(pad, info, i), i),
lighting::coords_at(pad, lighting::state_at(pad, info, above), above),
))
};
let (still, flow, overlay) = if water {
@@ -393,7 +399,7 @@ pub(super) fn tesselate(
// FluidState.shouldRenderBackwardUpFace over the 3x3 cells above.
let backward = (-1..=1).any(|ox| {
(-1..=1).any(|oz| {
let s = at([x + ox, y + 1, z + oz]);
let s = at(UP + ox as isize * X_STEP + oz as isize * P_STEP);
!same(s) && !s.solid
})
});
@@ -412,7 +418,7 @@ pub(super) fn tesselate(
corners,
layer,
color(cardinal(D::Up)),
light([x, y, z]),
light(0),
backward,
Direction::Up as usize,
);
@@ -428,12 +434,12 @@ pub(super) fn tesselate(
],
still,
color(cardinal(D::Down)),
light([x, y - 1, z]),
light(-UP),
false,
Direction::Down as usize,
);
}
let side_light = light([x, y, z]);
let side_light = light(0);
let up_shade = cardinal(D::Up);
for (i, d) in [
Direction::North,
Loaded 100 of 1035 files, more files were not shown because too many files have changed in this diff. Show more