Merge main into fix-11722-old-pick-item

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-08 01:02:24 +01:00
commit fee1085bec
887 files changed
+84176 -15757

No files matched your search

+10 -10
View File
@@ -121,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio, controllers, `discord`, `friends` and `heartbeat` (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
@@ -152,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
@@ -191,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
(`pack`), both in game.rs.
(`pack`), both in game.rs, their methods in `game/resource_tables.rs`.
## Single player runs on a server thread
+4
View File
@@ -61,6 +61,10 @@ another project's source may also be read and used, **only when that project's l
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
Generated
+35 -19
View File
@@ -147,6 +147,15 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
dependencies = [
"rustversion",
]
[[package]]
name = "arcstr"
version = "1.2.0"
@@ -243,6 +252,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -1554,7 +1569,7 @@ name = "horizon-client-render"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"base64 0.22.1",
"bytemuck",
"chrono",
"cpal",
@@ -1623,7 +1638,7 @@ dependencies = [
name = "horizon-cosmetics"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"glam",
"png",
"serde",
@@ -1819,7 +1834,7 @@ dependencies = [
name = "horizon-release-sign"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"ring",
]
@@ -1828,7 +1843,7 @@ name = "horizon-server"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"base64 0.22.1",
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
@@ -1859,7 +1874,7 @@ dependencies = [
name = "horizon-server-files"
version = "0.1.0"
dependencies = [
"base64",
"base64 0.22.1",
"horizon-core",
"horizon-protocol",
"horizon-world",
@@ -2392,9 +2407,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.16.4"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
[[package]]
name = "lru-slab"
@@ -2493,10 +2508,11 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "mysql"
version = "27.0.0"
version = "28.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
checksum = "ca3de8a73b7c70f76dbcdd4b6abc9b53bbb6c4f7bd6c79d8e8a7ea2157c02487"
dependencies = [
"arc-swap",
"bufstream",
"bytes",
"crossbeam-queue",
@@ -2520,11 +2536,11 @@ dependencies = [
[[package]]
name = "mysql_common"
version = "0.36.2"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a"
dependencies = [
"base64",
"base64 0.22.1",
"bitflags 2.13.2",
"btoi",
"byteorder",
@@ -3140,7 +3156,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
dependencies = [
"base64",
"base64 0.22.1",
"cbc",
"cms",
"der",
@@ -3192,11 +3208,11 @@ dependencies = [
[[package]]
name = "pem"
version = "3.0.6"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
dependencies = [
"base64",
"base64 0.23.1",
"serde_core",
]
@@ -4378,9 +4394,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"libc",
"mio",
@@ -4559,7 +4575,7 @@ version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"base64 0.22.1",
"log",
"once_cell",
"rustls",
+15
View File
@@ -82,6 +82,21 @@ libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
[profile.dev]
debug = "line-tables-only"
# The native mod API is generated by a build script that parses every file of game/ with syn on
# each gameplay edit; unoptimized that took ~6 s of every rebuild, optimized ~1.5 s.
[profile.dev.package.horizon-mod-api-gen]
opt-level = 3
[profile.dev.package.syn]
opt-level = 3
[profile.dev.package.proc-macro2]
opt-level = 3
[profile.release.package.horizon-mod-api-gen]
opt-level = 3
[profile.release.package.syn]
opt-level = 3
[profile.release.package.proc-macro2]
opt-level = 3
[profile.release]
lto = "thin"
codegen-units = 1
+12
View File
@@ -49,6 +49,18 @@ Everything below is in the current release.
<sub>Nightly 2026-11-05 on a Mac mini (Apple M4 Pro, Metal): vanilla's textures, no mods or shader packs, render distance 32 with distant terrain on.</sub>
## Performance
**Horizon draws more frames per second than Java Edition, and loads faster, on the same machine, world and path.** `tools/perf_bench.py` runs each game in turn, from a fresh world (seed 1234), waiting for the terrain and then flying it over a village, a forest, an ocean and a mountain. Measured on 2026-10-29/30 on an idle Windows 11 machine (Ryzen 7 9800X3D, RTX 5080), Horizon v0.2.97 as the launcher installs it, 1280x720 in a window, VSync off, three games per figure. Frames per second, mean of the four scenes, with the slowest 1% of frames in brackets:
| Render distance | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- |
| 8 chunks | 3,289 (1,327) | 2,437 (640) | 2,375 (709) |
| 16 chunks | 2,074 (598) | 1,661 (375) | 1,586 (432) |
| 32 chunks | 1,207 (333) | 1,031 (176) | 879 (264) |
"Vanilla + mods" is Sodium, Lithium, FerriteCore and ImmediatelyFast. Horizon reaches the title screen in 2.9–3.2 s against vanilla's 7.5–7.7 s (the modded client's 10.8–11.1 s), loads a scene's chunks 2.9–3.8 times faster, and playing holds a steady working set of 1.1–1.7 GB against 1.9–2.9 GB. Every scene with its confidence interval and the run's load log: [the 2026-10-29 reports](benchmarks/perf/windows-9800x3d-5080-2026-10-29.md). Method, and how to measure your own machine: [docs/benchmark.md](docs/benchmark.md). [playhorizon.dev/benchmarks](https://playhorizon.dev/benchmarks) puts these runs beside a 2016 laptop, a Mac mini and a gaming desktop.
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev), or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true):
+58 -3
View File
@@ -1,5 +1,36 @@
# Third-party notices
## Code removed for its license
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
license.
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
pack format.
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
## Catnip / Ponder
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
@@ -15,8 +46,11 @@ original classes, game shaders, textures or prepared resources are distributed h
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). Its GL backend also reports
the context's sampled textures and samplers per shader stage (usually 32 on desktop GL,
where WebGL2's minimum 16 was reported before), so a pipeline layout the driver accepts is
not refused. It is licensed under MIT or Apache-2.0; both licenses are in that folder
(`LICENSE.MIT`, `LICENSE.APACHE`).
## naga
@@ -225,7 +259,9 @@ THE SOFTWARE.
## Just Enough Items
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
The Recipes GUI sprites and settings icon under `crates/horizon-client-render/src/jei_recipes` are from
[Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems/tree/0371fa6fafa14635a356019e4604f5580b26a34f)
by mezz and contributors, under the MIT license in that directory (`LICENSE.txt`).
## Flywheel native model directional shading
@@ -254,3 +290,22 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## fdlibm
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
notice also heads both files:
```
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
Developed at SunSoft, a Sun Microsystems, Inc. business.
Permission to use, copy, modify, and distribute this
software is freely granted, provided that this notice
is preserved.
```
+55 -14
View File
@@ -917,6 +917,46 @@ impl NumericProperty {
/// (`None` removes a prototype component).
pub type Components = BTreeMap<String, Value>;
/// A stack's components read in place: its patch first, then its item's defaults. Reading one copies
/// nothing, where [`ItemModels::components`] clones the defaults for a stack with a patch (an enchanted
/// sword's are a few dozen JSON values, on every draw of it).
#[derive(Clone, Copy)]
pub struct ComponentsView<'a> {
prototype: &'a Components,
patch: &'a BTreeMap<String, Option<Value>>,
}
impl<'a> ComponentsView<'a> {
pub fn get(&self, key: &str) -> Option<&'a Value> {
match self.patch.get(key) {
Some(patched) => patched.as_ref(),
None => self.prototype.get(key),
}
}
pub fn contains_key(&self, key: &str) -> bool {
self.get(key).is_some()
}
/// Components with no patch: `components` as they are.
pub fn of(components: &'a Components) -> Self {
static NO_PATCH: BTreeMap<String, Option<Value>> = BTreeMap::new();
Self {
prototype: components,
patch: &NO_PATCH,
}
}
/// The merged map, for what needs one whole.
pub fn to_map(&self) -> Components {
let mut out = self.prototype.clone();
for (key, value) in self.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
out
}
}
/// An item stack as the resolver reads it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StackView {
@@ -1087,14 +1127,15 @@ impl ItemModels {
if stack.patch.is_empty() {
return std::borrow::Cow::Borrowed(prototype);
}
let mut out = prototype.clone();
for (key, value) in &stack.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
std::borrow::Cow::Owned(self.components_view(stack).to_map())
}
/// The stack's components, read in place (see [`ComponentsView`]).
pub fn components_view<'a>(&'a self, stack: &'a StackView) -> ComponentsView<'a> {
static NONE: Components = BTreeMap::new();
ComponentsView {
prototype: self.prototypes.get(&stack.item).unwrap_or(&NONE),
patch: &stack.patch,
}
std::borrow::Cow::Owned(out)
}
/// `ItemModelResolver.updateForTopItem`.
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
@@ -1112,7 +1153,7 @@ impl ItemModels {
ctx: &Context,
env: &dyn ItemEnv,
) {
let components = self.components(stack);
let components = self.components_view(stack);
let Some(Value::String(model_id)) = components.get("minecraft:item_model") else {
return;
};
@@ -1122,7 +1163,7 @@ impl ItemModels {
let mut state = Resolve {
models: self,
stack,
components: &components,
components,
ctx,
env,
};
@@ -1140,7 +1181,7 @@ impl ItemModels {
self.properties(stack).2
}
fn properties(&self, stack: &StackView) -> (bool, bool, f32) {
match self.components(stack).get("minecraft:item_model") {
match self.components_view(stack).get("minecraft:item_model") {
Some(Value::String(id)) => identifier(id)
.ok()
.and_then(|id| self.definitions.get(&id))
@@ -1159,7 +1200,7 @@ impl ItemModels {
struct Resolve<'a> {
models: &'a ItemModels,
stack: &'a StackView,
components: &'a Components,
components: ComponentsView<'a>,
ctx: &'a Context<'a>,
env: &'a dyn ItemEnv,
}
@@ -1455,9 +1496,9 @@ impl Resolve<'_> {
fn numeric(&self, property: &NumericProperty) -> f32 {
let owner = self.ctx.owner;
match property {
NumericProperty::BundleFullness => {
self.env.bundle_fullness(self.stack, self.components)
}
NumericProperty::BundleFullness => self
.env
.bundle_fullness(self.stack, &self.components.to_map()),
NumericProperty::Compass {
wobble,
target,
@@ -63,7 +63,7 @@ pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
/// with a matching level. An empty predicate list still needs the component.
pub(super) fn matches(
predicate: &Value,
components: &Components,
components: ComponentsView<'_>,
env: &dyn ItemEnv,
) -> Option<bool> {
let (id, expected) = predicate.as_object()?.iter().next()?;
@@ -143,13 +143,17 @@ mod tests {
json!({"minecraft:density":0}),
)]);
assert_eq!(
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
matches(
&json!({"minecraft:enchantments":[{}]}),
ComponentsView::of(&components),
&NoEnv
),
Some(true)
);
assert_eq!(
matches(
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
&components,
ComponentsView::of(&components),
&NoEnv
),
Some(false)
@@ -166,12 +170,21 @@ mod tests {
"minecraft:enchantments".into(),
json!({"minecraft:density":3}),
)]);
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
assert_eq!(
matches(&predicate, ComponentsView::of(&components), &Tags),
Some(true)
);
assert_eq!(
matches(&predicate, ComponentsView::of(&components), &NoEnv),
Some(false)
);
components.insert(
"minecraft:enchantments".into(),
json!({"minecraft:density":2}),
);
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
assert_eq!(
matches(&predicate, ComponentsView::of(&components), &Tags),
Some(false)
);
}
}
@@ -1,4 +1,5 @@
use super::*;
use serde_json::json;
#[test]
fn identifier_adds_default_namespace() {
@@ -111,3 +112,49 @@ fn custom_condition_validates_its_name_and_both_branches() {
assert!(matches!(node("example:charged", empty).unwrap(),
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
}
#[test]
fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
let mut models = ItemModels::default();
models.prototypes.insert(
"minecraft:diamond_sword".into(),
Components::from_iter([
(
"minecraft:item_model".into(),
json!("minecraft:diamond_sword"),
),
("minecraft:max_damage".into(), json!(1561)),
("minecraft:rarity".into(), json!("common")),
]),
);
let mut stack = StackView::new("minecraft:diamond_sword", 1);
stack.patch.insert(
"minecraft:enchantments".into(),
Some(json!({"minecraft:sharpness": 5})),
);
stack
.patch
.insert("minecraft:rarity".into(), Some(json!("rare")));
stack.patch.insert("minecraft:max_damage".into(), None);
let view = models.components_view(&stack);
assert_eq!(view.get("minecraft:rarity"), Some(&json!("rare")));
assert_eq!(view.get("minecraft:max_damage"), None, "a removed default");
assert_eq!(
view.get("minecraft:item_model"),
Some(&json!("minecraft:diamond_sword"))
);
assert!(view.contains_key("minecraft:enchantments"));
assert!(!view.contains_key("minecraft:custom_name"));
// Every key reads as the merged map has it.
let merged = models.components(&stack);
for key in [
"minecraft:rarity",
"minecraft:max_damage",
"minecraft:item_model",
"minecraft:enchantments",
"minecraft:custom_name",
] {
assert_eq!(view.get(key), merged.get(key), "{key}");
}
assert_eq!(view.to_map(), *merged);
}
+2 -2
View File
@@ -290,12 +290,12 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
[x * inv, y * inv, z * inv, w * inv]
}
/// JOML `Math.sin(float)` without fast math.
fn joml_sin(rad: f32) -> f32 {
pub fn joml_sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let tau = (std::f64::consts::PI * 2.0) as f32;
let a = angle + std::f32::consts::FRAC_PI_2;
@@ -217,9 +217,10 @@ pub fn stack(tag: &Tag) -> Option<Tag> {
Some(Tag::compound(out))
}
/// `ItemStackTemplate.CODEC`: an item id or `{id, count?, components?}`.
/// Encoding uses the primary record form, including for a bare item id.
fn template(tag: &Tag) -> Option<Tag> {
match tag {
Tag::String(_) => Some(tag.clone()),
Tag::String(_) => Some(Tag::compound([("id", tag.clone())])),
Tag::Compound(_) => {
let mut out = vec![("id", string(&text(tag.get("id"))?))];
if let Some(count) = int(tag.get("count")).filter(|c| *c != 1) {
@@ -1011,3 +1012,69 @@ mod tests {
assert_eq!(items[1].get("count"), Some(&Tag::Int(5)));
}
}
#[cfg(test)]
mod pot_template_tests {
use super::*;
use horizon_world::nbt::parse_snbt;
#[test]
fn decorated_pot_string_template_load_save_uses_item_compound() {
let parse = |s: &str| parse_snbt(s).expect("valid input NBT").0;
let input = |front: &str| {
let mut tag = parse(
r#"{id:"minecraft:decorated_pot",x:4,y:200,z:4,sherds:{back:{id:"minecraft:brick"},right:{id:"minecraft:archer_pottery_sherd"}},item:{id:"minecraft:wheat",count:3,components:{"minecraft:custom_name":"H290 item"}}}"#,
);
let Tag::Compound(root) = &mut tag else {
unreachable!()
};
let Some(Tag::Compound(sides)) = root.get_mut(&"sherds".into()) else {
unreachable!()
};
sides.insert("front".into(), parse(front));
tag
};
let other_fields = |loaded: &Tag, saved: &Tag| {
assert_eq!(saved.get("id"), loaded.get("id"));
for key in ["x", "y", "z", "item"] {
assert_eq!(saved.get(key), loaded.get(key), "{key}");
}
let loaded_sides = loaded.get("sherds").unwrap();
let saved_sides = saved.get("sherds").unwrap();
for side in ["back", "right"] {
assert_eq!(saved_sides.get(side), loaded_sides.get(side), "{side}");
}
assert_eq!(saved_sides.get("left"), None);
};
let plain = r#"{id:"minecraft:angler_pottery_sherd"}"#;
let defaults = r#"{id:"minecraft:angler_pottery_sherd",count:1,components:{}}"#;
let rich = r#"{id:"minecraft:angler_pottery_sherd",count:3,components:{"minecraft:custom_data":{h290:1},"minecraft:custom_name":"H290 sherd"}}"#;
// Actual canonical load/save entry, not the private template helper.
for (front, expected) in [(plain, plain), (defaults, plain), (rich, rich)] {
let loaded = input(front);
let original = loaded.clone();
let saved = canonical(&loaded).expect("actual decorated-pot codec");
other_fields(&loaded, &saved);
assert_eq!(
saved.get("sherds").unwrap().get("front"),
Some(&parse(expected))
);
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
assert_eq!(loaded, original, "read-only canonicalization");
}
println!(
"H290 actual canonical compound/default/count/components, other sides/item/name and idempotency controls admitted"
);
let loaded = input(r#""minecraft:angler_pottery_sherd""#);
let original = loaded.clone();
let saved = canonical(&loaded).expect("actual decorated-pot codec");
other_fields(&loaded, &saved);
assert_eq!(loaded, original, "read-only canonicalization");
assert_eq!(
saved.get("sherds").unwrap().get("front"),
Some(&parse(plain)),
"string template must save the primary item compound",
);
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
}
}
+62 -3
View File
@@ -490,6 +490,37 @@ impl GlyphStyle {
}
}
/// How much the gameplay guide says.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GuideDetail {
#[default]
Full,
/// Only what can be done to what's in front of the player or under the crosshair.
Basic,
Off,
}
impl GuideDetail {
pub const ALL: [GuideDetail; 3] = [GuideDetail::Full, GuideDetail::Basic, GuideDetail::Off];
/// The name in `horizon-controller.txt`.
pub fn name(self) -> &'static str {
match self {
GuideDetail::Full => "full",
GuideDetail::Basic => "basic",
GuideDetail::Off => "off",
}
}
/// Reads a name, or the `true` and `false` this option first saved. Anything else is full.
pub fn parse(value: &str) -> Self {
match value {
"basic" => GuideDetail::Basic,
"off" | "false" => GuideDetail::Off,
_ => GuideDetail::Full,
}
}
}
/// The on-screen keyboard A opens on a text field in focus mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextKeyboard {
@@ -537,6 +568,8 @@ pub fn steam_keyboard(choice: TextKeyboard) -> bool {
pub struct PadSettings {
pub enabled: bool,
pub vibration: bool,
/// The pad keeps driving the game while its window has no focus.
pub play_in_background: bool,
/// A DualSense's triggers push back (Horizon's; docs/controllers.md).
pub adaptive_triggers: bool,
pub lightbar: Lightbar,
@@ -556,6 +589,8 @@ pub struct PadSettings {
/// by default on the Switch only.
pub nintendo_letters: bool,
pub keyboard: TextKeyboard,
/// The gameplay guide: what the buttons do now, in the HUD's corners while the pad plays.
pub guide_detail: GuideDetail,
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
pub pointer_speed: f32,
pub scroll_speed: f32,
@@ -569,6 +604,7 @@ impl Default for PadSettings {
Self {
enabled: true,
vibration: true,
play_in_background: false,
adaptive_triggers: true,
lightbar: Lightbar::Health,
look_sensitivity: 0.5,
@@ -581,6 +617,7 @@ impl Default for PadSettings {
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
guide_detail: GuideDetail::Full,
pointer_speed: 1.0,
scroll_speed: 12.0,
repeat_delay: 0.35,
@@ -635,6 +672,7 @@ impl PadSettings {
match key.trim() {
"enabled" => s.enabled = flag(s.enabled),
"vibration" => s.vibration = flag(s.vibration),
"playInBackground" => s.play_in_background = flag(s.play_in_background),
"adaptiveTriggers" => s.adaptive_triggers = flag(s.adaptive_triggers),
"lightbar" => s.lightbar = Lightbar::parse(value),
"lookSensitivity" => s.look_sensitivity = unit(s.look_sensitivity),
@@ -649,6 +687,7 @@ impl PadSettings {
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
"gameplayGuide" => s.guide_detail = GuideDetail::parse(value),
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
@@ -706,7 +745,8 @@ impl PadSettings {
self.keyboard.name(),
);
text.push_str(&format!(
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
self.guide_detail.name(),
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
for (action, button) in &self.bindings {
@@ -715,6 +755,10 @@ impl PadSettings {
button.map_or("none", Button::name)
));
}
text.push_str(&format!(
"playInBackground:{}\n",
bool_text(self.play_in_background)
));
text.push_str("bindLayout:1\n");
text
}
@@ -786,7 +830,8 @@ pub fn stick_axes(
{
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
// is the right trigger and must not drive look.
(left, [left_z, right[0]])
// gilrs turns over only the axes it calls Y, and this Y is on one it doesn't.
(left, [left_z, -right[0]])
} else {
(left, right)
}
@@ -1752,6 +1797,15 @@ bind_key.hotbar.2:north
}
}
#[test]
fn playing_in_the_background_is_off_until_saved_on() {
assert!(!PadSettings::parse("vibration:false\n").play_in_background);
let mut on = PadSettings::default();
assert!(!on.play_in_background);
on.play_in_background = true;
assert!(PadSettings::parse(&on.serialize()).play_in_background);
}
#[test]
fn a_file_from_before_these_defaults_keeps_its_buttons() {
// Saved settings that never bound D-pad down: it stays on the advancements.
@@ -1793,6 +1847,7 @@ bind_key.hotbar.2:north
s.look_sensitivity = 0.75;
s.glyphs = GlyphStyle::PlayStation;
s.nintendo_letters = !s.nintendo_letters;
s.guide_detail = GuideDetail::Basic;
let text = s.serialize();
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
assert!(text.contains("bind_key.jump:east\n"));
@@ -1820,7 +1875,11 @@ bind_key.hotbar.2:north
false,
);
assert_eq!(left, [0.2, 0.3]);
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
assert_eq!(
right,
[-0.5, -0.7],
"X from LeftZ, Y from RightStickX, turned over"
);
let kept = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
@@ -0,0 +1,962 @@
//! The gameplay guide: what the buttons do right now, in the corners of the HUD while a pad plays
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
//! places the rows around what was drawn.
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
/// What a use does, by what takes it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verb {
Use,
Open,
Ride,
Trade,
Feed,
Place,
Eat,
Drink,
Shoot,
Load,
Throw,
Raise,
Equip,
Fish,
Zoom,
Boost,
}
impl Verb {
fn caption(self) -> &'static str {
match self {
Verb::Use => "horizon.controller.guide.use",
Verb::Open => "horizon.controller.guide.open",
Verb::Ride => "horizon.controller.guide.ride",
Verb::Trade => "horizon.controller.guide.trade",
Verb::Feed => "horizon.controller.guide.feed",
Verb::Place => "horizon.controller.guide.place",
Verb::Eat => "horizon.controller.guide.eat",
Verb::Drink => "horizon.controller.guide.drink",
Verb::Shoot => "horizon.controller.guide.shoot",
Verb::Load => "horizon.controller.guide.load",
Verb::Throw => "horizon.controller.guide.throw",
Verb::Raise => "horizon.controller.guide.raise",
Verb::Equip => "horizon.controller.guide.equip",
Verb::Fish => "horizon.controller.guide.fish",
Verb::Zoom => "horizon.controller.guide.zoom",
Verb::Boost => "horizon.controller.guide.boost",
}
}
}
/// `Minecraft.hitResult`, with what each button would do to it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Target {
#[default]
None,
Block {
breakable: bool,
/// The block takes the use before the held item gets its turn.
takes: Option<Verb>,
},
Entity {
attackable: bool,
takes: Option<Verb>,
},
}
/// An item in use, by what letting go of it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Active {
Shoot,
Throw,
Stop,
}
/// What the guide knows of the player this moment.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Facts {
pub spectator: bool,
pub target: Target,
/// What `Item.use` does on the target or in the air: the main hand's, else the off hand's.
pub item_use: Option<Verb>,
/// Pick Block finds something it can select or, in creative, add.
pub can_pick: bool,
pub using: Option<Active>,
/// `LocalPlayer.isHandsBusy`: paddling a boat.
pub hands_busy: bool,
pub riding: bool,
/// The vehicle jumps when told (a saddled horse).
pub vehicle_jumps: bool,
pub on_ground: bool,
pub in_water: bool,
pub flying: bool,
pub may_fly: bool,
pub gliding: bool,
/// In the air with a glider on: a jump would start gliding.
pub can_glide: bool,
/// Sneaking that the button latched, so the next press stands up.
pub sneak_latched: bool,
pub can_sprint: bool,
pub main_hand_item: bool,
pub off_hand_item: bool,
}
/// One button's line: the button and the translation key of what it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Row {
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
pub with: Option<Button>,
pub button: Button,
pub caption: &'static str,
}
impl Row {
fn new(button: Button, caption: &'static str) -> Self {
Row {
with: None,
button,
caption,
}
}
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
Row {
with: Some(with),
button,
caption,
}
}
}
/// The two columns, most useful row first.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Guide {
/// Top left: movement and the player's own actions.
pub left: Vec<Row>,
/// Top right: break, use and pick block.
pub right: Vec<Row>,
}
impl Guide {
/// The rows this detail keeps. Basic is the right column: what the buttons do to what's in
/// front of the player, or the plain face buttons in an inventory.
pub fn at_detail(self, detail: GuideDetail) -> Self {
match detail {
GuideDetail::Full => self,
GuideDetail::Basic => Guide {
left: Vec::new(),
right: self.right,
},
GuideDetail::Off => Guide {
left: Vec::new(),
right: Vec::new(),
},
}
}
}
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
/// on Start, which is always pause.
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
let row = |action: &str, caption: Option<&'static str>| {
let button = settings
.binding(action)
.filter(|button| *button != Button::Start)?;
Some(Row::new(button, caption?))
};
let player = !facts.spectator;
let any_item = facts.main_hand_item || facts.off_hand_item;
Guide {
left: [
row("key.jump", jump_caption(facts)),
row("key.sneak", sneak_caption(facts)),
row("key.sprint", facts.can_sprint.then_some("key.sprint")),
row("key.inventory", player.then_some("key.inventory")),
row(
"key.drop",
(player && facts.main_hand_item).then_some("horizon.controller.guide.drop"),
),
row(
"key.swapOffhand",
(player && any_item).then_some("horizon.controller.guide.swap"),
),
]
.into_iter()
.flatten()
.collect(),
right: [
row("key.attack", attack_caption(facts)),
row("key.use", use_caption(facts)),
row("key.pickItem", pick_caption(facts)),
]
.into_iter()
.flatten()
.collect(),
}
}
fn jump_caption(f: &Facts) -> Option<&'static str> {
if f.riding {
f.vehicle_jumps.then_some("key.jump")
} else if f.flying {
Some("horizon.controller.guide.fly_up")
} else if f.gliding {
None
} else if f.in_water {
Some("horizon.controller.guide.swim_up")
} else if f.on_ground {
Some("key.jump")
} else if f.can_glide {
Some("horizon.controller.guide.glide")
} else {
f.may_fly.then_some("horizon.controller.guide.fly")
}
}
fn sneak_caption(f: &Facts) -> Option<&'static str> {
if f.riding {
Some("horizon.controller.guide.dismount")
} else if f.flying {
Some("horizon.controller.guide.fly_down")
} else if f.gliding {
None
} else if f.in_water && !f.on_ground {
Some("horizon.controller.guide.swim_down")
} else if f.sneak_latched {
Some("horizon.controller.guide.stand")
} else {
Some("key.sneak")
}
}
/// `Minecraft.handleKeybinds` spends attack clicks while an item is in use, and
/// `startAttack` does nothing with busy hands.
fn attack_caption(f: &Facts) -> Option<&'static str> {
if f.using.is_some() || f.hands_busy {
return None;
}
match f.target {
Target::Entity {
attackable: true, ..
} if f.spectator => Some("horizon.controller.guide.spectate"),
Target::Entity {
attackable: true, ..
} => Some("horizon.controller.guide.attack"),
Target::Block {
breakable: true, ..
} => Some("horizon.controller.guide.break"),
_ => None,
}
}
/// `Minecraft.startUseItem`: what is aimed at first, then the item.
fn use_caption(f: &Facts) -> Option<&'static str> {
match f.using {
Some(Active::Shoot) => return Some("horizon.controller.guide.release_shoot"),
Some(Active::Throw) => return Some("horizon.controller.guide.release_throw"),
Some(Active::Stop) => return Some("horizon.controller.guide.release_stop"),
None => {}
}
if f.hands_busy {
return None;
}
let takes = match f.target {
Target::Block { takes, .. } | Target::Entity { takes, .. } => takes,
Target::None => None,
};
takes.or(f.item_use).map(Verb::caption)
}
fn pick_caption(f: &Facts) -> Option<&'static str> {
if f.spectator {
// key.spectatorHotbar is this button: it opens the teleport menu.
return Some("horizon.controller.guide.spectator_menu");
}
(f.can_pick && f.using.is_none()).then_some("key.pickItem")
}
/// What the crosshair is on in an inventory.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Under {
#[default]
Nothing,
/// A slot. `same` is the carried stack being its kind; `result` a crafting result, which is
/// only ever taken whole.
Slot {
item: bool,
same: bool,
result: bool,
/// The carried stack can go here.
accepts: bool,
},
Recipe {
direct_craft: bool,
alternatives: bool,
},
/// One of the recipe book's other controls.
Control,
}
/// What an inventory's rows are resolved from.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct MenuFacts {
pub under: Under,
pub carrying: bool,
/// The recipe book is showing: the bumpers turn its pages, the triggers its categories.
pub book: bool,
/// The bumpers switch tabs.
pub tabs: bool,
}
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
pub fn resolve_menu(f: &MenuFacts) -> Guide {
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
use Button::{Select, South, West};
const TAKE: &str = "horizon.controller.guide.take";
const MOVE: &str = "horizon.controller.guide.quick_move";
let under = match f.under {
// A result takes only into empty hands or the same stack.
Under::Slot {
item: true,
result: true,
same,
..
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
Under::Slot { result: true, .. } => Vec::new(),
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
Under::Slot { item, same, .. } if f.carrying => vec![
Row::new(
South,
if item && !same {
"horizon.controller.guide.swap_stack"
} else {
"horizon.controller.guide.put"
},
),
Row::new(West, "horizon.controller.guide.put_one"),
],
Under::Slot { item: true, .. } => vec![
Row::new(South, TAKE),
Row::new(West, "horizon.controller.guide.take_half"),
Row::new(North, MOVE),
Row::new(Select, "horizon.controller.guide.drop"),
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
],
Under::Nothing | Under::Slot { .. } => Vec::new(),
Under::Recipe {
direct_craft,
alternatives,
} => [
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
direct_craft.then_some(Row::pair(
LeftTrigger,
South,
"horizon.controller.direct_craft",
)),
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
]
.into_iter()
.flatten()
.collect(),
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
};
let screen = [
f.book.then_some(Row::pair(
LeftBumper,
RightBumper,
"horizon.controller.guide.pages",
)),
f.book.then_some(Row::pair(
LeftTrigger,
RightTrigger,
"horizon.controller.guide.categories",
)),
(f.tabs && !f.book).then_some(Row::pair(
LeftBumper,
RightBumper,
"horizon.controller.guide.tabs",
)),
];
let close = Row::new(East, "horizon.controller.guide.close");
let rows = std::iter::once(close)
.chain(under)
.chain(screen.into_iter().flatten());
let (right, left) =
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
Guide { left, right }
}
/// A row's height and the view's margin, in GUI pixels.
pub const ROW_HEIGHT: f32 = 11.0;
const MARGIN: f32 = 2.0;
fn overlap(a: &Rect, b: &Rect) -> bool {
a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3]
}
/// Where a column's rows go, down from a top corner of the `[width, height]` view: under whatever
/// a row would overlap in `taken`, or nowhere when that leaves the view's upper three quarters (the
/// row is dropped: the rows come in the order they matter). The placed rows join `taken`.
pub fn place_column(
view: [f32; 2],
left: bool,
widths: &[f32],
taken: &mut Vec<Rect>,
) -> Vec<Option<Rect>> {
place(view, left, false, widths, taken)
}
/// `place_column`'s rows up from a bottom corner, over what they would overlap, within the view's
/// lower quarter: the first row is the lowest.
pub fn place_column_up(
view: [f32; 2],
left: bool,
widths: &[f32],
taken: &mut Vec<Rect>,
) -> Vec<Option<Rect>> {
place(view, left, true, widths, taken)
}
fn place(
view: [f32; 2],
left: bool,
up: bool,
widths: &[f32],
taken: &mut Vec<Rect>,
) -> Vec<Option<Rect>> {
let mut y = if up {
view[1] - MARGIN - ROW_HEIGHT
} else {
MARGIN
};
widths
.iter()
.map(|&width| {
if width > view[0] - 2.0 * MARGIN {
return None;
}
let x = if left {
MARGIN
} else {
view[0] - MARGIN - width
};
let mut at = y;
loop {
let outside = if up {
at < view[1] * 0.75
} else {
at + ROW_HEIGHT > view[1] * 0.75
};
if outside {
return None;
}
let rect = [x, at, width, ROW_HEIGHT];
let hit = taken.iter().filter(|t| overlap(&rect, t));
let past = if up {
hit.map(|t| t[1]).fold(f32::INFINITY, f32::min)
} else {
hit.map(|t| t[1] + t[3]).fold(f32::NEG_INFINITY, f32::max)
};
if past.is_infinite() {
taken.push(rect);
y = if up {
at - ROW_HEIGHT - 1.0
} else {
at + ROW_HEIGHT + 1.0
};
return Some(rect);
}
at = if up {
past.floor() - ROW_HEIGHT - 1.0
} else {
past.ceil() + 1.0
};
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn walking() -> Facts {
Facts {
on_ground: true,
can_sprint: true,
main_hand_item: true,
..Facts::default()
}
}
fn on_block(f: Facts, breakable: bool, takes: Option<Verb>) -> Facts {
Facts {
target: Target::Block { breakable, takes },
..f
}
}
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
rows.iter().map(|r| (r.button, r.caption)).collect()
}
fn captions(rows: &[Row]) -> Vec<&'static str> {
rows.iter().map(|r| r.caption).collect()
}
#[test]
fn survival_on_a_block_shows_break_use_and_pick_on_the_default_buttons() {
let facts = Facts {
can_pick: true,
item_use: Some(Verb::Place),
..on_block(walking(), true, None)
};
let guide = resolve(&facts, &PadSettings::default());
assert_eq!(
rows(&guide.right),
[
(Button::RightTrigger, "horizon.controller.guide.break"),
(Button::LeftTrigger, "horizon.controller.guide.place"),
(Button::DPadLeft, "key.pickItem"),
]
);
assert_eq!(
rows(&guide.left),
[
(Button::South, "key.jump"),
(Button::RightStick, "key.sneak"),
(Button::LeftStick, "key.sprint"),
(Button::North, "key.inventory"),
(Button::DPadDown, "horizon.controller.guide.drop"),
(Button::West, "horizon.controller.guide.swap"),
]
);
}
#[test]
fn a_rebound_action_moves_its_row_and_an_unbound_one_loses_it() {
let mut settings = PadSettings::default();
settings.bind("key.attack", Some(Button::North));
settings.bind("key.pickItem", None);
settings.bind("key.jump", Some(Button::Start));
let facts = Facts {
can_pick: true,
..on_block(walking(), true, None)
};
let guide = resolve(&facts, &settings);
assert_eq!(
rows(&guide.right),
[(Button::North, "horizon.controller.guide.break")]
);
// Start only ever pauses.
assert!(!captions(&guide.left).contains(&"key.jump"));
}
#[test]
fn what_a_block_or_entity_takes_comes_before_the_held_item() {
let settings = PadSettings::default();
let chest = Facts {
item_use: Some(Verb::Place),
..on_block(walking(), true, Some(Verb::Open))
};
assert_eq!(
captions(&resolve(&chest, &settings).right),
[
"horizon.controller.guide.break",
"horizon.controller.guide.open"
]
);
let villager = Facts {
item_use: Some(Verb::Eat),
target: Target::Entity {
attackable: true,
takes: Some(Verb::Trade),
},
..walking()
};
assert_eq!(
captions(&resolve(&villager, &settings).right),
[
"horizon.controller.guide.attack",
"horizon.controller.guide.trade"
]
);
// A mob the item means nothing to: the item is used on.
let cow = Facts {
target: Target::Entity {
attackable: true,
takes: None,
},
..villager
};
assert_eq!(
captions(&resolve(&cow, &settings).right)[1],
"horizon.controller.guide.eat"
);
}
#[test]
fn nothing_to_do_leaves_the_right_column_empty() {
let settings = PadSettings::default();
assert!(resolve(&walking(), &settings).right.is_empty());
// An unbreakable block too (bedrock, a creative sword, adventure without can_break).
assert!(
resolve(&on_block(walking(), false, None), &settings)
.right
.is_empty()
);
}
#[test]
fn an_item_in_use_leaves_only_letting_go() {
let facts = Facts {
using: Some(Active::Shoot),
can_pick: true,
..on_block(walking(), true, Some(Verb::Open))
};
assert_eq!(
captions(&resolve(&facts, &PadSettings::default()).right),
["horizon.controller.guide.release_shoot"]
);
}
#[test]
fn busy_hands_neither_attack_nor_use() {
let facts = Facts {
hands_busy: true,
riding: true,
item_use: Some(Verb::Eat),
..on_block(Facts::default(), true, None)
};
assert!(resolve(&facts, &PadSettings::default()).right.is_empty());
}
#[test]
fn movement_follows_what_the_player_is_doing() {
let settings = PadSettings::default();
let first_two = |f: Facts| {
let left = resolve(&f, &settings).left;
captions(&left).into_iter().take(2).collect::<Vec<_>>()
};
assert_eq!(
first_two(Facts {
flying: true,
may_fly: true,
..Facts::default()
}),
[
"horizon.controller.guide.fly_up",
"horizon.controller.guide.fly_down"
]
);
assert_eq!(
first_two(Facts {
in_water: true,
..Facts::default()
}),
[
"horizon.controller.guide.swim_up",
"horizon.controller.guide.swim_down"
]
);
// In the air, only a glider or creative flight makes a jump do anything.
assert_eq!(first_two(Facts::default()), ["key.sneak", "key.inventory"]);
assert_eq!(
first_two(Facts {
can_glide: true,
..Facts::default()
})[0],
"horizon.controller.guide.glide"
);
assert_eq!(
first_two(Facts {
sneak_latched: true,
on_ground: true,
..Facts::default()
})[1],
"horizon.controller.guide.stand"
);
// A boat can't jump; a saddled horse can; both dismount.
assert_eq!(
first_two(Facts {
riding: true,
on_ground: true,
..Facts::default()
})[0],
"horizon.controller.guide.dismount"
);
assert_eq!(
first_two(Facts {
riding: true,
vehicle_jumps: true,
..Facts::default()
}),
["key.jump", "horizon.controller.guide.dismount"]
);
// Gliding: neither jump nor sneak does anything.
assert_eq!(
first_two(Facts {
gliding: true,
..Facts::default()
}),
["key.inventory"]
);
}
#[test]
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
let facts = Facts {
spectator: true,
flying: true,
may_fly: true,
main_hand_item: true,
target: Target::Entity {
attackable: true,
takes: None,
},
..Facts::default()
};
let guide = resolve(&facts, &PadSettings::default());
assert_eq!(
captions(&guide.right),
[
"horizon.controller.guide.spectate",
"horizon.controller.guide.spectator_menu"
]
);
assert_eq!(
captions(&guide.left),
[
"horizon.controller.guide.fly_up",
"horizon.controller.guide.fly_down"
]
);
}
#[test]
fn rows_stack_from_their_corner() {
let mut taken = Vec::new();
let left = place_column([200.0, 200.0], true, &[50.0, 30.0], &mut taken);
assert_eq!(left[0], Some([2.0, 2.0, 50.0, 11.0]));
assert_eq!(left[1], Some([2.0, 14.0, 30.0, 11.0]));
let right = place_column([200.0, 200.0], false, &[40.0], &mut taken);
assert_eq!(right[0], Some([158.0, 2.0, 40.0, 11.0]));
}
#[test]
fn a_row_goes_under_what_it_would_overlap() {
// The effect icons in the top right corner.
let mut taken = vec![[150.0, 1.0, 50.0, 24.0]];
let right = place_column([200.0, 200.0], false, &[40.0, 40.0], &mut taken);
assert_eq!(right[0], Some([158.0, 26.0, 40.0, 11.0]));
assert_eq!(right[1], Some([158.0, 38.0, 40.0, 11.0]));
}
#[test]
fn the_columns_of_a_narrow_view_never_overlap_and_late_rows_drop() {
// One quarter of a small window, a boss bar across its top: the rows have 31..67 left.
let view = [160.0, 90.0];
let bar = [0.0, 12.0, 160.0, 19.0];
let mut taken = vec![bar];
let right = place_column(view, false, &[90.0, 90.0, 90.0], &mut taken);
let left = place_column(view, true, &[90.0; 6], &mut taken);
let placed: Vec<Rect> = right.iter().chain(&left).flatten().copied().collect();
for (i, a) in placed.iter().enumerate() {
assert!(a[1] + a[3] <= 67.5 && a[0] >= 0.0 && a[0] + a[2] <= 160.0);
assert!(!overlap(a, &bar));
for b in &placed[i + 1..] {
assert!(!overlap(a, b), "{a:?} overlaps {b:?}");
}
}
// Three rows' worth of room, and the right column came first.
assert_eq!(right.iter().flatten().count(), 3);
assert_eq!(left.iter().flatten().count(), 0);
}
#[test]
fn rows_stack_up_from_a_bottom_corner_and_drop_above_its_quarter() {
let view = [200.0, 100.0];
let mut taken = vec![[0.0, 80.0, 60.0, 5.0]];
let left = place_column_up(view, true, &[50.0, 50.0], &mut taken);
assert_eq!(left[0], Some([2.0, 87.0, 50.0, 11.0]));
// Over the panel at 80 would be 68, under the lower quarter's 75.
assert_eq!(left[1], None);
let right = place_column_up(view, false, &[40.0], &mut taken);
assert_eq!(right[0], Some([158.0, 87.0, 40.0, 11.0]));
}
fn short(rows: &[Row]) -> Vec<&str> {
rows.iter()
.map(|r| r.caption.rsplit('.').next().unwrap_or(""))
.collect()
}
#[test]
fn an_inventorys_face_buttons_go_right_and_the_rest_left() {
let on = |under, carrying| {
resolve_menu(&MenuFacts {
under,
carrying,
..MenuFacts::default()
})
};
let slot = |item, same, result| Under::Slot {
item,
same,
result,
accepts: true,
};
let close_only = ["close"];
let empty: [&str; 0] = [];
for quiet in [Under::Nothing, slot(false, false, false)] {
let guide = on(quiet, false);
assert_eq!(short(&guide.right), close_only);
assert_eq!(short(&guide.left), empty);
}
let item = on(slot(true, false, false), false);
assert_eq!(
short(&item.right),
["close", "take", "take_half", "quick_move"]
);
assert_eq!(short(&item.left), ["drop", "drop_stack"]);
assert_eq!(
short(&on(slot(false, false, false), true).right),
["close", "put", "put_one"]
);
assert_eq!(
short(&on(slot(true, false, false), true).right),
["close", "swap_stack", "put_one"]
);
assert_eq!(
short(&on(slot(true, true, false), true).right),
["close", "put", "put_one"]
);
assert_eq!(
short(&on(slot(true, false, true), false).right),
["close", "take", "quick_move"]
);
let recipe = on(
Under::Recipe {
direct_craft: true,
alternatives: true,
},
false,
);
assert_eq!(
short(&recipe.right),
["close", "place_recipe", "place_recipe_all", "alternatives"]
);
assert_eq!(recipe.left.len(), 1);
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
assert_eq!(recipe.left[0].button, Button::South);
let plain = on(
Under::Recipe {
direct_craft: false,
alternatives: false,
},
false,
);
assert_eq!(
short(&plain.right),
["close", "place_recipe", "place_recipe_all"]
);
assert!(plain.left.is_empty());
}
#[test]
fn an_inventorys_screen_rows_follow_its_book_and_tabs() {
let left = |book, tabs| {
resolve_menu(&MenuFacts {
book,
tabs,
..MenuFacts::default()
})
.left
};
assert!(left(false, false).is_empty());
assert_eq!(short(&left(true, false)), ["pages", "categories"]);
assert_eq!(short(&left(false, true)), ["tabs"]);
}
#[test]
fn a_row_wider_than_the_view_is_dropped() {
let mut taken = Vec::new();
assert_eq!(
place_column([50.0, 200.0], true, &[60.0, 20.0], &mut taken),
[None, Some([2.0, 2.0, 20.0, 11.0])]
);
}
}
#[cfg(test)]
mod detail_tests {
use super::*;
fn rows() -> Guide {
Guide {
left: vec![Row::new(Button::South, "left")],
right: vec![Row::new(Button::East, "right")],
}
}
#[test]
fn full_keeps_both_columns() {
let guide = rows().at_detail(GuideDetail::Full);
assert_eq!((guide.left.len(), guide.right.len()), (1, 1));
}
#[test]
fn basic_keeps_the_right_column() {
let guide = rows().at_detail(GuideDetail::Basic);
assert_eq!((guide.left.len(), guide.right.len()), (0, 1));
}
#[test]
fn off_keeps_nothing() {
let guide = rows().at_detail(GuideDetail::Off);
assert_eq!((guide.left.len(), guide.right.len()), (0, 0));
}
#[test]
fn a_file_saved_with_the_old_switch_keeps_its_meaning() {
assert_eq!(GuideDetail::parse("true"), GuideDetail::Full);
assert_eq!(GuideDetail::parse("false"), GuideDetail::Off);
assert_eq!(GuideDetail::parse("basic"), GuideDetail::Basic);
}
}
#[cfg(test)]
mod slot_tests {
use super::*;
/// The rows for the slot: South or West, whichever column they're in.
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
let guide = resolve_menu(&MenuFacts {
under: Under::Slot {
item,
same,
result,
accepts,
},
carrying,
..MenuFacts::default()
});
[guide.left, guide.right]
.iter()
.flatten()
.filter(|row| matches!(row.button, Button::South | Button::West))
.count()
}
#[test]
fn a_slot_that_refuses_the_carried_stack_offers_nothing() {
assert_eq!(rows(false, false, false, false, true), 0);
assert_eq!(rows(true, true, false, false, true), 0);
assert!(rows(false, false, false, true, true) > 0);
}
#[test]
fn a_result_is_taken_only_into_empty_hands_or_the_same_stack() {
assert!(rows(true, false, true, true, false) > 0);
assert!(rows(true, true, true, true, true) > 0);
assert_eq!(rows(true, false, true, true, true), 0);
}
}
+1
View File
@@ -6,6 +6,7 @@
pub mod actions;
pub mod clipboard;
pub mod gamepad;
pub mod gameplay_guide;
pub mod held;
pub mod input;
pub mod interaction_input;
+11 -4
View File
@@ -42,7 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -73,8 +74,8 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
@@ -113,12 +114,18 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
heartbeat = ["horizon-telemetry/live"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
+1 -1
View File
@@ -26,7 +26,7 @@ horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
@@ -245,6 +245,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.fxaa,
settings.taa,
settings.upscale,
0.85,
);
let mut notes = Vec::new();
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
+259 -5
View File
@@ -47,6 +47,53 @@ struct Channel {
world: u32,
/// The sound file it started and when, to tell another player's copy of it.
started: Option<(String, std::time::Instant)>,
/// Its sound event plays clearly through a mod's muffle (`Audio::muffle`).
unmuffled: bool,
}
/// The cutoff of the muffle's low-pass at full strength: the world heard through ringing ears.
const MUFFLED_CUTOFF: f32 = 300.0;
/// The cutoff it sweeps down from as its strength rises.
const OPEN_CUTOFF: f32 = 20_000.0;
/// How long the muffle takes to glide to a new level: a game tick's step (50 ms) would click.
const MUFFLE_GLIDE: f32 = 0.02;
/// A mod's muffle (`Audio::muffle`) in the mixer: the other sounds are mixed apart, low-passed by
/// two one-pole stages and turned down, then added to the sounds it leaves clear. While it is off
/// (never set, or faded out) the sounds mix straight into the output, as without it.
#[derive(Default)]
struct Muffle {
/// `[amount, lowpass]`, each 0 to 1, as the game's tick last set them.
target: [f32; 2],
/// Where the output is now, gliding to `target`.
current: [f32; 2],
/// The low-pass stages' state for the left and right output channels.
stages: [[f32; 2]; 2],
/// The muffled sounds of one buffer, reused.
bus: Vec<f32>,
}
impl Muffle {
fn active(&self) -> bool {
self.target != [0.0; 2] || self.current.iter().any(|v| *v > 1e-4)
}
/// Adds `bus` (the muffled sounds) to `data`, low-passed and turned down.
fn apply(&mut self, bus: &[f32], data: &mut [f32], channels: usize, rate: u32) {
let rate = rate as f32;
let glide = 1.0 - (-1.0 / (MUFFLE_GLIDE * rate)).exp();
for (frame, muffled) in data.chunks_mut(channels).zip(bus.chunks(channels)) {
for (current, target) in self.current.iter_mut().zip(self.target) {
*current += (target - *current) * glide;
}
let [amount, lowpass] = self.current;
let gain = 1.0 - amount;
let cutoff = OPEN_CUTOFF * (MUFFLED_CUTOFF / OPEN_CUTOFF).powf(lowpass);
let a = 1.0 - (-std::f32::consts::TAU * cutoff / rate).exp();
for ((out, s), stage) in frame.iter_mut().zip(muffled).zip(&mut self.stages) {
stage[0] += (s - stage[0]) * a;
stage[1] += (stage[0] - stage[1]) * a;
// Dry at no strength, so a muffle that only turns sounds down leaves them as they were.
*out += (s + (stage[1] - s) * lowpass) * gain;
}
}
}
}
#[derive(Default)]
struct Mixer {
@@ -65,6 +112,8 @@ struct Mixer {
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
/// A mod's muffle of the sounds it does not exempt.
muffle: Muffle,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -100,6 +149,21 @@ pub struct Heard {
pub range: f32,
pub looping: bool,
}
/// A mod's muffle (`Audio::muffle`): its full strength, and the ticks it fades over and has left.
#[derive(Clone, Copy, Debug, PartialEq)]
struct MuffleFade {
amount: f32,
lowpass: f32,
ticks: u32,
left: u32,
}
impl MuffleFade {
/// `[amount, lowpass]` now: full when set, falling in a straight line to none.
fn level(&self) -> [f32; 2] {
let left = self.left as f32 / self.ticks as f32;
[self.amount * left, self.lowpass * left]
}
}
/// The mixer side of the engine (`Backend`).
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
@@ -247,6 +311,8 @@ pub struct Audio {
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
/// A mod's muffle fading out (`muffle`), advanced by `tick`.
muffle: Option<MuffleFade>,
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
#[cfg(feature = "audio")]
default_seen: Option<String>,
@@ -439,6 +505,7 @@ impl Backend for MixerBackend {
owner: self.owner,
world: self.world,
started: None,
unmuffled: false,
},
);
Some(id)
@@ -466,6 +533,9 @@ impl Backend for MixerBackend {
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
self.with(channel, |c| c.unmuffled = unmuffled);
}
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
@@ -702,6 +772,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
// A mod's muffle: the sounds it doesn't exempt mix into a bus of their own first.
let muffling = mixer.muffle.active();
let mut bus = std::mem::take(&mut mixer.muffle.bus);
if muffling {
bus.clear();
bus.resize(data.len(), 0.0);
}
for (id, [left, right, gain], step) in voices.iter().copied() {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
@@ -711,7 +788,12 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
for frame in data.chunks_mut(channels) {
let out: &mut [f32] = if muffling && !c.unmuffled {
&mut bus
} else {
&mut *data
};
for frame in out.chunks_mut(channels) {
let mut i = c.position as usize;
if i + 1 >= frames {
if c.looping && frames > 1 {
@@ -746,6 +828,14 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
}
mixer.voices = voices;
if muffling {
mixer.muffle.apply(&bus, data, channels, rate);
} else {
// Off: the next muffle starts from silence, not from the last one's filter.
mixer.muffle.current = [0.0; 2];
mixer.muffle.stages = [[0.0; 2]; 2];
}
mixer.muffle.bus = bus;
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
@@ -1071,12 +1161,13 @@ impl Audio {
listener: [0.0; 3],
listener_yaw: 0.0,
master: 1.0,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
random: crate::random_source::create(),
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
muffle: None,
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
@@ -1232,12 +1323,13 @@ impl Audio {
listener: [0.0; 3],
listener_yaw: 0.0,
master: self.master,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
random: crate::random_source::create(),
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
muffle: None,
// The device belongs to the first player's output, which looks after it.
#[cfg(feature = "audio")]
default_seen: self.default_seen.clone(),
@@ -1370,6 +1462,50 @@ impl Audio {
}
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
// A paused game holds a mod's muffle where it is, as it holds the sounds.
if !paused && let Some(fade) = &mut self.muffle {
fade.left = fade.left.saturating_sub(1);
let level = fade.level();
if fade.left == 0 {
self.muffle = None;
self.engine.set_unmuffled(Vec::new(), &mut self.backend);
}
self.set_muffle_level(level);
}
}
/// A mod's muffle (`native_muffle_sounds`): `amount` (0 to 1) of every sound's volume taken
/// away and `lowpass` (0 to 1) dulling it, from untouched to a 300 Hz low-pass, fading in a
/// straight line to none over `ticks` client ticks; the sound events in `exempt` (namespaced,
/// `mymod:ear.ringing`) play clearly through it, those already playing too. It replaces any
/// muffle in place; `ticks` 0 or no strength ends it. The output's, so in splitscreen every
/// player hears it. False for an amount or strength outside 0 to 1.
pub fn muffle(&mut self, amount: f32, lowpass: f32, ticks: u32, exempt: &[&str]) -> bool {
if !(0.0..=1.0).contains(&amount) || !(0.0..=1.0).contains(&lowpass) {
return false;
}
let on = ticks > 0 && (amount > 0.0 || lowpass > 0.0);
let exempt = if on {
exempt
.iter()
.map(|e| crate::sound_events::normalize(e))
.collect()
} else {
Vec::new()
};
self.engine.set_unmuffled(exempt, &mut self.backend);
self.muffle = on.then_some(MuffleFade {
amount,
lowpass,
ticks,
left: ticks,
});
self.set_muffle_level(self.muffle.map_or([0.0; 2], |fade| fade.level()));
true
}
fn set_muffle_level(&mut self, level: [f32; 2]) {
if let Ok(mut m) = self.backend.mixer.lock() {
m.muffle.target = level;
}
}
/// The `uiSoundReserve` extra: positional sounds leave
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
@@ -1482,7 +1618,7 @@ impl Audio {
"minecraft:ui.button.click",
1.0,
0.25,
SoundEngine::unseeded(),
crate::random_source::create(),
),
false,
);
@@ -1624,7 +1760,7 @@ impl Audio {
f64::from(pos[2]) + 0.5,
];
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
let instance = SoundInstance::for_jukebox_song(&event, at, crate::random_source::create());
self.play_instance(instance, true).0
}
/// Stop and drop a held instance.
@@ -1835,6 +1971,7 @@ mod tests {
owner: 0,
world: 0,
started: None,
unmuffled: false,
}
}
@@ -2255,6 +2392,123 @@ mod tests {
assert_eq!(data, [0.5; 2]);
}
/// One second of a `hz` sine at 48 kHz, amplitude 0.3 (looped, it repeats seamlessly).
fn sine(hz: f32) -> Vec<f32> {
(0..48_000)
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin())
.collect()
}
fn rms(data: &[f32]) -> f32 {
(data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt()
}
/// `seconds` of the mixer's stereo output, in the 10 ms buffers an output stream asks for.
fn listen(m: &mut Mixer, seconds: f32) -> Vec<f32> {
let mut out = Vec::new();
let mut buffer = [0.0; 960];
for _ in 0..(seconds * 100.0).round() as usize {
mix(m, &mut buffer, 2, 48_000);
out.extend_from_slice(&buffer);
}
out
}
#[test]
fn a_muffle_dulls_and_quietens_every_sound_but_the_exempt_ones() {
// A looping tone heard through a muffle of `[amount, lowpass]`, once it has settled.
let heard = |hz: f32, muffle: [f32; 2], unmuffled: bool| {
let mut m = Mixer::default();
let mut c = channel(sine(hz), None, [0.0, 0.0, 2.0], true);
c.unmuffled = unmuffled;
m.channels.insert(1, c);
m.muffle.target = muffle;
listen(&mut m, 0.2);
listen(&mut m, 0.2)
};
let plain = |hz: f32| rms(&heard(hz, [0.0; 2], false));
// Fully low-passed, a 6 kHz whine all but goes and a 100 Hz hum keeps most of its level.
assert!(rms(&heard(6000.0, [0.0, 1.0], false)) < plain(6000.0) * 0.05);
assert!(rms(&heard(100.0, [0.0, 1.0], false)) > plain(100.0) * 0.8);
// Half the volume taken and no low-pass: half the level, the tone itself untouched.
let ducked = rms(&heard(6000.0, [0.5, 0.0], false)) / plain(6000.0);
assert!((ducked - 0.5).abs() < 1e-3, "{ducked}");
// An exempt sound plays through as if there were no muffle, sample for sample.
assert_eq!(
heard(6000.0, [0.5, 1.0], true),
heard(6000.0, [0.0; 2], false)
);
}
#[test]
fn a_faded_muffle_leaves_the_sound_as_it_was() {
let mixer = || {
let mut m = Mixer::default();
m.channels
.insert(1, channel(sine(3000.0), None, [2.0, 0.0, 1.0], true));
m
};
let (mut muffled, mut plain) = (mixer(), mixer());
muffled.muffle.target = [0.7, 1.0];
assert_ne!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
muffled.muffle.target = [0.0; 2];
listen(&mut muffled, 0.5);
listen(&mut plain, 0.5);
assert_eq!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
}
#[test]
fn a_mods_muffle_fades_with_the_game_ticks_and_spares_its_exempt_sounds() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
audio.engine.registry.add_file(
"minecraft",
br#"{"ambient.cave":{"sounds":["ambient/cave/cave1"]}}"#,
&|_, _| true,
);
audio.engine.registry.add_file(
"hztest",
br#"{"ear.ringing":{"sounds":["ear/ringing"]}}"#,
&|_, _| true,
);
// The engine makes its channels; with no output, none loads a file.
audio.engine.loaded = true;
let marks = |audio: &Audio| {
let m = audio.backend.mixer.lock().unwrap();
let mut marks: Vec<_> = m
.channels
.iter()
.map(|(&id, c)| (id, c.unmuffled))
.collect();
marks.sort();
marks
};
let level = |audio: &Audio| audio.backend.mixer.lock().unwrap().muffle.target;
let close =
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
// Two sounds already playing, and one exempt sound started under the muffle.
audio.play("ambient.cave", None, 1.0, 1.0);
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
assert!(audio.muffle(0.8, 1.0, 4, &["hztest:ear.ringing"]));
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
assert_eq!(marks(&audio), [(1, false), (2, true), (3, true)]);
assert!(close(level(&audio), [0.8, 1.0]));
let ticks = TickContext::default();
audio.tick(false, &ticks);
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
// A paused game holds it.
audio.tick(true, &ticks);
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
for _ in 0..3 {
audio.tick(false, &ticks);
}
assert_eq!(level(&audio), [0.0; 2]);
// Over: its exemptions go with it.
assert_eq!(marks(&audio), [(1, false), (2, false), (3, false)]);
// Out of range changes nothing.
assert!(!audio.muffle(1.5, 0.0, 10, &[]));
assert_eq!(level(&audio), [0.0; 2]);
}
#[test]
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
// No fingerprint (not Linux, or the cards could not be read): the list
@@ -77,6 +77,9 @@ pub struct BlockEntities {
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// State-aware native factory ownership. Shared across level snapshots;
/// an empty slice means the declarations have not yet been compiled.
stateful_entities: Arc<[bool]>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
@@ -209,6 +212,21 @@ impl BlockEntities {
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_stateful_entities(&mut self, states: Vec<bool>) {
self.stateful_entities = states.into();
}
pub fn has_stateful_entity_table(&self) -> bool {
!self.stateful_entities.is_empty()
}
pub fn stateful_entities(&self) -> Arc<[bool]> {
self.stateful_entities.clone()
}
pub fn stateful_entity(&self, state: u32) -> bool {
self.stateful_entities
.get(state as usize)
.copied()
.unwrap_or(false)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
@@ -3,8 +3,9 @@
//! decorated pots, conduits, enchanting-table and lectern books and copper
//! golem statues, End portals and End gateways. Models are vanilla's baked `LayerDefinitions` layers
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
//! vanilla renderer. Faces carry their tint and world normal: the entity
//! shader (and a shader pack) applies `minecraft_mix_light` from the normal, as
//! vanilla's does.
use crate::{
Result,
bidi::visual,
@@ -128,6 +129,14 @@ fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
}
/// A world normal as `Vertex::normal` holds it: RGBA8_SNORM, `w` positive so
/// the entity shader and a shader pack both shade by it. Block-entity models
/// carry no normals of their own (vanilla computes each face's from its
/// polygon, then `minecraft_mix_light` uses it in the shader).
fn pack_normal(normal: Vec3) -> [i8; 4] {
let c = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8;
[c(normal.x), c(normal.y), c(normal.z), 127]
}
/// Everything a draw call needs besides the geometry.
#[derive(Clone, Copy)]
@@ -165,6 +174,7 @@ pub fn draw_part(
light,
lights,
glow,
..
} = paint;
for poly in &part.polygons {
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
@@ -177,11 +187,21 @@ pub fn draw_part(
} else {
normal
};
let color = pack_color(tint, 255, diffuse(normal, lights));
// The colour is the tint alone when the caller shades by the level's
// lights: the entity shader applies `minecraft_mix_light` from the
// normal, as vanilla does, so a shader pack receives the unshaded
// colour Iris gives it (a baked shade would be applied a second time
// by the pack's lighting). Other lights (an item preview's
// `Lighting.Entry`) are not the shader's to know, so those stay baked
// into the colour, as before.
let baked = !(lights == diffuse_lights(false) || lights == diffuse_lights(true));
let brightness = if baked { diffuse(normal, lights) } else { 1.0 };
let color = pack_color(tint, 255, brightness);
let normal = if baked { [0; 4] } else { pack_normal(normal) };
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal: [0; 4],
normal,
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
@@ -653,15 +673,16 @@ fn sign_text_calls(
let line_height = data.line_height();
let midpoint = (4.0 * line_height / 2.0).floor();
let dark = 0xFF00_0000 | text.dark_color();
let (color, outline, light) = if text.glowing {
let (color, outline, light, tier) = if text.glowing {
let c = text.text_color();
(
0xFF00_0000 | c,
if c == 0 || near { dark } else { 0 },
240 | 240 << 8,
crate::mesher::LOW,
)
} else {
(dark, 0, vertex_light(packed))
(dark, 0, vertex_light(packed), 0)
};
let advance = |c: char| (scene.glyph)(c).advance;
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
@@ -693,6 +714,7 @@ fn sign_text_calls(
color: run,
outline,
light,
tier,
}),
}
pen += span.text.chars().map(advance).sum::<f32>();
@@ -707,6 +729,7 @@ fn sign_text_calls(
color,
outline,
light,
tier,
});
}
}
@@ -761,7 +784,7 @@ pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut V
.transform_point3(Vec3::new(cx, cy, z))
.to_array(),
uv,
layer: 0,
layer: u32::from(call.tier) << 16,
color: c,
light: call.light,
@@ -830,6 +853,9 @@ pub struct TextCall {
pub outline: u32,
/// Horizon packed light (sky*16 | block*16 << 8).
pub light: u32,
/// HDR emission tier of its glyphs (`mesher::hdr_tier`), 0 for none: glowing sign text
/// shines in HDR output and in shader packs (`horizonTier`), as full-bright text does in SDR.
pub tier: u8,
}
/// Inputs from the game for one frame of block-entity rendering.
@@ -3774,6 +3800,105 @@ mod tests {
// Straight down: ambient only.
assert!((diffuse(-Vec3::Y, lights) - 0.4).abs() < 1e-6);
}
/// A face's colour carries its tint alone and its normal carries the
/// shading: the entity shader applies `minecraft_mix_light` from it, as a
/// shader pack's lighting does, so a baked shade would be applied twice.
#[test]
fn a_face_keeps_its_tint_and_normal_for_the_shader_to_shade() {
let quad = |y: f32, z0: f32, z1: f32| {
[
[0.0, y, z0, 0.0, 0.0],
[0.0, y, z1, 0.0, 1.0],
[16.0, y, z1, 1.0, 1.0],
[16.0, y, z0, 1.0, 0.0],
]
};
// Opposite windings: the first faces up, the second down.
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![quad(0.0, 0.0, 16.0), quad(16.0, 16.0, 0.0)],
children: vec![],
};
let mut out = Vec::new();
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0x123456,
light: 0,
lights: diffuse_lights(false),
glow: None,
},
&mut out,
);
assert_eq!(out.len(), 8);
// The same tint on both faces: no directional shade baked in (`pack_color`
// stores the tint in the vertex colour's BGR byte order).
let tint = pack_color(0x12_3456, 255, 1.0);
assert!(out.iter().all(|v| v.color == tint));
let (up, down) = (&out[0], &out[4]);
assert_eq!(up.normal, [0, 127, 0, 127]);
assert_eq!(down.normal, [0, -127, 0, 127]);
// The shader still gets the difference the old baked colours held.
let lights = diffuse_lights(false);
let up_normal = Vec3::new(0.0, f32::from(up.normal[1]) / 127.0, 0.0);
let down_normal = Vec3::new(0.0, f32::from(down.normal[1]) / 127.0, 0.0);
assert!(diffuse(up_normal, lights) > diffuse(down_normal, lights));
}
/// Lights the entity shader does not know (an item preview's
/// `Lighting.Entry`) stay baked into the colour, and `normal.w` 0 tells
/// the shader to keep it: `game/item_draw` passes those for block-entity
/// items in inventories and hands.
#[test]
fn lights_other_than_the_levels_stay_baked() {
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[
[0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 16.0, 0.0, 1.0],
[16.0, 0.0, 16.0, 1.0, 1.0],
[16.0, 0.0, 0.0, 1.0, 0.0],
]],
children: vec![],
};
let lights = (Vec3::X, Vec3::Z);
let mut out = Vec::new();
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0x12_3456,
light: 0,
lights,
glow: None,
},
&mut out,
);
let baked = pack_color(0x12_3456, 255, diffuse(Vec3::Y, lights));
assert!(out.iter().all(|v| v.color == baked));
assert!(out.iter().all(|v| v.normal == [0; 4]));
assert_ne!(baked, pack_color(0x12_3456, 255, 1.0));
}
#[test]
fn part_poses_compose_like_model_part() {
let part = ModelPart {
@@ -3917,6 +4042,7 @@ mod tests {
color: 0xFFFF_FF00,
outline: 0xFF33_3300,
light: 240 | 240 << 8,
tier: 0,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
@@ -3990,6 +4116,7 @@ mod tests {
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
tier: 0,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
@@ -993,7 +993,9 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
scene.nether = true;
let nether_closed = render_cached(&scene);
assert_same_vertices(&nether_closed, &render_direct(&renderer, &scene, &blocks));
assert_vertices_differ(&closed_again, &nether_closed);
// The nether is the entity shader's flag now (`entity_light`'s `l1` and
// `globals.time.y`), not a baked colour, so the cached geometry serves both.
assert_same_vertices(&closed_again, &nether_closed);
}
#[test]
+5
View File
@@ -2071,6 +2071,11 @@ impl ChatScreen {
for i in 0..limit {
let y = ry + 12 * i as i32;
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
// A row for the pad: nothing else can pick a suggestion without Tab.
crate::widgets::target_as(
[rx as f32, y as f32, rw as f32, 12.0],
crate::widgets::Kind::Row,
);
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
if moved {
self.select(l, (i + l.offset) as isize);
@@ -620,6 +620,14 @@ impl Chunk {
entities: Vec::new(),
}
}
/// The original `structures.starts`, including starts with unknown pieces.
pub fn saved_structure_starts(&self) -> Option<&horizon_world::nbt::Compound> {
let structures = self.saved.as_ref()?.root.get(&"structures".into())?;
match structures.get("starts")? {
horizon_world::nbt::Tag::Compound(starts) => Some(starts),
_ => None,
}
}
/// The minimum block Y of the column.
#[inline]
pub fn min_y(&self) -> i32 {
@@ -59,6 +59,9 @@ pub enum BlockEntity {
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CopperGolemStatueBlockEntity`: its own components, which hold the
/// custom name of the golem it was (`createStatue`).
CopperGolemStatue(Compound),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
+23 -2
View File
@@ -208,7 +208,7 @@ where
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
movement,
@@ -279,7 +279,7 @@ impl SceneCollisions<'_, '_> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
movement,
@@ -300,6 +300,7 @@ impl SceneCollisions<'_, '_> {
CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
kind: horizon_world::collision_context::EntityContextKind::Living,
y,
descending: false,
main_hand: Some(CollisionItem::EMPTY),
@@ -335,6 +336,26 @@ where
{
no_collision_in(world, bounds, true)
}
/// `CollisionGetter.noCollision(entity, bounds)`: retain the real entity snapshot.
pub fn no_collision_with_context<W, E>(
world: &mut W,
bounds: Aabb,
context: CollisionContext<'_>,
) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
{
no_world_collision(
world,
CollisionCheckRequest {
entity: context.entity().unwrap_or(EntityIdentity(0)),
context,
bounds,
candidate_budget: 1_000_000,
},
)
.unwrap_or(false)
}
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
@@ -395,6 +395,9 @@ impl BlockEntity {
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name.ends_with("copper_golem_statue") {
return Some(Self::CopperGolemStatue(Default::default()));
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -460,6 +463,7 @@ impl BlockEntity {
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
Self::Skull(_) => is_skull(name),
Self::Banner(_) => name.ends_with("_banner"),
Self::CopperGolemStatue(_) => name.ends_with("copper_golem_statue"),
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
Self::Mod(_) => !name.starts_with("minecraft:"),
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
@@ -510,6 +514,7 @@ impl BlockEntity {
Self::Lectern(_) => "minecraft:lectern",
Self::Sculk(s) => s.id(),
Self::Banner(_) => "minecraft:banner",
Self::CopperGolemStatue(_) => "minecraft:copper_golem_statue",
Self::Crafter(_) => "minecraft:crafter",
Self::CreakingHeart(_) => "minecraft:creaking_heart",
Self::Spawner(_) => "minecraft:mob_spawner",
@@ -558,7 +563,7 @@ impl BlockEntity {
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Banner(_) | Self::CopperGolemStatue(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
@@ -575,6 +580,7 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::CopperGolemStatue(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
@@ -609,6 +615,7 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::CopperGolemStatue(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
+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",
+179 -15
View File
@@ -141,7 +141,8 @@ impl DataPack {
pub fn discover(world: &Path) -> Vec<DataPack> {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
/// `FolderRepositorySource.loadPacks`: packs in Create World's temporary
/// folder or a world's data-pack directory, with required folder metadata.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
@@ -150,7 +151,7 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let path = e.path();
if path.is_dir() && path.join("pack.mcmeta").exists() {
if path.is_dir() && folder_metadata_present(&path) {
packs.push(DataPack {
id: format!("file/{name}"),
source: Source::Dir(path),
@@ -168,6 +169,22 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// `Pack.readPackMetadata`: even its fallback codec requires a pack section
/// and description. Keep this folder gate separate from format compatibility
/// and the existing interpretation of the description component.
fn folder_metadata_present(path: &Path) -> bool {
let Some(meta) = std::fs::read(path.join("pack.mcmeta"))
.ok()
.and_then(|bytes| crate::packs::parse_meta(&bytes))
else {
return false;
};
meta.as_object()
.and_then(|root| root.get("pack"))
.and_then(serde_json::Value::as_object)
.and_then(|pack| pack.get("description"))
.is_some_and(|description| !description.is_null())
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
@@ -608,7 +625,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -617,7 +634,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -625,7 +642,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
| "villager_trade" | "dialog" | "damage_type" | "data_maps" | "enchantment" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -659,6 +676,7 @@ impl DataStore {
"damage_type",
"data_maps",
"dialog",
"enchantment",
"function",
"item_modifier",
"loot_table",
@@ -714,15 +732,55 @@ impl DataStore {
}
s
}
/// `FunctionManager.getTag`: the functions a tag expands to (nested tags
/// included, each function once).
/// `ServerFunctionLibrary.getTag`: only valid function tags expand
/// (nested tags included, each function once).
pub fn tag_functions(&self, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen_fns = std::collections::HashSet::new();
let mut seen_tags = std::collections::HashSet::new();
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
let invalid = self.invalid_function_tags(id);
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags, &invalid);
out
}
/// `TagLoader.tryBuildTag`: missing required members invalidate their tag and
/// required parents. Optional invalid children contribute nothing.
/// Resolve against the current accepted function map, without caching a
/// candidate store or changing the existing expansion's cycle handling.
fn invalid_function_tags<'a>(&'a self, id: &'a str) -> std::collections::HashSet<&'a str> {
let mut invalid = std::collections::HashSet::new();
let mut parents: std::collections::HashMap<&str, Vec<&str>> =
std::collections::HashMap::new();
let mut seen = std::collections::HashSet::new();
let mut pending = vec![id];
while let Some(tag) = pending.pop() {
if !seen.insert(tag) {
continue;
}
let Some(entries) = self.function_tags.get(tag) else {
invalid.insert(tag);
continue;
};
for (entry, required) in entries {
if let Some(child) = entry.strip_prefix('#') {
pending.push(child);
if *required {
parents.entry(child).or_default().push(tag);
}
} else if *required && !self.functions.contains_key(entry) {
invalid.insert(tag);
}
}
}
pending.extend(invalid.iter().copied());
while let Some(child) = pending.pop() {
for &parent in parents.get(child).into_iter().flatten() {
if invalid.insert(parent) {
pending.push(parent);
}
}
}
invalid
}
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
/// once, so reference cycles end and nesting depth is not capped.
fn expand<'a>(
@@ -731,18 +789,19 @@ impl DataStore {
out: &mut Vec<String>,
seen_fns: &mut std::collections::HashSet<&'a str>,
seen_tags: &mut std::collections::HashSet<&'a str>,
invalid: &std::collections::HashSet<&'a str>,
) {
if !seen_tags.insert(id) {
if invalid.contains(id) || !seen_tags.insert(id) {
return;
}
let Some(entries) = self.function_tags.get(id) else {
return;
};
for (e, required) in entries {
for (e, _) in entries {
match e.strip_prefix('#') {
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
Some(inner) => self.expand(inner, out, seen_fns, seen_tags, invalid),
None => {
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
if self.functions.contains_key(e) && seen_fns.insert(e) {
out.push(e.clone());
}
}
@@ -924,6 +983,97 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn folder_metadata_is_required_before_contents_are_discovered() {
let root =
std::env::temp_dir().join(format!("horizon-folder-metadata-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for (name, metadata) in [
("broken", Some("{")),
("root_empty", Some("{}")),
("root_array", Some("[]")),
("pack_empty", Some(r#"{"pack":{}}"#)),
("pack_string", Some(r#"{"pack":"bad"}"#)),
("missing", None),
(
"full",
Some(
r#"{"pack":{"description":"full","pack_format":121,"min_format":[121,0],"max_format":[121,0]}}"#,
),
),
(
"description",
Some(r#"{"pack":{"description":"description only"}}"#),
),
("numeric", Some(r#"{"pack":{"description":42}}"#)),
("null", Some(r#"{"pack":{"description":null}}"#)),
("bom", Some("\u{feff}{\"pack\":{\"description\":\"BOM\"}}")),
(
"comment",
Some("{\"pack\":{\"description\":\"comment\"}}\n// trailing"),
),
] {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("data/h146/function")).unwrap();
std::fs::write(
dir.join(format!("data/h146/function/{name}.mcfunction")),
"scoreboard players set admitted h146 1\n",
)
.unwrap();
if let Some(metadata) = metadata {
std::fs::write(dir.join("pack.mcmeta"), metadata).unwrap();
}
}
let packs = discover_folder(&root);
assert_eq!(
packs.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
[
"file/bom",
"file/comment",
"file/description",
"file/full",
"file/numeric"
]
);
let store = DataStore::load(&packs.iter().collect::<Vec<_>>());
assert_eq!(
store
.functions
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
[
"h146:bom",
"h146:comment",
"h146:description",
"h146:full",
"h146:numeric"
]
);
let numeric = packs.iter().find(|p| p.id == "file/numeric").unwrap();
let raw: serde_json::Value =
serde_json::from_slice(&numeric.read("pack.mcmeta").unwrap()).unwrap();
assert_eq!(raw["pack"]["description"], 42);
// Builtin and feature construction still have their independent paths.
let resources = root.join("resources");
let feature = resources.join("data/minecraft/datapacks/feature");
std::fs::create_dir_all(&feature).unwrap();
std::fs::write(
feature.join("pack.mcmeta"),
r#"{"pack":{"description":"feature"}}"#,
)
.unwrap();
assert_eq!(
feature_packs(&resources)
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>(),
["feature"]
);
assert_eq!(DataPack::builtin(&resources).id, "vanilla");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
@@ -961,7 +1111,7 @@ mod tests {
std::fs::write(path, t).unwrap();
};
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", "{}");
write(pack, "pack.mcmeta", r#"{"pack":{"description":"fixture"}}"#);
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(pack, "data/p/structure/room/a.nbt", pack);
write(
@@ -1132,6 +1282,20 @@ mod tests {
#[test]
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
let mut store = DataStore::default();
// Every referenced function exists; missing required ones invalidate a tag.
for id in (0..25_000)
.map(|i| format!("t:f{i}"))
.chain(["t:x", "t:y", "t:deep"].map(str::to_owned))
{
store.functions.insert(
id.clone(),
Function {
id,
lines: Vec::new(),
parameters: Vec::new(),
},
);
}
let big: Vec<(String, bool)> = (0..50_000)
.map(|i| (format!("t:f{}", i % 25_000), true))
.collect();
@@ -1216,13 +1380,13 @@ mod tests {
write(
"fns",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
);
write("fns", "data/x/function/f.mcfunction", "say hi");
write(
"off",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
@@ -399,8 +399,11 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
let mut sky = environment.sample(day_time, None, None);
apply_weather(&mut sky, rain, thunder);
// Level.updateSkyBrightness: darkening from the sky light level.
(15.0 - 15.0 * sky.sky_light_level).round() as i32
// Level.updateSkyBrightness: `(int)(15.0F - skyLightLevel)`, the float
// truncated, not rounded (#11323): the darkening steps up only once the
// light has fallen a whole level. `sky_light_level` is the attribute as a
// fraction of 15, so the product is its raw value in `f32`.
(15.0 - 15.0 * sky.sky_light_level) as i32
}
#[cfg(test)]
@@ -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);
}
}
+20 -15
View File
@@ -9,6 +9,7 @@ use crate::{
environment::Sky,
world::World,
};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::path::Path;
/// `FogType` of the camera position.
@@ -64,7 +65,7 @@ impl FogTables {
}
/// Fog state that evolves over time.
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Debug)]
pub struct FogState {
/// `LocalPlayer.waterVisionTime` (0..600).
pub water_vision_time: i32,
@@ -72,24 +73,28 @@ pub struct FogState {
pub rain_fog_multiplier: f32,
/// `LightmapRenderStateExtractor.blockLightFlicker`.
pub block_light_flicker: f32,
flicker_seed: u64,
/// `LightmapRenderStateExtractor.random`: `RandomSource.create()`.
flicker_random: LegacyRandom,
}
impl Default for FogState {
fn default() -> Self {
Self {
water_vision_time: 0,
rain_fog_multiplier: 0.0,
block_light_flicker: 0.0,
flicker_random: crate::random_source::create(),
}
}
}
impl FogState {
fn next_float(&mut self) -> f32 {
// splitmix64: the vanilla source is unseeded `RandomSource.create()`.
self.flicker_seed = self.flicker_seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.flicker_seed;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
((z ^ (z >> 31)) >> 40) as f32 / (1u32 << 24) as f32
}
/// `LightmapRenderStateExtractor.tick`: the torch light flickers.
pub fn tick_flicker(&mut self) {
let r = &mut self.flicker_random;
let (a, b, c, d) = (
self.next_float(),
self.next_float(),
self.next_float(),
self.next_float(),
r.next_float(),
r.next_float(),
r.next_float(),
r.next_float(),
);
self.block_light_flicker += (a - b) * c * d * 0.1;
self.block_light_flicker *= 0.9;
@@ -750,7 +755,7 @@ mod tests {
rain_fog_multiplier: 0.1,
..FogState::default()
};
let mut settled = start;
let mut settled = start.clone();
settle_rain_fog(&mut settled, 0.5, 15, true);
assert_eq!(settled.rain_fog_multiplier, 0.5);
let mut eased = start;
+135 -32
View File
@@ -126,11 +126,16 @@ struct Cache {
random: horizon_core::random::LegacyRandom,
}
pub struct Font {
/// The atlas rects of the sheets under `textures/font`.
/// The atlas rects of every texture, by resource name: a bitmap provider's
/// `file` may name any texture, not only one under `textures/font`.
sheets: HashMap<String, Rect>,
missing: Glyph,
white: Rect,
root: PathBuf,
/// The game's own resources, below a pack overlay: a font file the overlay
/// lacks (a pack adds providers over vanilla's, or the overlay left a
/// directory out) is read from here, as vanilla's resource stack does.
fallback: Option<PathBuf>,
/// Atlas size and the reserved dynamic region (y, height).
atlas: (u32, u32),
region: (u32, u32),
@@ -199,6 +204,13 @@ fn parse_hex(text: &[u8], out: &mut HashMap<u32, HexGlyph>) {
out.insert(codepoint, HexGlyph { rows, left, right });
}
}
/// A resource file's bytes, from `root` or, when the overlay lacks it, from
/// the game's own resources below (`defaults`).
fn read_resource(root: &Path, defaults: Option<&Path>, path: &str) -> Option<Vec<u8>> {
std::fs::read(root.join(path))
.ok()
.or_else(|| defaults.and_then(|d| std::fs::read(d.join(path)).ok()))
}
fn read_json(root: &Path, path: &str) -> Option<serde_json::Value> {
serde_json::from_slice(&std::fs::read(root.join(path)).ok()?).ok()
}
@@ -281,6 +293,7 @@ fn hex_advance(h: &HexGlyph) -> f32 {
/// pixels, copied into the atlas when first drawn.
fn load_bitmap(
root: &Path,
defaults: Option<&Path>,
p: &serde_json::Value,
sheets: &HashMap<String, Rect>,
pixels: &[u8],
@@ -290,22 +303,36 @@ fn load_bitmap(
let mut out = HashMap::new();
let file = p["file"].as_str().unwrap_or("");
let (ns, name) = file.split_once(':').unwrap_or(("minecraft", file));
let in_atlas = (ns == "minecraft")
.then(|| sheets.get(name.trim_end_matches(".png")).copied())
.flatten();
let name = name.trim_end_matches(".png");
// The atlas names the default namespace's textures by their path and every
// other namespace's as `namespace:path` (HudAtlas::build).
let atlas_name = if ns == "minecraft" {
name.to_owned()
} else {
format!("{ns}:{name}")
};
let in_atlas = sheets.get(&atlas_name).copied();
// The sheet: its size, and its pixels when they are not in the atlas.
let (sw, sh, sheet) = match in_atlas {
Some(rect) => {
let (w, h) = rect.texels(atlas);
(w, h, None)
}
None => match crate::hud::decode(&root.join(resource_path(file, "textures", ""))) {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
log::warn!("font sheet {file} could not be read");
return out;
None => {
let path = resource_path(file, "textures", "");
let sheet = crate::hud::decode(&root.join(&path)).or_else(|first| {
defaults
.and_then(|d| crate::hud::decode(&d.join(&path)).ok())
.ok_or(first)
});
match sheet {
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
Err(_) => {
log::warn!("font sheet {file} could not be read");
return out;
}
}
},
}
};
let rows: Vec<Vec<char>> = p["chars"]
.as_array()
@@ -382,11 +409,13 @@ fn load_bitmap(
}
/// `TrueTypeGlyphProviderDefinition.load`: a TrueType file (not CFF) under
/// `font/`, its Unicode characters but the `skip` ones.
fn load_true_type(root: &Path, p: &serde_json::Value) -> Option<TrueType> {
fn load_true_type(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> Option<TrueType> {
let file = p["file"].as_str()?;
let data = std::fs::read(root.join(resource_path(file, "font", "")))
.map_err(|e| log::warn!("font file {file}: {e}"))
.ok()?;
let path = resource_path(file, "font", "");
let Some(data) = read_resource(root, defaults, &path) else {
log::warn!("font file {file} could not be read");
return None;
};
// FT_Get_Font_Format must say TrueType: CFF outlines ("OTTO") are refused.
if data.starts_with(b"OTTO") {
log::warn!("font file {file} is not in TTF format");
@@ -486,13 +515,16 @@ impl TrueType {
)
}
}
fn load_hex(root: &Path, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
fn load_hex(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
let mut glyphs = HashMap::new();
let Some(file) = p["hex_file"].as_str() else {
return glyphs;
};
let path = root.join(resource_path(file, "", "").replace("//", "/"));
let Ok(zip) = Zip::open(&path) else {
let path = resource_path(file, "", "").replace("//", "/");
let Some(zip) = Zip::open(&root.join(&path))
.ok()
.or_else(|| defaults.and_then(|d| Zip::open(&d.join(&path)).ok()))
else {
return glyphs;
};
let names: Vec<String> = zip
@@ -603,7 +635,24 @@ impl Font {
region: (u32, u32),
white: Rect,
) -> Self {
Self::load_with(root, sprites, pixels, atlas, region, white, false)
Self::load_with(root, None, sprites, pixels, atlas, region, white, false)
}
/// [`Font::load`] over a pack overlay: a sheet, TrueType file or Unihex
/// file the overlay lacks is read from `defaults`, the game's own
/// resources, as vanilla's resource stack does. (Definitions stay the
/// overlay's: a pack's filter may hide one from a lower pack.)
#[allow(clippy::too_many_arguments)]
pub fn load_over(
root: &Path,
defaults: &Path,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
region: (u32, u32),
white: Rect,
) -> Self {
let fallback = (root != defaults).then_some(defaults);
Self::load_with(root, fallback, sprites, pixels, atlas, region, white, false)
}
/// A server measures text but never draws it. Decode bitmap sheets only
/// to obtain advances, then release their pixels; never rasterize glyphs.
@@ -616,10 +665,21 @@ impl Font {
w: 1,
h: 1,
};
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
Self::load_with(
root,
None,
&HashMap::new(),
&[],
(1, 1),
(0, 0),
white,
true,
)
}
#[allow(clippy::too_many_arguments)]
fn load_with(
root: &Path,
fallback: Option<&Path>,
sprites: &HashMap<String, Rect>,
pixels: &[u8],
atlas: (u32, u32),
@@ -627,11 +687,7 @@ impl Font {
white: Rect,
metrics_only: bool,
) -> Self {
let sheets = sprites
.iter()
.filter(|(k, _)| k.starts_with("font/"))
.map(|(k, v)| (k.clone(), *v))
.collect();
let sheets = sprites.clone();
let font = Self {
sheets,
missing: Glyph {
@@ -645,6 +701,7 @@ impl Font {
},
white,
root: root.to_owned(),
fallback: fallback.map(|p| p.to_owned()),
atlas,
region,
metrics_only,
@@ -656,11 +713,8 @@ impl Font {
pending: Vec::new(),
full: false,
},
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64),
),
// `Font.random`: `RandomSource.create()`.
random: crate::random_source::create(),
}),
generation: std::cell::Cell::new(next_generation()),
uniform: false,
@@ -698,6 +752,7 @@ impl Font {
Filter::of(p),
load_bitmap(
&self.root,
self.fallback.as_deref(),
p,
&self.sheets,
pixels,
@@ -708,7 +763,7 @@ impl Font {
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
"ttf" => Some(Provider::TrueType(
Filter::of(p),
Box::new(load_true_type(&self.root, p)?),
Box::new(load_true_type(&self.root, self.fallback.as_deref(), p)?),
)),
_ => None,
})
@@ -868,7 +923,8 @@ impl Font {
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
let glyphs = glyphs
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !(non_fishy && fishy_advance(hex_advance(&h)))
{
@@ -1026,7 +1082,8 @@ impl Font {
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
let glyphs = glyphs
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
if let Some(h) = glyphs.get(&u32::from(c)) {
return Some(hex_advance(h));
}
@@ -1512,6 +1569,52 @@ mod tests {
assert!(texels.iter().any(|t| t[0] == 255));
}
/// A bitmap provider's `file` can name any texture. When the atlas has that
/// texture (HUD packs keep their glyph sheets outside `textures/font`), the
/// glyphs are read from it instead of being copied into the small run-time
/// region one by one; the region then cannot fill up and turn later glyphs
/// into the missing one, whose advance shifts the rest of a HUD line.
#[test]
fn a_bitmap_sheet_outside_font_is_read_from_the_atlas() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let (_, _, pixels) = crate::hud::decode_bytes(&sheet).unwrap();
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 8,
};
for (ns, file, key) in [
("minecraft", "minecraft:hud/sheet.png", "hud/sheet"),
(
"hudengine",
"hudengine:font/sheet.png",
"hudengine:font/sheet",
),
] {
let def = format!(
r#"{{"providers":[{{"type":"bitmap","file":"{file}","ascent":7,"chars":["ab"]}}]}}"#
);
// The image is only in the atlas: the pack's copy is not on disk.
let root = root(
ns,
&[("assets/minecraft/font/default.json", def.as_bytes())],
);
let sprites = HashMap::from([(key.to_string(), rect)]);
let font = Font::load(&root, &sprites, &pixels, (16, 8), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
assert_eq!(font.glyph('a').advance, 3.0, "{ns}");
assert_eq!(font.glyph('b').advance, 7.0, "{ns}");
assert!(font.glyph('a').rect.is_some(), "{ns}");
assert!(
font.take_patches().is_empty(),
"{ns}: the atlas sheet is not copied into the run-time region"
);
}
}
#[test]
fn styles_pick_their_font_and_unknown_fonts_draw_missing_glyphs() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
+420 -49
View File
@@ -171,7 +171,10 @@ mod gpu_culling;
mod hdr;
mod hdr_icc;
mod hdr_stats;
pub(crate) mod hud_coverage;
pub use hdr::Calibration as HdrCalibration;
mod hold;
pub use hold::WorldHold;
pub mod block_light;
mod custom_sky;
mod displace;
@@ -444,6 +447,52 @@ struct GpuStaticEntitySection {
vertices: u32,
}
pub type SectionKey = (i32, i32, i32);
/// What a section's mesh brings with it besides its quads (`Renderer::upload`): placed with
/// the mesh, now or when a hold lets go of it.
struct MeshFacts<'a> {
visibility: u64,
block_entities: &'a [([i32; 3], u32)],
lights: &'a [([i32; 3], u32)],
bounds: Option<[glam::Vec3; 2]>,
/// It has translucent quads.
translucent: bool,
/// It has no quads at all: that is its mesh.
empty: bool,
}
impl<'a> MeshFacts<'a> {
fn of(mesh: &'a SectionMesh) -> Self {
Self {
visibility: mesh.visibility,
block_entities: &mesh.block_entities,
lights: &mesh.lights,
bounds: mesh.bounds,
translucent: !mesh.translucent.is_empty(),
empty: mesh.solid.is_empty() && mesh.translucent.is_empty(),
}
}
}
/// A section's new mesh waiting behind a hold (gfx/hold.rs): uploaded, with what goes with it.
struct HeldMesh {
gpu: Option<SectionGpu>,
visibility: u64,
block_entities: Vec<([i32; 3], u32)>,
lights: Vec<([i32; 3], u32)>,
bounds: Option<[glam::Vec3; 2]>,
translucent: bool,
empty: bool,
}
impl HeldMesh {
fn facts(&self) -> MeshFacts<'_> {
MeshFacts {
visibility: self.visibility,
block_entities: &self.block_entities,
lights: &self.lights,
bounds: self.bounds,
translucent: self.translucent,
empty: self.empty,
}
}
}
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
/// it, at half the world's resolution (the material stage's `reflection`).
struct MirrorTarget {
@@ -1171,6 +1220,7 @@ pub struct Renderer {
debug_lines_opaque: wgpu::RenderPipeline,
debug_lines: wgpu::RenderPipeline,
debug_lines_on_top: wgpu::RenderPipeline,
debug_crosshair: Option<wgpu::RenderPipeline>,
debug_line_buffer: wgpu::Buffer,
debug_line_capacity: u64,
/// The terrain resources with the debug crosshair's projection
@@ -1191,6 +1241,8 @@ pub struct Renderer {
static_entity_catalog_revision: u64,
static_entity_frame_stats: StaticCacheFrameStats,
hdr_mark_pipeline: wgpu::RenderPipeline,
/// `hdr_mark_pipeline` at `entity_pipeline`'s depth bias, for what that drew.
hdr_entity_mark_pipeline: wgpu::RenderPipeline,
hdr_hand_unmark_pipeline: wgpu::RenderPipeline,
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
weather: weather::Weather,
@@ -1328,6 +1380,9 @@ pub struct Renderer {
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
/// Removed sections fading out over far terrain (`leave_section`).
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
/// A mod's hold on the terrain's picture (`hold_world`, gfx/hold.rs) and the new meshes
/// waiting behind it.
hold: hold::Hold<HeldMesh>,
/// Finished tile meshes `upload_lods` has yet to upload.
lod_pending: Option<lod::Pending>,
detail_columns: FxHashMap<(i32, i32), SectionMask>,
@@ -1424,6 +1479,8 @@ pub struct Renderer {
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
/// with HDR off since reaches the swapchain through `readback`.
frame_format: wgpu::TextureFormat,
/// The no-pack float HDR lane, with independent blending and an R8 coverage target.
hud_coverage: bool,
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
/// the frame that exists (`set_native_shader_pack`).
frame_kept_logged: bool,
@@ -2450,6 +2507,14 @@ impl Renderer {
"near_section_vertices",
self.sections.values().map(used).sum(),
),
(
"held_section_vertices",
self.hold
.sections()
.filter_map(|held| held.gpu.as_ref())
.map(used)
.sum(),
),
(
"far_tile_vertices",
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
@@ -2693,6 +2758,13 @@ impl Renderer {
config.format,
linear,
);
let hud_coverage = hud_coverage::supported(
linear,
hdr_output.is_some(),
device.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2843,8 +2915,9 @@ impl Renderer {
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
blend: Option<wgpu::BlendState>,
coverage: bool| {
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
@@ -2885,7 +2958,17 @@ impl Renderer {
}),
multiview_mask: None,
cache: None,
})
};
if coverage {
hud_coverage::pipeline(
&device,
&desc,
wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
)
.expect("the debug crosshair's coverage")
} else {
kept.create_render_pipeline(&desc)
}
};
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
@@ -2909,6 +2992,7 @@ impl Renderer {
depth_write,
depth_test,
blend,
false,
)
};
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
@@ -2931,6 +3015,7 @@ impl Renderer {
true,
true,
None,
false,
)
};
SolidPipeline {
@@ -3175,6 +3260,22 @@ impl Renderer {
false,
Some(alpha_blend),
);
let debug_crosshair = hud_coverage.then(|| {
pipeline_with(
&sprite_constants,
&layout,
"3d crosshair coverage",
"vs_debug_line",
"fs_debug_line",
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
false,
false,
Some(alpha_blend),
true,
)
});
drop(terrain_step);
let globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon globals"),
@@ -3528,6 +3629,7 @@ impl Renderer {
&atlas_view,
&glint_view,
&glint_sampler,
hud_coverage,
)?;
hud.atlas_texture = Some(atlas.clone());
let entity_step = crate::spans::startup::step("renderer.entity_pipelines");
@@ -3966,17 +4068,19 @@ impl Renderer {
// paths here, so their fp32 depths of the same plane differ by a few dozen
// ulps and the tie came out either way, as a jagged strip of the trapdoor
// through the hull's floor. The same constant as the shadows' covers that
// spread; a model's feet sit 0.001 above the ground, far beyond it.
// spread; a model's feet sit 0.001 above the ground, far beyond it. HDR output's
// marks on what it draws take it too (`hdr_entity_mark_pipeline`).
let entity_bias = wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.0,
clamp: 0.0,
};
let entity_pipeline = entity_pipeline(
"Entities",
None,
true,
wgpu::CompareFunction::GreaterEqual,
wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.0,
clamp: 0.0,
},
entity_bias,
alpha_blend,
false,
);
@@ -4048,12 +4152,26 @@ impl Renderer {
"fs_hdr_mark",
wgpu::ColorWrites::ALPHA,
);
// The same at `entity_pipeline`'s depth bias, for what it drew (entities, the hand,
// translucent particles): without it the marks test a hair behind the depth it left
// and never land.
let hdr_entity_mark_pipeline = entity_pipeline_with(
"HDR entity mark",
None,
false,
wgpu::CompareFunction::GreaterEqual,
entity_bias,
wgpu::BlendState::REPLACE,
false,
"fs_hdr_mark",
wgpu::ColorWrites::ALPHA,
);
let hdr_hand_unmark_pipeline = entity_pipeline_with(
"HDR hand unmark",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
entity_bias,
wgpu::BlendState::REPLACE,
false,
"fs_hdr_hand_unmark",
@@ -4746,6 +4864,7 @@ impl Renderer {
debug_lines_opaque,
debug_lines,
debug_lines_on_top,
debug_crosshair,
debug_line_buffer,
debug_line_capacity: 4096,
crosshair_group,
@@ -4757,6 +4876,7 @@ impl Renderer {
entity_pipeline,
static_entity_pipeline,
hdr_mark_pipeline,
hdr_entity_mark_pipeline,
hdr_hand_unmark_pipeline,
hdr_mark_lightning_pipeline,
weather,
@@ -4860,6 +4980,7 @@ impl Renderer {
lod_cutout: Default::default(),
lod_retiring: Default::default(),
lod_leaving: Vec::new(),
hold: Default::default(),
lod_pending: None,
detail_columns: Default::default(),
detail_revision: 0,
@@ -4905,6 +5026,7 @@ impl Renderer {
emission_stats,
hdr_output,
frame_format,
hud_coverage,
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
@@ -4963,7 +5085,7 @@ impl Renderer {
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
if self.hdr.is_some() {
// Drawn into the world: the light sources it holds are marked as the world's are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
}
@@ -5454,12 +5576,28 @@ impl Renderer {
if let Some(copies) = &mut self.capture_copies {
copies.section(key, mesh);
}
if self.hold.holds(key) && self.shown(key) && self.upload_held(key, mesh) {
return;
}
if let Some(gpu) = self.hold.take(key).and_then(|held| held.gpu) {
self.release_mesh(gpu);
}
let facts = MeshFacts::of(mesh);
let fading = self.before_mesh(key, &facts);
let section = self.upload_mesh(mesh);
self.after_mesh(key, &facts, section, fading);
}
/// What goes with section `key`'s new mesh, before its quads go in: its old mesh is
/// released. The fade it keeps.
fn before_mesh(&mut self, key: SectionKey, mesh: &MeshFacts) -> Option<Instant> {
self.occlusion.set(key, mesh.visibility);
let changed = if mesh.block_entities.is_empty() {
self.block_entities.remove(&key).is_some()
} else {
let old = self.block_entities.insert(key, mesh.block_entities.clone());
old.as_deref() != Some(&mesh.block_entities[..])
let old = self
.block_entities
.insert(key, mesh.block_entities.to_vec());
old.as_deref() != Some(mesh.block_entities)
};
if changed {
self.block_entity_revision = next_block_entity_revision();
@@ -5478,8 +5616,8 @@ impl Renderer {
let lights_changed = if mesh.lights.is_empty() {
self.light_blocks.remove(&key).is_some()
} else {
let old = self.light_blocks.insert(key, mesh.lights.clone());
old.as_deref() != Some(&mesh.lights[..])
let old = self.light_blocks.insert(key, mesh.lights.to_vec());
old.as_deref() != Some(mesh.lights)
};
if lights_changed {
self.light_revision += 1;
@@ -5487,7 +5625,7 @@ impl Renderer {
if !self.lod_retiring.is_empty() {
self.lod_retiring.remove(&key);
}
if !mesh.translucent.is_empty() && !self.sections.contains(key) {
if mesh.translucent && !self.sections.contains(key) {
// New translucent terrain, not an edit's remesh: large pages wait for it.
self.shrink_gate.wait(Instant::now(), SHRINK_SETTLE);
}
@@ -5506,13 +5644,19 @@ impl Renderer {
self.newest_upload = Some(now);
}
self.release_section(key);
let section = self
.upload_mesh(mesh)
.map(|section| SectionGpu { fading, ..section });
self.set_detail(
key,
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
);
fading
}
/// Section `key`'s new mesh drawn from now on, its quads uploaded (`section`: none when it
/// has none, or the arena had no room).
fn after_mesh(
&mut self,
key: SectionKey,
mesh: &MeshFacts,
section: Option<SectionGpu>,
fading: Option<Instant>,
) {
let section = section.map(|section| SectionGpu { fading, ..section });
self.set_detail(key, section.is_some() || mesh.empty);
if let Some(mut section) = section {
let sections = self.layout.sections();
if let Some(gpu) = &mut self.gpu_culling
@@ -5536,6 +5680,94 @@ impl Renderer {
.insert_marked(key, section, mesh.bounds, marked);
}
}
/// Whether section `key` has its mesh on screen (`set_detail`): an empty one counts.
fn shown(&self, key: SectionKey) -> bool {
self.detail_columns
.get(&(key.0, key.2))
.is_some_and(|mask| mask.contains(key.1 as usize))
}
/// The bytes a section's quads take in the arenas.
fn mesh_bytes(gpu: Option<&SectionGpu>) -> u64 {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
gpu.map_or(0, |s| {
(s.solid_slots + u64::from(s.translucent) * 4) * stride
})
}
/// A mod's hold on the terrain's picture (gfx/hold.rs, `WorldHold`), asked once a frame
/// before the frame's uploads: while held, a section on screen that is meshed again waits,
/// uploaded, behind the mesh drawn. What no longer waits takes its place on this frame.
pub fn hold_world(&mut self, ask: &WorldHold) {
let waiting = match self.hold.ask(ask, Instant::now()) {
hold::Release::None => return,
hold::Release::All => self.hold.take_all(),
hold::Release::Outside => self.hold.take_outside(),
};
self.place_held(waiting);
}
/// Whether a mod's hold covers chunk column `column` (`hold_world`): what changes there waits.
pub fn world_holds_column(&self, column: (i32, i32)) -> bool {
self.hold.holds((column.0, 0, column.1))
}
/// Section `key`'s new mesh waits behind the hold, uploaded. False when the arena has no
/// room for it: what waited takes its place (giving the old meshes back), the hold is
/// spent, and this one goes in as ever.
fn upload_held(&mut self, key: SectionKey, mesh: &SectionMesh) -> bool {
let facts = MeshFacts::of(mesh);
let gpu = self.upload_mesh(mesh);
if gpu.is_none() && !facts.empty {
log::warn!("Horizon: no memory left for a held world's new sections: let go");
self.hold.spend();
let waiting = self.hold.take_all();
self.place_held(waiting);
return false;
}
let bytes = Self::mesh_bytes(gpu.as_ref());
let held = HeldMesh {
gpu,
visibility: facts.visibility,
block_entities: facts.block_entities.to_vec(),
lights: facts.lights.to_vec(),
bounds: facts.bounds,
translucent: facts.translucent,
empty: facts.empty,
};
let (old, over) = self.hold.wait(key, held, bytes);
if let Some(gpu) = old.and_then(|old| old.gpu) {
self.release_mesh(gpu);
}
if over {
log::warn!(
"Horizon: a held world's new sections came to {} MiB: let go",
self.hold.bytes() >> 20
);
self.hold.spend();
let waiting = self.hold.take_all();
self.place_held(waiting);
}
true
}
/// Sections that waited behind the hold take their place, on this frame.
fn place_held(&mut self, waiting: Vec<(SectionKey, HeldMesh)>) {
if waiting.is_empty() {
return;
}
let (count, started) = (waiting.len(), Instant::now());
let bytes: u64 = waiting
.iter()
.map(|(_, held)| Self::mesh_bytes(held.gpu.as_ref()))
.sum();
for (key, mut held) in waiting {
let gpu = held.gpu.take();
let facts = held.facts();
let fading = self.before_mesh(key, &facts);
self.after_mesh(key, &facts, gpu, fading);
}
log::debug!(
"Horizon: {count} held sections ({} MiB) took their place in {:.1} ms",
bytes >> 20,
started.elapsed().as_secs_f64() * 1e3
);
}
fn upload_mesh(&mut self, mesh: &SectionMesh) -> Option<SectionGpu> {
let solid = (mesh.solid.len() / 4) as u32;
let translucent = (mesh.translucent.len() / 4) as u32;
@@ -5828,7 +6060,13 @@ impl Renderer {
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
// (meshes waiting behind a hold move with the rest, or they would keep the page)
.chain(
self.hold
.sections_mut()
.filter_map(|held| held.gpu.as_mut()),
);
let flat = self
.flat
.as_mut()
@@ -5871,7 +6109,12 @@ impl Renderer {
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
.chain(
self.hold
.sections_mut()
.filter_map(|held| held.gpu.as_mut()),
);
match begin_shrink(arena, &self.device, meshes, *part) {
Some(true) => break,
Some(false) => wait = SHRINK_RETRY, // the other arena may still go
@@ -5895,7 +6138,13 @@ impl Renderer {
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
// (meshes waiting behind a hold move with the rest, or they would keep the page)
.chain(
self.hold
.sections_mut()
.filter_map(|held| held.gpu.as_mut()),
);
// As many quads a frame either way: a quad's indices are a quarter of its bytes.
let cap = match part {
Part::Indices => COMPACT_BUDGET / 4,
@@ -6068,6 +6317,8 @@ impl Renderer {
self.newest_lod_fade = None;
self.block_entity_revision = next_block_entity_revision();
self.sections.clear();
// (a hold on land that is gone ends; the arenas' clearing below takes what waited)
self.hold.clear();
self.lods.clear();
self.lod_cutout.clear();
self.lod_retiring.clear();
@@ -6138,6 +6389,13 @@ impl Renderer {
if want == self.packed_quads {
return;
}
let held_packed = |held: &HeldMesh| held.gpu.as_ref().is_some_and(|s| s.packed);
if !want && self.hold.sections().any(held_packed) {
// (held meshes in the layout going away take their place, and go with the rest)
self.hold.spend();
let waiting = self.hold.take_all();
self.place_held(waiting);
}
self.packed_quads = want;
packed::pack_quads(want);
if !want && self.sections.values().any(|s| s.packed) {
@@ -6245,6 +6503,11 @@ impl Renderer {
if let Some(copies) = &mut self.capture_copies {
copies.remove_columns(&columns);
}
if !self.hold.is_empty() && !columns.is_empty() {
let gone = columns.iter().copied().collect();
let held = self.hold.take_columns(&gone);
self.release_meshes(held.into_iter().filter_map(|held| held.gpu).collect());
}
// Most of the view at once (a teleport at render distance 32 drops some 3700
// columns): a pass over each store instead of a lookup per section key, which
// took 10 ms of one frame. Far terrain fades the sections out instead.
@@ -6497,8 +6760,9 @@ impl Renderer {
DisplayWatch::default()
};
let format = self.frame_format;
self.hdr =
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
self.hdr = output.map(|output| {
hdr::Hdr::new(&self.device, &self.queue, format, output, self.hud_coverage)
});
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
@@ -6590,7 +6854,16 @@ impl Renderer {
self.frame_gen.on = on;
}
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
/// `sharpness`: how much TAA's upscaled picture is sharpened, 0..=1.
pub fn set_world_post(
&mut self,
scale: f32,
antialias: bool,
taa: bool,
upscale: f32,
sharpness: f32,
) {
self.world_post.set_sharpness(&self.device, sharpness);
let taa = taa && self.world_post.supports_taa();
self.world_post.scale = scale.clamp(0.25, 2.0);
self.world_post.antialias = antialias && !taa;
@@ -6669,6 +6942,7 @@ impl Renderer {
post,
self.frame_format,
world,
&std::sync::atomic::AtomicBool::new(false),
)
});
self.pack_loading = None;
@@ -6785,6 +7059,8 @@ impl Renderer {
.map(|f| f.draw_layout.clone());
let terrain = self.pack_atlas.is_some();
let clouds = self.cloud_bindings.clone();
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancelled = cancel.clone();
let build = move || {
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let vertex = wgpu::VertexBufferLayout {
@@ -6819,7 +7095,7 @@ impl Renderer {
weather: true,
hand: true,
};
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world)
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world, &cancelled)
};
// A pack is not the game's code: a panic building it turns the pack off (#3525).
let thread = std::thread::Builder::new()
@@ -6834,9 +7110,15 @@ impl Renderer {
thread,
label,
started,
_cancel: CancelOnDrop(cancel),
});
}
Err(e) => log::error!("shader pack {label}: no thread to build it on: {e}"),
Err(e) => {
log::error!("shader pack {label}: no thread to build it on: {e}");
// Off for this dimension, as a pack that fails, not tried again every frame.
self.pack_loading = None;
self.pack_post = Some((dimension, None));
}
}
}
/// The pack `load_shader_pack` builds, once its thread is done: it runs from now on, as
@@ -10276,7 +10558,7 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
if self.hdr.is_some() {
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
@@ -10738,7 +11020,7 @@ impl Renderer {
);
if self.hdr.is_some() {
// Fireworks: their sparks are translucent-layer particles.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(
0..translucent_particle_quads * 6,
((base_quads - translucent_item_quads - translucent_particle_quads) * 4)
@@ -11088,10 +11370,18 @@ impl Renderer {
);
}
laps.lap("r_passes");
// What an SDR picture of the frame shows: on HDR output's layered frame its HUD target,
// the HUD over the world clipped to white.
let sdr_texture = self
.hdr
.as_ref()
.and_then(|h| h.hud())
.cloned()
.unwrap_or_else(|| target_texture.clone());
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
let shown_texture = match &fg_frames {
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
_ => &target_texture,
_ => &sdr_texture,
};
let readback = readback.or_else(|| {
self.screenshot
@@ -11129,7 +11419,7 @@ impl Renderer {
(Some(_), None) => {
if let Some(image) = &self.offscreen {
self.readback
.draw_onto(&self.device, &mut encoder, &target_texture, image);
.draw_onto(&self.device, &mut encoder, &sdr_texture, image);
}
}
(None, _) => {}
@@ -11320,8 +11610,15 @@ impl Renderer {
} else {
None
};
// HDR output's layered frame keeps the world after the hand and the camera overlays,
// as light (`Hdr::layered`); else it is kept here, before them.
let layered = self.hdr.as_ref().is_some_and(|h| h.layered());
if let Some(hdr) = &self.hdr {
hdr.keep_world(encoder, timing.as_mut());
if layered {
hdr.begin(&self.queue, !frame.title);
} else {
hdr.keep_world(encoder, timing.as_mut());
}
}
if overlay.hand_quads > 0 {
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -11360,7 +11657,14 @@ impl Renderer {
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(encoder, timing.as_mut());
if let Some(view) = hdr.hand_light_view() {
// On the layered frame the hand's marks go to the frame, before they become
// light with the world's.
let marks = if layered {
Some(view_target.clone())
} else {
hdr.hand_light_view()
};
if let Some(view) = marks {
// The light sources in the hand may shine, the rest of it stays at the
// world's brightness: nothing the world kept here shows through it.
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -11392,11 +11696,19 @@ impl Renderer {
let quads = 0..overlay.hand_quads * 6;
pass.set_pipeline(&self.hdr_hand_unmark_pipeline);
pass.draw_indexed(quads.clone(), overlay.first_hand_vertex, 0..1);
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(quads, overlay.first_hand_vertex, 0..1);
}
}
}
// The layered frame's marks become light: what draws from here draws on light, and
// nothing is lifted after.
if layered
&& !frame.title
&& let Some(hdr) = &self.hdr
{
hdr.resolve(encoder, timing.as_mut());
}
// A native mod's turn after the hand, on the real frame only.
if overlay.real && !frame.title {
native_render::native_hand_drawn(&mut native_render::HandDrawn {
@@ -11529,11 +11841,25 @@ impl Renderer {
}
}
}
if layered && let Some(hdr) = &self.hdr {
hdr.keep_world(encoder, timing.as_mut());
}
// What the HUD and menus draw on: the layered frame's HUD target, else the frame.
let hud = self
.hdr
.as_ref()
.and_then(|h| h.hud())
.map(|t| t.create_view(&Default::default()));
let hud_target = hud.as_ref().unwrap_or(view_target);
let coverage = self.hdr.as_ref().and_then(|h| h.coverage_view());
if let Some(view) = &coverage {
hud_coverage::clear(encoder, view);
}
// GameRenderer.render3dHud: the debug crosshair after the hand and
// the screen effects.
if overlay.crosshair > 0 {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: view_target,
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
view: hud_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
@@ -11541,6 +11867,9 @@ impl Renderer {
store: wgpu::StoreOp::Store,
},
})];
if let Some(view) = &coverage {
attachments.push(Some(hud_coverage::attachment(view)));
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("3d crosshair"),
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Crosshair")),
@@ -11555,7 +11884,11 @@ impl Renderer {
}),
..Default::default()
});
pass.set_pipeline(&self.debug_lines_on_top);
pass.set_pipeline(if coverage.is_some() {
self.debug_crosshair.as_ref().unwrap()
} else {
&self.debug_lines_on_top
});
pass.set_bind_group(0, &self.crosshair_group, &[]);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
let first = overlay.first_crosshair;
@@ -11567,7 +11900,8 @@ impl Renderer {
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
self.hud.draw_range(
encoder,
view_target,
hud_target,
coverage.as_ref(),
&self.depth,
frame.hud,
[self.config.width, self.config.height],
@@ -11578,24 +11912,48 @@ impl Renderer {
let format = self.frame_format;
let device = &self.device;
let software = is_software(self.info.device_type);
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
.apply(
&self.device,
let blur = self
.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software));
let size = [self.config.width, self.config.height];
match self.hdr.as_ref().filter(|_| layered) {
// The blur keeps light above white: it blurs the picture so far, the HUD over
// the world's light, which becomes the world the menu draws over.
Some(hdr) => {
let (_, source) = blur.source(device, size);
hdr.composite(encoder, &source, timing.as_mut());
blur.blur(
device,
&self.queue,
encoder,
view_target,
size,
radius,
timing.as_mut(),
);
hdr.keep_world(encoder, timing.as_mut());
}
None => blur.apply(
device,
&self.queue,
encoder,
target_texture,
view_target,
[self.config.width, self.config.height],
size,
radius,
timing.as_mut(),
);
),
}
if let Some(view) = &coverage {
hud_coverage::clear(encoder, view);
}
} else if let Some(blur) = &mut self.menu_blur {
blur.release();
}
self.hud.draw_range(
encoder,
view_target,
hud_target,
coverage.as_ref(),
&self.depth,
frame.hud,
[self.config.width, self.config.height],
@@ -14563,8 +14921,12 @@ mod shadow_caster_tests {
/// A `shaders/` pack building on its own thread (`Renderer::load_shader_pack`): the dimension it
/// is for, the thread (a panic in it contained), and what the game named it with and when.
/// Dropping it (another pack, none, or another dimension) cancels the build: the thread stops at
/// the next program instead of compiling the rest for nothing.
struct PackLoading {
dimension: i32,
/// Held to be dropped with it: that cancels the build.
_cancel: CancelOnDrop,
#[allow(clippy::type_complexity)]
thread: std::thread::JoinHandle<
std::result::Result<std::result::Result<pack_post::PackPost, pack_post::Refused>, String>,
@@ -14572,3 +14934,12 @@ struct PackLoading {
label: String,
started: std::time::Instant,
}
/// Sets its flag when dropped: `PackLoading`'s cancel, which `PackPost::new` checks per program.
struct CancelOnDrop(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelOnDrop {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
+632 -99
View File
@@ -72,8 +72,29 @@ fn named(name: Option<&str>) -> Option<Output> {
struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
/// The world as `keep_world` kept it; on the layered frame, the frame as the world and the
/// hand marked it, for `resolve`.
world: wgpu::Texture,
coverage: Option<wgpu::Texture>,
/// The layered frame's HUD target: what the HUD draws on.
hud: Option<wgpu::Texture>,
group: wgpu::BindGroup,
/// The layered frame's passes' bindings: `resolve`, `keep_world` and `composite`.
passes: Option<[wgpu::BindGroup; 3]>,
}
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
/// "The HUD and menus"): the world's marks become light once, after the world and the hand
/// (`resolve`); the camera overlays draw on that light; the HUD draws on a target of its own,
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
/// (`composite`).
struct Layered {
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// What `resolve` and `composite` read, written before they run (`begin`).
params: wgpu::Buffer,
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
@@ -155,6 +176,7 @@ pub struct Hdr {
targets: Option<Targets>,
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
debug: bool,
layered: Option<Layered>,
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
@@ -181,28 +203,67 @@ impl Hdr {
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
coverage: bool,
) -> Self {
let debug = std::env::var("HORIZON_HDR_DEBUG").ok();
Self::with_debug(device, queue, format, output, coverage, debug.as_deref())
}
fn with_debug(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
coverage: bool,
debug: Option<&str>,
) -> Self {
let coverage_debug = coverage && debug == Some("coverage");
// The layered frame's last pass, else today's.
let entry_point = if coverage { "fs_hdr_layered" } else { "fs_hdr" };
let mut source = include_str!("../hdr.wgsl").to_owned();
if coverage_debug {
// The debug view wraps the last pass as it is, so the picture under the tint is
// exactly the one without it.
let entry = format!(
"@fragment fn {entry_point}(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>"
);
assert!(
source.contains(&entry),
"hdr.wgsl's {entry_point} changed its signature"
);
source = source.replace(&entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
source.push_str(&entry);
source.push_str(
r#" {
let picture = picture_hdr(pos);
let c = textureLoad(coverage, vec2<i32>(pos.xy), 0).r;
// Magenta at paper white, in the output's own encoding.
let paper = vec3(params.paper, 0.0, params.paper);
let magenta = select(paper, pq(BT709_TO_BT2020 * paper * 80.0), params.pq > 0.5);
return vec4(mix(picture.rgb, magenta, c), picture.a);
}
"#,
);
}
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR bindings"),
entries: &[
texture(0),
texture(1),
texture(3),
wgpu::BindGroupLayoutEntry {
// hdr.wgsl's bindings: the textures at `textures`, and its `Params` at 2 if `params`.
let layout = |label, textures: &[u32], params: bool| {
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let mut entries: Vec<_> = textures.iter().map(|&b| texture(b)).collect();
if params {
entries.push(wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
@@ -211,17 +272,22 @@ impl Hdr {
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline_for = |format| {
});
}
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries: &entries,
})
};
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &[Some(layout)],
..Default::default()
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR"),
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
@@ -234,7 +300,7 @@ impl Hdr {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_hdr"),
entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
@@ -246,6 +312,30 @@ impl Hdr {
cache: None,
})
};
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
let display = layout("Horizon HDR bindings", layout_bindings, true);
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
let layered = coverage.then(|| {
let with = |label, textures: &[u32], params, entry| {
let layout = layout(label, textures, params);
let pipeline = pass(label, &layout, entry, format);
(layout, pipeline)
};
Layered {
resolve: with("Horizon HDR marks to light", &[1], true, "fs_resolve"),
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
params: params(),
}
});
let alpha_pass = |label, blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -288,27 +378,20 @@ impl Hdr {
alpha: scale_alpha,
}),
);
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
logo: logo_mask(device, queue),
layout,
pipeline: pipeline_for(output.format()),
capture_pipeline: pipeline_for(Output::Pq.format()),
layout: display,
clear_alpha,
hand_mark,
params: params(),
capture_pipeline: pipeline_for(Output::Pq.format()),
capture_params: params(),
output,
format,
targets: None,
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
debug: debug == Some("1"),
layered,
paper_white: 200.0,
peak: 1000.0,
black: 0.0,
@@ -324,7 +407,7 @@ impl Hdr {
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
@@ -335,52 +418,185 @@ impl Hdr {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
format,
usage: usage | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
};
let frame = texture(
"Horizon HDR frame",
self.format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture(
"Horizon HDR world",
self.format,
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
let layered = self.layered.is_some();
let coverage = layered.then(|| {
texture(
"HUD coverage",
super::hud_coverage::FORMAT,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
// off; the last pass clips it at white.
let hud = layered.then(|| {
texture(
"HUD",
self.format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
let group = self.group(
device,
&frame,
&world,
coverage.as_ref(),
hud.as_ref(),
&self.params,
);
let passes = self
.layered
.as_ref()
.zip(coverage.as_ref().zip(hud.as_ref()))
.map(|(l, (coverage, hud))| {
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let (frame, world, coverage, hud) =
(view(&frame), view(&world), view(coverage), view(hud));
let entry = |binding, view| wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(view),
};
let params = wgpu::BindGroupEntry {
binding: 2,
resource: l.params.as_entire_binding(),
};
let group = |layout, entries: &[wgpu::BindGroupEntry]| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR layered bindings"),
layout,
entries,
})
};
[
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
group(&l.keep.0, &[entry(0, &frame)]),
group(
&l.composite.0,
&[
entry(0, &frame),
params,
entry(4, &coverage),
entry(5, &hud),
],
),
]
});
self.targets = Some(Targets {
size,
frame,
world,
coverage,
hud,
group,
passes,
});
}
self.targets.as_ref().unwrap().frame.clone()
}
/// The float frame with HUD coverage (`Layered`).
pub fn layered(&self) -> bool {
self.layered.is_some()
}
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
/// once `keep_world` made it, and which an ordinary screenshot shows.
pub fn hud(&self) -> Option<&wgpu::Texture> {
self.targets.as_ref()?.hud.as_ref()
}
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
/// drew a world (`world`) or not.
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
if let Some(l) = &self.layered {
queue.write_buffer(
&l.params,
0,
bytemuck::cast_slice(&self.params(world, false)),
);
}
}
/// On the layered frame, after the world and the hand drew and marked it: the marks
/// become light in the frame (`fs_resolve`), read from a copy of it in the world target.
pub fn resolve(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
return;
};
let Some([group, ..]) = &t.passes else {
return;
};
encoder.copy_texture_to_texture(
t.frame.as_image_copy(),
t.world.as_image_copy(),
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
Self::draw(
encoder,
&l.resolve.1,
group,
&view,
"HDR marks to light",
timing,
);
}
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
/// into `target`, for the menu blur to blur it and make it the world (`keep_world` after).
pub fn composite(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if let (
Some(l),
Some(Targets {
passes: Some([_, _, group]),
..
}),
) = (&self.layered, &self.targets)
{
Self::draw(
encoder,
&l.composite.1,
group,
target,
"HDR composite",
timing,
);
}
}
pub fn coverage_view(&self) -> Option<wgpu::TextureView> {
Some(
self.targets
.as_ref()?
.coverage
.as_ref()?
.create_view(&Default::default()),
)
}
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
/// patch through as bright as the display goes and the logo under it shows.
@@ -394,6 +610,9 @@ impl Hdr {
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
///
/// On the layered frame the frame keeps the world, the hand and the camera overlays, as
/// light, and this makes the HUD's backdrop from it: clipped to white, alpha 0 (`fs_keep`).
pub fn keep_world(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -402,6 +621,11 @@ impl Hdr {
let Some(t) = &self.targets else {
return;
};
if let (Some(l), Some(hud), Some([_, group, _])) = (&self.layered, &t.hud, &t.passes) {
let view = hud.create_view(&Default::default());
Self::draw(encoder, &l.keep.1, group, &view, "HDR HUD backdrop", timing);
return;
}
encoder.copy_texture_to_texture(
t.frame.as_image_copy(),
t.world.as_image_copy(),
@@ -429,13 +653,14 @@ impl Hdr {
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
/// from the UI (UI Brightness). The layered frame keeps the hand before `keep_world`,
/// so it needs no mark there.
pub fn mark_hand(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
let Some(t) = self.targets.as_ref().filter(|_| self.layered.is_none()) else {
return;
};
let view = t.frame.create_view(&Default::default());
@@ -466,9 +691,10 @@ impl Hdr {
}
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
/// reads for it too), and what tints the world rather than covering it.
/// reads for it too), and what tints the world rather than covering it. None on the
/// layered frame, where both draw on the frame before `keep_world`.
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
let t = self.targets.as_ref()?;
let t = self.targets.as_ref().filter(|_| self.layered.is_none())?;
Some(t.world.create_view(&Default::default()))
}
@@ -494,7 +720,15 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
let view = target.create_view(&Default::default());
Self::draw(
encoder,
&self.pipeline,
&t.group,
&view,
"HDR output",
timing,
);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
@@ -517,38 +751,66 @@ impl Hdr {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR screenshot bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.capture_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
let group = self.group(
device,
&t.frame,
&t.world,
t.coverage.as_ref(),
t.hud.as_ref(),
&self.capture_params,
);
queue.write_buffer(
&self.capture_params,
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
let view = texture.create_view(&Default::default());
Self::draw(
encoder,
&self.capture_pipeline,
&group,
&view,
"HDR output",
None,
);
Some(texture)
}
fn group(
&self,
device: &wgpu::Device,
frame: &wgpu::Texture,
world: &wgpu::Texture,
coverage: Option<&wgpu::Texture>,
hud: Option<&wgpu::Texture>,
params: &wgpu::Buffer,
) -> wgpu::BindGroup {
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let (frame, world) = (view(frame), view(world));
let layered = coverage.zip(hud).map(|(c, h)| (view(c), view(h)));
let texture = |binding, view| wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(view),
};
let mut entries = vec![
texture(0, &frame),
wgpu::BindGroupEntry {
binding: 2,
resource: params.as_entire_binding(),
},
texture(3, &self.logo),
];
match &layered {
Some((coverage, hud)) => entries.extend([texture(4, coverage), texture(5, hud)]),
None => entries.push(texture(1, &world)),
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
layout: &self.layout,
entries: &entries,
})
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
let paper = self.paper_white.max(1.0);
@@ -577,16 +839,17 @@ impl Hdr {
params
}
/// One full-screen pass of `pipeline` into `view`.
fn draw(
encoder: &mut wgpu::CommandEncoder,
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
view: &wgpu::TextureView,
label: &'static str,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
@@ -595,8 +858,8 @@ impl Hdr {
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &attachments,
..Default::default()
});
@@ -655,9 +918,279 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
#[cfg(test)]
mod tests {
use super::{Output, named, pick};
use super::{Hdr, Output, named, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
fn coverage_target_resizes_resets_and_shows_in_the_debug_view() {
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;
};
for enabled in [false, true] {
for debug in [None, Some("coverage")] {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, enabled, debug);
hdr.frame(&device, [2, 1]);
assert_eq!(hdr.coverage_view().is_some(), enabled);
hdr.frame(&device, [4, 2]);
let t = hdr.targets.as_ref().unwrap();
if let Some(coverage) = &t.coverage {
assert_eq!(coverage.size(), t.frame.size());
}
let mut encoder = device.create_command_encoder(&Default::default());
let frame_view = t.frame.create_view(&Default::default());
let mut color = super::super::hud_coverage::attachment(&frame_view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::BLACK);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(color)],
..Default::default()
});
hdr.keep_world(&mut encoder, None);
if let Some(view) = hdr.coverage_view() {
super::super::hud_coverage::clear(&mut encoder, &view);
let mut color = super::super::hud_coverage::attachment(&view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::WHITE);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(color)],
..Default::default()
});
}
let capture = hdr.capture(&device, &queue, &mut encoder, false).unwrap();
let buffer = super::super::copy_frame(&device, &mut encoder, &capture, [4, 2]);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
let [r, g, b] = [0, 10, 20].map(|shift| (pixel >> shift) & 0x3ff);
if enabled && debug.is_some() {
// Fully covered: BT.709 magenta at paper white (200 nits here) in BT.2020
// PQ, not PQ's 10 000 nits (1023, 0, 1023).
let near = |got: u32, want: u32| got.abs_diff(want) <= 4;
assert!(near(r, 550) && near(g, 346) && near(b, 583), "{r} {g} {b}");
} else {
assert_eq!(pixel, 0xc0000000);
}
if let Some(view) = hdr.coverage_view() {
let mut encoder = device.create_command_encoder(&Default::default());
super::super::hud_coverage::clear(&mut encoder, &view);
let coverage = hdr.targets.as_ref().unwrap().coverage.as_ref().unwrap();
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 512,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
coverage.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
coverage.size(),
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert_eq!(&buffer.get_mapped_range(..).unwrap()[..4], &[0; 4]);
}
}
}
}
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Clears all of `texture` to `color`.
fn fill(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: [f64; 4]) {
let view = texture.create_view(&Default::default());
let mut attachment = super::super::hud_coverage::attachment(&view);
let [r, g, b, a] = color;
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a });
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(attachment)],
..Default::default()
});
}
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
/// for HDR10.
fn texel(
device: &wgpu::Device,
queue: &wgpu::Queue,
mut encoder: wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> [f32; 4] {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
if texture.format() == Output::Pq.format() {
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
}
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
#[test]
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
let Some((device, queue)) = gpu() else {
return;
};
// What the last pass shows of a world pixel nothing covers, from today's frame
// (`layered` false) or the layered one, whose marks turn into light before it.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, world);
if layered {
hdr.begin(&queue, true);
hdr.resolve(&mut encoder, None);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
} else {
hdr.keep_world(&mut encoder, None);
}
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
texel(&device, &queue, encoder, &capture)
};
// Unmarked, below white and above it; HDR lighting; light sources of each tier.
for world in [
[0.5, 0.4, 0.3, 1.0],
[1.5, 1.2, 0.6, 1.0],
[0.8, 0.7, 0.6, 0.88],
[0.9, 0.8, 0.4, 0.05],
[0.9, 0.8, 0.4, 0.2],
[0.9, 0.8, 0.4, 0.4],
[0.9, 0.8, 0.4, 0.6],
] {
let (today, layered) = (shown(false, world), shown(true, world));
// Half floats round the light apart by a step at most.
assert!(
today.iter().zip(layered).all(|(a, b)| (a - b).abs() <= 1.0),
"{world:?}: {today:?} {layered:?}"
);
if world[3] < 1.0 {
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
}
}
}
#[test]
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: F::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
// The world's light, the HUD's target over it (on the world clipped to white), its
// coverage, UI Brightness, and the composite, all in gamma as the frame stores them.
for (world, hud, coverage, ui, want) in [
// Nothing over it: the world, light above white included.
(3.0, 1.0, 0.0, 1.0, 3.0),
// Half-transparent black: half of the world shows, as the HUD blends.
(2.0, 0.5, 0.5, 1.0, 1.0),
// Covered: the HUD as drawn, at UI Brightness.
(2.0, 0.3, 1.0, 1.0, 0.3),
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
// Added over the world (the text highlight), which it doesn't hide.
(0.6, 0.8, 0.0, 1.0, 0.8),
// At or below white, at 100%: the HUD's picture as drawn.
(0.7, 0.45, 0.5, 1.0, 0.45),
] {
hdr.ui = ui;
let frame = hdr.frame(&device, [1, 1]);
let t = hdr.targets.as_ref().unwrap();
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, [world, world, world, 1.0]);
fill(&mut encoder, t.hud.as_ref().unwrap(), [hud, hud, hud, 0.0]);
fill(&mut encoder, t.coverage.as_ref().unwrap(), [coverage; 4]);
hdr.begin(&queue, true);
hdr.composite(&mut encoder, &target.create_view(&Default::default()), None);
let got = texel(&device, &queue, encoder, &target);
assert!(
(got[0] - want).abs() < 0.01 && got[3] == 1.0,
"{world} {hud} {coverage} {ui}: {got:?}"
);
}
}
#[test]
fn the_menu_blur_keeps_the_light_it_blurs() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let size = [8, 8];
let frame = hdr.frame(&device, size);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, [4.0, 4.0, 4.0, 1.0]);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let mut blur = crate::menu_blur::MenuBlur::new(&device, F::Rgba16Float, false);
let (_, source) = blur.source(&device, size);
hdr.begin(&queue, true);
hdr.composite(&mut encoder, &source, None);
let view = frame.create_view(&Default::default());
blur.blur(&device, &queue, &mut encoder, &view, size, 3.0, None);
hdr.keep_world(&mut encoder, None);
let hud = hdr.hud().unwrap().clone();
// The blurred world keeps its light, and the HUD's backdrop starts again from it.
let [r, ..] = texel(&device, &queue, encoder, &frame);
assert!((r - 4.0).abs() < 0.01, "{r}");
let encoder = device.create_command_encoder(&Default::default());
assert_eq!(texel(&device, &queue, encoder, &hud), [1.0, 1.0, 1.0, 0.0]);
}
#[test]
fn hdr_shaders_validate() {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
@@ -0,0 +1,335 @@
//! A held world (`WorldHold`, the native hook `native_world_hold`): the terrain goes on being
//! drawn as it stands while what changes under it is meshed, and all of it takes its place on
//! one frame. A section already on screen that is meshed again is uploaded to the arena as ever,
//! over as many frames as its mesh takes to come, but waits beside the mesh drawn (`Hold`);
//! letting go only swaps which of the two is drawn, with nothing left to upload. Sections not on
//! screen yet (terrain streaming in) are not held.
//!
//! Both meshes of a waiting section are in memory, so a hold costs up to what the changed land's
//! new meshes come to. It is bounded, and each bound lets go at once rather than losing anything:
//! `MAX_BYTES` of waiting meshes, `MAX_HOLD` of time, and the arena running out
//! (`Renderer::upload`). It never outlives what asked for it: the mods are asked every frame,
//! and the sections' being cleared (the world left, another dimension) drops what waits. A hold
//! that ended by itself (spent) stays ended until no mod has asked for a frame.
use super::SectionKey;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::time::{Duration, Instant};
/// The longest a hold lasts.
pub(super) const MAX_HOLD: Duration = Duration::from_secs(30);
/// The most bytes of new meshes that wait.
pub(super) const MAX_BYTES: u64 = 1 << 30;
/// The most circles a hold is kept to (`WorldHold::hold_within`).
pub const MAX_AREAS: usize = 64;
/// What the mods ask of the terrain's picture this frame (`native_world_hold`): nothing, all of
/// it held, or only the sections inside some circles. Several mods add up: one holding all of it
/// holds all of it, and circles join.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldHold {
all: bool,
within: Vec<[f64; 3]>,
}
impl WorldHold {
/// Hold the whole world's terrain as it is drawn, this frame.
pub fn hold(&mut self) {
self.all = true;
}
/// Hold the sections whose middle lies within `radius` blocks of (`x`, `z`), this frame. A
/// circle that isn't finite or has a negative radius is left out, as are those past
/// `MAX_AREAS`.
pub fn hold_within(&mut self, x: f64, z: f64, radius: f64) {
let good = x.is_finite() && z.is_finite() && radius.is_finite() && radius >= 0.0;
if good && self.within.len() < MAX_AREAS {
self.within.push([x, z, radius]);
}
}
/// Whether anything is held.
pub fn is_held(&self) -> bool {
self.all || !self.within.is_empty()
}
/// Whether section `key` is in what is held: all of the world, or a circle its middle lies in.
pub fn covers(&self, key: SectionKey) -> bool {
if self.all {
return true;
}
let (x, z) = (f64::from(key.0) * 16.0 + 8.0, f64::from(key.2) * 16.0 + 8.0);
self.within
.iter()
.any(|&[cx, cz, r]| (x - cx).powi(2) + (z - cz).powi(2) <= r * r)
}
}
/// What a frame's ask means for the sections waiting.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Release {
/// They go on waiting.
None,
/// Those the hold no longer covers take their place.
Outside,
/// All of them take their place.
All,
}
/// A section waiting behind the hold and the bytes its new mesh takes.
struct Waiting<S> {
section: S,
bytes: u64,
}
/// The renderer's side of a hold: whether it is on, what it covers, and the sections waiting.
pub(super) struct Hold<S> {
ask: WorldHold,
since: Option<Instant>,
spent: bool,
waiting: FxHashMap<SectionKey, Waiting<S>>,
bytes: u64,
}
impl<S> Default for Hold<S> {
fn default() -> Self {
Self {
ask: WorldHold::default(),
since: None,
spent: false,
waiting: FxHashMap::default(),
bytes: 0,
}
}
}
impl<S> Hold<S> {
/// Whether a new mesh of section `key` waits.
pub fn holds(&self, key: SectionKey) -> bool {
self.since.is_some() && !self.spent && self.ask.covers(key)
}
pub fn is_empty(&self) -> bool {
self.waiting.is_empty()
}
pub fn bytes(&self) -> u64 {
self.bytes
}
/// A frame's ask, at `now`.
pub fn ask(&mut self, ask: &WorldHold, now: Instant) -> Release {
if !ask.is_held() {
let was = self.since.is_some();
(self.since, self.spent) = (None, false);
self.ask = WorldHold::default();
return if was && !self.waiting.is_empty() {
Release::All
} else {
Release::None
};
}
if self.spent {
return Release::None;
}
let since = *self.since.get_or_insert(now);
if now.saturating_duration_since(since) >= MAX_HOLD {
log::warn!(
"Horizon: a mod held the world for {} s: let go",
MAX_HOLD.as_secs()
);
self.spend();
return Release::All;
}
if *ask == self.ask {
return Release::None;
}
// (fewer circles or smaller: some of what waits is let go; more: nothing waits that didn't)
self.ask.clone_from(ask);
if self.waiting.is_empty() {
Release::None
} else {
Release::Outside
}
}
/// Ended by itself: nothing waits from now on, until no mod has asked for a frame.
/// What waited is the caller's to place (`take_all`) or drop.
pub fn spend(&mut self) {
self.spent = self.since.is_some();
}
/// Section `key`'s new mesh waits: the one that waited before for it comes back, to free.
/// The bool is whether what waits is now too much (`MAX_BYTES`).
pub fn wait(&mut self, key: SectionKey, section: S, bytes: u64) -> (Option<S>, bool) {
let old = self.take(key);
self.bytes += bytes;
self.waiting.insert(key, Waiting { section, bytes });
(old, self.bytes > MAX_BYTES)
}
/// Section `key`'s waiting mesh, no longer waiting.
pub fn take(&mut self, key: SectionKey) -> Option<S> {
if self.waiting.is_empty() {
return None;
}
let w = self.waiting.remove(&key)?;
self.bytes -= w.bytes;
Some(w.section)
}
/// Everything waiting, no longer waiting.
pub fn take_all(&mut self) -> Vec<(SectionKey, S)> {
self.bytes = 0;
self.waiting.drain().map(|(k, w)| (k, w.section)).collect()
}
/// What waits that the hold no longer covers.
pub fn take_outside(&mut self) -> Vec<(SectionKey, S)> {
let ask = &self.ask;
let all = self.since.is_none() || self.spent;
let out: Vec<_> = self
.waiting
.extract_if(|&k, _| all || !ask.covers(k))
.collect();
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
out.into_iter().map(|(k, w)| (k, w.section)).collect()
}
/// What waits in these columns, which are going.
pub fn take_columns(&mut self, columns: &FxHashSet<(i32, i32)>) -> Vec<S> {
if self.waiting.is_empty() {
return Vec::new();
}
let out: Vec<_> = self
.waiting
.extract_if(|k, _| columns.contains(&(k.0, k.2)))
.collect();
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
out.into_iter().map(|(_, w)| w.section).collect()
}
/// The sections are all gone: what waits goes with them and the hold is spent.
pub fn clear(&mut self) {
self.waiting.clear();
self.bytes = 0;
self.spend();
}
pub fn sections(&self) -> impl Iterator<Item = &S> {
self.waiting.values().map(|w| &w.section)
}
pub fn sections_mut(&mut self) -> impl Iterator<Item = &mut S> {
self.waiting.values_mut().map(|w| &mut w.section)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn all() -> WorldHold {
let mut h = WorldHold::default();
h.hold();
h
}
fn within(circles: &[[f64; 3]]) -> WorldHold {
let mut h = WorldHold::default();
for &[x, z, r] in circles {
h.hold_within(x, z, r);
}
h
}
#[test]
fn a_hold_covers_sections_by_their_middle() {
assert!(all().covers((900, 0, -900)));
assert!(!WorldHold::default().covers((0, 0, 0)));
assert!(within(&[[8.0, 8.0, 1.0]]).covers((0, 3, 0)));
assert!(!within(&[[0.0, 0.0, 11.0]]).covers((0, 3, 0)));
assert!(within(&[[0.0, 0.0, 12.0]]).covers((0, 3, 0)));
assert!(within(&[[1000.0, 0.0, 1.0], [24.0, 8.0, 0.5]]).covers((1, 0, 0)));
assert!(within(&[[-8.0, -8.0, 0.0]]).covers((-1, 0, -1)));
}
#[test]
fn bad_circles_are_left_out_and_there_are_at_most_max_areas() {
let h = within(&[
[f64::NAN, 0.0, 5.0],
[0.0, f64::INFINITY, 5.0],
[0.0, 0.0, -1.0],
[0.0, 0.0, f64::INFINITY],
]);
assert!(!h.is_held());
let mut many = WorldHold::default();
for i in 0..MAX_AREAS + 10 {
many.hold_within(i as f64 * 100.0, 0.0, 1.0);
}
assert_eq!(many.within.len(), MAX_AREAS);
// (several mods: all of it from one holds all of it)
many.hold();
assert!(many.covers((-5000, 0, 5000)));
}
#[test]
fn a_hold_starts_and_lets_go_when_no_one_asks() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
assert!(!g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t), Release::None);
assert!(g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t + Duration::from_secs(5)), Release::None);
// (nothing waited: nothing to place)
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
g.ask(&all(), t);
g.wait((0, 0, 0), 7, 100);
assert_eq!(g.ask(&WorldHold::default(), t), Release::All);
assert_eq!(g.take_all(), vec![((0, 0, 0), 7)]);
assert!(!g.holds((0, 0, 0)));
}
#[test]
fn a_hold_ends_by_itself_and_stays_ended_until_let_go() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
assert_eq!(g.ask(&all(), t + MAX_HOLD), Release::All);
assert!(!g.holds((0, 0, 0)));
assert_eq!(g.ask(&all(), t + MAX_HOLD * 2), Release::None);
assert!(!g.holds((0, 0, 0)));
g.ask(&WorldHold::default(), t + MAX_HOLD * 2);
g.ask(&all(), t + MAX_HOLD * 3);
assert!(g.holds((0, 0, 0)));
}
#[test]
fn smaller_circles_let_go_what_they_leave() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&within(&[[0.0, 0.0, 100.0]]), t);
assert!(g.holds((0, 0, 0)) && g.holds((5, 0, 0)) && !g.holds((7, 0, 0)));
g.wait((0, 0, 0), 1, 10);
g.wait((5, 0, 0), 2, 20);
assert_eq!(g.ask(&within(&[[0.0, 0.0, 100.0]]), t), Release::None);
assert_eq!(g.ask(&within(&[[0.0, 0.0, 40.0]]), t), Release::Outside);
assert_eq!(g.take_outside(), vec![((5, 0, 0), 2)]);
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
assert!(!g.holds((5, 0, 0)));
// (to all of it again: nothing more is let go, and all of it waits again)
assert_eq!(g.ask(&all(), t), Release::Outside);
assert!(g.take_outside().is_empty());
assert!(g.holds((5, 0, 0)));
}
#[test]
fn what_waits_is_counted_and_capped() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
assert_eq!(g.wait((0, 0, 0), 1, MAX_BYTES - 10), (None, false));
// (a newer mesh of the same section replaces it, and the old one comes back to free)
assert_eq!(g.wait((0, 0, 0), 2, MAX_BYTES - 20), (Some(1), false));
assert_eq!(g.bytes(), MAX_BYTES - 20);
assert_eq!(g.wait((1, 0, 0), 3, 20), (None, false));
assert_eq!(g.wait((2, 0, 0), 4, 1), (None, true));
assert_eq!(g.take((1, 0, 0)), Some(3));
assert_eq!(g.take((1, 0, 0)), None);
assert_eq!(g.bytes(), MAX_BYTES - 19);
}
#[test]
fn columns_going_and_the_sections_cleared_drop_what_waits() {
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
g.ask(&all(), t);
g.wait((0, 0, 0), 1, 10);
g.wait((0, 5, 0), 2, 10);
g.wait((3, 0, 0), 3, 10);
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
let mut dropped = g.take_columns(&gone);
dropped.sort_unstable();
assert_eq!(dropped, vec![1, 2]);
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
g.clear();
assert!(g.is_empty() && g.bytes() == 0 && !g.holds((3, 0, 0)));
// (cleared while held: spent until let go; with no hold, nothing to end)
assert_eq!(g.ask(&all(), t), Release::None);
assert!(!g.holds((3, 0, 0)));
let mut none = Hold::<u32>::default();
none.clear();
none.ask(&all(), t);
assert!(none.holds((3, 0, 0)));
}
}
@@ -0,0 +1,485 @@
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
pub(crate) fn supported(
lane: (bool, bool, bool),
hdr_offered: bool,
attachments: u32,
flags: wgpu::DownlevelFlags,
format: wgpu::TextureFormatFeatures,
) -> bool {
!lane.0
&& lane.1
&& lane.2
&& hdr_offered
&& attachments >= 2
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
&& format
.allowed_usages
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING)
&& format
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
}
pub(crate) fn attachment(view: &wgpu::TextureView) -> wgpu::RenderPassColorAttachment<'_> {
wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
}
}
pub(crate) fn clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
let mut color = attachment(view);
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD coverage reset"),
color_attachments: &[Some(color)],
..Default::default()
});
}
pub(crate) struct Pipeline {
plain: wgpu::RenderPipeline,
coverage: Option<wgpu::RenderPipeline>,
}
impl Pipeline {
/// `None` when `enabled` and the shader can't be given a coverage output (a resource
/// pack's, which the caller then doesn't use).
pub(crate) fn new(
device: &wgpu::Device,
desc: &wgpu::RenderPipelineDescriptor<'_>,
source: wgpu::ShaderSource<'_>,
enabled: bool,
) -> Option<Self> {
let coverage = if enabled {
Some(pipeline(device, desc, source)?)
} else {
None
};
Some(Self {
plain: device.create_render_pipeline(desc),
coverage,
})
}
pub(crate) fn get(&self, coverage: bool) -> &wgpu::RenderPipeline {
if coverage {
self.coverage.as_ref().expect("HUD coverage pipeline")
} else {
&self.plain
}
}
}
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
/// `None` for a fragment shader without a colour output at location 0.
pub(super) fn pipeline(
device: &wgpu::Device,
desc: &wgpu::RenderPipelineDescriptor<'_>,
source: wgpu::ShaderSource<'_>,
) -> Option<wgpu::RenderPipeline> {
let mut desc = desc.clone();
let fragment = desc.fragment.as_mut()?;
let mut module = match source {
wgpu::ShaderSource::Wgsl(s) => naga::front::wgsl::parse_str(&s).ok()?,
wgpu::ShaderSource::Naga(m) => m.into_owned(),
_ => return None,
};
let primary = fragment.targets.first()?.clone()?;
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
let additive = matches!(
(blend.src_factor, blend.dst_factor),
(
wgpu::BlendFactor::One
| wgpu::BlendFactor::Src
| wgpu::BlendFactor::SrcAlpha
| wgpu::BlendFactor::Zero,
wgpu::BlendFactor::One
)
) && blend.operation == wgpu::BlendOperation::Add;
wrap(&mut module, fragment.entry_point?, blend)?;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: desc.label,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let over = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrc,
operation: wgpu::BlendOperation::Add,
};
let targets = [
Some(primary),
Some(wgpu::ColorTargetState {
format: FORMAT,
blend: Some(wgpu::BlendState {
color: over,
alpha: over,
}),
write_mask: if additive {
wgpu::ColorWrites::empty()
} else {
wgpu::ColorWrites::RED
},
}),
];
fragment.module = &shader;
fragment.targets = &targets;
Some(device.create_render_pipeline(&desc))
}
fn wrap(module: &mut naga::Module, name: &str, blend: wgpu::BlendComponent) -> Option<()> {
use naga::{Expression as E, Statement as S, TypeInner as T};
use wgpu::BlendFactor as B;
let span = naga::Span::UNDEFINED;
let entry = module.entry_points.iter_mut().find(|e| e.name == name)?;
// Checked before anything is taken apart: a resource pack's shader may have no colour.
let result = entry.function.result.clone()?;
if let T::Struct { members, .. } = &module.types[result.ty].inner {
members
.iter()
.find(|m| matches!(m.binding, Some(naga::Binding::Location { location: 0, .. })))?;
} else if !matches!(
result.binding,
Some(naga::Binding::Location { location: 0, .. })
) {
return None;
}
let mut original = std::mem::take(&mut entry.function);
entry.function.arguments = original.arguments.clone();
for arg in &mut original.arguments {
arg.binding = None;
}
original.result.as_mut().unwrap().binding = None;
original.name = Some("hud_color".into());
let function = module.functions.append(original, span);
let f = &mut entry.function;
let args = (0..f.arguments.len())
.map(|i| f.expressions.append(E::FunctionArgument(i as u32), span))
.collect();
let call = f.expressions.append(E::CallResult(function), span);
f.body.push(
S::Call {
function,
arguments: args,
result: Some(call),
},
span,
);
let one = f
.expressions
.append(E::Literal(naga::Literal::F32(1.0)), span);
let start = f.expressions.len();
let (mut members, mut values, color, offset) = match &module.types[result.ty].inner {
T::Struct {
members,
span: size,
} => {
// HUD pipelines have one target, so a pack's other colour outputs went nowhere;
// left in, one at location 1 would collide with the coverage.
let (members, values): (Vec<_>, Vec<_>) = members
.iter()
.enumerate()
.filter(|(_, m)| {
!matches!(m.binding, Some(naga::Binding::Location { location, .. }) if location != 0)
})
.map(|(i, m)| {
let value = f.expressions.append(
E::AccessIndex {
base: call,
index: i as u32,
},
span,
);
(m.clone(), value)
})
.unzip();
let color = members
.iter()
.position(|m| {
matches!(m.binding, Some(naga::Binding::Location { location: 0, .. }))
})
.unwrap();
let color = values[color];
(members, values, color, *size)
}
_ => (
vec![naga::StructMember {
name: Some("color".into()),
ty: result.ty,
binding: result.binding,
offset: 0,
}],
vec![call],
call,
16,
),
};
let mut component = |index| {
f.expressions
.append(E::AccessIndex { base: color, index }, span)
};
let [r, g, b, a] = [component(0), component(1), component(2), component(3)];
let mut math = |fun, arg, arg1| {
f.expressions.append(
E::Math {
fun,
arg,
arg1: Some(arg1),
arg2: None,
arg3: None,
},
span,
)
};
let low = math(naga::MathFunction::Min, r, g);
let low = math(naga::MathFunction::Min, low, b);
let high = math(naga::MathFunction::Max, r, g);
let high = math(naga::MathFunction::Max, high, b);
// A scalar cannot reproduce RGB-dependent replacement or inversion: surviving pixels
// cover fully, so the later composite cannot leak world light through them.
let c = if blend.operation != wgpu::BlendOperation::Add {
one
} else {
match (blend.src_factor, blend.dst_factor) {
(B::SrcAlpha | B::One, B::OneMinusSrcAlpha) => a,
(B::Zero, B::OneMinusSrc) => high,
// Multiplying retains src.rgb (or 1 - alpha + src.rgb) of the backdrop.
// Use the greatest channel darkening, as for the credits vignette above.
(B::Zero, B::Src)
| (B::Dst, B::Zero)
| (B::Dst, B::OneMinusSrcAlpha)
| (B::Dst, B::Src) => {
let top = if blend.dst_factor == B::OneMinusSrcAlpha {
a
} else {
one
};
let factor = if blend.dst_factor == B::Src && blend.src_factor == B::Dst {
f.expressions.append(
E::Binary {
op: naga::BinaryOperator::Add,
left: low,
right: low,
},
span,
)
} else {
low
};
f.expressions.append(
E::Binary {
op: naga::BinaryOperator::Subtract,
left: top,
right: factor,
},
span,
)
}
_ => one,
}
};
let c = f.expressions.append(
E::Math {
fun: naga::MathFunction::Saturate,
arg: c,
arg1: None,
arg2: None,
arg3: None,
},
span,
);
let scalar = module.types.insert(
naga::Type {
name: None,
inner: T::Scalar(naga::Scalar::F32),
},
span,
);
members.push(naga::StructMember {
name: Some("coverage".into()),
ty: scalar,
binding: Some(naga::Binding::Location {
location: 1,
interpolation: None,
sampling: None,
blend_src: None,
per_primitive: false,
}),
offset,
});
let ty = module.types.insert(
naga::Type {
name: Some("HudCoverage".into()),
inner: T::Struct {
members,
span: (offset + 4).next_multiple_of(16),
},
},
span,
);
values.push(c);
let value = f.expressions.append(
E::Compose {
ty,
components: values,
},
span,
);
f.result = Some(naga::FunctionResult { ty, binding: None });
f.body.push(S::Emit(f.expressions.range_from(start)), span);
f.body.push(S::Return { value: Some(value) }, span);
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
let format = wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
};
let lane = (false, true, true);
assert!(supported(lane, true, 2, flags, format));
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
// pack's float frame, and unsupported devices never allocate coverage.
for other in [
(false, false, true),
(true, false, true),
(true, true, true),
(false, true, false),
] {
assert!(!supported(other, true, 2, flags, format));
}
assert!(!supported(lane, false, 2, flags, format));
assert!(!supported(lane, true, 1, flags, format));
assert!(!supported(
lane,
true,
2,
wgpu::DownlevelFlags::empty(),
format
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
flags: wgpu::TextureFormatFeatureFlags::empty(),
..format
}
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::TEXTURE_BINDING,
..format
}
));
}
#[test]
fn disabled_coverage_builds_only_the_original_pipeline() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4(0.0); }".into(),
),
});
let desc = wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: None,
multiview_mask: None,
cache: None,
};
// The coverage source must not even be parsed outside the lane.
let pipeline = Pipeline::new(
&device,
&desc,
wgpu::ShaderSource::Wgsl("invalid".into()),
false,
)
.unwrap();
assert!(pipeline.coverage.is_none());
}
#[test]
fn coverage_wraps_the_debug_crosshair_without_changing_world_entry_points() {
let mut module = naga::front::wgsl::parse_str(include_str!("../terrain.wgsl")).unwrap();
wrap(
&mut module,
"fs_debug_line",
wgpu::BlendState::ALPHA_BLENDING.color,
)
.unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn coverage_replaces_a_colour_output_the_hud_has_no_target_for() {
let mut module = naga::front::wgsl::parse_str(
"struct Out { @location(0) color: vec4<f32>, @location(1) unused: vec4<f32> }
@fragment fn fs() -> Out { return Out(vec4(0.5), vec4(1.0)); }",
)
.unwrap();
wrap(&mut module, "fs", wgpu::BlendState::ALPHA_BLENDING.color).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
let result = module.entry_points[0].function.result.as_ref().unwrap();
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
panic!("the wrapped entry returns a struct");
};
let names: Vec<_> = members.iter().map(|m| m.name.as_deref().unwrap()).collect();
assert_eq!(names, ["color", "coverage"]);
}
}
@@ -90,6 +90,27 @@ impl NativePack {
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
// A backend may refuse a layout or pipeline the pack asks for (a stage's bindings
// past its sampled-texture limit, say, as OpenGL's 16 was before #10095): the pack
// is refused with that error instead of a validation panic in the renderer.
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let built = Self::build(device, queue, terrain, report, constants, lights);
let refused = pollster::block_on(scope.pop());
match (built, refused) {
(Ok(built), None) => Ok(built),
(Ok(_), Some(e)) => Err(e.to_string()),
(Err(e), _) => Err(e),
}
}
fn build(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
@@ -278,9 +299,90 @@ impl NativePack {
#[cfg(test)]
mod tests {
use super::chosen_shadow;
use super::{NativePack, TerrainShape, chosen_shadow};
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
/// A device with OpenGL's minimum 16 sampled textures per stage refuses a pack whose
/// sky adds the terrain's 10 to the frame's 9: that is the pack's error to report, not
/// a validation panic (#10095).
#[test]
fn a_pack_the_backend_refuses_is_an_error_not_a_panic() {
use horizon_shader_api::{MANIFEST, Pack, check};
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let mut limits = adapter.limits();
limits.max_sampled_textures_per_shader_stage = 16;
let Ok((device, queue)) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
else {
return;
};
// The terrain's group 0: its uniform and the 10 sampled textures (_terrain.wgsl_'s
// 8 sprite arrays, the lightmap and the emission thresholds).
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty,
count: None,
};
let mut entries = vec![entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
)];
entries.extend((1..=10).map(|binding| {
entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
}));
let globals = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &entries,
});
let empty = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let terrain = TerrainShape {
globals: &globals,
flat: None,
sprites: [1.0, 2048.0],
format: wgpu::TextureFormat::Rgba8Unorm,
sprite_tags: &[],
sprite_materials: &[],
entities: &empty,
weather_groups: [&empty, &empty],
pbr: None,
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[sky]\nfile = \"sky.wgsl\"\n";
let files = [
(MANIFEST, manifest),
(
"sky.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let built = NativePack::new(&device, &queue, &terrain, report, &[], Default::default());
assert!(built.is_err(), "the pack should be refused");
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
@@ -728,6 +728,13 @@ impl PackAtlas {
),
("mc_Entity", entity),
("at_midBlock", mid_block),
// Horizon's own: the face's HDR tier (`mesher::emitting`'s code in the colour's low
// alpha nibble): 0 for none, 1 to 4 for low to very bright, plus 4 when the whole
// face shines (`mesher::WHOLE`).
(
"horizonTier",
"float(15u - (uint(round(hz_v_color.a * 255.0)) & 15u))",
),
(
"mc_midTexCoord",
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
@@ -157,6 +157,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
flat,
geometry,
origin,
0x80ffffff,
);
let fade = if requested { expected } else { 1.0 };
let expected = [
@@ -182,6 +183,96 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
assert_eq!(count, 98);
}
/// `horizonTier`: the HDR tier the mesher marks a light source's faces with (`mesher::emitting`),
/// read by a pack's terrain program, flat or instanced.
#[test]
fn terrain_tier_reaches_pack_pixels() {
let instance = crate::gfx::test_gpu::instance();
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
Ok(adapter) => adapter,
Err(error) => {
eprintln!("SKIP terrain tier GPU control: no adapter: {error}");
return;
}
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
queue.submit([encoder.finish()]);
let folder = ProbePack(
std::env::temp_dir().join(format!("horizon-terrain-tier-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
std::fs::write(
folder.0.join("shaders/gbuffers_terrain.vsh"),
"#version 330\nin float horizonTier;\nout vec4 observation;\n\
void main() {\nobservation = vec4(horizonTier, 0.0, 0.0, 0.0);\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}",
)
.unwrap();
std::fs::write(
folder.0.join("shaders/gbuffers_terrain.fsh"),
"#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }",
)
.unwrap();
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
use crate::mesher::{BRIGHT, LOW, VERY_BRIGHT, WHOLE, emitting};
// No light, low, very bright, and bright all over (4 more).
let cases = [
(0, 0.0),
(LOW, 1.0),
(VERY_BRIGHT, 4.0),
(BRIGHT | WHOLE, 7.0),
];
for flat in [false, true] {
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&horizon_shaders::GameInputs {
terrain: Some(atlas.terrain_inputs(flat)),
..Default::default()
},
);
let program = post
.terrain
.as_ref()
.unwrap_or_else(|| panic!("{:?}", post.notes));
for (tier, expected) in cases {
let origin = section_instance([0.0; 3], 0, 1.0);
let pixel = sample(
&device,
&queue,
&atlas,
program.program.as_ref().unwrap(),
flat,
false,
origin,
emitting(0xffff_ffff, tier),
);
assert_eq!(pixel[0], expected, "tier {tier} flat={flat}");
}
}
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -190,6 +281,7 @@ fn sample(
flat: bool,
geometry: bool,
origin: [f32; 4],
color: u32,
) -> [f32; 4] {
let buffer = |bytes: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -209,7 +301,7 @@ fn sample(
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
.into_iter()
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, color, 0))
.collect();
let data = buffer(
bytemuck::cast_slice(&vertices),
@@ -30,7 +30,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
/// 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.
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
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\
@@ -107,6 +107,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
"at_velocity",
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
),
// Horizon's own, as for terrain: the HDR tier of a light-giving item, particle or glowing
// text (`mesher::hdr_tier`, the overlay's high bits), 0 for none, 1 to 4 for low to very
// bright.
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
];
horizon_shaders::GbuffersInputs {
declarations,
@@ -1628,3 +1632,44 @@ mod velocity_translate_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod tier_translate_tests {
/// A pack's entity and hand programs may read `horizonTier`, the overlay's HDR tier.
#[test]
fn a_pack_reading_horizon_tier_translates_for_entities_and_the_hand() {
let dir = std::env::temp_dir().join(format!("hz-entity-tier-{}", 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 float horizonTier;\nvarying float t;\n\
void main() {\n gl_Position = ftransform();\n t = horizonTier;\n}\n",
)
.unwrap();
std::fs::write(
shaders.join("gbuffers_entities.fsh"),
"#version 120\nvarying float t;\nvoid main() {\n gl_FragData[0] = vec4(t);\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 attribute = inputs.attributes.iter().find(|(n, _)| n == "horizonTier");
assert!(attribute.is_some(), "{who}");
horizon_shaders::translate_gbuffers(
&vsh,
&fsh,
horizon_shaders::Target::Buffers,
&inputs,
)
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
}
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -149,6 +149,7 @@ void main() {
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
&Default::default(),
)
.expect("the pack runs");
let mut pass = post
+357 -69
View File
@@ -381,12 +381,13 @@ impl Gpu {
}
}
/// A pass's pipeline: a draw, or a compute dispatch.
/// A pass's pipeline: a draw, or a compute dispatch. Its slot is filled when the pack's
/// pipelines are compiled (`compile`), all of them at once once the pack is laid out.
#[derive(Clone)]
enum Pipeline {
Render(wgpu::RenderPipeline),
Render(Slot<wgpu::RenderPipeline>),
Compute {
pipeline: wgpu::ComputePipeline,
pipeline: Slot<wgpu::ComputePipeline>,
/// `workGroups`, else `workGroupsRender` and the local size.
groups: Option<[u32; 3]>,
render: [f32; 2],
@@ -401,7 +402,7 @@ struct Pass {
pipeline: Pipeline,
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
@@ -472,7 +473,7 @@ impl Pass {
fn render(&self) -> &wgpu::RenderPipeline {
match &self.pipeline {
Pipeline::Render(p) => p,
Pipeline::Render(p) => p.get().expect("compiled with its pack"),
Pipeline::Compute { .. } => unreachable!("a compute pass draws nothing"),
}
}
@@ -515,6 +516,7 @@ struct Targets {
}
/// What a pack's gbuffers programs read and draw from the game's world.
#[derive(Clone)]
pub(super) struct World<'a> {
/// The block atlas and its size.
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
@@ -1243,6 +1245,7 @@ fn block_ids(
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
/// (flat.rs), the layout of their bind group 1.
#[derive(Clone)]
pub(super) struct Geometry<'a> {
pub buffers: &'a [Option<wgpu::VertexBufferLayout<'a>>],
pub table: Option<&'a wgpu::BindGroupLayout>,
@@ -1286,6 +1289,177 @@ struct Resources<'a> {
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
/// unset): the translator's helpers blend a gather of it (`hz_depth_linear`).
shadow_linear: u8,
/// While the pack is laid out for `compile`: the pipelines its programs need. `None`
/// compiles each as its program is built.
jobs: Option<std::cell::RefCell<Vec<Job>>>,
}
impl Resources<'_> {
/// The slot of the render pipeline `job` makes: filled now, or by `compile` when the pack
/// is laid out.
fn render_pipeline(&self, device: &wgpu::Device, job: RenderJob) -> Slot<wgpu::RenderPipeline> {
let slot = Slot::default();
let job = Job::Render(slot.clone(), job);
match &self.jobs {
Some(jobs) => jobs.borrow_mut().push(job),
None => job.create(device),
}
slot
}
/// As `render_pipeline`, for a compute pipeline.
fn compute_pipeline(
&self,
device: &wgpu::Device,
job: ComputeJob,
) -> Slot<wgpu::ComputePipeline> {
let slot = Slot::default();
let job = Job::Compute(slot.clone(), job);
match &self.jobs {
Some(jobs) => jobs.borrow_mut().push(job),
None => job.create(device),
}
slot
}
}
/// A pipeline, made once: `Pipeline`'s, filled by its `Job`.
type Slot<T> = std::sync::Arc<std::sync::OnceLock<T>>;
/// What a render pipeline of a pack's program is made of, kept to make it later.
#[derive(Clone)]
struct RenderJob {
label: String,
layout: wgpu::PipelineLayout,
vs: wgpu::ShaderModule,
fs: wgpu::ShaderModule,
/// The vertex buffers: stride, step mode and attributes.
buffers: Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>>,
primitive: wgpu::PrimitiveState,
depth_stencil: Option<wgpu::DepthStencilState>,
targets: Vec<Option<wgpu::ColorTargetState>>,
}
/// What a compute pipeline of a pack's program is made of.
struct ComputeJob {
label: String,
layout: wgpu::PipelineLayout,
module: wgpu::ShaderModule,
}
/// A pipeline to make, and the slot it goes in.
enum Job {
Render(Slot<wgpu::RenderPipeline>, RenderJob),
Compute(Slot<wgpu::ComputePipeline>, ComputeJob),
}
impl Job {
/// Makes the pipeline (the driver compiles its shaders here) into its slot.
fn create(&self, device: &wgpu::Device) {
match self {
Job::Render(slot, j) => {
let buffers: Vec<Option<wgpu::VertexBufferLayout>> = j
.buffers
.iter()
.map(|b| {
b.as_ref()
.map(|(stride, step, attributes)| wgpu::VertexBufferLayout {
array_stride: *stride,
step_mode: *step,
attributes,
})
})
.collect();
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&j.label),
layout: Some(&j.layout),
vertex: wgpu::VertexState {
module: &j.vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &buffers,
},
primitive: j.primitive,
depth_stencil: j.depth_stencil.clone(),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &j.fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &j.targets,
}),
multiview_mask: None,
cache: None,
});
let _ = slot.set(pipeline);
}
Job::Compute(slot, j) => {
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(&j.label),
layout: Some(&j.layout),
module: &j.module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let _ = slot.set(pipeline);
}
}
}
fn label(&self) -> &str {
match self {
Job::Render(_, j) => &j.label,
Job::Compute(_, j) => &j.label,
}
}
}
/// Makes the pipelines of `jobs` on as many threads as the machine has cores (up to 8): the
/// driver compiles a big pack's programs for tens of seconds one at a time, on one core. The
/// first one wgpu or the driver refuses, with why; `None` when all are made.
///
/// A refusal doesn't stop the others: every job is made, so that the serial rebuild `new` then
/// runs finds the driver's shader cache warm instead of compiling the rest cold, one at a time.
/// `cancel` set stops handing out jobs: the caller then drops the pack (`PackPost::new`).
fn compile(
device: &wgpu::Device,
jobs: &[Job],
cancel: &std::sync::atomic::AtomicBool,
) -> Option<(String, String)> {
use std::sync::atomic::{AtomicUsize, Ordering};
let threads = std::thread::available_parallelism()
.map_or(1, |n| n.get())
.clamp(1, 8)
.min(jobs.len().max(1));
let next = AtomicUsize::new(0);
let refused = std::sync::Mutex::new(None);
std::thread::scope(|scope| {
for _ in 0..threads {
scope.spawn(|| {
while !cancel.load(Ordering::Relaxed)
&& let Some(job) = jobs.get(next.fetch_add(1, Ordering::Relaxed))
{
if let Err(e) = checked(device, || job.create(device)) {
let mut refused = refused.lock().unwrap_or_else(|e| e.into_inner());
refused.get_or_insert((job.label().to_owned(), e));
}
}
});
}
});
refused.into_inner().unwrap_or_else(|e| e.into_inner())
}
/// Vertex buffer layouts owned, for a `RenderJob`.
fn owned_buffers(
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
) -> Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>> {
buffers
.iter()
.map(|b| {
b.as_ref()
.map(|b| (b.array_stride, b.step_mode, b.attributes.to_vec()))
})
.collect()
}
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
@@ -1894,60 +2068,47 @@ fn build_pass(
packed::quantise_outputs(&mut fragment, &packed_outputs);
packed::bitcast_outputs(&mut fragment, &int_outputs);
let (vs, fs) = (module(program.vertex), module(fragment));
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or(&[], |g| g.buffers),
let job = RenderJob {
label: p.name.clone(),
layout: pipeline_layout,
vs,
fs,
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or_else(Vec::new, |g| owned_buffers(g.buffers)),
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets,
}),
multiview_mask: None,
cache: None,
})
}),
targets: targets.to_vec(),
};
let pipeline = descriptor(&targets);
// The program's own blend replaced by vanilla's for the part; a buffer's own
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
// buffer only, with a probe pack of ours).
// buffer only, with a probe pack of ours). Two more pipelines of the program, made
// with the others (`compile`).
let sky_parts = p.vanilla_sky_blends.then(|| {
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
let targets: Vec<_> = (targets.iter().enumerate())
let targets: Vec<_> = (job.targets.iter().enumerate())
.map(|(l, t)| {
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
t.clone().map(|t| wgpu::ColorTargetState {
@@ -1956,10 +2117,16 @@ fn build_pass(
})
})
.collect();
descriptor(&targets)
pack.render_pipeline(
device,
RenderJob {
targets,
..job.clone()
},
)
})
});
(layout, pipeline, sky_parts)
(layout, pack.render_pipeline(device, job), sky_parts)
});
let (layout, pipeline, sky_parts) = match created {
Ok(created) => created,
@@ -2099,15 +2266,12 @@ fn build_compute(
label: Some(&name),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(&name),
layout: Some(&pipeline_layout),
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
(layout, pipeline)
let job = ComputeJob {
label: name.clone(),
layout: pipeline_layout,
module,
};
(layout, pack.compute_pipeline(device, job))
});
let (layout, pipeline) = match created {
Ok(created) => created,
@@ -2176,17 +2340,73 @@ pub struct Refused {
pub notes: Vec<String>,
}
impl Refused {
/// The reason of a build stopped by its `cancel` flag (`PackPost::new`).
const CANCELLED: &str = "cancelled";
fn cancelled(notes: Vec<String>) -> Self {
Self {
reason: Self::CANCELLED.into(),
notes,
}
}
/// Whether the build was cancelled (another pack or dimension asked for): not the pack's
/// fault, so nothing to report to the player.
pub fn is_cancelled(&self) -> bool {
self.reason == Self::CANCELLED
}
}
impl PackPost {
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
///
/// The pack is laid out first and its pipelines are made after, all at once (`compile`).
/// When wgpu or the driver refuses one, the pack is built again a program at a time, so that
/// the program is skipped with its note as it always was.
///
/// `cancel` set (the pack isn't wanted any more) stops the build between programs: a refusal
/// whose `is_cancelled` is true.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
post: Post,
screen: wgpu::TextureFormat,
world: World<'_>,
cancel: &std::sync::atomic::AtomicBool,
) -> Result<Self, Refused> {
use std::sync::atomic::Ordering;
let spare = (post.clone(), world.clone());
let (built, jobs) = Self::build(device, queue, adapter, post, screen, world, true, cancel)?;
let refused = compile(device, &jobs, cancel);
// Some pipelines may be left unmade: the pack can't draw.
if cancel.load(Ordering::Relaxed) {
return Err(Refused::cancelled(built.notes));
}
let Some((program, refused)) = refused else {
return Ok(built);
};
log::warn!(
"shader pack: {program} was refused ({refused}): building it again a program at a time"
);
drop((built, jobs));
let (post, world) = spare;
Self::build(device, queue, adapter, post, screen, world, false, cancel)
.map(|(built, _)| built)
}
/// `new`'s work: with `defer`, the pipelines are left to make (the jobs returned).
#[allow(clippy::too_many_arguments)]
fn build(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
mut post: Post,
screen: wgpu::TextureFormat,
world: World<'_>,
) -> Result<Self, Refused> {
defer: bool,
cancel: &std::sync::atomic::AtomicBool,
) -> Result<(Self, Vec<Job>), Refused> {
// What the pack's shaders need of its storage buffers, before its parts move out.
let minimums = post.buffer_minimums();
let features = device.features();
@@ -2338,8 +2558,13 @@ impl PackPost {
shadow_linear: (0..2)
.filter(|&n| !shadow_config.nearest[n])
.fold(0, |m, n| m | 1 << n),
jobs: defer.then(Default::default),
};
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
// Not wanted any more: the rest is skipped and the build refused below.
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
let in_shadow = p.group.shadow();
let mut compute = std::mem::take(&mut p.compute);
if !gpu.compute {
@@ -2688,6 +2913,9 @@ impl PackPost {
.filter(|p| !skip.split(',').any(|s| s == p.name))
.filter_map(|p| build(p, None))
.collect();
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return Err(Refused::cancelled(notes));
}
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
// Terrain in two parts draws both or neither: one alone would leave the other part's
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
@@ -3021,7 +3249,10 @@ impl PackPost {
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Ok(Self {
let jobs = (pack.jobs.as_ref())
.map(std::cell::RefCell::take)
.unwrap_or_default();
let built = Self {
notes,
setup_pending: true,
passes,
@@ -3190,7 +3421,8 @@ impl PackPost {
u
},
shadow_cache: Default::default(),
})
};
Ok((built, jobs))
}
/// `Renderer::finish_sequence_frame`: the centre depth the finished frame copied.
@@ -3518,6 +3750,7 @@ impl PackPost {
else {
return;
};
let pipeline = pipeline.get().expect("compiled with its pack");
let groups = groups.unwrap_or_else(|| {
let n = |k: usize| (size[k] as f32 * render[k] / local[k] as f32).ceil() as u32;
[n(0), n(1), 1]
@@ -3732,8 +3965,8 @@ impl PackPost {
pass.sky_parts
.as_ref()
.and_then(|[plain, stars]| match part.stage {
"SKY" | "VOID" => Some(plain),
"STARS" => Some(stars),
"SKY" | "VOID" => plain.get(),
"STARS" => stars.get(),
_ => None,
});
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
@@ -5418,6 +5651,57 @@ mod tests {
assert_eq!(used, [1, 2, 3]);
}
/// A pack's pipelines are made together on several threads: each lands in its pass's slot,
/// and one wgpu refuses comes back by name (the pack is then built a program at a time).
#[test]
fn pipelines_compile_together_and_a_refused_one_is_named() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = device.create_pipeline_layout(&Default::default());
let module = |source: &str| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
})
};
let job = |label: &str, module: wgpu::ShaderModule| {
let slot = Slot::default();
let job = ComputeJob {
label: label.into(),
layout: layout.clone(),
module,
};
(slot.clone(), Job::Compute(slot, job))
};
let plain = "@compute @workgroup_size(1) fn main() {}";
let go = std::sync::atomic::AtomicBool::new(false);
let (slots, jobs): (Vec<_>, Vec<_>) = (0..12)
.map(|i| job(&format!("composite{i}"), module(plain)))
.unzip();
// Cancelled before it starts: nothing is made (`new` then drops the pack).
assert_eq!(compile(&device, &jobs, &true.into()), None);
assert!(slots.iter().all(|s| s.get().is_none()));
assert_eq!(compile(&device, &jobs, &go), None);
assert!(slots.iter().all(|s| s.get().is_some()));
// A binding the layout doesn't have: wgpu refuses the pipeline.
let bound = "@group(0) @binding(0) var<storage, read_write> b: array<u32>;\n\
@compute @workgroup_size(1) fn main() { b[0] = 1u; }";
let (_, bad) = job("deferred3", module(bound));
let (slots, mut jobs): (Vec<_>, Vec<_>) =
(0..4).map(|i| job(&format!("c{i}"), module(plain))).unzip();
jobs.insert(2, bad);
let (program, _why) = compile(&device, &jobs, &go).expect("the pipeline is refused");
assert_eq!(program, "deferred3");
// The refusal stops none of the others: they are all made.
assert!(slots.iter().all(|s| s.get().is_some()));
}
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
#[test]
@@ -5507,6 +5791,7 @@ mod tests {
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
jobs: None,
};
let reads = |group: Group| {
let (_, samplers, ..) = bindings(
@@ -5601,6 +5886,7 @@ mod tests {
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
jobs: None,
packed: [None; BUFFERS],
};
let (_, samplers, ..) = bindings(
@@ -5684,6 +5970,7 @@ mod tests {
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
jobs: None,
};
let mut notes = Vec::new();
let (.., images) = bindings(
@@ -5826,6 +6113,7 @@ mod tests {
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
jobs: None,
};
let draws = DrawUniforms::new(&device);
let mut notes = Vec::new();
@@ -99,6 +99,7 @@ fn the_atlas_blends_its_two_nearest_levels() {
weather: false,
hand: false,
},
&Default::default(),
)
.expect("the pack runs");
for p in gpu.render(&mut post, [4, 4]) {
@@ -83,6 +83,7 @@ impl Gpu {
post,
T::Rgba8Unorm,
world,
&Default::default(),
)
.expect("the pack runs");
assert!(
@@ -244,6 +244,7 @@ impl Fixture {
weather: false,
hand: false,
},
&Default::default(),
)
}
@@ -187,6 +187,7 @@ fn pack(
weather: true,
hand: false,
},
&Default::default(),
)
.expect("weather pack runs");
assert!(
@@ -304,6 +304,7 @@ fn pack_with(
weather: false,
hand: false,
},
&Default::default(),
)
.expect("the pack runs");
assert!(post.notes.is_empty(), "{:?}", post.notes);
@@ -355,6 +355,25 @@ pub const NAMES: &[&str] = &[
"hasSkylight",
"hasCeiling",
"cloudTime",
// Horizon's own: Horizon Extras' dynamic lights (`dynamic_light.rs`), nearest first: how
// many, and each one's camera-relative position and light level (zeros past the count).
"horizonDynamicLightCount",
"horizonDynamicLight0",
"horizonDynamicLight1",
"horizonDynamicLight2",
"horizonDynamicLight3",
"horizonDynamicLight4",
"horizonDynamicLight5",
"horizonDynamicLight6",
"horizonDynamicLight7",
"horizonDynamicLight8",
"horizonDynamicLight9",
"horizonDynamicLight10",
"horizonDynamicLight11",
"horizonDynamicLight12",
"horizonDynamicLight13",
"horizonDynamicLight14",
"horizonDynamicLight15",
"isElytraFlying",
"cloudHeight",
];
@@ -929,6 +948,12 @@ impl FrameUniforms {
"gbufferProjectionInverse" => m4(projection_inverse),
"gbufferPreviousModelView" => m4(previous_view),
"gbufferPreviousProjection" => m4(previous_projection),
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
n if dynamic_light_index(n).is_some() => Value::f(
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
.copied()
.unwrap_or([0.0; 4]),
),
"heldItemId" => Value::i(&[inputs.held_items[0]]),
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
@@ -1785,6 +1810,29 @@ pub(super) mod initial_history_tests {
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
}
#[test]
fn the_dynamic_lights_reach_the_pack_nearest_first() {
let hud = HudBatch::new([800, 450]);
let mut f = frame(&hud);
let lights = [[1.0, 2.0, 3.0, 14.0], [-4.0, 0.5, 6.0, 7.0]];
f.dynamic_lights = &lights;
let mut u = FrameUniforms::default();
u.update(&f, &PackInputs::default(), [800, 450]);
let count = index("horizonDynamicLightCount").unwrap();
assert_eq!(int(&u, count), 2);
assert_eq!(float(&u, "horizonDynamicLight0")[..4], lights[0]);
assert_eq!(float(&u, "horizonDynamicLight1")[..4], lights[1]);
// Past the count, and the last of the sixteen: zeros.
assert_eq!(float(&u, "horizonDynamicLight2")[..4], [0.0; 4]);
assert_eq!(float(&u, "horizonDynamicLight15")[..4], [0.0; 4]);
assert_eq!(index("horizonDynamicLight16"), None);
// Off (no lights): none.
f.dynamic_lights = &[];
u.update(&f, &PackInputs::default(), [800, 450]);
assert_eq!(int(&u, count), 0);
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
@@ -2534,6 +2582,12 @@ pub(super) mod initial_history_tests {
}
}
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
fn dynamic_light_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
(k < crate::dynamic_light::MAX).then_some(k)
}
#[cfg(test)]
#[path = "pack_uniforms/ftransform_test.rs"]
mod ftransform_test;
@@ -55,6 +55,13 @@ pub struct Post {
sampler: wgpu::Sampler,
copy: wgpu::RenderPipeline,
fxaa: wgpu::RenderPipeline,
/// Contrast-adaptive sharpening of TAA's upscaled picture: the jittered frames are
/// spread over more output pixels, and the blend softens edges.
sharpen: wgpu::RenderPipeline,
/// The strength `sharpen` was built with (`set_sharpness`), and what rebuilding it takes.
sharpness: f32,
shader: wgpu::ShaderModule,
pipeline_layout: wgpu::PipelineLayout,
format: wgpu::TextureFormat,
/// World resolution relative to the window (0.5..=2.0).
pub scale: f32,
@@ -232,8 +239,12 @@ impl Post {
..Default::default()
});
Self {
shader: shader.clone(),
pipeline_layout: pipeline_layout.clone(),
copy: pipeline("fs_copy", &pipeline_layout, format),
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
sharpen: sharpen_pipeline(device, &shader, &pipeline_layout, format, 0.85),
sharpness: 0.85,
layout,
sampler,
format,
@@ -474,6 +485,21 @@ impl Post {
}
}
/// The Upscaling Sharpness setting, 0..=1: rebuilds the sharpen pipeline when it changed.
pub fn set_sharpness(&mut self, device: &wgpu::Device, sharpness: f32) {
let sharpness = sharpness.clamp(0.0, 1.0);
if sharpness != self.sharpness {
self.sharpen = sharpen_pipeline(
device,
&self.shader,
&self.pipeline_layout,
self.format,
sharpness,
);
self.sharpness = sharpness;
}
}
/// The size of the world's own target, when it has one.
pub fn size(&self) -> Option<[u32; 2]> {
self.target.as_ref().map(|t| t.size)
@@ -535,7 +561,12 @@ impl Post {
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i], timing);
let pipeline = if self.upscale > 1.0 {
&self.sharpen
} else {
&self.copy
};
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -545,6 +576,23 @@ impl Post {
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let pipeline = if self.antialias {
&self.fxaa
} else {
&self.copy
};
self.draw_onto(encoder, screen, group, pipeline, timing);
}
/// Draw what `group` binds onto `screen` through `pipeline`.
fn draw_onto(
&self,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
pipeline: &wgpu::RenderPipeline,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
@@ -561,16 +609,51 @@ impl Post {
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(if self.antialias {
&self.fxaa
} else {
&self.copy
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
}
/// `fs_sharpen` at `sharpness` (its override), drawing onto `format`.
fn sharpen_pipeline(
device: &wgpu::Device,
shader: &wgpu::ShaderModule,
layout: &wgpu::PipelineLayout,
format: wgpu::TextureFormat,
sharpness: f32,
) -> wgpu::RenderPipeline {
let constants = [("sharpness", f64::from(sharpness))];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon sharpen"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_post"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_sharpen"),
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,
})
}
/// This frame's clip space to the previous frame's, for camera-relative projections:
/// a point `camera - previous_camera` further from the previous camera than from this one.
pub(super) fn reprojection(
+9 -1
View File
@@ -725,6 +725,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// How much the upscaled picture is sharpened, 0..=100 (percent of the most).
pub upscaling_sharpness: u32,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// Frame generation (Horizon setting), off by default.
@@ -813,6 +815,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
upscaling_sharpness: 85,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
@@ -935,6 +938,9 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"upscalingSharpness" => {
g.upscaling_sharpness = number(g.upscaling_sharpness).min(100)
}
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
@@ -980,7 +986,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -1019,6 +1025,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.upscaling_sharpness,
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
@@ -1202,6 +1209,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
upscaling_sharpness: 40,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
@@ -97,6 +97,7 @@ pub enum H {
ResolutionScale,
Antialiasing,
Upscaling,
UpscalingSharpness,
ScreenshotQuality,
FrameGeneration,
Hdr,
@@ -372,6 +373,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::UpscalingSharpness,
Low,
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
),
h(
H::ScreenshotQuality,
None,
@@ -785,6 +791,7 @@ impl H {
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
// Auto, then 200..2000 by 50.
@@ -804,6 +811,7 @@ impl H {
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite => Some(41),
H::HdrPeak => Some(37),
H::HdrBlackLevel => Some(100),
@@ -893,6 +901,9 @@ impl H {
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::UpscalingSharpness => {
labelled("Upscaling Sharpness", format!("{}%", g.upscaling_sharpness))
}
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
@@ -965,6 +976,7 @@ impl H {
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
@@ -1663,6 +1675,7 @@ impl GraphicsUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, cols, y, &view);
}
@@ -3221,6 +3234,7 @@ mod tests {
H::UploadBudget,
H::Workers,
H::ResolutionScale,
H::UpscalingSharpness,
H::HdrPaperWhite,
H::HdrPeak,
H::HdrBlackLevel,
+107 -37
View File
@@ -33,6 +33,11 @@ struct Params {
@group(0) @binding(2) var<uniform> params: Params;
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@group(0) @binding(3) var logo: texture_2d<f32>;
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
// much of the world it hides there (gfx/hud_coverage.rs).
@group(0) @binding(4) var coverage: texture_2d<f32>;
@group(0) @binding(5) var hud: texture_2d<f32>;
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
// textures were drawn for SDR, so a curve on their brightest pixels blows out
@@ -149,49 +154,29 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
return vec4(0.0);
}
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
// Calibration: above its controls the test picture, straight in nits, past
// paper white, the lift and the black level.
let size = vec2<f32>(textureDimensions(frame));
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
let nits = vec3(calibration_nits(pos.xy, size));
if params.pq > 0.5 {
return vec4(pq(nits), 1.0);
}
return vec4(nits / 80.0, 1.0);
}
let f = frame_texel(textureLoad(frame, at, 0));
// 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);
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
// drawn over them since: grey text over the white sun still looks like the sun
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
// lower the brighter, and where in the band how much (hdr_mark). The hand
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
struct Lit {
light: vec3<f32>,
lifted: bool,
};
fn lit(f: vec3<f32>, a: f32, shows: bool) -> Lit {
var lifted = false;
let shows = params.world > 0.5 && params.peak > 1.0
&& (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);
let c = select(min(f, vec3(1.0)), f, shows && 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. 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 {
if shows && a >= 0.75 && 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 gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
@@ -200,10 +185,13 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
light *= select(gain, out / max(top, 1e-4), v > knee);
}
if shows && w.a < 0.75 {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
// brighter, and where in the band how much (hdr_mark).
if shows && a < 0.75 {
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
@@ -220,9 +208,27 @@ 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);
return Lit(min(light, vec3(params.peak)), lifted);
}
// Calibration: above its controls the test picture, straight in nits, past
// paper white, the lift and the black level.
fn calibrating(pos: vec4<f32>) -> bool {
let size = vec2<f32>(textureDimensions(frame));
return params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z;
}
fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
let nits = vec3(calibration_nits(pos.xy, vec2<f32>(textureDimensions(frame))));
if params.pq > 0.5 {
return vec4(pq(nits), 1.0);
}
return vec4(nits / 80.0, 1.0);
}
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
// lifted.
fn display(light: vec3<f32>) -> vec4<f32> {
let scrgb = light * params.paper;
if params.pq > 0.5 {
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
@@ -233,3 +239,67 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
return vec4(scrgb, 1.0);
}
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
if calibrating(pos) {
return calibration_picture(pos);
}
let at = vec2<i32>(pos.xy);
let f = frame_texel(textureLoad(frame, at, 0));
// 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);
let w = frame_texel(textureLoad(world, at, 0));
// The world's marks lift only where nothing was drawn over them since: grey text over
// the white sun still looks like the sun darkened, but it raised the frame's alpha. The
// hand writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
var light = lit(f.rgb, w.a, shows).light;
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
return display(light);
}
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1. What draws
// after it (camera overlays, the menu blur) draws on light, and nothing is lifted later.
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
let shows = params.world > 0.5 && params.peak > 1.0;
return vec4(pow(lit(w.rgb, w.a, shows).light, vec3(1.0 / 2.2)), 1.0);
}
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
// the HUD found it before.
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
}
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
// (1 - c) of the world, light above white included. Where the world is at or below white, at
// 100%, that is the HUD's picture as drawn.
fn layered(at: vec2<i32>) -> vec3<f32> {
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
let c = textureLoad(coverage, at, 0).r;
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
}
// Hdr::composite: the picture so far, stored as the frame stores light, for the menu blur.
@fragment fn fs_composite(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
}
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
if calibrating(pos) {
return calibration_picture(pos);
}
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
}
+187 -14
View File
@@ -531,6 +531,49 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
Some(((name.to_owned(), w, h, pixels), flags, animation.is_some()))
}
/// The texture of every `bitmap` font provider. A provider's `file` may name
/// any texture, so a pack's glyph sheet need not live under `textures/font`.
fn bitmap_font_textures(root: &Path) -> Vec<PathBuf> {
fn jsons(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
jsons(&path, out);
} else if path.extension().is_some_and(|e| e == "json") {
out.push(path);
}
}
}
let assets = root.join("assets");
let mut defs = Vec::new();
for ns in fs::read_dir(&assets).into_iter().flatten().flatten() {
jsons(&ns.path().join("font"), &mut defs);
}
let mut out = Vec::new();
let mut seen = HashSet::new();
for def in defs {
let Ok(bytes) = fs::read(&def) else { continue };
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes);
let Ok(json) = serde_json::from_slice::<serde_json::Value>(bytes) else {
continue;
};
for p in json["providers"].as_array().into_iter().flatten() {
if p["type"] != "bitmap" {
continue;
}
let Some(file) = p["file"].as_str() else {
continue;
};
let (ns, path) = file.split_once(':').unwrap_or(("minecraft", file));
let path = assets.join(ns).join("textures").join(path);
if path.is_file() && seen.insert(path.clone()) {
out.push(path);
}
}
}
out
}
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
/// the full atlas. A pack may add others through `atlases/blocks.json`.
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
@@ -1102,6 +1145,18 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// A bitmap provider's `file` may be outside the walked folders (HUD
// packs keep their glyph sheets under their own namespace or path).
// Pack those sheets too, so Font reads them from the atlas instead of
// copying every glyph into the small run-time region.
if folders.contains(&"font") {
let mut seen: HashSet<PathBuf> = files.iter().cloned().collect();
files.extend(
bitmap_font_textures(root)
.into_iter()
.filter(|p| seen.insert(p.clone())),
);
}
// Content mods' entity textures: their equipment assets name them as
// `<ns>:<path>`, and native mods draw their block entities with them
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
@@ -1333,6 +1388,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.
@@ -1435,8 +1520,9 @@ impl HudAtlas {
let white = sprites["horizon/white"];
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
let font = crate::font::Font::load(
let font = crate::font::Font::load_over(
root,
defaults,
&sprites,
&pixels,
(width, height),
@@ -2349,17 +2435,19 @@ impl HudUniform {
}
}
}
use crate::gfx::hud_coverage::{self, Pipeline};
pub struct HudRenderer {
pub sprite_animations: Vec<(Rect, crate::sprites::SpriteAnimation, (usize, usize, u32))>,
/// The shared atlas texture, for run-time glyph uploads.
pub atlas_texture: Option<wgpu::Texture>,
pipeline: wgpu::RenderPipeline,
pipeline: Pipeline,
/// `RenderPipelines.CROSSHAIR`.
invert_pipeline: wgpu::RenderPipeline,
additive_pipeline: wgpu::RenderPipeline,
depth_pipeline: wgpu::RenderPipeline,
vignette_pipeline: wgpu::RenderPipeline,
end_portal_pipeline: wgpu::RenderPipeline,
invert_pipeline: Pipeline,
additive_pipeline: Pipeline,
depth_pipeline: Pipeline,
vignette_pipeline: Pipeline,
end_portal_pipeline: Pipeline,
/// The layout of `group`, which the CIT glint pipelines share.
layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
@@ -2369,6 +2457,7 @@ pub struct HudRenderer {
buffer: wgpu::Buffer,
capacity: u64,
format: wgpu::TextureFormat,
coverage: bool,
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
@@ -2383,6 +2472,7 @@ impl HudRenderer {
view: &wgpu::TextureView,
glint_view: &wgpu::TextureView,
glint_sampler: &wgpu::Sampler,
coverage: bool,
) -> Result<Self> {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HUD"),
@@ -2495,7 +2585,7 @@ impl HudRenderer {
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let create = |label, blend, fragment_entry, depth: bool| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -2529,7 +2619,9 @@ impl HudRenderer {
}),
multiview_mask: None,
cache: None,
})
};
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
};
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
let depth_pipeline = create(
@@ -2620,6 +2712,7 @@ impl HudRenderer {
buffer,
capacity,
format,
coverage,
atlas_view: view.clone(),
pack_text: None,
zoom: 1.0,
@@ -2634,7 +2727,7 @@ impl HudRenderer {
textures: Option<&cit_glint::GlintTextures>,
) {
self.cit_glint = textures.and_then(|t| {
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t, self.coverage)
});
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
@@ -2644,7 +2737,7 @@ impl HudRenderer {
shader: Option<&horizon_shaders::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
});
}
pub fn animate_sprites(
@@ -2793,6 +2886,7 @@ impl HudRenderer {
self.draw_range(
encoder,
target,
None,
depth,
batch,
size,
@@ -2806,6 +2900,7 @@ impl HudRenderer {
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
coverage: Option<&wgpu::TextureView>,
depth: &wgpu::TextureView,
batch: &HudBatch,
size: [u32; 2],
@@ -2815,7 +2910,7 @@ impl HudRenderer {
if vertices.is_empty() || size.contains(&0) {
return;
}
let attachments = [Some(wgpu::RenderPassColorAttachment {
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
@@ -2824,6 +2919,9 @@ impl HudRenderer {
store: wgpu::StoreOp::Store,
},
})];
if let Some(view) = coverage {
attachments.push(Some(hud_coverage::attachment(view)));
}
let runs = batch.runs_in(vertices);
let mut at = 0;
while at < runs.len() {
@@ -2875,16 +2973,17 @@ impl HudRenderer {
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
if let Some(cit) = &self.cit_glint {
cit.draw(&mut pass, *blend, range.clone());
cit.draw(&mut pass, *blend, range.clone(), coverage.is_some());
}
continue;
}
};
let pipeline = pipeline.get(coverage.is_some());
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
if text {
pack_text.draw(&mut pass, part);
pack_text.draw(&mut pass, part, coverage.is_some());
pass.set_bind_group(0, &self.group, &[]);
} else {
pass.set_pipeline(pipeline);
@@ -2903,6 +3002,10 @@ impl HudRenderer {
}
}
#[cfg(test)]
#[path = "hud/coverage_tests.rs"]
mod coverage_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -3861,6 +3964,30 @@ texture=wide",
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
}
}
/// A bitmap font's `file` may be any texture, not only one under
/// `textures/font`; the atlas packs it so the font can read its glyphs
/// from there instead of copying them into the run-time region.
#[test]
fn a_bitmap_font_sheet_outside_font_is_packed() {
let root = std::env::temp_dir().join(format!("horizon-font-pack-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
write_png(&root, "hud/sheet", 16, 8);
let def = root.join("assets/minecraft/font/default.json");
fs::create_dir_all(def.parent().unwrap()).unwrap();
fs::write(
&def,
r#"{"providers":[{"type":"bitmap","file":"minecraft:hud/sheet.png","ascent":7,"chars":["ab"]}]}"#,
)
.unwrap();
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas
.sprites
.get("hud/sheet")
.expect("the font sheet is packed");
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 8));
}
/// A content mod's GUI textures join the atlas under its namespace;
/// its other textures and vanilla's names stay as they were.
#[test]
@@ -3980,6 +4107,52 @@ texture=wide",
.unwrap();
root
}
/// A pack layer that adds a font provider over the game's own resources:
/// its `font/default.json` names `minecraft:font/test.png`, which only the
/// game's root holds (`HudAtlas::build_over`'s `defaults`).
fn pack_font_over_defaults(name: &str) -> (PathBuf, PathBuf) {
let base = std::env::temp_dir().join(format!(
"horizon-font-fallback-{name}-{}",
std::process::id()
));
let pack = base.join("pack");
let vanilla = base.join("vanilla");
fs::create_dir_all(pack.join("assets/minecraft/font")).unwrap();
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
fs::write(
pack.join("assets/minecraft/font/default.json"),
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/test.png","ascent":7,"chars":["ab"]}]}"#,
)
.unwrap();
let mut pixels = vec![0u8; 16 * 8 * 4];
for y in 0..8 {
for x in (0..3).chain(8..13) {
pixels[(y * 16 + x) * 4 + 3] = 255;
}
}
let mut encoder = png::Encoder::new(
fs::File::create(vanilla.join("assets/minecraft/textures/font/test.png")).unwrap(),
16,
8,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
(pack, vanilla)
}
#[test]
fn a_pack_overlay_reads_font_sheets_from_the_game_below_it() {
let (pack, vanilla) = pack_font_over_defaults("sheets");
let atlas = HudAtlas::build_over(&pack, &vanilla).unwrap();
let glyph = atlas.font.glyph('a');
let _ = fs::remove_dir_all(pack.parent().unwrap());
assert!(glyph.rect.is_some(), "text draws, not the missing glyph");
assert!(glyph.advance > 0.0);
}
#[test]
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
let root = two_glyph_font("atlas");
@@ -5,6 +5,7 @@
//! vanilla's glint's is. Without enchantment rules nothing here is made.
use super::HudVertex;
use crate::gfx::hud_coverage::Pipeline;
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
use std::path::Path;
@@ -230,7 +231,7 @@ pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
/// pipeline per blend.
pub(super) struct CitGlintPass {
group: wgpu::BindGroup,
pipelines: Vec<wgpu::RenderPipeline>,
pipelines: Vec<Pipeline>,
}
impl CitGlintPass {
@@ -241,6 +242,7 @@ impl CitGlintPass {
format: wgpu::TextureFormat,
hud: &wgpu::BindGroupLayout,
textures: &GlintTextures,
coverage: bool,
) -> Option<Self> {
use wgpu::util::DeviceExt;
if textures.is_empty() {
@@ -381,7 +383,7 @@ impl CitGlintPass {
.blends
.iter()
.map(|&blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some("CIT glint"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -409,7 +411,9 @@ impl CitGlintPass {
}),
multiview_mask: None,
cache: None,
})
};
let source = wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into());
Pipeline::new(device, &desc, source, coverage).expect("the CIT glint's coverage")
})
.collect();
Some(Self { group, pipelines })
@@ -421,9 +425,10 @@ impl CitGlintPass {
pass: &mut wgpu::RenderPass<'_>,
blend: u8,
vertices: std::ops::Range<u32>,
coverage: bool,
) {
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
pass.set_pipeline(pipeline);
pass.set_pipeline(pipeline.get(coverage));
pass.set_bind_group(1, &self.group, &[]);
pass.draw(vertices, 0..1);
}
@@ -0,0 +1,311 @@
use super::*;
use crate::gfx::hud_coverage;
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
hud: HudRenderer,
}
impl Gpu {
fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
if !hud_coverage::supported(
(false, true, true),
true,
adapter.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
) {
eprintln!("skipping HUD coverage: adapter lacks independent blending or R8 rendering");
return None;
}
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).ok()?;
let atlas = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 1,
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,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&[255, 255, 255, 255, 255, 255, 255, 0],
);
let view = atlas.create_view(&Default::default());
let hud = HudRenderer::new(
&device,
wgpu::TextureFormat::Rgba16Float,
&view,
&view,
&device.create_sampler(&Default::default()),
true,
)
.unwrap();
Some(Self { device, queue, hud })
}
fn texture(&self, format: wgpu::TextureFormat) -> wgpu::Texture {
self.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
fn draw(&self, batch: &HudBatch, initial: f64, coverage: bool) -> (Vec<u8>, Vec<u8>) {
let frame = self.texture(wgpu::TextureFormat::Rgba16Float);
let mask = self.texture(hud_coverage::FORMAT);
let depth = self.texture(wgpu::TextureFormat::Depth32Float);
let [frame_view, mask_view, depth_view] =
[&frame, &mask, &depth].map(|t| t.create_view(&Default::default()));
self.queue
.write_buffer(&self.hud.buffer, 0, bytemuck::cast_slice(&batch.vertices));
self.queue.write_buffer(
&self.hud.uniform,
0,
bytemuck::bytes_of(&HudUniform::new([4.0, 2.0, 1.0, 0.75], batch.end_portal, 0)),
);
let mut encoder = self.device.create_command_encoder(&Default::default());
for (view, value) in [(&frame_view, 0.3), (&mask_view, initial)] {
let mut attachment = hud_coverage::attachment(view);
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color {
r: value,
g: value,
b: value,
a: value,
});
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(attachment)],
..Default::default()
});
}
self.hud.draw_range(
&mut encoder,
&frame_view,
coverage.then_some(&mask_view),
&depth_view,
batch,
[4, 2],
0..batch.vertices.len() as u32,
None,
);
let buffers = [&frame, &mask].map(|t| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 512,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
t.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
t.size(),
);
buffer
});
self.queue.submit([encoder.finish()]);
let [color, mask] = buffers.map(|buffer| {
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
self.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
buffer.get_mapped_range(..).unwrap().to_vec()
});
(
color
.chunks(256)
.flat_map(|r| r[..32].iter().copied())
.collect(),
mask.chunks(256)
.flat_map(|r| r[..4].iter().copied())
.collect(),
)
}
fn check(&self, batch: &HudBatch, initial: f64, expected: [u8; 4]) {
let (plain, _) = self.draw(batch, initial, false);
let (color, mask) = self.draw(batch, initial, true);
assert_eq!(color, plain, "coverage changed the primary colour or alpha");
for (got, want) in mask.iter().zip(expected.repeat(2)) {
assert!(
got.abs_diff(want) <= 1,
"coverage {mask:?}, expected {expected:?}"
);
}
}
}
fn quad(batch: &mut HudBatch, blend: Blend, color: u32, x: f32, uv: f32) {
let start = batch.vertices.len();
batch.quad(
[[x, 0.0], [x + 2.0, 0.0], [x + 2.0, 2.0], [x, 2.0]],
[[uv, 0.5]; 4],
color,
);
batch.blend_since(start, blend);
}
#[test]
fn coverage_follows_hud_blends_discard_scissor_and_depth_without_changing_colour() {
let Some(gpu) = Gpu::new() else {
return;
};
for (blend, color, initial, count, expected) in [
(Blend::Alpha, rgba(255, 255, 255, 128), 0.0, 2, 192),
(Blend::Additive, rgba(255, 255, 255, 128), 0.3, 1, 77),
(Blend::Invert, rgba(255, 255, 255, 32), 0.0, 1, 255),
(Blend::EndPortal, rgba(255, 255, 255, 255), 0.0, 1, 255),
(Blend::Vignette, rgba(102, 102, 102, 255), 0.0, 1, 102),
] {
let mut batch = HudBatch::new([4, 2]);
for _ in 0..count {
quad(&mut batch, blend, color, 0.0, 0.25);
}
// Crosshair fragments with zero texture alpha discard; EndPortal has no discard.
if blend == Blend::Invert {
quad(&mut batch, blend, color, 2.0, 0.75);
}
gpu.check(
&batch,
initial,
[
expected,
expected,
(initial * 255.0).round() as u8,
(initial * 255.0).round() as u8,
],
);
}
let mut batch = HudBatch::new([4, 2]);
for depth in [0.25, 0.75] {
quad(
&mut batch,
Blend::Alpha,
rgba(255, 255, 255, 128),
0.0,
0.25,
);
let start = batch.vertices.len() - 6;
for vertex in &mut batch.vertices[start..] {
vertex.depth = depth;
}
}
batch.blends.push((0..12, Blend::Depth([1, 0, 2, 2])));
gpu.check(&batch, 0.0, [0, 128, 0, 0]);
}
#[test]
fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
let Some(mut gpu) = Gpu::new() else {
return;
};
use horizon_optifine::cit::parse_blend;
for (name, expected) in [
("alpha", 128),
("add", 0),
("dodge", 0),
("glint", 0),
("burn", 102),
("multiply", 26),
("overlay", 51),
("screen", 255),
("subtract", 255),
("replace", 255),
("0 768", 153),
("774 0", 153),
("770 771 772 773", 128),
] {
let textures = cit_glint::GlintTextures {
width: 1,
height: 1,
pixels: vec![102, 102, 102, 128],
slots: vec![cit_glint::Slot {
rgba: [1.0; 4],
blur: false,
blend: 0,
}],
blends: vec![parse_blend(name).unwrap()],
..Default::default()
};
gpu.hud
.set_cit_glints(&gpu.device, &gpu.queue, Some(&textures));
let mut batch = HudBatch::new([4, 2]);
quad(
&mut batch,
Blend::CitGlint(0),
rgba(0, 0, 0, 255),
0.0,
0.25,
);
quad(
&mut batch,
Blend::CitGlint(0),
rgba(0, 0, 0, 255),
2.0,
0.75,
);
gpu.check(&batch, 0.0, [expected, expected, 0, 0]);
}
}
#[test]
fn coverage_uses_pack_texts_final_alpha_and_discard() {
let Some(mut gpu) = Gpu::new() else {
return;
};
// A translated-style struct result and early return; no dependency on game resources.
let program = horizon_shaders::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_some());
let mut batch = HudBatch::new([4, 2]);
for x in [0.0, 2.0, 0.0, 2.0] {
quad(&mut batch, Blend::Alpha, rgba(255, 255, 255, 255), x, 0.25);
}
for v in &mut batch.vertices {
v.text = 1;
}
gpu.check(&batch, 0.0, [191, 191, 0, 0]);
}
#[test]
fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let Some(mut gpu) = Gpu::new() else {
return;
};
let program = horizon_shaders::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_none());
}
@@ -8,6 +8,7 @@ use std::sync::Arc;
use horizon_shaders::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
use crate::gfx::hud_coverage::Pipeline;
/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two
/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom
@@ -81,7 +82,7 @@ const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
/// The pipeline and its resources.
pub(super) struct PackText {
pipeline: wgpu::RenderPipeline,
pipeline: Pipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
@@ -92,6 +93,7 @@ impl PackText {
format: wgpu::TextureFormat,
atlas: &wgpu::TextureView,
program: &CoreProgram,
coverage: bool,
) -> Option<Self> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = |label, module: &naga::Module| {
@@ -179,7 +181,7 @@ impl PackText {
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let desc = wgpu::RenderPipelineDescriptor {
label: Some("Resource pack text"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
@@ -208,11 +210,17 @@ impl PackText {
}),
multiview_mask: None,
cache: None,
});
};
let source = wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.fragment.clone()));
let pipeline = Pipeline::new(device, &desc, source, coverage);
if let Some(e) = pollster::block_on(scope.pop()) {
log::warn!("the resource pack's text shader is not used: {e}");
return None;
}
let Some(pipeline) = pipeline else {
log::warn!("the resource pack's text shader is not used: it has no colour output");
return None;
};
Some(Self {
pipeline,
group,
@@ -234,8 +242,13 @@ impl PackText {
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
/// pipeline and bind group again after.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
pass.set_pipeline(&self.pipeline);
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
range: std::ops::Range<u32>,
coverage: bool,
) {
pass.set_pipeline(self.pipeline.get(coverage));
pass.set_bind_group(0, &self.group, &[]);
pass.draw(range.clone(), range.start..range.start + 1);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

+2
View File
@@ -42,6 +42,7 @@ pub mod dynamic_light;
pub mod effects;
pub mod environment;
pub mod extras;
pub(crate) mod extras_ui;
pub mod fast_hash;
pub mod fluid_render;
pub mod fog;
@@ -93,6 +94,7 @@ pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod random_source;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
+29 -5
View File
@@ -202,20 +202,44 @@ impl MenuBlur {
target: &wgpu::TextureView,
size: [u32; 2],
radius: f32,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
let copy = self.source(device, size).0;
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[copy].0.as_image_copy(),
copy.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
self.blur(device, queue, encoder, target, size, radius, timing);
}
/// The blur's copy of a frame of `size`, for a picture drawn into it rather than copied
/// (HDR output's composite, `Hdr::composite`), then `blur`.
pub fn source(
&mut self,
device: &wgpu::Device,
size: [u32; 2],
) -> (wgpu::Texture, wgpu::TextureView) {
self.ensure(device, size);
self.targets[if self.half { 2 } else { 0 }].clone()
}
/// Blur what `source` holds into `target`, as `apply` blurs its copy of the frame.
#[allow(clippy::too_many_arguments)]
pub fn blur(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
size: [u32; 2],
radius: f32,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
+240 -198
View File
@@ -12,6 +12,7 @@ use crate::{
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
lang::{self, LanguageInfo},
settings::{SOUND_SOURCES, Settings},
settings_shell as shell,
text::{self, Style, Text},
ui,
};
@@ -19,6 +20,14 @@ use crate::{
fn tr(key: &str) -> Text {
Text::translate(key, &[])
}
/// False only in a game built without the `horizon-extras` feature (docs/forking.md), which says
/// so once at startup: the Options screen then has vanilla's Telemetry Data slot instead of
/// Horizon Settings and Mods, and Credits & Attribution names the official build.
pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
fn horizon_extras() -> bool {
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
}
fn generic(caption: &str, value: Text) -> Text {
Text::translate("options.generic_value", &[tr(caption), value])
}
@@ -293,6 +302,7 @@ impl Opt {
// the namespace the client bundles (assets/horizon/lang).
Opt::PadEnabled => "horizon.options.controller",
Opt::PadVibration => "horizon.options.vibration",
Opt::PadPlayInBackground => "horizon.options.play_in_background",
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
Opt::PadLightbar => "horizon.options.lightbar",
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
@@ -308,6 +318,7 @@ impl Opt {
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
Opt::PadGlyphs => "horizon.options.button_glyphs",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
Opt::ModelPart(i) => [
"options.modelPart.cape",
"options.modelPart.jacket",
@@ -828,7 +839,13 @@ impl Opt {
Opt::JapaneseGlyphs => on_off(s.japanese_glyphs),
Opt::PadEnabled => on_off(s.controller.enabled),
Opt::PadVibration => on_off(s.controller.vibration),
Opt::PadPlayInBackground => on_off(s.controller.play_in_background),
Opt::PadAdaptiveTriggers => on_off(s.controller.adaptive_triggers),
Opt::PadGameplayGuide => tr(match s.controller.guide_detail {
gamepad::GuideDetail::Full => "horizon.options.gameplay_guide.full",
gamepad::GuideDetail::Basic => "horizon.options.gameplay_guide.basic",
gamepad::GuideDetail::Off => "horizon.options.gameplay_guide.off",
}),
Opt::PadInvertX => on_off(s.controller.invert_x),
Opt::PadInvertY => on_off(s.controller.invert_y),
Opt::PadToggleSneak => tr(if s.controller.toggle_sneak {
@@ -1125,6 +1142,9 @@ impl Opt {
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
Opt::PadVibration => s.controller.vibration = !s.controller.vibration,
Opt::PadPlayInBackground => {
s.controller.play_in_background = !s.controller.play_in_background
}
Opt::PadAdaptiveTriggers => {
s.controller.adaptive_triggers = !s.controller.adaptive_triggers
}
@@ -1146,6 +1166,14 @@ impl Opt {
.unwrap_or(0) as u8;
s.controller.glyphs = GlyphStyle::ALL[usize::from(next(i, 4))];
}
Opt::PadGameplayGuide => {
let all = gamepad::GuideDetail::ALL;
let i = all
.iter()
.position(|d| *d == s.controller.guide_detail)
.unwrap_or(0) as u8;
s.controller.guide_detail = all[usize::from(next(i, 3))];
}
Opt::PadKeyboard => {
let all = gamepad::TextKeyboard::ALL;
let i = all
@@ -1619,6 +1647,8 @@ pub struct OptionsUi {
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
pub controller: crate::controller_ui::ControllerUi,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
pub shader: crate::shader_ui::ShaderUi,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
@@ -1651,7 +1681,7 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// The Extras or PvP & HUD items that take a whole row (`wide_extras`), measured
/// The PvP & HUD items that take a whole row (`wide_extras`), measured
/// when the page is drawn.
wide_extras: Vec<crate::extras::Item>,
}
@@ -1697,6 +1727,7 @@ impl Default for OptionsUi {
center_language: false,
graphics: Default::default(),
controller: Default::default(),
extras: Default::default(),
shader: Default::default(),
split_profiles: Default::default(),
calibration: None,
@@ -1765,24 +1796,23 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
Kind::Cycle => button(x, y, w, opt.label(s), Act::Cycle(opt)),
}
}
/// The Extras or PvP & HUD page's rows: its items under their headers.
fn extras_rows(page: Page) -> Vec<Result<crate::extras::Item, &'static str>> {
match page {
Page::Pvp => crate::pvp::rows()
.into_iter()
.map(|r| r.map(crate::extras::Item::Pvp))
.collect(),
_ => crate::extras::rows(),
}
/// The least GUI size a shell screen lays its keys out for, before it has been drawn at its own.
const MIN_KEY_GUI: (f32, f32) = (480.0, 240.0);
/// The PvP & HUD page's rows: its items under their headers.
fn pvp_rows() -> Vec<Result<crate::extras::Item, &'static str>> {
crate::pvp::rows()
.into_iter()
.map(|r| r.map(crate::extras::Item::Pvp))
.collect()
}
/// The items of `page`'s rows whose label, with any of their first values, is wider than a
/// The items of PvP & HUD's rows whose label, with any of their first values, is wider than a
/// half-width button holds (`width` measures text with the screen's font): those take the
/// whole row, so no label slides past its edges, in any language.
fn wide_extras(s: &Settings, page: Page, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
// A button's label keeps 2 px off each edge (`AbstractButton.renderString`'s margin),
// past which it slides back and forth.
let room = 150.0 - 4.0;
extras_rows(page)
pvp_rows()
.into_iter()
.filter_map(Result::ok)
.filter(|&item| {
@@ -2006,6 +2036,9 @@ impl OptionsUi {
if !self.focus_from_keyboard {
self.focus = None;
}
if let Some(shell) = self.shell_mut() {
shell.blur_search();
}
}
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
@@ -2058,7 +2091,10 @@ impl OptionsUi {
}
Page::Controller | Page::PadBinds => self.controller.typing(),
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
_ => self.settings_search_active(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
},
}
}
/// Jump straight to a page (control socket / tests).
@@ -2174,6 +2210,9 @@ impl OptionsUi {
if let Some(at) = Self::shader_page(page) {
self.shader.open(at);
}
if let Some(shell) = self.shell_mut() {
shell.open();
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
@@ -2184,6 +2223,34 @@ impl OptionsUi {
self.studio.refresh();
}
}
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
_ => None,
}
}
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&mut self.extras),
_ => None,
}
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::BindKey(name) => self.selected_key = Some(name),
shell::Output::Push(page) => self.push(page),
shell::Output::Done => events.extend(self.back(s)),
}
}
events
}
/// What the graphics screen's actions do to these options.
fn graphics_events(
&mut self,
@@ -2534,41 +2601,6 @@ impl OptionsUi {
}
rows
}
/// The row that reaches the Zoom mod that replaced the extra: its switch and settings page
/// when it is built in, else a note saying where it is installed (Options > Mods). Zoom
/// became an official native mod, so Extras has no setting of its own for it (#8713).
fn zoom_mod_row(s: &Settings) -> Row {
let Some(i) = s
.mods
.installed
.iter()
.position(|m| m.id == "zoom" && m.native.is_some())
else {
// A note, not a button: Options > Mods already has the one way into the Mods page
// (`every_setting_and_page_is_reached_from_one_place`).
return Row::Note(lang::get("horizon.extras.zoom_mod_install"));
};
let label = || Text::option_name_value(tr("horizon.extras.zoom"), on_off(s.mods.is_on(i)));
let toggle = |width| button(0.0, 0.0, width, label(), Act::ToggleMod(i));
let settings = s.mods.installed[i]
.native
.as_ref()
.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty());
if settings {
Row::Buttons(
toggle(150.0),
Some(button(
0.0,
0.0,
150.0,
tr("horizon.mods.settings"),
Act::ModSettings(i),
)),
)
} else {
Row::Buttons(toggle(310.0), None)
}
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
@@ -2609,7 +2641,7 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => Vec::new(),
Page::Extras | Page::Pvp => {
Page::Pvp => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen; one whose label would run
// past half a row in this language (`wide_extras`) gets the whole row.
@@ -2637,21 +2669,12 @@ impl OptionsUi {
widget
}
};
// PvP & HUD opens with its preset; Extras doesn't repeat it, nor the way there
// (Horizon Settings has it).
// PvP & HUD opens with its preset (Horizon Settings has the way there).
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
let mut rows = Vec::new();
if self.page() == Page::Extras {
// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now;
// Extras keeps a way to the one players look for here (#8713).
rows.push(Self::zoom_mod_row(s));
}
if self.page() == Page::Pvp {
// The editor moves what the game shows: only from a world.
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
let edit = if self.in_world { edit } else { inactive(edit) };
rows.push(Row::Buttons(preset, Some(edit)));
}
// The editor moves what the game shows: only from a world.
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
let edit = if self.in_world { edit } else { inactive(edit) };
let mut rows = vec![Row::Buttons(preset, Some(edit))];
let mut section = Vec::new();
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
let mut half = None;
@@ -2667,7 +2690,7 @@ impl OptionsUi {
}
rows.extend(half.map(|h| Row::Buttons(extra(h, 150.0), None)));
};
for row in extras_rows(self.page()) {
for row in pvp_rows() {
match row {
Err(header) => {
flush(&mut rows, &mut section);
@@ -3098,6 +3121,22 @@ impl OptionsUi {
("horizon.pages.open.mods", Some(Page::Mods)),
("options.credits_and_attribution", Some(Page::Credits)),
];
// horizon-extras: without it, vanilla's last row instead of Horizon Settings and
// Mods: Telemetry Data (greyed: nothing is sent) and Credits & Attribution.
let plain;
let entries: &[_] = if horizon_extras() {
&entries
} else {
plain = [
&entries[..entries.len() - 3],
&[
("options.telemetry", None),
("options.credits_and_attribution", Some(Page::Credits)),
],
]
.concat();
&plain
};
let rows = entries.len().div_ceil(2) as f32;
let gy = (61.0 + 30.0f32).min(h - 33.0 - rows * 24.0);
for (i, (key, page)) in entries.iter().enumerate() {
@@ -3147,6 +3186,22 @@ impl OptionsUi {
let y = y + 28.0 * (i + 1) as f32;
out.push(inactive(button(x, y, 210.0, tr(key), Act::None)));
}
// horizon-extras: a build without them says where the official one is.
if !horizon_extras() {
out.push(Widget {
x: 0.0,
y: (y + 92.0).min(h - 42.0),
w,
h: 9.0,
kind: WKind::Text {
text: tr("horizon.plain.official"),
color: 0xFFA0A0A0,
centered: true,
},
list: false,
row: 0,
});
}
out.push(done(h - 27.0));
top = 0.0;
bottom = 0.0;
@@ -3649,6 +3704,12 @@ impl OptionsUi {
self.shader.draw(s, batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.selected_key;
let in_world = self.in_world;
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
// The controller picks a tile up between presses: the screen's size from the draw.
@@ -3677,8 +3738,8 @@ impl OptionsUi {
crate::screen_draw::paint(batch, atlas, &draws);
return;
}
if matches!(self.page(), Page::Extras | Page::Pvp) {
self.wide_extras = wide_extras(s, self.page(), |t| atlas.font.width(t));
if self.page() == Page::Pvp {
self.wide_extras = wide_extras(s, |t| atlas.font.width(t));
}
let (widgets, top, bottom, content) = self.layout(s, w, h);
self.size = (w, h);
@@ -4015,6 +4076,22 @@ impl OptionsUi {
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
return self.graphics_events(s, actions);
}
if self.shell().is_some() {
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
winit::event::MouseButton::Right
} else {
winit::event::MouseButton::Left
};
return self.bind_mouse(s, button);
}
let shift = self.shift;
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
shell.click(s, (x, y), size, drag, right, shift)
});
return self.shell_events(s, outputs);
}
if Self::controller_page(self.page()).is_some() {
// Waiting on a button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
@@ -4291,7 +4368,9 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let (shader_slider, shader_pack) = self.shader.release();
let graphics_slider = self.graphics.release() | self.controller.release() | shader_slider;
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
let graphics_slider =
self.graphics.release() | self.controller.release() | shader_slider | shell_slider;
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
@@ -4369,6 +4448,13 @@ impl OptionsUi {
) -> Vec<Event> {
self.size = size;
self.framebuffer = framebuffer;
if self.resource_error.is_some() {
return Vec::new();
}
if let Some(shell) = self.shell_mut() {
let outputs = shell.wheel(s, size, cursor, amount);
return self.shell_events(s, outputs);
}
if Self::graphics_page(self.page()).is_some() {
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
return self.graphics_events(s, actions);
@@ -4460,6 +4546,10 @@ impl OptionsUi {
if self.resource_error.is_some() {
return;
}
if let Some(shell) = self.shell_mut() {
shell.scroll(s, size, amount);
return;
}
if Self::graphics_page(self.page()).is_some() {
self.graphics.scroll(s, size, amount);
return;
@@ -4656,6 +4746,10 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if let Some(shell) = self.shell_mut() {
if shell.typing() {
shell.type_text(text);
}
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
self.graphics.type_text(text);
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
@@ -4693,6 +4787,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
if Self::graphics_page(self.page()).is_some() {
return self.graphics.shortcut(shortcut, clipboard);
}
@@ -4826,6 +4923,14 @@ impl OptionsUi {
}
return events;
}
let gui = (
self.size.0.max(MIN_KEY_GUI.0),
self.size.1.max(MIN_KEY_GUI.1),
);
if let Some(shell) = self.shell_mut() {
let outputs = shell.key(s, key, text, shift, gui);
return self.shell_events(s, outputs);
}
if Self::controller_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
@@ -5184,6 +5289,7 @@ impl OptionsUi {
#[cfg(test)]
mod tests {
use super::*;
use crate::extras_ui::ExtraRow;
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
@@ -5629,6 +5735,9 @@ mod tests {
if ui.controller_open() {
texts.extend(ui.controller.row_names(&s));
}
if ui.page() == Page::Extras {
texts.extend(ui.extras.row_labels(&s));
}
}
texts
})
@@ -5997,7 +6106,7 @@ mod tests {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
ui.show(Page::Pvp);
let (widgets, _, _, _) = ui.layout(&s, 480.0, 480.0);
let extra = widgets
.iter()
@@ -6007,7 +6116,7 @@ mod tests {
WKind::Button {
act: Act::Extra(item),
..
} if !matches!(item, crate::extras::Item::SortKey)
} if item.key_name().is_none()
)
})
.unwrap();
@@ -6053,19 +6162,17 @@ mod tests {
#[test]
fn extras_reaches_the_zoom_mod_that_replaced_the_extra() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
let english = || lang::Language::load(&[], "en_us");
// Without the mod, Extras says where it is installed (a note, not a second way into
// Options > Mods).
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Text { text, .. } if text.plain() == "Zoom: install from Mods"
)),
"Extras has no hint for the Zoom mod"
labels.iter().any(|l| l == "Zoom: install from Mods"),
"Extras has no hint for the Zoom mod: {labels:?}"
);
// Built in, Zoom's settings live on its page: Extras opens that page.
s.mods.installed.push(crate::mod_list::Installed {
@@ -6079,30 +6186,28 @@ mod tests {
buttons: Vec::new(),
}),
});
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::ModSettings(0),
label,
..
} if label.plain() == "Settings"
)),
"Extras doesn't open the Zoom mod's settings"
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
assert!(labels.iter().any(|l| l == "Settings"), "{labels:?}");
// The mod's switch is beside it, so Extras keeps the Zoom setting it had.
assert!(labels.iter().any(|l| l.starts_with("Zoom: ")), "{labels:?}");
let size = (480.0, 480.0);
let on = s.mods.is_on(0);
let at = ui.extras.point_at(&s, size, ExtraRow::Zoom(0));
assert_eq!(
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
vec![Event::ButtonClick, Event::Changed]
);
// The mod's switch is on the same row, so Extras keeps the Zoom setting it had.
assert!(
widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::ToggleMod(0),
label,
..
} if label.plain().starts_with("Zoom")
)),
"Extras doesn't switch the Zoom mod"
assert_ne!(s.mods.is_on(0), on);
let at = ui.extras.point_at(&s, size, ExtraRow::ZoomSettings(0));
assert_eq!(
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
vec![
Event::ButtonClick,
Event::ButtonClick,
Event::NativeSettings(0)
]
);
assert_eq!(ui.page(), Page::NativeMod(0));
}
#[test]
@@ -6446,32 +6551,27 @@ mod tests {
);
assert_eq!(ui.page(), page);
}
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
assert_eq!(
widgets
.iter()
.filter(|w| matches!(
w.kind,
WKind::Button {
act: Act::Extra(_),
..
}
))
.count(),
crate::extras::rows().iter().filter(|r| r.is_ok()).count()
ui.extras.row_labels(&s).len(),
// And the note about the Zoom mod over them.
1 + crate::extras::rows().iter().filter(|r| r.is_ok()).count()
);
let press = |ui: &mut OptionsUi, s: &mut Settings, item: crate::extras::Item| {
let at = ui.extras.point_at(s, size, ExtraRow::Item(item));
ui.click(s, &atlas, at.0, at.1, size, [960, 960], false, false)
};
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Sort)),
press(&mut ui, &mut s, crate::extras::Item::Sort),
vec![Event::ButtonClick, Event::Changed]
);
assert!(s.extras.sort);
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Pregen)),
press(&mut ui, &mut s, crate::extras::Item::Pregen),
vec![Event::ButtonClick, Event::Changed]
);
assert!(s.extras.pregen);
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::SortKey)),
press(&mut ui, &mut s, crate::extras::Item::SortKey),
vec![Event::ButtonClick]
);
assert_eq!(ui.selected_key, Some(crate::extras::SORT_KEY));
@@ -6484,12 +6584,6 @@ mod tests {
crate::extras::Extras::parse(&s.extras.serialize()),
s.extras
);
// In a window too short for the page, the wheel scrolls to its end.
let short = (480.0, 180.0);
ui.scroll(&s, short, -1000.0);
let (_, top, bottom, content) = ui.layout(&s, short.0, short.1);
assert!(content > bottom - top);
assert_eq!(ui.scroll, content - (bottom - top));
assert_eq!(
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None),
vec![Event::Changed]
@@ -6497,6 +6591,24 @@ mod tests {
assert_eq!(ui.page(), Page::Horizon);
}
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
#[test]
fn the_wheel_and_the_stick_scroll_a_page_on_the_shell_alike() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Extras);
// Too short for the page.
let short = (480.0, 180.0);
ui.wheel(&mut s, short, (0.0, 0.0), [960, 360], -1.0);
let one = ui.extras.scroll_amount();
assert!(one > 0.0);
ui.scroll_at(&s, short, (0.0, 0.0), -1.0);
assert_eq!(ui.extras.scroll_amount(), 2.0 * one);
ui.scroll_at(&s, short, (0.0, 0.0), 2.0);
assert_eq!(ui.extras.scroll_amount(), 0.0);
}
#[test]
fn classic_graphics_presets_set_the_distances() {
let atlas = atlas();
@@ -7327,7 +7439,7 @@ mod tests {
/// An Extras or PvP & HUD label wider than half a row takes the whole row, so every label
/// fits its button: "Keep channels for UI sounds: Off" did not.
#[test]
fn extras_labels_fit_their_buttons() {
fn pvp_labels_fit_their_buttons() {
// Vanilla's font: 6 px a glyph, a few narrower ones.
let width = |t: &str| {
t.chars()
@@ -7342,9 +7454,9 @@ mod tests {
};
let s = Settings::default();
let mut ui = OptionsUi::default();
for page in [Page::Extras, Page::Pvp] {
ui.stack = vec![Page::Main, page];
ui.wide_extras = wide_extras(&s, page, width);
{
ui.stack = vec![Page::Main, Page::Pvp];
ui.wide_extras = wide_extras(&s, width);
let rows = ui.list_rows(&s);
let buttons: Vec<&Widget> = rows
.iter()
@@ -7358,20 +7470,11 @@ mod tests {
continue;
};
let label = label.plain();
assert!(width(&label) <= b.w - 4.0, "{page:?}: {label} on {}", b.w);
assert!(width(&label) <= b.w - 4.0, "{label} on {}", b.w);
}
// Short ones keep vanilla's two to a row.
assert!(buttons.iter().any(|b| b.w == 150.0), "{page:?}");
assert!(buttons.iter().any(|b| b.w == 150.0));
}
ui.stack = vec![Page::Main, Page::Extras];
ui.wide_extras = wide_extras(&s, Page::Extras, width);
for item in [
crate::extras::Item::UiSoundReserve,
crate::extras::Item::WheelCyclesSettings,
] {
assert!(ui.wide_extras.contains(&item), "{item:?}");
}
assert!(!ui.wide_extras.contains(&crate::extras::Item::AutoReconnect));
}
/// Every setting a player reaches, from the Options screen through every button that opens
@@ -7787,20 +7890,9 @@ mod tests {
s.extras.wheel_cycles_settings = true;
ui.show(Page::Extras);
let size = (480.0, 600.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let sort_wd = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::Sort),
..
}
)
})
.unwrap();
let cursor = (sort_wd.x + 5.0, sort_wd.y + 5.0);
let cursor = ui
.extras
.point_at(&s, size, ExtraRow::Item(crate::extras::Item::Sort));
assert!(!s.extras.sort);
ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
assert!(s.extras.sort);
@@ -8283,55 +8375,5 @@ mod tests {
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
assert_eq!(s.particles, 0);
ui.shift = false;
// Also verify Act::Extra on Page::Extras
ui.show(Page::Extras);
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
if let Some(extra_btn) = widgets.iter().find(|wd| {
matches!(
wd.kind,
WKind::Button {
active: true,
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
}) {
// Extras has enough rows to scroll: click the cycle button inside
// the list's viewport, as a player would after scrolling to it.
ui.scroll = (extra_btn.y - top).max(0.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let extra_btn = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
})
.unwrap();
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
use horizon_cosmetics::prefs::Others;
// Not chosen, nothing owned: shows Hidden.
assert_eq!(s.extras.cosmetics_others, None);
assert_eq!(s.extras.effective_others(), Others::Hidden);
// Forward (wraps to Shown), now a choice
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
// Backward with shift
ui.shift = true;
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
// Backward again
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
ui.shift = false;
}
}
}
+112 -7
View File
@@ -462,7 +462,7 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// The first JSON value of a `pack.mcmeta`: what follows it is ignored, as some packs sign the
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()?
@@ -837,24 +837,55 @@ fn build_overlay_uncached(
dependencies,
&mut extract,
)?;
// What the zips may unpack to, all threads together: a server's pack is a download the
// player only accepts, and a gigabyte of zeros deflates to a megabyte, so without it a pack
// at the download limit wrote hundreds of gigabytes here. Vanilla never unpacks a pack.
let budget = std::sync::atomic::AtomicU64::new(unpack_budget(packs));
// Inflating and writing a big pack's files one by one was most of a cold build; its
// folders all exist by now.
crate::par::par_map(&extract, |(owner, inner, target)| {
let extracted: io::Result<()> = crate::par::par_map(&extract, |(owner, inner, target)| {
let Some(Some(z)) = zips.get(*owner) else {
return Ok(());
};
// One unreadable entry costs that file, not the pack: an error here
// failed the build and the reload dropped every server pack in the stack.
match z.read(inner) {
Ok(bytes) => fs::write(target, bytes),
let copied = fs::File::create(target).and_then(|file| {
let mut file = io::BufWriter::new(file);
z.copy_to(inner, &mut file, &budget)?;
io::Write::flush(&mut file)
});
match copied {
Ok(()) => Ok(()),
// Past the budget the build fails, and the reload drops the server packs.
Err(e) if e.kind() == io::ErrorKind::FileTooLarge => Err(e),
// One unreadable entry costs that file, not the pack: an error here
// failed the build and the reload dropped every server pack in the stack.
Err(e) => {
log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}");
let _ = fs::remove_file(target);
Ok(())
}
}
})
.into_iter()
.collect()
.collect();
if extracted.is_err() {
// What was unpacked before the budget ran out goes too, not only the marker.
let _ = fs::remove_dir_all(out);
}
extracted
}
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
/// for a small pack of text, and never past 8 GiB.
fn unpack_budget(packs: &[Pack]) -> u64 {
packs
.iter()
.filter_map(|p| match &p.source {
Source::Zip(z) => fs::metadata(z).ok().map(|m| m.len()),
Source::Dir(_) => None,
})
.sum::<u64>()
.saturating_mul(16)
.clamp(64 << 20, 8 << 30)
}
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
/// than from the top pack only.
@@ -1000,7 +1031,10 @@ pub fn resource_root(vanilla: &Path, packs: &[Pack], cache: &Path) -> io::Result
(h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)
});
let out = cache.join(format!("{hash:016x}"));
cache::hold(&out)?;
build_overlay(vanilla, packs, &out)?;
// Old selections' overlays would otherwise stay for good, each a whole unpacked copy.
cache::prune(cache);
Ok(out)
}
@@ -1736,6 +1770,77 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn a_pack_that_unpacks_past_its_budget_fails_and_leaves_nothing() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-bomb-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
fs::write(
vanilla.join("assets/minecraft/textures/block/stone.png"),
b"vanilla",
)
.unwrap();
fs::create_dir_all(base.join("packs")).unwrap();
let pack = |name: &str, files: &[(&str, usize)]| {
let path = base.join("packs").join(name);
let mut zip = zip::ZipWriter::new(fs::File::create(&path).unwrap());
let deflated = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("pack.mcmeta", deflated).unwrap();
zip.write_all(br#"{"pack":{"description":"d","pack_format":97}}"#)
.unwrap();
let zeros = vec![0u8; 1 << 20];
for (file, mib) in files {
zip.start_file(*file, deflated).unwrap();
for _ in 0..*mib {
zip.write_all(&zeros).unwrap();
}
}
zip.finish().unwrap();
fs::metadata(&path).unwrap().len()
};
// Four 24 MiB files of zeros: well under the per-file limit, 96 MiB in all from a zip of
// a few hundred KiB, past the 64 MiB a small pack may unpack to.
let size = pack(
"bomb.zip",
&[
("assets/minecraft/textures/block/a.png", 24),
("assets/minecraft/textures/block/b.png", 24),
("assets/minecraft/textures/block/c.png", 24),
("assets/minecraft/textures/block/d.png", 24),
],
);
assert!(size < 1 << 20, "the bomb is small: {size} bytes");
let packs = discover(&vanilla, &base.join("packs"));
assert_eq!(packs.len(), 1);
let out = base.join("overlay");
let error = build_overlay(&vanilla, &packs, &out).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::FileTooLarge, "{error}");
assert!(
!out.exists(),
"what was unpacked before the budget ran out stays"
);
// The same files within the budget unpack whole.
fs::remove_file(base.join("packs/bomb.zip")).unwrap();
pack(
"fine.zip",
&[
("assets/minecraft/textures/block/a.png", 24),
("assets/minecraft/textures/block/b.png", 24),
],
);
let packs = discover(&vanilla, &base.join("packs"));
build_overlay(&vanilla, &packs, &out).unwrap();
assert_eq!(
fs::metadata(out.join("assets/minecraft/textures/block/b.png"))
.unwrap()
.len(),
24 << 20
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn zip_entries_never_leave_the_overlay() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
@@ -297,6 +297,107 @@ fn lock_path(out: &Path) -> PathBuf {
marker_path(out).with_extension("lock")
}
/// The overlays [`prune`] keeps in a cache folder, newest use first. Each holds a whole unpacked
/// copy of its zip packs, and every server pack, and every new version of one, makes another.
pub(super) const KEEP: usize = 8;
/// The file whose shared lock says a process uses `out`, and whose time is its last use.
fn use_path(out: &Path) -> PathBuf {
marker_path(out).with_extension("use")
}
/// Marks `out` as used by this process until it exits ([`prune`] never deletes it), and as used
/// now. The lock is taken before the overlay is built or read, so a prune in another process
/// that took the folder first finishes deleting it before this one rebuilds it.
pub(super) fn hold(out: &Path) -> io::Result<()> {
use std::sync::{Mutex, PoisonError};
static HELD: Mutex<Vec<(PathBuf, fs::File)>> = Mutex::new(Vec::new());
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner);
let at = match held.iter().position(|(path, _)| path == out) {
Some(at) => at,
None => {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(use_path(out))?;
file.lock_shared()?;
held.push((out.into(), file));
held.len() - 1
}
};
// Only the order of the prune depends on it: a filesystem without the time loses nothing.
let _ = held[at].1.set_modified(std::time::SystemTime::now());
Ok(())
}
/// Deletes the overlays in `cache` past the [`KEEP`] most recently used, skipping any that a
/// process holds ([`hold`]) or builds. Only `<16 hex digits>` folders are overlays.
pub(super) fn prune(cache: &Path) {
let Ok(entries) = fs::read_dir(cache) else {
return;
};
let mut overlays: Vec<(std::time::SystemTime, PathBuf)> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.len() == 16 && n.bytes().all(|b| b.is_ascii_hexdigit()))
&& p.is_dir()
})
.map(|p| {
// An overlay built before use files existed goes by its marker or its folder.
let used = [use_path(&p), marker_path(&p), p.clone()]
.iter()
.find_map(|f| fs::metadata(f).and_then(|m| m.modified()).ok())
.unwrap_or(std::time::UNIX_EPOCH);
(used, p)
})
.collect();
overlays.sort_by_key(|(used, _)| std::cmp::Reverse(*used));
for (_, old) in overlays.into_iter().skip(KEEP) {
if let Err(e) = remove_unused(&old) {
log::warn!(
"Horizon: couldn't remove the old pack overlay {}: {e}",
old.display()
);
}
}
}
/// Deletes the overlay `out` unless a process builds or uses it. The lock files stay: deleting
/// them would let two processes lock different files of the same name.
fn remove_unused(out: &Path) -> io::Result<()> {
let open = |path: PathBuf| {
fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
};
let building = open(lock_path(out))?;
let using = open(use_path(out))?;
for lock in [&building, &using] {
match lock.try_lock() {
Ok(()) => {}
Err(fs::TryLockError::WouldBlock) => return Ok(()),
Err(fs::TryLockError::Error(e)) => return Err(e),
}
}
// The marker first: a folder half deleted is never taken for a built one.
match fs::remove_file(marker_path(out)) {
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
_ => {}
}
fs::remove_dir_all(out)
}
pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
@@ -368,6 +469,77 @@ mod tests {
use crate::{packs, text::Text};
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn prune_keeps_the_most_recently_used_overlays_and_those_in_use() {
let f = Fixture::new();
let cache = f.0.join("cache");
let now = std::time::SystemTime::now();
// KEEP + 4 overlays, overlay 0 used last; one more not an overlay.
let overlays: Vec<PathBuf> = (0..KEEP + 4)
.map(|i| {
let out = cache.join(format!("{i:016x}"));
f.write(&format!("cache/{i:016x}/assets/a.png"), b"x");
f.write(&format!("cache/{i:016x}.overlay-cache.json"), b"{}");
let used = fs::File::create(use_path(&out)).unwrap();
used.set_modified(now - std::time::Duration::from_secs(60 * i as u64))
.unwrap();
out
})
.collect();
f.write("cache/notes/keep.txt", b"x");
// Another process uses the oldest, and builds the second oldest.
let using = fs::File::open(use_path(&overlays[KEEP + 3])).unwrap();
using.lock_shared().unwrap();
let building = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(lock_path(&overlays[KEEP + 2]))
.unwrap();
building.lock().unwrap();
prune(&cache);
let kept: Vec<bool> = overlays.iter().map(|o| o.is_dir()).collect();
let mut expected = vec![true; KEEP];
expected.extend([false, false, true, true]);
assert_eq!(kept, expected);
assert!(
!marker_path(&overlays[KEEP]).exists(),
"never reused half deleted"
);
assert!(cache.join("notes/keep.txt").exists());
// Released, they go at the next prune.
drop((using, building));
prune(&cache);
assert_eq!(
overlays.iter().filter(|o| o.is_dir()).count(),
KEEP,
"only the most recent stay"
);
assert!(overlays[..KEEP].iter().all(|o| o.is_dir()));
}
#[test]
fn an_overlay_held_by_this_process_is_never_pruned() {
let f = Fixture::new();
let cache = f.0.join("cache");
let held = cache.join("00000000000000ff");
f.write("cache/00000000000000ff/assets/a.png", b"x");
hold(&held).unwrap();
// Older than KEEP others, and still in use.
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
fs::File::options()
.write(true)
.open(use_path(&held))
.unwrap()
.set_modified(old)
.unwrap();
for i in 0..KEEP {
f.write(&format!("cache/{i:016x}/assets/a.png"), b"x");
}
prune(&cache);
assert!(held.is_dir());
}
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
+92 -4
View File
@@ -58,6 +58,8 @@ pub struct ParticleOptions {
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
/// Pre-1.20.5 entity-effect providers use spawn velocity as RGB; true is the ambient provider.
pub legacy_entity_effect: Option<bool>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -72,6 +74,7 @@ impl ParticleOptions {
geyser: None,
vibration: None,
mod_particle: None,
legacy_entity_effect: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
@@ -3167,8 +3170,16 @@ impl ParticleEngine {
q.set_color(b, 0.0, b);
}
T::EntityEffect => {
q.set_color(o.color[0], o.color[1], o.color[2]);
q.rgba[3] = o.color[3];
if let Some(ambient) = o.legacy_entity_effect {
q.set_color(v[0] as f32, v[1] as f32, v[2] as f32);
// AmbientMobProvider overrides constructor alpha; MobProvider only sets RGB.
if ambient {
q.rgba[3] = 0.15;
}
} else {
q.set_color(o.color[0], o.color[1], o.color[2]);
q.rgba[3] = o.color[3];
}
}
T::Effect | T::InstantEffect => {
q.set_color(o.color[0], o.color[1], o.color[2]);
@@ -3177,7 +3188,12 @@ impl ParticleEngine {
_ => {}
}
q.behaviour = Behaviour::Spell {
original_alpha: q.rgba[3],
// The old SpellParticle.tick always eased back to 1, including ambient effects.
original_alpha: if o.legacy_entity_effect.is_some() {
1.0
} else {
q.rgba[3]
},
};
q.set = set;
q
@@ -3469,7 +3485,13 @@ impl ParticleEngine {
scaled(o.color[2], jb),
);
q.size = 0.26;
q.lifetime = duration;
// The older-server adapter marks pre-1.21.4 TargetColorParticleOption with -1.
// Its TrailParticle.Provider chose nextInt(40) + 10 after constructing the particle.
q.lifetime = if duration == -1 {
self.level_random.next_int_bound(40).unwrap_or(0) + 10
} else {
duration
};
q.has_physics = false;
q.free_move = true;
q.light = Light::FullBright;
@@ -4941,6 +4963,45 @@ const CREEPER: [[f64; 2]; 12] = [
mod tests {
use super::*;
#[test]
fn legacy_spell_providers_use_velocity_rgb_and_their_original_alpha_rules() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let velocity = [0.25, 0.5, 0.75];
for scoped in [false, true] {
engine.set_scope_eye(scoped.then_some([0.0; 3]));
for ambient in [false, true] {
let mut options =
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
options.legacy_entity_effect = Some(ambient);
let particle = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
let alpha = if ambient {
0.15
} else if scoped {
0.0
} else {
1.0
};
assert_eq!(particle.rgba, [0.25, 0.5, 0.75, alpha]);
assert_eq!(
particle.behaviour,
Behaviour::Spell {
original_alpha: 1.0
}
);
}
let options =
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
let current = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
assert_eq!(current.rgba, options.color);
assert_eq!(
current.behaviour,
Behaviour::Spell {
original_alpha: 0.8
}
);
}
}
#[test]
fn particle_light_cache_reuses_exact_blocks_and_fails_open_on_collisions() {
let reads = std::cell::Cell::new(0);
@@ -5700,6 +5761,33 @@ mod tests {
assert!((0.15 * 0.4 - 1e-6..=0.45 * 0.4 + 1e-6).contains(&s));
}
#[test]
fn legacy_trails_choose_a_lifetime_for_each_particle_from_the_level_random() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(1, 42, 2);
for _ in 0..5 {
engine.create(
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
[0.0; 3],
[0.0; 3],
);
}
// java.util.Random(42).nextInt(40) + 10, as vanilla 1.21.3 TrailParticle.Provider does.
assert_eq!(
engine
.pending
.iter()
.map(|p| p.lifetime)
.collect::<Vec<_>>(),
[20, 13, 18, 14, 20]
);
engine.create(
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, 37),
[0.0; 3],
[0.0; 3],
);
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
}
#[test]
fn sizes_follow_vanilla_curves() {
let mut r = LegacyRandom::new(2);
let mut p = Particle::at(&mut r, ParticleType::Flame, [0.0; 3]);
@@ -0,0 +1,63 @@
//! The randoms vanilla creates unseeded: `RandomSource.create()` (sounds,
//! screens, the torch flicker, a raid's random) and an entity's own random
//! (`Entity.random`). Each is seeded from one shared counter and the clock, as
//! `RandomSupport.generateUniqueSeed` does. Kept out of `horizon_core::random`,
//! whose streams are only ever seeded explicitly.
use horizon_core::random::LegacyRandom;
/// `RandomSource.create()`.
pub fn create() -> LegacyRandom {
LegacyRandom::new(unique_seed())
}
/// An entity's own random (`Entity.random`). Vanilla's is
/// `RandomSource.createThreadLocalInstance`, seeded from Java's
/// `ThreadLocalRandom`; Horizon seeds it as `create` does. Draws from an
/// entity's own random never touch the level's.
pub fn entity() -> LegacyRandom {
create()
}
/// `RandomSupport.generateUniqueSeed`. Under `HORIZON_STILL` (screenshots that
/// come out the same every run) the clock is left out, so the seeds repeat run
/// to run, in the order they are taken.
pub fn unique_seed() -> i64 {
use std::sync::atomic::{AtomicI64, Ordering};
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
let next = loop {
let next = current.wrapping_mul(1_181_783_497_276_652_981);
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break next,
Err(v) => current = v,
}
};
if still() {
return next;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
next ^ nanos
}
/// `HORIZON_STILL=1`, read once, as the clock's `still_requested` reads it.
fn still() -> bool {
static STILL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*STILL.get_or_init(|| std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1"))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::RandomSource;
/// One shared counter: seeds taken back to back never repeat, even when
/// the clock hasn't moved between them.
#[test]
fn each_seed_and_each_random_is_new() {
let seeds: std::collections::HashSet<i64> = (0..1000).map(|_| unique_seed()).collect();
assert_eq!(seeds.len(), 1000);
assert_ne!(create().next_long(), create().next_long());
}
}
@@ -830,6 +830,7 @@ pub enum Opt {
/// Horizon's controller settings (`horizon-controller.txt`).
PadEnabled,
PadVibration,
PadPlayInBackground,
PadAdaptiveTriggers,
PadLightbar,
PadLookSensitivity,
@@ -845,6 +846,7 @@ pub enum Opt {
PadTriggerThreshold,
PadGlyphs,
PadKeyboard,
PadGameplayGuide,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// 3D skin layers: the player's outer layer as extruded slabs.
File diff suppressed because it is too large. Load diff
@@ -644,6 +644,7 @@ impl ShaderUi {
hover,
focused,
held: self.dragging == Some(item),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
@@ -861,6 +861,9 @@ pub trait Backend {
fn unpause(&mut self, channel: ChannelId);
/// `Channel.stopped`.
fn stopped(&mut self, channel: ChannelId) -> bool;
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
/// event is one the mod exempted. A backend without a muffle ignores it.
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
}
/// A `SoundEventListener.onPlaySound` call.
#[derive(Clone, Debug, PartialEq)]
@@ -937,6 +940,9 @@ pub struct SoundEngine {
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
pub listener_reserve: usize,
pub notifications: Vec<Notification>,
/// The sound events a mod's muffle leaves clear (`Audio::muffle`), normalized: their channels
/// are marked as they start, and those playing when the list changes are marked again.
unmuffled: Vec<String>,
}
fn handle_channel(h: &Handle) -> Option<ChannelId> {
h.channel
@@ -966,6 +972,21 @@ impl SoundEngine {
listening: false,
listener_reserve: 0,
notifications: Vec::new(),
unmuffled: Vec::new(),
}
}
/// The sound events that play clearly through a mod's muffle from now on, normalized
/// (`minecraft:ui.button.click`); the channels playing now follow the new list too.
pub fn set_unmuffled(&mut self, events: Vec<String>, backend: &mut impl Backend) {
self.unmuffled = events;
for (id, handle) in &self.instance_to_channel {
let (Some(instance), Some(channel)) = (
self.instances.get(id),
self.handles.get(handle).and_then(handle_channel),
) else {
continue;
};
backend.set_unmuffled(channel, self.unmuffled.contains(&instance.identifier));
}
}
/// Isolate a fixture's dispatch from process-wide native installation.
@@ -1315,6 +1336,7 @@ impl SoundEngine {
}
let position = instance.position;
let looping = instance.looping && instance.delay <= 0;
let unmuffled = self.unmuffled.contains(&instance.identifier);
let streaming = sound.stream;
let pool = usize::from(streaming);
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
@@ -1367,6 +1389,7 @@ impl SoundEngine {
backend.set_looping(channel, looping && !streaming);
backend.set_position(channel, position);
backend.set_relative(channel, relative);
backend.set_unmuffled(channel, unmuffled);
backend.start(channel, &sound, looping);
if tickable {
self.ticking.push(id);
@@ -1580,27 +1603,6 @@ impl SoundEngine {
pub fn active_instances(&self) -> Vec<InstanceId> {
self.instance_to_channel.iter().map(|(i, _)| *i).collect()
}
/// A fresh unseeded random (`RandomSource.create()`).
pub fn unseeded() -> LegacyRandom {
LegacyRandom::new(unique_seed())
}
}
/// `RandomSupport.generateUniqueSeed`.
pub fn unique_seed() -> i64 {
use std::sync::atomic::{AtomicI64, Ordering};
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
let next = loop {
let next = current.wrapping_mul(1_181_783_497_276_652_981);
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break next,
Err(v) => current = v,
}
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
next ^ nanos
}
/// The `SoundManager` calls game code makes.
pub trait Sounds {
@@ -1774,7 +1776,7 @@ mod tests {
silent: false,
underwater: false,
};
let mut s = SoundInstance::riding_happy_ghast(&ghast, SoundEngine::unseeded());
let mut s = SoundInstance::riding_happy_ghast(&ghast, crate::random_source::create());
assert_eq!(s.volume, 0.0);
let mut ctx = TickContext {
ridden: Some(ghast),
@@ -1821,7 +1823,7 @@ mod tests {
attack_scale: 0.0,
sniffer_digging: true,
};
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
let mut s = SoundInstance::sniffer(3, crate::random_source::create());
assert_eq!(s.position, [0.0; 3]);
assert!(!s.looping);
let mut ctx = TickContext {
@@ -1836,7 +1838,7 @@ mod tests {
s.tick(&ctx);
assert!(s.stopped);
// Removed (missing): stop.
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
let mut s = SoundInstance::sniffer(3, crate::random_source::create());
ctx.mobs.clear();
s.tick(&ctx);
assert!(s.stopped);
@@ -1850,7 +1852,7 @@ mod tests {
silent: false,
underwater: true,
};
let mut s = SoundInstance::riding_nautilus(&nautilus, SoundEngine::unseeded());
let mut s = SoundInstance::riding_nautilus(&nautilus, crate::random_source::create());
let mut ctx = TickContext {
ridden: Some(nautilus),
..TickContext::default()
@@ -1953,7 +1955,7 @@ mod tests {
position: [10.0, 64.0, 5.0],
speed: 0.0,
};
let mut s = SoundInstance::minecart(&cart, SoundEngine::unseeded());
let mut s = SoundInstance::minecart(&cart, crate::random_source::create());
assert!(s.can_start_silent);
assert_eq!(s.volume, 0.0);
@@ -1993,8 +1995,10 @@ mod tests {
position: [0.0, 60.0, 0.0],
speed: 0.4,
};
let mut surface = SoundInstance::riding_minecart(&cart, false, SoundEngine::unseeded());
let mut underwater = SoundInstance::riding_minecart(&cart, true, SoundEngine::unseeded());
let mut surface =
SoundInstance::riding_minecart(&cart, false, crate::random_source::create());
let mut underwater =
SoundInstance::riding_minecart(&cart, true, crate::random_source::create());
let mut ctx = TickContext {
vehicle: Some(99),
@@ -1,8 +1,9 @@
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
//! calls `preview` when there is no level).
use crate::random_source;
use crate::settings::SOUND_SOURCES;
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
use crate::sound_engine::{InstanceId, SoundInstance, Sounds};
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
@@ -44,7 +45,7 @@ impl SoundPreview {
if let Some(old) = self.active.take() {
sounds.release(old);
}
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
let instance = SoundInstance::for_ui(event, 1.0, volume, random_source::create());
self.active = Some(sounds.play_instance(instance, true).0);
}
@@ -72,7 +73,7 @@ impl SoundPreview {
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
use crate::sound_engine::{Bound, SoundEngine, TickContext, UI, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
+57 -5
View File
@@ -70,6 +70,12 @@ pub type Proto = Box<dyn std::any::Any + Send>;
pub trait ChunkGenerator: Send + Sync + 'static {
type Worker: Send + 'static;
fn worker(&self) -> Self::Worker;
/// The persistent wrapper supplies starts from their owning chunk, in every dimension.
fn set_saved_starts(
&self,
_source: Arc<horizon_world::worldgen::generator::SavedStructureStarts>,
) {
}
fn lod_sampler(self: Arc<Self>) -> Box<dyn LodSampler>
where
Self: Sized,
@@ -1126,18 +1132,29 @@ impl Streaming {
pub fn lod_source(&self) -> Arc<dyn LodSource> {
self.saves.clone()
}
/// Resource packs reloaded (`LevelRenderer.allChanged`): mesh with the new
/// Resource packs reloaded (`LevelExtractor.allChanged`): mesh with the new
/// models and sprites, and rebuild every loaded section. Meshes in flight
/// were made with the old resources; their results are dropped.
pub fn reload_resources(&mut self, world: &mut World, context: Arc<WorkerContext>) {
*self.shared.context.write().unwrap() = context;
self.reload_sections(world);
}
/// `LevelExtractor.allChanged`: discard mesh work made before the reload and
/// rebuild every loaded, lit section.
pub fn reload_sections(&mut self, world: &mut World) {
self.meshing.clear();
self.compile_started.clear();
Self::mark_loaded_sections_dirty(world, &self.lit);
}
/// The resident sections `LevelExtractor.allChanged` queues again. An
/// unlit column will queue itself when its light job completes.
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
for &(x, z) in world.chunks.keys() {
if self.lit.contains(&(x, z)) {
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x, sy, z));
}
if !lit.contains(&(x, z)) {
continue;
}
for sy in 0..world.layout().sections() as i32 {
world.dirty_sections.insert((x, sy, z));
}
}
}
@@ -4462,3 +4479,38 @@ mod cover_tests {
}
}
}
#[cfg(test)]
mod reload_tests {
use super::*;
#[test]
fn reload_marks_every_section_of_loaded_lit_columns() {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
world.chunks.insert((2, -3), Arc::new(Chunk::empty(2, -3)));
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
let lit = FxHashSet::from_iter([(2, -3), (99, 99)]);
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
let sections = world.layout().sections() as i32;
assert_eq!(world.dirty_sections.len(), sections as usize);
for sy in 0..sections {
assert!(world.dirty_sections.contains(&(2, sy, -3)));
assert!(!world.dirty_sections.contains(&(4, sy, 5)));
}
}
#[test]
fn reload_keeps_existing_work_and_ignores_unloaded_lit_columns() {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
world.dirty_sections.insert((4, 3, 5));
let lit = FxHashSet::from_iter([(99, 99)]);
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
assert_eq!(world.dirty_sections.len(), 1);
assert!(world.dirty_sections.contains(&(4, 3, 5)));
}
}
+99
View File
@@ -9,6 +9,102 @@ use horizon_assets::palette::apply_palette;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
/// Runtime trim slots and the completed ordered requests for their current atlas.
/// A frame with more requests than slots keeps the same final residents as the
/// sequential LRU pass, without decoding and evicting them again every frame.
#[derive(Default)]
pub struct TrimSlots {
slots: Vec<(String, usize)>,
atlas_revision: u64,
prepared: Option<Vec<TrimTexture>>,
}
impl TrimSlots {
/// The renderer was rebuilt: runtime pixels must be uploaded again.
pub fn forget(&mut self) {
self.atlas_revision = 0;
self.prepared = None;
}
pub fn prepare(
&mut self,
wanted: Vec<TrimTexture>,
root: &Path,
atlas: &mut crate::hud::HudAtlas,
patches: &mut Vec<crate::font::Patch>,
) {
if self.atlas_revision != atlas.revision {
for (name, _) in self.slots.drain(..) {
atlas.sprites.remove(&name);
}
self.atlas_revision = atlas.revision;
self.prepared = None;
}
let before = self.slots.len();
self.slots
.retain(|(name, _)| atlas.sprites.contains_key(name));
if before != self.slots.len() {
self.prepared = None;
}
if self.prepared.as_ref() == Some(&wanted) {
return;
}
let mut complete = true;
for t in &wanted {
let name = t.sprite();
if let Some(i) = self.slots.iter().position(|e| e.0 == name) {
let e = self.slots.remove(i);
self.slots.push(e);
continue;
}
let Some((w, h, rgba)) = t.generate(root) else {
complete = false;
continue;
};
let slot =
match (0..crate::hud::TRIM_SLOTS).find(|i| !self.slots.iter().any(|e| e.1 == *i)) {
Some(free) => free,
None => {
let (old, slot) = self.slots.remove(0);
atlas.sprites.remove(&old);
slot
}
};
let Some(area) = atlas.sprites.get(&format!("horizon/trim{slot}")).copied() else {
complete = false;
continue;
};
if w > area.w || h > area.h {
complete = false;
continue;
}
let (aw, ah) = (atlas.width as f32, atlas.height as f32);
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
patches.push(crate::font::Patch {
x: x as u32,
y: y as u32,
width: w,
height: h,
rgba,
});
atlas.sprites.insert(
name.clone(),
crate::hud::Rect {
u0: area.u0,
v0: area.v0,
u1: (x + w as f32) / aw,
v1: (y + h as f32) / ah,
w,
h,
},
);
self.slots.push((name, slot));
}
// Failed or not-yet-available textures retain their per-frame retries.
self.prepared = complete.then_some(wanted);
}
}
/// An RGBA image: width, height, pixels.
pub type Image = (u32, u32, Vec<u8>);
@@ -249,6 +345,9 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
}
#[cfg(test)]
mod slots_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,192 @@
use super::*;
use crate::hud::{HudAtlas, Rect, TRIM_SLOTS};
use std::path::PathBuf;
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-trim-slots-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn texture(&self, id: u8) -> TrimTexture {
TrimTexture {
texture: format!("test:trims/entity/humanoid/{id}"),
palette: None,
}
}
fn write(&self, id: u8, width: u32) {
let path = asset_path(&self.0, "textures", &self.texture(id).texture, ".png");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), width, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[id, 255 - id, 42, 255].repeat(width as usize))
.unwrap();
}
fn atlas(&self) -> HudAtlas {
let mut atlas = HudAtlas::fonts(&self.0).unwrap();
atlas.width = TRIM_SLOTS as u32 * 2;
atlas.height = 1;
atlas.pixels = vec![0; atlas.width as usize * 4];
for i in 0..TRIM_SLOTS {
atlas.sprites.insert(
format!("horizon/trim{i}"),
Rect {
u0: i as f32 / TRIM_SLOTS as f32,
u1: (i + 1) as f32 / TRIM_SLOTS as f32,
v0: 0.0,
v1: 1.0,
w: 2,
h: 1,
},
);
}
atlas
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn upload(atlas: &mut HudAtlas, patches: &mut Vec<crate::font::Patch>) {
for patch in patches.drain(..) {
for y in 0..patch.height as usize {
let offset = ((patch.y as usize + y) * atlas.width as usize + patch.x as usize) * 4;
let width = patch.width as usize * 4;
atlas.pixels[offset..offset + width]
.copy_from_slice(&patch.rgba[y * width..(y + 1) * width]);
}
}
}
fn color(atlas: &HudAtlas, texture: &TrimTexture) -> Option<[u8; 4]> {
let rect = atlas.sprites.get(&texture.sprite())?;
let x = (rect.u0 * atlas.width as f32).round() as usize;
Some(atlas.pixels[x * 4..x * 4 + 4].try_into().unwrap())
}
#[test]
fn overflow_keeps_the_rendered_pixels_without_reuploading_each_frame() {
let fixture = Fixture::new();
let wanted: Vec<_> = (0..33).map(|id| fixture.texture(id)).collect();
for id in 0..33 {
fixture.write(id, 2);
}
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
// The original sequential 32-slot pass finishes with requests 1..=32.
assert_eq!(color(&atlas, &wanted[0]), None);
for (id, texture) in wanted.iter().enumerate().skip(1) {
assert_eq!(
color(&atlas, texture),
Some([id as u8, 255 - id as u8, 42, 255])
);
}
for _ in 0..3 {
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert!(
patches.is_empty(),
"unchanged overflow must not upload again"
);
}
wanted
.iter()
.skip(1)
.for_each(|t| assert!(color(&atlas, t).is_some()));
}
#[test]
fn changing_request_order_or_equipment_changes_the_resident_images() {
let fixture = Fixture::new();
for id in 0..34 {
fixture.write(id, 2);
}
let mut wanted: Vec<_> = (0..33).map(|id| fixture.texture(id)).collect();
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
wanted.rotate_left(1);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &fixture.texture(1)), None);
assert_eq!(color(&atlas, &fixture.texture(0)), Some([0, 255, 42, 255]));
wanted = vec![fixture.texture(33), fixture.texture(2)];
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([33, 222, 42, 255]));
assert_eq!(color(&atlas, &wanted[1]), Some([2, 253, 42, 255]));
}
#[test]
fn an_atlas_rebuild_uploads_the_same_requests_again() {
let fixture = Fixture::new();
fixture.write(7, 2);
let wanted = vec![fixture.texture(7)];
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
let mut atlas = fixture.atlas();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
upload(&mut atlas, &mut patches);
atlas = fixture.atlas();
fixture.write(7, 1);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].width, 1, "reload must decode the new image");
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
atlas.pixels.fill(0);
slots.forget();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(
patches.len(),
1,
"a rebuilt renderer needs the pixels again"
);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
atlas.sprites.remove(&wanted[0].sprite());
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1, "an erased sprite must be restored too");
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([7, 248, 42, 255]));
}
#[test]
fn missing_or_oversized_images_are_retried_when_they_become_available() {
let fixture = Fixture::new();
let wanted = vec![fixture.texture(9)];
let mut atlas = fixture.atlas();
let mut slots = TrimSlots::default();
let mut patches = Vec::new();
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), None);
fixture.write(9, 3);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert!(patches.is_empty());
fixture.write(9, 2);
slots.prepare(wanted.clone(), &fixture.0, &mut atlas, &mut patches);
assert_eq!(patches.len(), 1);
upload(&mut atlas, &mut patches);
assert_eq!(color(&atlas, &wanted[0]), Some([9, 246, 42, 255]));
}
+17 -4
View File
@@ -134,6 +134,10 @@ fn scaling(sprite: &str, r: &Rect) -> NineSlice {
width: r.w,
height: r.h,
border: match sprite {
"horizon/jei/button_enabled_v2"
| "horizon/jei/button_disabled_v2"
| "horizon/jei/button_highlight_v2" => [6; 4],
"horizon/jei/search_background_v2" => [6; 4],
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
@@ -1636,10 +1640,19 @@ impl EditBox {
}
/// Draw the field; `format` styles the visible text (command colouring).
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, format: Option<&Text>) {
self.draw_with_skin(batch, atlas, format, None);
}
pub fn draw_with_skin(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
format: Option<&Text>,
skin: Option<&str>,
) {
self.layout(atlas);
crate::widgets::target_as(
self.frame
.unwrap_or([self.x, self.y, self.width, self.height]),
[self.x, self.y, self.width, self.height],
crate::widgets::Kind::Field,
);
crate::widgets::narratable(|| {
@@ -1651,11 +1664,11 @@ impl EditBox {
.focused(self.focused)
});
if self.bordered {
let sprite = if self.focused {
let sprite = skin.unwrap_or(if self.focused {
"gui/sprites/widget/text_field_highlighted"
} else {
"gui/sprites/widget/text_field"
};
});
nine_slice(
batch,
atlas,
@@ -56,6 +56,32 @@ fn luma(c: vec3<f32>) -> f32 {
return vec4(b, centre.a);
}
// Contrast-adaptive sharpening (after AMD's CAS) of TAA's upscaled picture: each
// pixel pushed away from its four neighbours, less where they already differ a lot,
// so edges get their crispness back without halos or lifted grain.
// The Upscaling Sharpness setting, 0..1.
override sharpness: f32 = 0.85;
@fragment fn fs_sharpen(in: PostOut) -> @location(0) vec4<f32> {
let size = vec2<i32>(textureDimensions(world));
let p = clamp(vec2<i32>(in.uv * vec2<f32>(size)), vec2(0), size - 1);
let centre = textureLoad(world, p, 0);
let c = centre.rgb;
let n = textureLoad(world, clamp(p + vec2(0, -1), vec2(0), size - 1), 0).rgb;
let s = textureLoad(world, clamp(p + vec2(0, 1), vec2(0), size - 1), 0).rgb;
let w = textureLoad(world, clamp(p + vec2(-1, 0), vec2(0), size - 1), 0).rgb;
let e = textureLoad(world, clamp(p + vec2(1, 0), vec2(0), size - 1), 0).rgb;
let lo = min(c, min(min(n, s), min(w, e)));
let hi = max(c, max(max(n, s), max(w, e)));
// How much room there is before clipping, per channel: little at strong edges.
let room = clamp(min(lo, 2.0 - hi) / max(hi, vec3(0.0001)), vec3(0.0), vec3(1.0));
// Up to -1/5, the most CAS allows (the Upscaling Sharpness setting's 100%).
let lobe = -sqrt(room) * 0.2 * sharpness;
let sharp = (c + lobe * (n + s + w + e)) / (1.0 + 4.0 * lobe);
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return vec4(clamp(sharp, lo, hi), centre.a);
}
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
// depth buffer through the camera's motion and clamped to this frame's 3x3
// neighbourhood so that what moved or appeared does not ghost.
@@ -95,6 +121,28 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
return max(c / weight, vec3(0.0));
}
fn to_ycocg(c: vec3<f32>) -> vec3<f32> {
return vec3(0.25 * (c.r + 2.0 * c.g + c.b), 0.5 * (c.r - c.b), 0.25 * (2.0 * c.g - c.r - c.b));
}
fn from_ycocg(c: vec3<f32>) -> vec3<f32> {
return max(vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z), vec3(0.0));
}
// Moves `c` toward the box's centre until it lies inside (clipping keeps the
// colour's hue where a per-channel clamp would shift it).
fn clip_box(c: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> vec3<f32> {
let mid = 0.5 * (hi + lo);
let half_size = max(0.5 * (hi - lo), vec3(0.0001));
let d = c - mid;
let unit = abs(d / half_size);
let far = max(unit.x, max(unit.y, unit.z));
if far > 1.0 {
return mid + d / far;
}
return c;
}
// It runs at the history's size, which upscaling makes larger than the world's.
// The history is unjittered: each output pixel takes the world pixel whose
// jittered sample lands nearest its centre, weighted by how near it lands.
@@ -106,17 +154,24 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
let last = vec2<i32>(size) - 1;
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
let centre = textureLoad(world, p, 0);
var lo = centre.rgb;
var hi = centre.rgb;
var lo = to_ycocg(centre.rgb);
var hi = lo;
// Mean and spread of the 3x3 (YCoCg): the history is clipped to a box around
// the mean a few deviations wide, tighter than min..max, so that what changed
// does not linger as a smear.
var m1 = vec3(0.0);
var m2 = vec3(0.0);
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
// the edges of what is in front from trailing.
var near = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let q = clamp(p + vec2(x, y), vec2(0), last);
let c = textureLoad(world, q, 0).rgb;
let c = to_ycocg(textureLoad(world, q, 0).rgb);
lo = min(lo, c);
hi = max(hi, c);
m1 += c;
m2 += c * c;
near = max(near, textureLoad(depth, q, 0).r);
}
}
@@ -126,12 +181,19 @@ fn sample_history(uv: vec2<f32>) -> vec3<f32> {
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
return vec4(centre.rgb, centre.a);
}
let past = clamp(sample_history(uv), lo, hi);
// How far this pixel moved since the last frame, in output pixels.
let speed = length((uv - in.uv) * vec2<f32>(textureDimensions(history)));
let moving = clamp(speed / 6.0, 0.0, 1.0);
let mean = m1 / 9.0;
let sigma = sqrt(max(m2 / 9.0 - mean * mean, vec3(0.0)));
let wide = mix(1.25, 0.75, moving) * sigma;
let past = from_ycocg(clip_box(to_ycocg(sample_history(uv)), max(lo, mean - wide), min(hi, mean + wide)));
// How far the sample landed from this pixel's centre, in output pixels: a
// Gaussian of it scales the new frame's share, so that frames whose sample
// fell far away (most of them when upscaling) barely move the pixel.
// fell far away (most of them when upscaling) barely move the pixel. Under
// motion the history is resampled every frame and blurs, so new frames weigh more.
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
let share = 0.1 * exp(-2.0 * dot(off, off));
let share = mix(0.1, 0.3, moving) * exp(-2.0 * dot(off, off));
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return vec4(mix(past, centre.rgb, share), centre.a);
}
+81 -21
View File
@@ -7,7 +7,10 @@ use std::{
fs,
io::{self, Read, Seek, SeekFrom},
path::Path,
sync::Arc,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use zip::{CompressionMethod, ZipArchive};
@@ -169,30 +172,12 @@ impl Zip {
self.index(name).is_some()
}
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
let index = self
.index(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
// A clone shares the reader's data and the central directory: reading
// needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let method = entry.compression();
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
entry.read_to_end(&mut raw)?;
let (method, raw) = self.raw(name)?;
match method {
CompressionMethod::Stored => Ok(raw),
CompressionMethod::Deflated => {
// Java's ZipFile trusts neither size an entry declares: it
// inflates the stream to its end and keeps what comes out.
// Packs in the wild (a Nexo-generated one served through
// Dropbox, for one) carry central-directory sizes that point
// at other files, and miniz_oxide, which refuses input past
// the stream's end, failed whole entries with "corrupt
// deflate stream" and with them the whole pack. Only the
// size limit stands between the archive and a zip bomb.
let decoder = flate2::read::DeflateDecoder::new(raw.as_slice());
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
decoder
inflate(&raw)
.take(MAX_ENTRY as u64 + 1)
.read_to_end(&mut decoded)
.map_err(|_| invalid("corrupt deflate stream"))?;
@@ -204,6 +189,81 @@ impl Zip {
_ => Err(invalid("unsupported zip compression")),
}
}
/// What [`Self::read`] reads, written to `out` a piece at a time as it inflates, each piece
/// taken from `budget`: the bytes left for everything sharing it. Past the budget it stops
/// with [`io::ErrorKind::FileTooLarge`], `out` holding part of the entry; the entry's own
/// limit ([`MAX_ENTRY`]) still applies. Memory stays at the compressed entry and one piece,
/// whatever the entry inflates to. The bytes written.
pub fn copy_to(
&self,
name: &str,
out: &mut impl io::Write,
budget: &AtomicU64,
) -> io::Result<u64> {
let (method, raw) = self.raw(name)?;
let mut from: Box<dyn Read + '_> = match method {
CompressionMethod::Stored => Box::new(raw.as_slice()),
CompressionMethod::Deflated => Box::new(inflate(&raw)),
_ => return Err(invalid("unsupported zip compression")),
};
let mut piece = vec![0; 64 << 10];
let mut total = 0u64;
loop {
let n = match from.read(&mut piece) {
Ok(0) => return Ok(total),
Ok(n) => n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => return Err(invalid("corrupt deflate stream")),
};
total += n as u64;
if total > MAX_ENTRY as u64 {
return Err(invalid("entry inflates past the size limit"));
}
take(budget, n as u64)?;
out.write_all(&piece[..n])?;
}
}
/// The entry `name` as stored: its compression and its bytes.
fn raw(&self, name: &str) -> io::Result<(CompressionMethod, Vec<u8>)> {
let index = self
.index(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
// A clone shares the reader's data and the central directory: reading
// needs `&mut`.
let mut archive = self.archive.clone();
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
let method = entry.compression();
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
entry.read_to_end(&mut raw)?;
Ok((method, raw))
}
}
/// `n` bytes taken from `budget`, or `FileTooLarge` (taking nothing) when it has fewer left.
fn take(budget: &AtomicU64, n: u64) -> io::Result<()> {
let mut left = budget.load(Ordering::Relaxed);
loop {
let rest = left.checked_sub(n).ok_or_else(|| {
io::Error::new(
io::ErrorKind::FileTooLarge,
"the packs unpack to more than their size allows",
)
})?;
match budget.compare_exchange_weak(left, rest, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return Ok(()),
Err(now) => left = now,
}
}
}
/// A deflated entry's bytes as they inflate. Java's ZipFile trusts neither size an entry
/// declares: it inflates the stream to its end and keeps what comes out. Packs in the wild (a
/// Nexo-generated one served through Dropbox, for one) carry central-directory sizes that point
/// at other files, and miniz_oxide, which refuses input past the stream's end, failed whole
/// entries with "corrupt deflate stream" and with them the whole pack. Only the size limits
/// stand between the archive and a zip bomb.
fn inflate(raw: &[u8]) -> flate2::read::DeflateDecoder<&[u8]> {
flate2::read::DeflateDecoder::new(raw)
}
/// The 22 fixed bytes of the end-of-central-directory record.
+20 -5
View File
@@ -37,7 +37,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -65,9 +66,9 @@ log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md). The
# anonymous "I'm playing" ping is horizon-client-lib's (`heartbeat.rs`), the `heartbeat` feature.
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
@@ -108,12 +109,26 @@ 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", "horizon-extras"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official and partner servers added to
# the server list, the Horizon Settings and Mods pages, Discord, Play with Friends and the
# heartbeat. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["discord", "friends", "heartbeat"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs) and joining one.
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
# The anonymous "I'm playing" ping (docs/telemetry.md).
heartbeat = ["horizon-client-lib/heartbeat"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
@@ -47,6 +47,21 @@
"horizon.studio.replay.seek.invalid": "Ungültige Wiedergabeposition",
"horizon.studio.replay.status": "Studio-Wiedergabe: %s / %s ms (%s%%), Geschwindigkeit %s×, pausiert %s, beendet %s",
"horizon.studio.replay.closed": "Studio-Wiedergabe geschlossen",
"horizon.studio.freecam.enabled": "Studio-Freikamera aktiviert. WASD bewegt sie, Leertaste und Umschalt heben oder senken sie, Strg beschleunigt.",
"horizon.studio.freecam.disabled": "Studio-Freikamera deaktiviert",
"horizon.studio.freecam.unavailable": "Die Studio-Freikamera konnte keine Spielerkamera finden",
"horizon.studio.freecam.replay_required": "Die Studio-Freikamera ist während der Wiedergabe verfügbar",
"horizon.studio.freecam.usage": "Verwendung: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Kameraschlüsselbild bei %s ms hinzugefügt. Der Pfad wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.cleared": "Kamerapfad geleert. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.removed": "Kameraschlüsselbild %s entfernt. Die Änderung wird beim Schließen der Wiedergabe gespeichert.",
"horizon.studio.keyframe.save_error": "Kamerapfad konnte nicht gespeichert werden: %s",
"horizon.studio.keyframe.remove_usage": "Verwendung: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Kameraschlüsselbildindex liegt außerhalb des Bereichs",
"horizon.studio.keyframe.usage": "Verwendung: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Studio-Kamerapfadwiedergabe aktiviert",
"horizon.studio.path.unlocked": "Studio-Kamerapfadwiedergabe gestoppt",
"horizon.studio.path.usage": "Verwendung: /studio path <lock|unlock> (zuerst mindestens ein Schlüsselbild hinzufügen)",
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
@@ -113,6 +128,7 @@
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"Upscaling Sharpness": "Upscaling-Schärfe",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
@@ -208,6 +224,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Schärft das hochskalierte Bild. Höher: schärfere Kanten, mehr Flimmern.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
@@ -226,6 +243,7 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
"horizon.plain.official": "Offizielles Horizon mit Launcher, Mods und Updates: playhorizon.dev",
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
"horizon.extras.header.lighting": "Beleuchtung",
"horizon.extras.header.third_person": "Dritte Person",
@@ -5,7 +5,60 @@
"horizon.options.controller": "Controller",
"horizon.options.three_d_skin_layers": "3D Skin Layers",
"horizon.options.vibration": "Vibration",
"horizon.options.play_in_background": "Play in Background",
"horizon.options.adaptive_triggers": "Adaptive Triggers",
"horizon.options.gameplay_guide": "Gameplay Guide",
"horizon.options.gameplay_guide.basic": "Basic",
"horizon.options.gameplay_guide.full": "Full",
"horizon.options.gameplay_guide.off": "Off",
"horizon.controller.guide.use": "Use",
"horizon.controller.guide.open": "Open",
"horizon.controller.guide.ride": "Ride",
"horizon.controller.guide.trade": "Trade",
"horizon.controller.guide.feed": "Feed",
"horizon.controller.guide.place": "Place",
"horizon.controller.guide.eat": "Eat",
"horizon.controller.guide.drink": "Drink",
"horizon.controller.guide.shoot": "Shoot",
"horizon.controller.guide.load": "Load",
"horizon.controller.guide.throw": "Throw",
"horizon.controller.guide.raise": "Raise",
"horizon.controller.guide.equip": "Equip",
"horizon.controller.guide.fish": "Fish",
"horizon.controller.guide.zoom": "Zoom",
"horizon.controller.guide.boost": "Boost",
"horizon.controller.guide.break": "Break",
"horizon.controller.guide.attack": "Attack",
"horizon.controller.guide.spectate": "Spectate",
"horizon.controller.guide.spectator_menu": "Spectator Menu",
"horizon.controller.guide.release_shoot": "Release to Shoot",
"horizon.controller.guide.release_throw": "Release to Throw",
"horizon.controller.guide.release_stop": "Release to Stop",
"horizon.controller.guide.fly": "Fly",
"horizon.controller.guide.fly_up": "Fly Up",
"horizon.controller.guide.fly_down": "Fly Down",
"horizon.controller.guide.swim_up": "Swim Up",
"horizon.controller.guide.swim_down": "Swim Down",
"horizon.controller.guide.glide": "Glide",
"horizon.controller.guide.dismount": "Dismount",
"horizon.controller.guide.stand": "Stand Up",
"horizon.controller.guide.drop": "Drop Item",
"horizon.controller.guide.swap": "Swap Hands",
"horizon.controller.guide.take": "Take",
"horizon.controller.guide.take_half": "Take Half",
"horizon.controller.guide.put": "Put Down",
"horizon.controller.guide.put_one": "Put One",
"horizon.controller.guide.swap_stack": "Swap",
"horizon.controller.guide.quick_move": "Quick Move",
"horizon.controller.guide.drop_stack": "Drop Stack",
"horizon.controller.guide.place_recipe": "Place Recipe",
"horizon.controller.guide.place_recipe_all": "Place All",
"horizon.controller.guide.alternatives": "Alternatives",
"horizon.controller.guide.select": "Select",
"horizon.controller.guide.close": "Close",
"horizon.controller.guide.pages": "Pages",
"horizon.controller.guide.categories": "Categories",
"horizon.controller.guide.tabs": "Tabs",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Look Sensitivity",
"horizon.options.invert_x": "Invert Look X",
@@ -51,6 +104,21 @@
"horizon.studio.replay.seek.invalid": "Invalid replay position",
"horizon.studio.replay.status": "Studio replay: %s / %s ms (%s%%), speed %s×, paused %s, finished %s",
"horizon.studio.replay.closed": "Studio replay closed",
"horizon.studio.freecam.enabled": "Studio free camera enabled. Use WASD to move, Space and Shift to rise or descend, and Control to move faster.",
"horizon.studio.freecam.disabled": "Studio free camera disabled",
"horizon.studio.freecam.unavailable": "Studio free camera could not find a player camera",
"horizon.studio.freecam.replay_required": "Studio free camera is available during replay playback",
"horizon.studio.freecam.usage": "Usage: /studio freecam <on|off>",
"horizon.studio.keyframe.added": "Camera keyframe added at %s ms. The path is saved when you close the replay.",
"horizon.studio.keyframe.cleared": "Camera path cleared. The change is saved when you close the replay.",
"horizon.studio.keyframe.removed": "Removed camera keyframe %s. The change is saved when you close the replay.",
"horizon.studio.keyframe.save_error": "Could not save camera path: %s",
"horizon.studio.keyframe.remove_usage": "Usage: /studio keyframe remove <INDEX>",
"horizon.studio.keyframe.index_invalid": "Camera keyframe index is out of range",
"horizon.studio.keyframe.usage": "Usage: /studio keyframe <add|remove INDEX|clear>",
"horizon.studio.path.locked": "Studio camera path playback enabled",
"horizon.studio.path.unlocked": "Studio camera path playback stopped",
"horizon.studio.path.usage": "Usage: /studio path <lock|unlock> (add at least one keyframe first)",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.shader_options": "Shader Options",
@@ -163,6 +231,7 @@
"Resolution Scale": "Resolution Scale",
"Anti-aliasing": "Anti-aliasing",
"Upscaling": "Upscaling",
"Upscaling Sharpness": "Upscaling Sharpness",
"Paper White": "Paper White",
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
@@ -264,6 +333,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "World pixels per screen pixel. Below 100% is faster on the GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renders the world smaller and rebuilds it with TAA. Faster, softer.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR output on an HDR display: lava, fire and sunlight shine brighter.",
"How frames reach the screen while VSync is off.": "How frames reach the screen while VSync is off.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efficient sleeps until the next frame; Precise spins, like vanilla.",
@@ -313,6 +383,7 @@
"Brighter-than-white output on an HDR display.": "Brighter-than-white output on an HDR display.",
"Results for \"%s\"": "Results for \"%s\"",
"No settings match.": "No settings match.",
"horizon.plain.official": "Official Horizon with launcher, mods and updates: playhorizon.dev",
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
"horizon.extras.header.lighting": "Lighting",
"horizon.extras.header.third_person": "Third person",
@@ -886,6 +957,20 @@
"Hold the button to sneak, or press it once to start and again to stop.": "Hold the button to sneak, or press it once to start and again to stop.",
"How far the left stick moves before you walk. Raise it if you drift.": "How far the left stick moves before you walk. Raise it if you drift.",
"How far a trigger is pulled before it counts as pressed.": "How far a trigger is pulled before it counts as pressed.",
"Shows what your buttons do right now, in play and in inventories.": "Shows what your buttons do right now, in play and in inventories.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Shows only what you can do with what you're looking at, and the main inventory buttons.",
"Doesn't show what your buttons do.": "Doesn't show what your buttons do.",
"Shows the buttons of the controller you're using.": "Shows the buttons of the controller you're using.",
"Always shows Xbox buttons.": "Always shows Xbox buttons.",
"Always shows PlayStation buttons.": "Always shows PlayStation buttons.",
"Always shows Nintendo buttons.": "Always shows Nintendo buttons.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.",
"Always opens Horizon's keyboard.": "Always opens Horizon's keyboard.",
"Always opens Steam's keyboard.": "Always opens Steam's keyboard.",
"A DualSense's light shows your health, from green through amber to red.": "A DualSense's light shows your health, from green through amber to red.",
"A DualSense's light changes with the dimension you're in.": "A DualSense's light changes with the dimension you're in.",
"A DualSense keeps its own light.": "A DualSense keeps its own light.",
"Show the buttons for what you can do, while you play with a controller.": "Show the buttons for what you can do, while you play with a controller.",
"How fast the stick moves the crosshair in inventories.": "How fast the stick moves the crosshair in inventories.",
"How fast the right stick scrolls a list.": "How fast the right stick scrolls a list.",
"How long a held direction waits before it repeats.": "How long a held direction waits before it repeats.",
@@ -899,6 +984,7 @@
"Follow the controller, or always show one family's buttons.": "Follow the controller, or always show one family's buttons.",
"The buttons menus show right now.": "The buttons menus show right now.",
"Rumble when something happens to you.": "Rumble when something happens to you.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "The controller keeps playing while this window isn't focused, and losing focus won't pause.",
"A DualSense's triggers push back on bows and tools.": "A DualSense's triggers push back on bows and tools.",
"What a DualSense's light shows, over USB or Bluetooth.": "What a DualSense's light shows, over USB or Bluetooth.",
"Player profile": "Player profile",
@@ -1,5 +1,9 @@
{
"horizon.extras.header.world": "Mundo",
"horizon.extras.pregen": "Pregeneración de chunks",
"horizon.extras.pregen.tooltip": "Activa /pregen en tus mundos de un jugador y de LAN. Con los trucos activados, usa /pregen start para generar una zona antes de explorarla. No activa el comando en los servidores a los que te unes.",
"horizon.options.controller": "Mando",
"horizon.options.three_d_skin_layers": "Capas de skin 3D",
"horizon.options.vibration": "Vibración",
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
"horizon.options.lightbar": "Barra de luz",
@@ -9,6 +13,7 @@
"horizon.options.sneak_button": "Botón de agacharse",
"horizon.options.move_deadzone": "Zona muerta de movimiento",
"horizon.options.look_deadzone": "Zona muerta de vista",
"horizon.options.trigger_threshold": "Umbral del gatillo",
"horizon.options.button_glyphs": "Símbolos de botones",
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
"horizon.options.chat_delay": "Retardo del chat",
@@ -23,37 +28,99 @@
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Esta función aún no está disponible en Horizon.",
"horizon.options.search": "Buscar ajustes...",
"horizon.studio.record.usage": "Uso: /studio record <start [nombre]|stop|status>",
"horizon.studio.record.started": "Grabación de Studio iniciada: %s",
"horizon.studio.record.saved": "Grabación de Studio guardada: %s",
"horizon.studio.record.error": "Error de grabación de Studio: %s",
"horizon.studio.record.status.active": "Grabación de Studio activa — %s s, %s paquetes, %s KB (%s)",
"horizon.studio.record.status.inactive": "Grabación de Studio inactiva",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Paquetes de shaders",
"horizon.pages.shader_options": "Opciones de shaders",
"horizon.pages.controller": "Ajustes del mando",
"horizon.pages.controller_binds": "Asignaciones del mando",
"horizon.pages.extras": "Extras de Horizon",
"horizon.pages.settings": "Ajustes de Horizon",
"horizon.pages.studio": "Replay Studio",
"horizon.pages.agent": "Agente de IA",
"horizon.split.leave": "Salir",
"horizon.pages.split_settings": "Ajustes de pantalla dividida",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_setup": "Configuración de pantalla dividida",
"horizon.pages.music": "Música",
"horizon.pages.music.menu": "Menú",
"horizon.pages.music.overworld": "Superficie",
"horizon.pages.music.creative": "Creativo",
"horizon.pages.music.underwater": "Bajo el agua",
"horizon.pages.music.end": "El End",
"horizon.pages.music.boss": "Jefe",
"horizon.pages.music.credits": "Créditos",
"horizon.pages.music.overworld_biomes": "Biomas de la Superficie",
"horizon.pages.music.nether_biomes": "Biomas del Inframundo",
"horizon.pages.music.end_biomes": "Biomas del End",
"horizon.pages.music.other": "Otra música",
"horizon.pages.music.empty": "No hay pistas de música: no se han cargado los sonidos del juego.",
"horizon.pages.music.tooltip": "Las pistas integradas, por situación: reproduce una ahora o desactiva cualquiera para que el juego deje de elegirla.",
"horizon.pages.settings.tooltip": "Los ajustes propios de Horizon: gráficos, mandos, extras y mods.",
"horizon.pages.studio.tooltip": "Explora las grabaciones guardadas y sus metadatos.",
"horizon.pages.graphics.tooltip": "Gráficos, sin complicaciones: cada ajuste dice lo que cuesta.",
"horizon.pages.graphics_advanced.tooltip": "Todos los ajustes gráficos, incluidos los de Horizon, con lo que cuesta cada uno.",
"horizon.pages.controller.tooltip": "Mandos: sus botones, palancas, vibración y navegación de menús.",
"horizon.pages.extras.tooltip": "Añadidos opcionales, todos desactivados por defecto: el juego sigue siendo vanilla hasta que actives uno.",
"horizon.pages.agent.tooltip": "Deja que tu propio agente de IA juegue: horizon-ctl y clientes MCP en este equipo.",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.pages.split_setup.tooltip": "Las ventanas y los monitores, y quién juega dónde.",
"horizon.pages.shader_packs.tooltip": "Paquetes de shaders de la carpeta shaderpacks: los propios de Horizon y los del formato shaders/. Se ejecuta uno a la vez.",
"horizon.pages.mods.tooltip": "Mods hechos para Horizon, de la carpeta horizon-mods. Cada uno se ejecuta en un entorno aislado y sigue apagado hasta que lo actives.",
"horizon.pages.open.graphics": "Gráficos...",
"horizon.pages.open.graphics_advanced": "Gráficos avanzados...",
"horizon.pages.open.shader_packs": "Paquetes de shaders...",
"horizon.pages.open.controller": "Ajustes del mando...",
"horizon.pages.open.split_setup": "Pantalla dividida...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agente de IA...",
"horizon.pages.open.horizon_graphics": "Gráficos de Horizon...",
"horizon.pages.open.controller_binds": "Asignaciones del mando...",
"horizon.pages.open.horizon_settings": "Ajustes de Horizon...",
"horizon.pages.open.studio": "Replay Studio...",
"horizon.studio.saved_recordings": "Grabaciones guardadas",
"horizon.studio.empty": "No se han encontrado grabaciones en la carpeta de grabaciones.",
"horizon.studio.empty_hint": "Usa /studio record start <nombre> en un mundo o deja archivos .mcpr allí.",
"horizon.studio.open_folder": "Abrir grabaciones",
"horizon.studio.refresh": "Actualizar",
"horizon.studio.unknown_server": "Desconocido",
"horizon.pages.open.music": "Música...",
"horizon.mods.empty": "No hay mods: pon archivos .hmod en la carpeta horizon-mods",
"horizon.mods.stopped": " (Detenido)",
"horizon.mods.not_built": " (Sin compilar)",
"horizon.mods.dev": " (Desarrollo)",
"horizon.mods.settings": "Ajustes",
"horizon.mods.in_world": " (sal del mundo para cambiarlo)",
"horizon.mods.open_folder": "Abrir carpeta de mods",
"horizon.mods.browse": "Explorar mods...",
"horizon.mods.browse.title": "Explorar mods",
"horizon.mods.browse.search": "Buscar mods...",
"horizon.mods.browse.project": "Mod",
"horizon.mods.browse.loading": "Leyendo el registro de mods de Horizon...",
"horizon.mods.browse.failed": "No se pudo leer el registro de mods: %s",
"horizon.mods.browse.none": "No se ha encontrado ningún mod",
"horizon.mods.browse.has_update": " (actualización)",
"horizon.mods.browse.is_installed": " (instalado)",
"horizon.mods.browse.by": "de %s",
"horizon.mods.browse.installing": "Instalando...",
"horizon.mods.browse.install": "Instalar",
"horizon.mods.browse.update": "Actualizar",
"horizon.mods.browse.installed": "Instalado",
"horizon.mods.browse.open_page": "Abrir página",
"horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Registro de cambios",
"horizon.mods.browse.done": "%s %s se ha instalado y está activado.",
"horizon.mods.browse.install_failed": "No se pudo instalar: %s",
"horizon.mods.switch.worldgen": "cambia la generación del mundo",
"horizon.mods.switch.registries": "añade bloques, objetos u otras entradas del registro",
"horizon.mods.switch.restart": "Reinicia el juego para aplicar %s: añaden bloques, objetos o generación del mundo.",
"horizon.shader_options.profile": "Perfil",
"horizon.agent.copy_details": "Copiar datos de conexión",
"horizon.multiplayer.join": "Unirse",
"horizon.multiplayer.cancel_reconnecting": "Cancelar reconexión",
@@ -64,6 +131,7 @@
"LOD Distance": "Distancia LOD",
"Far Detail": "Detalle lejano",
"Cave Culling": "Oclusión de cuevas",
"GPU Culling": "Descarte en GPU",
"Entity Culling": "Oclusión de entidades",
"Chunk Updates": "Actualizaciones de chunks",
"Upload Budget": "Presupuesto de subida",
@@ -81,8 +149,13 @@
"Peak Brightness": "Brillo máximo",
"Black Level": "Nivel de negro",
"UI Brightness": "Brillo de la interfaz",
"HDR Lighting": "Iluminación HDR",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Las fuentes de luz, el sol y la luz solar brillan más allá del blanco. Desactivado: como SDR.",
"Smooth Gradients": "Degradados suaves",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Fotograma de 16 bits: los cielos y las sombras oscuros no se escalonan. Algo más de VRAM. Próximo inicio.",
"Battery Mode": "Modo batería",
"Graphics API": "API de gráficos",
"Translucent Batching": "Agrupación de translúcidos",
"Performance Overlay": "Superposición de rendimiento",
"Full Detail Distance": "Distancia de detalle completo",
"On": "Activado",
@@ -121,8 +194,12 @@
"Done": "Hecho",
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
"Only applies to asynchronous entity culling.": "Solo se aplica a la oclusión asíncrona de entidades.",
"Turn HDR on to use it.": "Activa el HDR para usarlo.",
"Your GPU or graphics API doesn't support it.": "Tu GPU o API de gráficos no lo admite.",
"Not with software rendering, which runs it on the CPU.": "No con renderizado por software, que lo ejecuta en la CPU.",
"Out of video memory: off until the next start.": "Sin memoria de vídeo: desactivado hasta el próximo inicio.",
"The graphics device was lost: off until the next start.": "Se ha perdido el dispositivo gráfico: desactivado hasta el próximo inicio.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Aún no en DirectX 12: prueba la API de gráficos Vulkan.",
"%s chunks": "%s chunks",
"%s per frame": "%s por fotograma",
"%s MiB/frame": "%s MiB/fotograma",
@@ -145,6 +222,7 @@
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Descarta el terreno cercano en la GPU: menos CPU con mucha distancia de renderizado, más GPU.",
"Hides entities behind opaque blocks.": "Oculta entidades tras bloques opacos.",
"No entity occlusion culling.": "Sin oclusión de entidades.",
"CPU occlusion test runs this frame.": "La CPU comprueba la oclusión en este fotograma.",
@@ -182,21 +260,15 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Límite de 60 FPS (30 en menús o sin foco), la mitad de los hilos: menos consumo.",
"Threads that generate, light and mesh chunks. Next start.": "Hilos que generan, iluminan y mallan chunks. Próximo inicio.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan y, donde falla, DirectX 12 (Windows) u OpenGL (Linux). Próximo inicio.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Agua y cristal en un mismo búfer: menos llamadas de dibujado, algo más de VRAM. Próximo inicio.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.realms.not_signed_in": "Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para jugar en Realms.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
"horizon.pages.shader_options": "Opciones de sombreadores",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.profile": "Perfil",
"Particle Culling": "Particle Culling",
"Draws every particle.": "Draws every particle.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.",
"General": "General",
"Advanced": "Avanzado",
"Undo": "Deshacer",
@@ -223,6 +295,318 @@
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
"Results for \"%s\"": "Resultados de \"%s\"",
"No settings match.": "Ningún ajuste coincide.",
"horizon.plain.official": "Horizon oficial con launcher, mods y actualizaciones: playhorizon.dev",
"horizon.title.not_affiliated": "No está afiliado a Mojang ni a Microsoft",
"horizon.extras.header.lighting": "Iluminación",
"horizon.extras.header.third_person": "Tercera persona",
"horizon.extras.header.particles": "Partículas",
"horizon.extras.header.inventory": "Inventario",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.multiplayer": "Multijugador",
"horizon.extras.header.sound": "Sonido",
"horizon.extras.header.menus": "Menús",
"horizon.extras.fullbright": "Fullbright",
"horizon.extras.dynamic_lights": "Luces dinámicas",
"horizon.extras.own_name_tag": "Etiqueta con tu nombre",
"horizon.extras.hide_block_particles": "Ocultar partículas al romper bloques",
"horizon.extras.auto_reconnect": "Reconexión automática",
"horizon.extras.ui_sound_reserve": "Reservar canales para los sonidos de la interfaz",
"horizon.extras.music_page": "Página de música",
"horizon.extras.mouse_tweaks": "Controles de Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "La rueda mueve objetos",
"horizon.extras.sort": "Botones de ordenar",
"horizon.extras.sort_key": "Tecla de ordenar",
"horizon.extras.no_ai": "Sin IA",
"horizon.extras.agent_control": "Control por agente",
"horizon.extras.discord_presence": "Presencia enriquecida de Discord",
"horizon.extras.zoom": "Zoom",
"horizon.extras.zoom_mod_install": "Zoom: instálalo desde Mods",
"horizon.extras.splitscreen": "Pantalla dividida",
"horizon.extras.chat_animation": "Chat fluido",
"horizon.extras.chat_indicator": "Indicador de mensajes",
"horizon.extras.wheel_cycles_list_settings.tooltip": "La rueda sobre un botón de una lista de ajustes cambia su valor. Desactivado, como en vanilla, la rueda desplaza la lista.",
"horizon.extras.wheel_cycles_settings": "La rueda cambia los ajustes",
"horizon.extras.pack_browser": "Explorador de paquetes de recursos",
"horizon.extras.fullbright.tooltip": "Muestra bloques y entidades con luz máxima, incluso en las zonas oscuras.",
"horizon.extras.dynamic_lights.tooltip": "Las antorchas y otras fuentes de luz que llevas, que has soltado o que arden iluminan los bloques a su alrededor.",
"horizon.extras.own_name_tag.tooltip": "Muestra tu nombre sobre tu cabeza en tercera persona, como lo ven los demás jugadores. Se oculta cuando el servidor oculta los nombres de tu equipo o cuando eres invisible.",
"horizon.extras.hide_block_particles.tooltip": "Oculta las partículas de escombros que salen de un bloque roto y se desprenden del que minas. Las demás partículas se mantienen.",
"horizon.extras.auto_reconnect.tooltip": "Vuelve a un servidor tras perder la conexión o tras un reinicio, nunca tras una expulsión o un baneo.",
"horizon.extras.ui_sound_reserve.tooltip": "Los sonidos del mundo dejan libres 16 de los 247 canales de sonido, para que una avalancha de ellos (una gran explosión) no silencie los clics de los menús.",
"horizon.extras.music_page.tooltip": "Música y sonidos gana una página Música...: reproduce cualquier pista integrada ahora o desactiva una para que el juego deje de elegirla. Nada cambia hasta que desactives una pista.",
"horizon.extras.mouse_tweaks.tooltip": "Arrastrar con el botón derecho coloca uno por ranura, arrastrar con el izquierdo reúne el objeto que llevas y Mayús + arrastrar con el izquierdo mueve todas las ranuras.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Abajo envía un objeto de la ranura al otro lado; arriba devuelve uno.",
"horizon.extras.sort.tooltip": "Ordena el inventario, los cofres, los barriles, los cofres del End y las cajas de shulker en el orden de las pestañas del modo creativo, combinando pilas.",
"horizon.extras.sort_key.tooltip": "Ordena la parte que hay bajo el cursor. Haz clic y luego pulsa una tecla; Escape la desasigna.",
"horizon.extras.no_ai.tooltip": "Oculta y desactiva todas las funciones de IA: el control por agente, horizon-ctl y los servidores MCP. También el interruptor del launcher.",
"horizon.extras.agent_control.tooltip": "Tu propio agente de IA puede jugar a este juego mediante horizon-ctl o un cliente MCP, solo en este equipo.",
"horizon.extras.discord_presence.tooltip": "Muestra «Jugando a Horizon» en tu perfil de Discord mientras Discord está abierto: menús, un jugador o multijugador, la dimensión y el tiempo jugado. Nunca el nombre de un mundo ni la dirección de un servidor.",
"Calibrate...": "Calibrar...",
"Calibrate Anyway...": "Calibrar de todos modos...",
"Use Automatic Setup": "Usar la configuración automática",
"Shortcut: %s": "Atajo: %s",
"Next": "Siguiente",
"HDR Calibration %s/%s: %s": "Calibración HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Configura las tres opciones siguientes a pantalla completa, con una imagen de prueba cada vez.",
"A key that opens this screen from the game. Not bound at first.": "Una tecla que abre esta pantalla desde el juego. Sin asignar al principio.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Tu pantalla no indicó cuánto brilla: calíbrala para configurarla.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Ya está configurado para tu pantalla. Calibra solo si el HDR se ve mal.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Devuelve Brillo máximo y Blanco de papel a la configuración de tu pantalla.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Configurado para tu pantalla. Déjalo en Automático salvo que se vea mal.",
"Lower it until the logo just disappears into the black.": "Bájalo hasta que el logo acabe de desaparecer en el negro.",
"Raise it until the logo just disappears into the bright square.": "Súbelo hasta que el logo acabe de desaparecer en el cuadrado brillante.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Haz que la tarjeta sea un blanco cómodo de mirar durante mucho tiempo, con la luz con la que juegas.",
"horizon.splitscreen.profiles.forget": "Olvidar",
"horizon.splitscreen.profiles.create": "Crear",
"horizon.splitscreen.profiles.you": " - tú",
"horizon.splitscreen.profiles.forget_named": "Olvidar a %s",
"horizon.splitscreen.profiles.previous": "Anterior",
"horizon.splitscreen.profiles.next": "Siguiente",
"horizon.splitscreen.profiles.new_button": "Nuevo perfil...",
"horizon.splitscreen.profiles.forget_title": "¿Olvidar el perfil?",
"horizon.splitscreen.profiles.forget_leaves": "Sale de la lista de esta instancia.",
"horizon.splitscreen.profiles.forget_kept": "Se conservan el progreso del mundo y los ajustes.",
"horizon.splitscreen.profiles.new_title": "Nuevo perfil",
"horizon.splitscreen.profiles.name_hint": "Nombre (de 3 a 16 letras, dígitos, _)",
"horizon.splitscreen.profiles.switch_title": "Cambiar de perfil",
"horizon.splitscreen.profiles.offline_note": "Los perfiles sin conexión pertenecen a esta instancia",
"horizon.splitscreen.profiles.move": "Mover dónde juegas",
"horizon.splitscreen.profiles.move_place": "Mover dónde juegas: %s",
"horizon.friends.title": "Jugar con amigos",
"horizon.friends.hint": "Tus amigos pegan el código tras el botón Amigos de la pantalla de título, en Conexión directa o en la página Jugar del launcher.",
"horizon.friends.copied": "Copiado",
"horizon.friends.copy_code": "Copiar código",
"horizon.friends.stop_sharing": "Dejar de compartir",
"horizon.friends.none_yet": "Aún no hay amigos aquí.",
"horizon.friends.here_now": "Aquí ahora: %s",
"horizon.friends.share_intro": "Comparte este mundo con un código de invitación: se abre a la LAN y tus amigos de internet se unen con el código. No interviene ningún servidor nuestro.",
"horizon.friends.start_sharing": "Empezar a compartir",
"horizon.friends.troubleshooting": "Solución de problemas...",
"horizon.friends.stopped": "Se ha dejado de compartir el código de invitación.",
"horizon.friends.short_code_changed": "Alguien intentó varias veces un código corto incorrecto, así que ha cambiado.",
"horizon.friends.wrong_code": "Alguien ha probado un código incorrecto.",
"horizon.friends.relay_stopped": "Demasiados códigos cortos incorrectos: el relé queda apagado hasta que vuelvas a compartir. Tus amigos aún pueden unirse con el código completo.",
"horizon.friends.code_ready": "Tu código de invitación está listo: Menú del juego, Jugar con amigos. Cópialo y envíaselo a tus amigos.",
"horizon.friends.share_failed": "No se pudo compartir el mundo: %s",
"horizon.friends.finding_route": "Buscando cómo pueden llegar tus amigos hasta ti…",
"horizon.osk.shift": "Mayús",
"horizon.osk.space": "Espacio",
"horizon.osk.delete": "Borrar",
"horizon.osk.type": "Escribir",
"horizon.osk.symbols": "Símbolos",
"horizon.osk.close": "Cerrar",
"horizon.extras.chat_animation.tooltip": "Cada línea nueva del chat entra deslizándose desde la izquierda mientras aparece, en lugar de aparecer entera.",
"horizon.extras.chat_indicator.tooltip": "La barra de color a la izquierda de cada línea del chat.",
"horizon.extras.pack_browser.tooltip": "Seleccionar paquetes de recursos gana un botón Añadir que busca en Modrinth e instala un paquete en tu carpeta de paquetes. Desactivado, el juego nunca contacta con Modrinth.",
"horizon.extras.splitscreen.tooltip": "Dentro de un mundo, otro mando puede unirse como jugador nuevo: pulsa A cuando lo pida y luego elige un perfil y dónde jugar.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "un monitor",
"horizon.splitscreen.setup.window": "Ventana %s",
"horizon.splitscreen.setup.new_window": "Nueva ventana",
"horizon.splitscreen.setup.fill_monitor": "Llenar %s",
"horizon.splitscreen.setup.done": "Hecho",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "Activada",
"horizon.splitscreen.setup.off": "Desactivada",
"horizon.splitscreen.setup.player": "Jugador %s",
"horizon.splitscreen.setup.revert": "Revertir",
"horizon.splitscreen.setup.apply": "Aplicar",
"horizon.splitscreen.setup.warning.empty_main": "Nadie juega en la ventana 1: se oculta",
"horizon.splitscreen.setup.warning.empty_close": "Nadie juega en la ventana %s: se cerrará al aplicar",
"horizon.splitscreen.setup.warning.empty_new": "Nadie juega en la ventana %s: no se abrirá",
"horizon.splitscreen.setup.warning.on_main": "La ventana %s ocupa el monitor de la ventana 1: sus jugadores juegan en la ventana 1",
"horizon.splitscreen.setup.warning.unplugged": "El monitor de la ventana %s no está conectado: sus jugadores juegan en la ventana 1",
"horizon.splitscreen.setup.status.off": "La pantalla dividida está desactivada.",
"horizon.splitscreen.setup.status.enable_hint": "Actívala y luego pulsa un botón en otro mando dentro de un mundo.",
"horizon.splitscreen.setup.status.join_hint": "Pulsa A para unirte y elegir un perfil.",
"horizon.splitscreen.setup.status.idle": "Pantalla dividida inactiva",
"horizon.splitscreen.setup.status.alone": "No hay otros jugadores locales.",
"horizon.splitscreen.setup.status.solo_controls": "Los controles individuales y la respuesta del mando no cambian.",
"horizon.splitscreen.setup.window_empty": "Ventana %s: vacía",
"horizon.splitscreen.setup.note.select": "Haz clic en un jugador o arrástralo a una ventana o a un monitor.",
"horizon.splitscreen.setup.note.apply": "Aplica para jugar así; Revertir para deshacer.",
"horizon.splitscreen.setup.note.place": "¿Dónde debería jugar %s?",
"horizon.splitscreen.setup.this_player": "este jugador",
"horizon.splitscreen.setup.switch_disabled": "La pantalla dividida se desactiva cuando los demás se han ido (desde su menú de pausa).",
"horizon.splitscreen.setup.window_place": "Ventana %s (%s de %s)",
"horizon.splitscreen.setup.names": "Nombres",
"horizon.splitscreen.setup.identify": "Identificar",
"horizon.splitscreen.setup.layout.two": "2 compartiendo: %s",
"horizon.splitscreen.setup.layout.three": "3 compartiendo: %s",
"horizon.splitscreen.setup.layout.two.0": "Automático",
"horizon.splitscreen.setup.layout.two.1": "Lado a lado",
"horizon.splitscreen.setup.layout.two.2": "Arriba y abajo",
"horizon.splitscreen.setup.layout.three.0": "Uno ancho arriba",
"horizon.splitscreen.setup.layout.three.1": "Uno alto a la izquierda",
"horizon.splitscreen.setup.layout.three.2": "Lado a lado",
"horizon.splitscreen.setup.help.player": "Haz clic o arrastra a %s para moverlo.",
"horizon.splitscreen.setup.help.layout_auto": "%s (lado a lado cuando la ventana es ancha). Pulsa para cambiar.",
"horizon.splitscreen.setup.help.layout": "%s. Pulsa para cambiar.",
"horizon.splitscreen.setup.help.switch": "Pantalla dividida en este equipo, activada o desactivada.",
"horizon.splitscreen.setup.help.identify": "La vista de cada jugador muestra en qué ventana está.",
"horizon.splitscreen.setup.help.names": "El nombre de cada jugador en la esquina de su vista.",
"horizon.splitscreen.setup.help.revert": "Deshace los cambios aún no aplicados.",
"horizon.splitscreen.setup.help.apply": "Juega como muestra el mapa.",
"horizon.splitscreen.setup.help.unchanged": "Aún no hay cambios.",
"horizon.splitscreen.setup.give_up_pad": "Ceder el mando",
"horizon.splitscreen.prompt.controller_connected": "Mando conectado",
"horizon.splitscreen.prompt.controller_disconnected": "Mando desconectado",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Vuelve a conectarlo o pulsa un botón en otro",
"horizon.splitscreen.prompt.join.one": "%s quiere unirse",
"horizon.splitscreen.prompt.join.many": "%s mandos quieren unirse",
"horizon.splitscreen.prompt.join.yes": "Unirse",
"horizon.splitscreen.prompt.join.as_p1": "Jugar como J1",
"horizon.splitscreen.prompt.join.no": "No",
"horizon.splitscreen.prompt.lost": "%s: mando desconectado",
"horizon.splitscreen.prompt.lost.use_another": "Usar otro",
"horizon.splitscreen.prompt.lost.leave": "Salir",
"horizon.splitscreen.setup.help.give_up_pad": "Tu mando deja de controlarte y se ofrece para unirse (A).",
"horizon.splitscreen.setup.note.held_at": "%s a %s: A lo suelta aquí, B lo devuelve.",
"horizon.splitscreen.setup.note.held": "%s recogido: izquierda/derecha eligen dónde, A lo suelta, B lo devuelve.",
"horizon.splitscreen.online_only": "Este servidor está en modo en línea: los perfiles sin conexión no pueden unirse; cada jugador necesita su propia cuenta de Minecraft.",
"horizon.splitscreen.own_account": "Este servidor requiere la cuenta de Minecraft de cada jugador",
"horizon.splitscreen.local_only": "Otros jugadores solo pueden unirse a este servidor con su propia cuenta de Minecraft: los perfiles sin conexión juegan en tu mundo, en LAN o en un servidor de tu red local.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Oculta modelos tras bloques opacos. Asíncrono comprueba fuera del hilo; las entidades se actualizan.",
"Block Entities": "Entidades de bloque",
"Shulker Boxes": "Cajas de shulker",
"Reuses settled boxes; lid movement stays animated.": "Reutiliza las cajas quietas; el movimiento de la tapa sigue animado.",
"Signs": "Carteles",
"Reuses sign text until text, light, or font changes.": "Reutiliza el texto de los carteles hasta que cambian el texto, la luz o la fuente.",
"Decorated Pots": "Vasijas decoradas",
"Reuses still pots; break wobble and sherd changes redraw.": "Reutiliza las vasijas quietas; el temblor al romperlas y los cambios de fragmentos se redibujan.",
"Bells": "Campanas",
"Reuses still bells; ringing stays animated.": "Reutiliza las campanas quietas; el repique sigue animado.",
"Chests": "Cofres",
"Reuses closed chests; moving lids stay animated.": "Reutiliza los cofres cerrados; las tapas en movimiento siguen animadas.",
"Banners": "Estandartes",
"Reuses poles; flags and pattern layers still update.": "Reutiliza los mástiles; las banderas y las capas del estampado siguen actualizándose.",
"Copper Golem Statues": "Estatuas de gólem de cobre",
"Reuses statue geometry until its block state changes.": "Reutiliza la geometría de la estatua hasta que cambia el estado de su bloque.",
"Leaf Culling": "Descarte de hojas",
"Every leaf face. Vanilla's fancy leaves.": "Todas las caras de las hojas. Las hojas detalladas de vanilla.",
"Distant Plant Dither": "Tramado de plantas lejanas",
"Keeps fine plants in high-res packs from fading far away.": "Evita que las plantas finas de los paquetes de alta resolución se difuminen a lo lejos.",
"Screenshot Quality": "Calidad de las capturas",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Capturas con F2 a este múltiplo de la resolución del juego. Más alto tarda más.",
"Frame Generation": "Generación de fotogramas",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Muestra un fotograma inventado entre cada dos reales: más fluido, algo más de latencia.",
"Shadow Cache": "Caché de sombras",
"Shaders": "Shaders",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Paquetes de shaders, los de Horizon y los de la carpeta shaders/, y sus opciones.",
"Shader packs": "Paquetes de shaders",
"Installed": "Instalados",
"Apply": "Aplicar",
"Reset all options": "Restablecer todas las opciones",
"A Horizon shader pack.": "Un paquete de shaders de Horizon.",
"An OptiFine or Iris shader pack.": "Un paquete de shaders de OptiFine o Iris.",
"In use: press to turn shaders off.": "En uso: pulsa para desactivar los shaders.",
"Press to use it.": "Pulsa para usarlo.",
"Put shader pack .zip files or folders in it.": "Pon en ella archivos .zip o carpetas de paquetes de shaders.",
"Puts every option of this pack back to its default.": "Devuelve todas las opciones de este paquete a sus valores por defecto.",
"Sets many options at once, as the pack groups them.": "Ajusta muchas opciones a la vez, tal como las agrupa el paquete.",
"No shader packs yet: open the pack folder and put some in it.": "Aún no hay paquetes de shaders: abre la carpeta de paquetes y pon algunos en ella.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Elige un paquete de shaders. Elegir el que está en uso desactiva los shaders.",
"Changed options apply with Apply, or when you leave.": "Las opciones cambiadas se aplican con Aplicar, o al salir.",
"Pack settings": "Ajustes del paquete",
"Built-in settings": "Ajustes integrados",
"%s settings": "Ajustes de %s",
"Settings made by the pack's author: they change only this pack.": "Ajustes creados por el autor del paquete: solo cambian este paquete.",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "El preajuste de vanilla: ajusta solo las opciones de vanilla, como hace Minecraft.",
"Fullscreen takes the display over, at the mode below.": "La pantalla completa se apodera del monitor, con el modo de abajo.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "El modo de pantalla que usa la pantalla completa exclusiva. Se aplica al salir.",
"Blurs the world behind menus.": "Desenfoca el mundo tras los menús.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Cómo se muestrean las texturas: más nítidas en ángulo con Anisotrópico.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Hasta dónde llega el filtrado anisotrópico. Más alto es más nítido en ángulo.",
"Only with Anisotropic texture filtering.": "Solo con filtrado anisotrópico de texturas.",
"Shader Packs...": "Paquetes de shaders...",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Elige un paquete de shaders, de Horizon o de la carpeta shaders/, y ajusta sus opciones.",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reutiliza el mapa de sombras del paquete cuando coinciden el sol, la vista y el mundo. Desactivado por defecto.",
"Find BlueMap": "Buscar BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "En servidores, busca su mapa web BlueMap para el terreno lejano. Próxima conexión.",
"BlueMap Radius": "Radio de BlueMap",
"How far around you BlueMap tiles are downloaded. Next join.": "Hasta dónde se descargan los mosaicos de BlueMap a tu alrededor. Próxima conexión.",
"Synchronous": "Síncrono",
"Asynchronous": "Asíncrono",
"Hidden Faces": "Caras ocultas",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Descarta las caras de hojas que hay tras dos capas de hojas: más rápido, las mismas copas.",
"Solid": "Sólido",
"Solid Aggressive": "Sólido agresivo",
"Hollow": "Hueco",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Las hojas totalmente dentro de una copa se dibujan sólidas: menos capas transparentes.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Las hojas con un lado abierto también se dibujan sólidas: más rápido, bordes más toscos.",
"Drops faces between leaves: faster, but gaps look through.": "Descarta las caras entre hojas: más rápido, pero se ve a través de los huecos.",
"Hotbar: Previous Slot": "Barra rápida: ranura anterior",
"Hotbar: Next Slot": "Barra rápida: ranura siguiente",
"horizon.agent.connected.one": "Conectado: 1 conexión de agente",
"horizon.agent.connected.many": "Conectado: %s conexiones de agente",
"horizon.agent.connected.one_ago": "Conectado: 1 conexión de agente, último comando hace %s s",
"horizon.agent.connected.many_ago": "Conectado: %s conexiones de agente, último comando hace %s s",
"horizon.agent.waiting": "Esperando a un agente.",
"horizon.agent.off": "Desactivado: los agentes no pueden llegar a este juego.",
"horizon.agent.session_opened": "Abierto para esta sesión por el programa que inició el juego.",
"horizon.agent.socket": "Socket: %s (copia los datos para ver su ruta completa)",
"horizon.agent.local_only": "Solo local: agentes en este equipo, nunca la red.",
"%s nits": "%s nits",
"Auto (%s nits)": "Automático (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
"Entity Culling Precheck": "Comprobación previa de oclusión de entidades",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Ejecuta la comprobación previa del AABB de los mobs tras esta cantidad de mobs cargados. Cero la desactiva.",
"%s mobs": "%s mobs",
"Only applies to asynchronous entity culling.": "Solo se aplica a la oclusión asíncrona de entidades.",
"horizon.extras.repro_keys": "Atajos de grabación",
"horizon.extras.repro_keys.tooltip": "F3+R inicia o detiene una grabación de repro, y F3+E escribe su paquete. Desactivado por defecto: una grabación copia el mundo.",
"horizon.agent.record_session": "Iniciar sesión de grabación",
"horizon.agent.record_stop": "Detener sesión de grabación",
"horizon.agent.export_repro": "Exportar paquete de repro",
"horizon.repro.started": "Grabación iniciada; F3+R la detiene y escribe el paquete.",
"horizon.repro.stopped": "Grabación detenida: %s",
"horizon.repro.bundle": "Paquete de repro: %s",
"horizon.repro.error": "Repro: %s",
"horizon.repro.no_recording": "Aún no hay ninguna grabación que exportar.",
"horizon.repro.replay_started": "Reproducción iniciada: %s",
"horizon.splitscreen.profiles.offline": " (Sin conexión)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Vuelve a iniciar sesión en el launcher",
"horizon.splitscreen.profiles.online": "Microsoft en línea",
"horizon.splitscreen.account_unavailable": "%s está sin conexión. Vuelve a iniciar sesión en el launcher y reinicia el juego.",
"horizon.splitscreen.account_expired": "La sesión de Microsoft de %s ha caducado. Vuelve a iniciar sesión en el launcher y reinicia el juego.",
"Particle Culling": "Descarte de partículas",
"Draws every particle.": "Dibuja todas las partículas.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Omite las partículas fuera de pantalla, subpíxel y ocultas por el terreno.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Omite las partículas fuera de pantalla, subpíxel y ocultas por el terreno con menos trabajo del hilo de renderizado.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Añadir un paquete de recursos desde Modrinth",
"horizon.pack.addWorking": "Consultando Modrinth...",
"horizon.pack.addNone": "No se han encontrado paquetes de recursos",
"horizon.pack.addTitle": "Añadir un paquete de recursos",
"horizon.pack.install": "Instalar",
"horizon.pack.installing": "Instalando",
"horizon.pack.installed": "Instalado",
"horizon.pack.search": "Buscar",
"horizon.pack.searchHint": "Buscar en Modrinth...",
"horizon.pack.filters": "Filtros",
"horizon.pack.filter.realistic": "Realista",
"horizon.pack.filter.simplistic": "Sencillo",
"horizon.pack.filter.vanillaLike": "Estilo vanilla",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "No se pudo añadir el paquete: %s",
"horizon.extras.header.cosmetics": "Cosméticos",
"horizon.extras.cosmetics_mine": "Mis cosméticos",
"horizon.extras.cosmetics_mine.tooltip": "Oculto: todos los jugadores de Horizon te ven sin tus cosméticos, tú incluido. Es el mismo interruptor que el Vestuario del launcher.",
"key.emote": "Rueda de emotes",
"horizon.emote_wheel.title": "Emotes",
"horizon.emote_wheel.empty": "Aún no hay emotes",
"horizon.emote_wheel.help": "Suelta o haz clic para reproducir · 1–8 · Esc cierra",
"horizon.extras.cosmetics_others": "Cosméticos de otros jugadores",
"horizon.extras.cosmetics_others.tooltip": "Lo que llevan otros jugadores de Horizon: todo, solo el estilo de sus etiquetas de nombre o nada. Oculto salvo que tengas cosméticos, hasta que elijas. Oculto, el juego nunca pregunta qué llevan.",
"horizon.extras.cosmetics_hide_fx": "Ocultar estelas y auras de otros jugadores",
"horizon.extras.cosmetics_hide_fx.tooltip": "Las estelas y auras de otros jugadores permanecen ocultas; sus demás cosméticos se siguen viendo. Desactivado por defecto.",
"horizon.extras.cosmetics.shown": "Mostrados",
"horizon.extras.cosmetics.hidden": "Ocultos",
"horizon.extras.cosmetics.name_tags": "Solo etiquetas de nombre",
"horizon.moderation.duration.minute": "%s minuto",
"horizon.moderation.duration.minutes": "%s minutos",
"horizon.moderation.duration.hour": "%s hora",
@@ -252,22 +636,266 @@
"horizon.tempban.list.empty": "No hay baneos temporales",
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)",
"Shader Packs...": "Paquetes de shaders...",
"Installed": "Instalados",
"Apply": "Aplicar",
"Reset all options": "Restablecer todas las opciones",
"A Horizon shader pack.": "Un paquete de shaders de Horizon.",
"An OptiFine or Iris shader pack.": "Un paquete de shaders de OptiFine o Iris.",
"In use: press to turn shaders off.": "En uso: pulsa para desactivar los shaders.",
"Press to use it.": "Pulsa para usarlo.",
"Put shader pack .zip files or folders in it.": "Pon en ella archivos .zip o carpetas de paquetes de shaders.",
"Puts every option of this pack back to its default.": "Devuelve todas las opciones de este paquete a sus valores por defecto.",
"Sets many options at once, as the pack groups them.": "Ajusta muchas opciones a la vez, tal como las agrupa el paquete.",
"No shader packs yet: open the pack folder and put some in it.": "Aún no hay paquetes de shaders: abre la carpeta de paquetes y pon algunos en ella.",
"Pick a shader pack. Picking the one in use turns shaders off.": "Elige un paquete de shaders. Elegir el que está en uso desactiva los shaders.",
"Changed options apply with Apply, or when you leave.": "Las opciones cambiadas se aplican con Aplicar, o al salir.",
"Pack settings": "Ajustes del paquete",
"Built-in settings": "Ajustes integrados",
"%s settings": "Ajustes de %s",
"Settings made by the pack's author: they change only this pack.": "Ajustes creados por el autor del paquete: solo cambian este paquete."
"horizon.options.pointer_speed": "Velocidad del puntero en los menús",
"horizon.options.scroll_speed": "Velocidad de desplazamiento en los menús",
"horizon.options.repeat_delay": "Retardo de repetición al navegar",
"horizon.options.repeat_interval": "Intervalo de repetición al navegar",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "En un jugador",
"horizon.extras.cosmetics_solo.tooltip": "Muestra tus cosméticos y la insignia de tu etiqueta de nombre también en tus mundos de un jugador (tu etiqueta con Etiqueta con tu nombre). Desactivado: solo en servidores.",
"horizon.extras.horizon_marks": "Logos de Horizon",
"horizon.extras.horizon_marks.tooltip": "Muestra el logo de Horizon junto a los jugadores de Horizon: un pequeño logo antes del nombre de los demás jugadores que usan Horizon, sobre sus cabezas y en la lista de jugadores. Solo lo ven los jugadores de Horizon: los servidores no reciben nada.",
"horizon.extras.horizon_me": "Mostrarme como Horizon",
"horizon.extras.horizon_me.tooltip": "Muéstrame como jugador de Horizon: los demás jugadores de Horizon ven el logo junto a tu nombre. Activado, el servicio responde a quien pregunte por ti que has jugado a Horizon en las últimas 3 horas; desactivado, nunca te informa.",
"horizon.pages.pvp": "PvP y HUD",
"horizon.pages.pvp.tooltip": "Punto de mira, HUD y ajustes de PvP, todos desactivados por defecto. Nada de aquí muestra lo que vanilla no muestra.",
"horizon.pages.open.pvp": "PvP y HUD...",
"horizon.pvp.header.crosshair": "Punto de mira",
"horizon.pvp.crosshair": "Punto de mira personalizado",
"horizon.pvp.crosshair_shape": "Forma",
"horizon.pvp.crosshair_size": "Tamaño",
"horizon.pvp.crosshair_gap": "Separación",
"horizon.pvp.crosshair_thickness": "Grosor",
"horizon.pvp.crosshair_colour": "Color",
"horizon.pvp.crosshair_outline": "Contorno",
"horizon.pvp.crosshair_dot": "Punto central",
"horizon.pvp.crosshair_hover": "Color sobre entidades",
"horizon.pvp.crosshair.tooltip": "Dibuja tu propio punto de mira en lugar del de vanilla. Sus primeros ajustes se parecen a los de vanilla.",
"horizon.pvp.crosshair_shape.tooltip": "Una cruz, una T, una X, un círculo, un cuadrado o solo un punto.",
"horizon.pvp.crosshair_size.tooltip": "La longitud de cada brazo, en píxeles de la interfaz.",
"horizon.pvp.crosshair_gap.tooltip": "El espacio vacío del centro.",
"horizon.pvp.crosshair_thickness.tooltip": "Lo ancha que es cada línea.",
"horizon.pvp.crosshair_colour.tooltip": "Invertido muestra invertido el color que hay debajo, como hace el punto de mira de vanilla.",
"horizon.pvp.crosshair_outline.tooltip": "Un borde negro fino, para que se vea sobre cualquier fondo.",
"horizon.pvp.crosshair_dot.tooltip": "Un punto en el centro del punto de mira.",
"horizon.pvp.crosshair_hover.tooltip": "Otro color mientras el punto de mira está sobre un mob o un jugador al que podrías golpear, tal como lo sabe el indicador de ataque de vanilla.",
"horizon.pvp.colour.inverted": "Invertido",
"horizon.pvp.colour.white": "Blanco",
"horizon.pvp.colour.red": "Rojo",
"horizon.pvp.colour.orange": "Naranja",
"horizon.pvp.colour.yellow": "Amarillo",
"horizon.pvp.colour.green": "Verde",
"horizon.pvp.colour.cyan": "Cian",
"horizon.pvp.colour.blue": "Azul",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Negro",
"horizon.pvp.shape.cross": "Cruz",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Círculo",
"horizon.pvp.shape.square": "Cuadrado",
"horizon.pvp.shape.dot": "Punto",
"horizon.pvp.outline": "Contorno de bloque personalizado",
"horizon.pvp.outline.tooltip": "Cambia el contorno del bloque que miras.",
"horizon.pvp.outline_colour": "Color",
"horizon.pvp.outline_colour.tooltip": "El color del contorno. Negro al 40 % es el de vanilla.",
"horizon.pvp.outline_opacity": "Opacidad",
"horizon.pvp.outline_opacity.tooltip": "Lo sólido que es el contorno.",
"horizon.pvp.outline_thickness": "Grosor",
"horizon.pvp.outline_thickness.tooltip": "El ancho del contorno, en veces el de vanilla.",
"horizon.pvp.outline_fill": "Relleno",
"horizon.pvp.outline_fill.tooltip": "Tiñe ligeramente las caras del bloque que miras.",
"horizon.pvp.hit": "Color de golpe personalizado",
"horizon.pvp.hit.tooltip": "Cambia el color con el que parpadean los mobs y los jugadores al recibir daño. Rojo al 30 % es el de vanilla.",
"horizon.pvp.hit_colour": "Color",
"horizon.pvp.hit_colour.tooltip": "El color con el que parpadean los mobs y los jugadores heridos.",
"horizon.pvp.hit_opacity": "Opacidad",
"horizon.pvp.hit_opacity.tooltip": "Cuánto los cubre el color.",
"horizon.pvp.header.outline": "Contorno de bloque",
"horizon.pvp.header.hit": "Color de golpe",
"horizon.pvp.hud_armour": "Armadura",
"horizon.pvp.hud_armour.tooltip": "La armadura que llevas, empezando por la cabeza, con los usos que le quedan a cada pieza.",
"horizon.pvp.hud_armour_corner": "Esquina de la armadura",
"horizon.pvp.hud_armour_corner.tooltip": "Dónde se muestra la armadura.",
"horizon.pvp.hud_armour_percent": "Durabilidad en %",
"horizon.pvp.hud_armour_percent.tooltip": "Los usos restantes como porcentaje del máximo de la pieza en lugar de un número.",
"horizon.pvp.hud_effects": "Efectos",
"horizon.pvp.hud_effects.tooltip": "Tus efectos con sus nombres, niveles y tiempo restante, en lugar de los iconos de arriba a la derecha.",
"horizon.pvp.hud_effects_corner": "Esquina de los efectos",
"horizon.pvp.hud_effects_corner.tooltip": "Dónde se muestran los efectos.",
"horizon.pvp.hud_held": "Cantidad del objeto en la mano",
"horizon.pvp.hud_held.tooltip": "Cuántas unidades del objeto que llevas en la mano hay en tu inventario; flechas con arco o ballesta.",
"horizon.pvp.hud_held_corner": "Esquina de la cantidad",
"horizon.pvp.hud_held_corner.tooltip": "Dónde se muestra la cantidad.",
"horizon.pvp.hud_background": "Fondos de los widgets",
"horizon.pvp.hud_background.tooltip": "Una caja oscura detrás de cada widget.",
"horizon.pvp.hud_opacity": "Opacidad del fondo",
"horizon.pvp.hud_opacity.tooltip": "Lo oscuras que son las cajas detrás de los widgets.",
"horizon.pvp.hud_text_colour": "Color del texto",
"horizon.pvp.hud_text_colour.tooltip": "El color del texto de los widgets. La durabilidad sigue yendo de verde a rojo.",
"horizon.pvp.hud_shadow": "Sombra del texto",
"horizon.pvp.hud_shadow.tooltip": "Una sombra bajo el texto de los widgets, como la tiene el texto del HUD de vanilla.",
"horizon.pvp.header.hud": "Widgets del HUD",
"horizon.pvp.corner.top_left": "Arriba a la izquierda",
"horizon.pvp.corner.top_right": "Arriba a la derecha",
"horizon.pvp.corner.bottom_left": "Abajo a la izquierda",
"horizon.pvp.corner.bottom_right": "Abajo a la derecha",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "La latencia que el servidor informa para ti, en milisegundos. Solo multijugador.",
"horizon.pvp.hud_ping_corner": "Esquina del ping",
"horizon.pvp.hud_ping_corner.tooltip": "Dónde se muestra el ping.",
"horizon.pvp.hud_coords": "Coordenadas",
"horizon.pvp.hud_coords.tooltip": "Tu posición en bloques y la dirección a la que miras, como las muestra F3. Se oculta cuando el mundo o el servidor limitan F3.",
"horizon.pvp.hud_coords_corner": "Esquina de las coordenadas",
"horizon.pvp.hud_coords_corner.tooltip": "Dónde se muestran las coordenadas.",
"horizon.pvp.hud_server": "Dirección del servidor",
"horizon.pvp.hud_server.tooltip": "La dirección del servidor en el que juegas. Solo multijugador.",
"horizon.pvp.hud_server_corner": "Esquina del servidor",
"horizon.pvp.hud_server_corner.tooltip": "Dónde se muestra la dirección.",
"horizon.pvp.hud_combo": "Contador de combo",
"horizon.pvp.hud_combo.tooltip": "Los golpes seguidos que el servidor confirmó, hasta que recibes daño o pasan 3 segundos sin ninguno.",
"horizon.pvp.hud_combo_corner": "Esquina del combo",
"horizon.pvp.hud_combo_corner.tooltip": "Dónde se muestra el combo.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Mirando: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "Norte",
"horizon.pvp.hud.direction.south": "Sur",
"horizon.pvp.hud.direction.east": "Este",
"horizon.pvp.hud.direction.west": "Oeste",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Los fotogramas dibujados en el último segundo.",
"horizon.pvp.hud_fps_corner": "Esquina de los FPS",
"horizon.pvp.hud_fps_corner.tooltip": "Dónde se muestran los FPS.",
"horizon.pvp.hud_cps": "Clics por segundo",
"horizon.pvp.hud_cps.tooltip": "Tus clics izquierdo y derecho en el último segundo.",
"horizon.pvp.hud_cps_corner": "Esquina de los clics",
"horizon.pvp.hud_cps_corner.tooltip": "Dónde se muestran los clics por segundo.",
"horizon.pvp.hud_keys": "Teclas y botones",
"horizon.pvp.hud_keys.tooltip": "Tus teclas de movimiento, botones del ratón y salto, iluminados mientras los mantienes, con los clics por segundo.",
"horizon.pvp.hud_keys_corner": "Esquina de las teclas",
"horizon.pvp.hud_keys_corner.tooltip": "Dónde se muestran las teclas.",
"horizon.pvp.hud_clock": "Reloj",
"horizon.pvp.hud_clock.tooltip": "La hora de tu ordenador.",
"horizon.pvp.hud_clock_corner": "Esquina del reloj",
"horizon.pvp.hud_clock_corner.tooltip": "Dónde se muestra el reloj.",
"horizon.pvp.hud_reach": "Alcance",
"horizon.pvp.hud_reach.tooltip": "A qué distancia de tus ojos cayó tu último golpe, durante 3 segundos. Solo mide: tu alcance sigue siendo el de vanilla.",
"horizon.pvp.hud_reach_corner": "Esquina del alcance",
"horizon.pvp.hud_reach_corner.tooltip": "Dónde se muestra el alcance.",
"horizon.pvp.hud_speed": "Velocidad",
"horizon.pvp.hud_speed.tooltip": "A qué velocidad te desplazas por el suelo, en bloques por segundo.",
"horizon.pvp.hud_speed_corner": "Esquina de la velocidad",
"horizon.pvp.hud_speed_corner.tooltip": "Dónde se muestra la velocidad.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Alcance: %s",
"horizon.pvp.hud.speed": "Velocidad: %s b/s",
"horizon.pvp.hud.keys.attack": "Clic izq.",
"horizon.pvp.hud.keys.use": "Clic der.",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Niveles de encantamiento como números",
"horizon.pvp.numeric_enchants.tooltip": "Los tooltips y la mesa de encantamientos muestran Filo 5 en lugar de Filo V.",
"horizon.pvp.header.items": "Objetos",
"horizon.pvp.fire_lower": "Fuego más bajo",
"horizon.pvp.fire_lower.tooltip": "Baja las llamas que ves mientras ardes, para que cubran menos pantalla.",
"horizon.pvp.shield_size": "Tamaño del escudo",
"horizon.pvp.shield_size.tooltip": "El tamaño del escudo en tu mano, en primera persona.",
"horizon.pvp.totem_size": "Tamaño de la animación del tótem",
"horizon.pvp.totem_size.tooltip": "El tamaño del tótem que llena la pantalla cuando te salva.",
"horizon.pvp.glint_colour": "Color del brillo de encantamiento",
"horizon.pvp.glint_colour.tooltip": "El color del brillo de los objetos y armaduras encantados. Desactivado es el morado de vanilla.",
"horizon.pvp.clear_water": "Agua clara",
"horizon.pvp.clear_water.tooltip": "Bajo el agua, ve tan lejos como te deja vanilla tras 30 segundos allí, desde el principio.",
"horizon.pvp.clear_lava": "Lava clara",
"horizon.pvp.clear_lava.tooltip": "En la lava, ve tan lejos como te deja la Resistencia al fuego de vanilla, sin el efecto.",
"horizon.pvp.header.visual": "Ajustes visuales",
"horizon.pvp.view_time": "Hora del día",
"horizon.pvp.view_time.tooltip": "La hora del día que ves: solo el cielo y su luz. La hora del mundo sigue y no se envía nada al servidor.",
"horizon.pvp.view_weather": "Clima",
"horizon.pvp.view_weather.tooltip": "El clima que ves: solo la lluvia, su luz y la niebla. El clima del mundo sigue y no se envía nada al servidor.",
"horizon.pvp.header.view": "Tu vista",
"horizon.pvp.view.world": "La del mundo",
"horizon.pvp.time.morning": "Mañana",
"horizon.pvp.time.noon": "Mediodía",
"horizon.pvp.time.sunset": "Atardecer",
"horizon.pvp.time.midnight": "Medianoche",
"horizon.pvp.weather.clear": "Despejado",
"horizon.pvp.weather.rain": "Lluvia",
"horizon.pvp.weather.thunder": "Tormenta",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Chat",
"horizon.chat.copied": "Mensaje copiado",
"horizon.chat.search": "Buscar en el chat:",
"horizon.pvp.chat_timestamps": "Marcas de tiempo",
"horizon.pvp.chat_timestamps.tooltip": "La hora a la que llegó cada mensaje, delante de él.",
"horizon.pvp.chat_compact": "Chat compacto",
"horizon.pvp.chat_compact.tooltip": "Un mensaje repetido seguido se muestra una vez, con cuántas veces llegó: (x3).",
"horizon.pvp.chat_copy": "Copiar al hacer clic",
"horizon.pvp.chat_copy.tooltip": "Hacer clic en un mensaje del chat abierto copia su texto, salvo que el clic haga otra cosa.",
"horizon.pvp.chat_search": "Búsqueda en el chat",
"horizon.pvp.chat_search.tooltip": "Ctrl+F en el chat abierto: lo que escribas deja solo los mensajes que lo contienen. Intro o Ctrl+F de nuevo lo detiene.",
"horizon.pvp.preset": "Preajuste de PvP",
"horizon.pvp.preset.tooltip": "Activa el punto de mira personalizado, los widgets de armadura y efectos, el sprint y agacharse alternos, el fuego más bajo y, si están instalados, los mods de contador de FPS y de teclas. Tus ajustes de cada uno se mantienen.",
"horizon.pvp.hud_editor_key": "Tecla del editor de HUD",
"horizon.pvp.hud_editor_key.tooltip": "Abre el editor de HUD desde el juego. Haz clic y luego pulsa una tecla; Escape la desasigna.",
"horizon.pvp.editor": "Editar HUD",
"horizon.pvp.editor.tooltip": "Mueve los widgets sobre el juego: arrástralos a cualquier parte, cambia su tamaño con la rueda y haz clic derecho en uno para devolverlo a su esquina.",
"horizon.pvp.editor.hint": "Arrastra los widgets · Rueda: tamaño · Clic derecho: a su esquina",
"horizon.pvp.combat_feel": "Sensación de combate",
"horizon.pvp.combat_feel.tooltip": "Legacy dibuja las peleas como en 1.8.9: el objeto en la mano, su balanceo, comer, el arco y una espada alzada mientras mantienes usar, sin la bajada tras cada golpe ni el indicador de ataque. Solo el aspecto: lo que envías al servidor no cambia.",
"horizon.pvp.combat_indicator": "Indicador de ataque en Legacy",
"horizon.pvp.combat_indicator.tooltip": "Mantiene el indicador de ataque en Legacy, que 1.8.9 no tenía.",
"horizon.pvp.combat_feel.modern": "Moderno",
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
"horizon.pvp.header.combat": "Sensación de combate",
"horizon.pvp.combat_sounds": "Sonidos de golpe de 1.8",
"horizon.pvp.combat_sounds.tooltip": "En Legacy, omite los sonidos de ataque que añadió 1.9 (fuerte, débil, barrido, crítico...). Los sonidos de daño se mantienen.",
"horizon.pvp.combat_rules": "Combate de 1.8 en los mundos que alojas",
"horizon.pvp.combat_rules.tooltip": "El modo un jugador y los mundos que abres a la LAN pelean con las reglas de 1.8.9: sin enfriamiento de ataque ni barrido, daño de arma de 1.8, Fuerza, Filo y golpes críticos. Los servidores a los que te unes mantienen sus propias reglas.",
"Bindings": "Asignaciones",
"Play": "Jugar",
"Device": "Dispositivo",
"Look": "Vista",
"Movement": "Movimiento",
"Menus": "Menús",
"Actions": "Acciones",
"Reset": "Restablecer",
"Detected": "Detectado",
"Glyphs": "Símbolos",
"Feedback": "Respuesta",
"Controller": "Mando",
"How the sticks turn and move you, and how menus repeat and scroll.": "Cómo te giran y te mueven las palancas, y cómo repiten y desplazan los menús.",
"Which button does what. Each player has their own.": "Qué hace cada botón. Cada jugador tiene las suyas.",
"The controller the game sees, its button glyphs and its feedback.": "El mando que ve el juego, sus símbolos de botones y su respuesta.",
"Press this row, then the button that should do it.": "Pulsa esta fila y luego el botón que deba hacerlo.",
"Off, the game ignores every controller for this player.": "Desactivado, el juego ignora todos los mandos de este jugador.",
"How fast the right stick turns the view.": "La rapidez con la que la palanca derecha gira la vista.",
"Turn the view the other way, left and right.": "Gira la vista al revés, de izquierda a derecha.",
"Turn the view the other way, up and down.": "Gira la vista al revés, de arriba abajo.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Cuánto se mueve la palanca derecha antes de que gire la vista. Súbelo si la vista se desplaza sola.",
"Hold the button to sneak, or press it once to start and again to stop.": "Mantén el botón para agacharte, o pulsa una vez para empezar y otra para parar.",
"How far the left stick moves before you walk. Raise it if you drift.": "Cuánto se mueve la palanca izquierda antes de que camines. Súbelo si te desplazas solo.",
"How far a trigger is pulled before it counts as pressed.": "Cuánto hay que apretar un gatillo para que cuente como pulsado.",
"How fast the stick moves the crosshair in inventories.": "La rapidez con la que la palanca mueve el cursor en los inventarios.",
"How fast the right stick scrolls a list.": "La rapidez con la que la palanca derecha desplaza una lista.",
"How long a held direction waits before it repeats.": "Cuánto espera una dirección mantenida antes de repetirse.",
"How long a held direction takes between repeats.": "Cuánto tarda una dirección mantenida entre repeticiones.",
"Which on-screen keyboard opens when a text field is pressed.": "Qué teclado en pantalla se abre al pulsar un campo de texto.",
"Every action back on its default button.": "Devuelve cada acción a su botón por defecto.",
"These settings belong to this player's profile.": "Estos ajustes pertenecen al perfil de este jugador.",
"The controller driving this player, or else the first one connected.": "El mando que controla a este jugador o, si no, el primero conectado.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "El fabricante y el producto USB que informa el sistema. Útil al reportar un mando que falla.",
"The glyph family the game picked from the controller's maker.": "La familia de símbolos que el juego eligió según el fabricante del mando.",
"Follow the controller, or always show one family's buttons.": "Sigue al mando o muestra siempre los botones de una familia.",
"The buttons menus show right now.": "Los botones que muestran los menús ahora mismo.",
"Rumble when something happens to you.": "Vibra cuando te pasa algo.",
"A DualSense's triggers push back on bows and tools.": "Los gatillos de un DualSense ofrecen resistencia con arcos y herramientas.",
"What a DualSense's light shows, over USB or Bluetooth.": "Lo que muestra la luz de un DualSense, por USB o Bluetooth.",
"Player profile": "Perfil de jugador",
"Vendor and product": "Fabricante y producto",
"Detected glyphs": "Símbolos detectados",
"Glyphs in use": "Símbolos en uso",
"Unknown": "Desconocido",
"No controller": "Sin mando",
"Editing: %s": "Editando: %s",
"Press the button for this. %s cancels.": "Pulsa el botón para esto. %s cancela.",
"This button also does: %s": "Este botón también hace: %s",
"horizon.pad.notice.connected.press": "Pulsa para usarlo",
"horizon.recipe_transfer.missing": "Faltan objetos",
"horizon.recipe_transfer.no_room": "Inventario lleno",
"horizon.recipe_transfer.unavailable": "La receta no cabe en este menú",
"horizon.recipe_transfer.unsupported": "El servidor requiere una receta conocida"
}
@@ -188,6 +188,7 @@
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
"horizon.plain.official": "Horizon officiel avec launcher, mods et mises à jour : playhorizon.dev",
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
"horizon.extras.header.lighting": "Éclairage",
"horizon.extras.header.third_person": "Troisième personne",
@@ -231,6 +231,7 @@
"Brighter-than-white output on an HDR display.": "Uscita più luminosa del bianco su un display HDR.",
"Results for \"%s\"": "Risultati per \"%s\"",
"No settings match.": "Nessuna impostazione corrisponde.",
"horizon.plain.official": "Horizon ufficiale con launcher, mod e aggiornamenti: playhorizon.dev",
"horizon.title.not_affiliated": "Non affiliato con Mojang o Microsoft",
"horizon.extras.header.lighting": "Illuminazione",
"horizon.extras.header.third_person": "Terza persona",
@@ -29,6 +29,7 @@
"horizon.pages.extras": "Horizon エクストラ",
"horizon.pages.settings": "Horizon 設定",
"horizon.pages.agent": "AIエージェント",
"horizon.plain.official": "ランチャー、Mod、アップデート付きの公式 Horizon: playhorizon.dev",
"horizon.split.leave": "休暇を取る",
"horizon.pages.split_settings": "分割画面設定",
"horizon.pages.split_profiles": "誰が遊んでるの?",
@@ -26,6 +26,7 @@
"horizon.pages.extras": "Horizon 추가 기능",
"horizon.pages.settings": "Horizon 설정",
"horizon.pages.agent": "AI 에이전트",
"horizon.plain.official": "런처, 모드, 업데이트가 포함된 공식 Horizon: playhorizon.dev",
"horizon.split.leave": "나가기",
"horizon.pages.split_settings": "화면 분할 설정",
"horizon.pages.split_profiles": "플레이어 선택",
@@ -223,6 +223,7 @@
"Brighter-than-white output on an HDR display.": "Helderder dan witte uitvoer op een HDR-scherm.",
"Results for \"%s\"": "Resultaten voor \"%s\"",
"No settings match.": "Er komen geen instellingen overeen.",
"horizon.plain.official": "Officiële Horizon met launcher, mods en updates: playhorizon.dev",
"horizon.title.not_affiliated": "Niet aangesloten bij Mojang of Microsoft",
"horizon.extras.header.lighting": "Verlichting",
"horizon.extras.header.third_person": "Derde persoon",
@@ -222,6 +222,7 @@
"Brighter-than-white output on an HDR display.": "Obraz jaśniejszy niż biel na ekranie HDR.",
"Results for \"%s\"": "Wyniki dla \"%s\"",
"No settings match.": "Żadne ustawienie nie pasuje.",
"horizon.plain.official": "Oficjalny Horizon z launcherem, modami i aktualizacjami: playhorizon.dev",
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
"horizon.extras.header.lighting": "Oświetlenie",
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
@@ -233,6 +233,7 @@
"Brighter-than-white output on an HDR display.": "Saída mais brilhante que o branco em uma tela HDR.",
"Results for \"%s\"": "Resultados para \"%s\"",
"No settings match.": "Nenhuma configuração corresponde.",
"horizon.plain.official": "Horizon oficial com launcher, mods e atualizações: playhorizon.dev",
"horizon.title.not_affiliated": "Não afiliado à Mojang ou Microsoft",
"horizon.extras.header.lighting": "Iluminação",
"horizon.extras.header.third_person": "Terceira pessoa",
@@ -76,6 +76,7 @@
"Resolution Scale": "Масштаб разрешения",
"Anti-aliasing": "Сглаживание",
"Upscaling": "Апскейл",
"Upscaling Sharpness": "Резкость апскейла",
"Paper White": "Белая точка",
"Peak Brightness": "Пиковая яркость",
"Black Level": "Уровень чёрного",
@@ -167,6 +168,7 @@
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пикселей мира на пиксель экрана. Ниже 100% — быстрее для видеокарты.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Сглаживает рваные края. FXAA: мягче. TAA: глаже всего, но движущееся может шлейфить.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Рисует мир мельче и восстанавливает его через TAA. Быстрее, но мягче.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Повышает резкость увеличенной картинки. Выше: чётче края, больше мерцания.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-вывод на HDR-экране: лава, огонь и солнце светятся ярче.",
"How frames reach the screen while VSync is off.": "Как кадры попадают на экран, когда вертикальная синхронизация выключена.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.",
@@ -185,6 +187,7 @@
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.plain.official": "Официальный Horizon с лаунчером, модами и обновлениями: playhorizon.dev",
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
@@ -187,6 +187,7 @@
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
"horizon.pages.split_profiles": "Kim oynuyor?",
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.plain.official": "Başlatıcı, modlar ve güncellemelerle resmi Horizon: playhorizon.dev",
"horizon.shader_options.profile": "Profil",
"Particle Culling": "Particle Culling",
"Draws every particle.": "Draws every particle.",
@@ -57,6 +57,7 @@
"horizon.mods.dev": " (розробка)",
"horizon.mods.settings": "Налаштування",
"horizon.mods.open_folder": "Відкрити теку з модами",
"horizon.plain.official": "Офіційний Horizon із лаунчером, модами та оновленнями: playhorizon.dev",
"horizon.shader_options.profile": "Профіль",
"horizon.agent.copy_details": "Копіювати дані з’єднання",
"horizon.multiplayer.join": "Приєднатися",
@@ -471,5 +472,450 @@
"Pack settings": "Налаштування пакета",
"Built-in settings": "Вбудовані налаштування",
"%s settings": "Налаштування %s",
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет."
"Settings made by the pack's author: they change only this pack.": "Налаштування, створені автором пакета: вони змінюють лише цей пакет.",
"horizon.extras.header.world": "Світ",
"horizon.extras.pregen": "Попередня генерація чанків",
"horizon.extras.pregen.tooltip": "Вмикає /pregen у ваших одиночних і LAN-світах. З увімкненими читами введіть /pregen start, щоб згенерувати область, перш ніж її досліджувати. На серверах, до яких ви приєднуєтесь, команда не вмикається.",
"horizon.options.three_d_skin_layers": "3D-шари скіна",
"horizon.options.play_in_background": "Гра у фоні",
"horizon.options.trigger_threshold": "Поріг тригера",
"horizon.studio.record.usage": "Використання: /studio record <start [назва]|stop|status>",
"horizon.studio.record.started": "Запис Studio розпочато: %s",
"horizon.studio.record.saved": "Запис Studio збережено: %s",
"horizon.studio.record.error": "Помилка запису Studio: %s",
"horizon.studio.record.status.active": "Запис Studio триває — %s с, %s пакетів, %s КБ (%s)",
"horizon.studio.record.status.inactive": "Запис Studio не ведеться",
"horizon.studio.replay.usage": "Використання: /studio replay <open ШЛЯХ|pause|resume|speed МНОЖНИК|seek МС|status|close>",
"horizon.studio.replay.open.usage": "Використання: /studio replay open <ШЛЯХ>",
"horizon.studio.replay.opened": "Повтор Studio відкрито: %s (%s с, %s пакетів)",
"horizon.studio.replay.open_error": "Не вдалося відкрити повтор: %s",
"horizon.studio.replay.paused": "Повтор Studio призупинено",
"horizon.studio.replay.resumed": "Повтор Studio продовжено",
"horizon.studio.replay.inactive": "Немає активного повтору Studio",
"horizon.studio.replay.speed.usage": "Використання: /studio replay speed <МНОЖНИК>",
"horizon.studio.replay.speed.set": "Швидкість повтору Studio: %s×",
"horizon.studio.replay.speed.invalid": "Неправильна швидкість повтору",
"horizon.studio.replay.seek.usage": "Використання: /studio replay seek <МІЛІСЕКУНДИ>",
"horizon.studio.replay.seeked": "Повтор Studio перемотано на %s мс (%s с)",
"horizon.studio.replay.seek.error": "Не вдалося перемотати повтор: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Перемотування назад доступне лише в окремому повторі.",
"horizon.studio.replay.seek.invalid": "Неправильна позиція повтору",
"horizon.studio.replay.status": "Повтор Studio: %s / %s мс (%s%%), швидкість %s×, пауза: %s, завершено: %s",
"horizon.studio.replay.closed": "Повтор Studio закрито",
"horizon.pages.studio": "Студія повторів",
"horizon.pages.music": "Музика",
"horizon.pages.music.menu": "Меню",
"horizon.pages.music.overworld": "Верхній світ",
"horizon.pages.music.creative": "Творчий режим",
"horizon.pages.music.underwater": "Під водою",
"horizon.pages.music.end": "Енд",
"horizon.pages.music.boss": "Бос",
"horizon.pages.music.credits": "Титри",
"horizon.pages.music.overworld_biomes": "Біоми Верхнього світу",
"horizon.pages.music.nether_biomes": "Біоми Незеру",
"horizon.pages.music.end_biomes": "Біоми Енду",
"horizon.pages.music.other": "Інша музика",
"horizon.pages.music.empty": "Немає музичних треків: звуки гри не завантажено.",
"horizon.pages.music.tooltip": "Вбудовані треки за ситуаціями: увімкніть будь-який зараз або вимкніть, щоб гра більше його не обирала.",
"horizon.pages.studio.tooltip": "Перегляд збережених записів і їхніх метаданих.",
"horizon.pages.open.studio": "Студія повторів...",
"horizon.studio.saved_recordings": "Збережені записи",
"horizon.studio.empty": "У теці записів немає жодного запису.",
"horizon.studio.empty_hint": "Введіть у світі /studio record start <назва> або покладіть туди файли .mcpr.",
"horizon.studio.open_folder": "Відкрити записи",
"horizon.studio.open_replay": "Відтворити повтор",
"horizon.studio.refresh": "Оновити",
"horizon.studio.unknown_server": "Невідомо",
"horizon.pages.open.music": "Музика...",
"horizon.mods.in_world": " (вийдіть зі світу, щоб змінити)",
"horizon.mods.browse": "Огляд модів...",
"horizon.mods.browse.title": "Огляд модів",
"horizon.mods.browse.search": "Пошук модів...",
"horizon.mods.browse.project": "Мод",
"horizon.mods.browse.loading": "Читання реєстру модів Horizon...",
"horizon.mods.browse.failed": "Не вдалося прочитати реєстр модів: %s",
"horizon.mods.browse.none": "Модів не знайдено",
"horizon.mods.browse.has_update": " (оновлення)",
"horizon.mods.browse.is_installed": " (встановлено)",
"horizon.mods.browse.by": "автор: %s",
"horizon.mods.browse.installing": "Встановлення...",
"horizon.mods.browse.install": "Встановити",
"horizon.mods.browse.update": "Оновити",
"horizon.mods.browse.installed": "Встановлено",
"horizon.mods.browse.open_page": "Відкрити сторінку",
"horizon.mods.browse.enabled": "Мод",
"horizon.mods.browse.changelog": "Список змін",
"horizon.mods.browse.done": "%s %s встановлено й увімкнено.",
"horizon.mods.browse.install_failed": "Не вдалося встановити: %s",
"horizon.mods.switch.worldgen": "змінює генерацію світу",
"horizon.mods.switch.registries": "додає блоки, предмети чи інші записи реєстрів",
"horizon.mods.switch.restart": "Перезапустіть гру, щоб застосувати %s: вони додають блоки, предмети або генерацію світу.",
"GPU Culling": "Відсікання на GPU",
"HDR Lighting": "HDR-освітлення",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Джерела світла, сонце й сонячне світло сяють яскравіше за білий. Вимкнено: як у SDR.",
"Smooth Gradients": "Плавні градієнти",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-бітний кадр: темне небо й тіні без смуг. Трохи більше відеопам’яті. З наступного запуску.",
"Not with software rendering, which runs it on the CPU.": "Не з програмним рендерингом, який виконує це на CPU.",
"Out of video memory: off until the next start.": "Забракло відеопам’яті: вимкнено до наступного запуску.",
"The graphics device was lost: off until the next start.": "Графічний пристрій втрачено: вимкнено до наступного запуску.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Поки не працює на DirectX 12: спробуйте графічний API Vulkan.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Відсікає ближній рельєф на GPU: за великої дальності промальовування менше навантажує CPU, більше — GPU.",
"horizon.realms.not_signed_in": "Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб грати на Realms.",
"horizon.extras.music_page": "Сторінка «Музика»",
"horizon.extras.zoom": "Наближення",
"horizon.extras.zoom_mod_install": "Наближення: встановіть у модах",
"horizon.extras.wheel_cycles_list_settings.tooltip": "Коліщатко миші над кнопкою в списку налаштувань змінює її значення. Вимкнено, як у ванільній грі: коліщатко прокручує список.",
"horizon.extras.pack_browser": "Огляд наборів ресурсів",
"horizon.extras.music_page.tooltip": "У «Музика і звуки» з’являється сторінка «Музика...»: увімкніть будь-який вбудований трек зараз або вимкніть, щоб гра більше його не обирала. Нічого не змінюється, доки ви не вимкнете трек.",
"horizon.friends.relay_stopped": "Забагато неправильних коротких кодів, тому ретранслятор вимкнено, доки ви знову не поділитеся світом. Друзі все ще можуть приєднатися за повним кодом.",
"horizon.extras.pack_browser.tooltip": "У «Вибрати набори ресурсів» з’являється кнопка «Додати», яка шукає в Modrinth і встановлює набір у вашу теку наборів. Вимкнено: гра ніколи не звертається до Modrinth.",
"horizon.splitscreen.online_only": "Цей сервер працює в онлайн-режимі: офлайн-профілі не можуть приєднатися, кожному гравцеві потрібен власний акаунт Minecraft.",
"horizon.splitscreen.own_account": "Цей сервер вимагає від кожного гравця власного акаунта Minecraft",
"horizon.splitscreen.local_only": "Інші гравці можуть приєднатися до цього сервера лише з власним акаунтом Minecraft: офлайн-профілі грають у вашому світі, через LAN або на сервері у вашій локальній мережі.",
"Shaders": "Шейдери",
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Пакети шейдерів — від Horizon і з теки shaders/ — та їхні налаштування.",
"Shader packs": "Пакети шейдерів",
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Власний пресет ванільної гри: задає лише ванільні налаштування, як це робить Minecraft.",
"Fullscreen takes the display over, at the mode below.": "Повноекранний режим займає весь дисплей у режимі, вказаному нижче.",
"The display mode exclusive fullscreen uses. Applies when you leave.": "Режим дисплея для ексклюзивного повноекранного режиму. Застосовується, коли ви виходите.",
"Blurs the world behind menus.": "Розмиває світ за меню.",
"How textures are sampled: sharper at a slant with Anisotropic.": "Як вибираються текстури: з анізотропною фільтрацією чіткіші під кутом.",
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Рівень анізотропної фільтрації. Вищий — чіткіше під кутом.",
"Only with Anisotropic texture filtering.": "Лише з анізотропною фільтрацією текстур.",
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Обирає пакет шейдерів — від Horizon чи з теки shaders/ — і задає його налаштування.",
"Hidden Faces": "Приховані грані",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Прибирає грані листя за двома шарами листя: швидше, а крони ті самі.",
"Entity Culling Precheck": "Попередня перевірка відсікання сутностей",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Запускає попередню перевірку AABB мобів, коли завантажено стільки мобів. Нуль вимикає її.",
"%s mobs": "%s мобів",
"horizon.splitscreen.profiles.offline": " (Офлайн)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Увійдіть знову в лаунчері",
"horizon.splitscreen.profiles.online": "Microsoft онлайн",
"horizon.splitscreen.account_unavailable": "%s офлайн. Увійдіть знову в лаунчері, потім перезапустіть гру.",
"horizon.splitscreen.account_expired": "Вхід Microsoft для %s закінчився. Увійдіть знову в лаунчері, потім перезапустіть гру.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Додати набір ресурсів із Modrinth",
"horizon.pack.addWorking": "Запит до Modrinth...",
"horizon.pack.addNone": "Наборів ресурсів не знайдено",
"horizon.pack.addTitle": "Додати набір ресурсів",
"horizon.pack.install": "Встановити",
"horizon.pack.installing": "Встановлення",
"horizon.pack.installed": "Встановлено",
"horizon.pack.search": "Пошук",
"horizon.pack.searchHint": "Пошук у Modrinth...",
"horizon.pack.filters": "Фільтри",
"horizon.pack.filter.realistic": "Реалістичні",
"horizon.pack.filter.simplistic": "Спрощені",
"horizon.pack.filter.vanillaLike": "Як ванільні",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Не вдалося додати набір: %s",
"horizon.extras.header.cosmetics": "Косметика",
"horizon.extras.cosmetics_mine": "Моя косметика",
"horizon.extras.cosmetics_mine.tooltip": "Приховано: усі гравці Horizon бачать вас без косметики, і ви теж. Той самий перемикач, що й у Гардеробі лаунчера.",
"key.emote": "Колесо емоцій",
"horizon.emote_wheel.title": "Емоції",
"horizon.emote_wheel.empty": "Емоцій поки немає",
"horizon.emote_wheel.help": "Відпустіть або клацніть, щоб відтворити · 1–8 · Esc закриває",
"horizon.extras.cosmetics_others": "Косметика інших гравців",
"horizon.extras.cosmetics_others.tooltip": "Що носять інші гравці Horizon: усе, лише стилі ніків або нічого. Приховано, якщо у вас немає косметики, доки ви не оберете. Коли приховано, гра ніколи не питає, що вони носять.",
"horizon.extras.cosmetics_hide_fx": "Приховати сліди й аури інших гравців",
"horizon.extras.cosmetics_hide_fx.tooltip": "Сліди й аури інших гравців приховано; решту їхньої косметики видно. Типово вимкнено.",
"horizon.extras.cosmetics.shown": "Показано",
"horizon.extras.cosmetics.hidden": "Приховано",
"horizon.extras.cosmetics.name_tags": "Лише ніки",
"horizon.moderation.duration.minute": "%s хв",
"horizon.moderation.duration.minutes": "%s хв",
"horizon.moderation.duration.hour": "%s год",
"horizon.moderation.duration.hours": "%s год",
"horizon.moderation.duration.day": "%s дн.",
"horizon.moderation.duration.days": "%s дн.",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Заглушених гравців немає",
"horizon.moderation.mutelist.count": "Заглушених гравців: %s:",
"horizon.moderation.chat_filter": "фільтр чату",
"horizon.moderation.until_unmuted": "до зняття заглушення",
"horizon.moderation.remaining": "ще на %s",
"horizon.moderation.mutelist.entry": "%1$s заглушено гравцем %2$s %3$s",
"horizon.moderation.invalid_duration": "%s — не тривалість: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <гравець> [тривалість] [причина]",
"horizon.moderation.usage.tempmute": "/tempmute <гравець> <тривалість> [причина]",
"horizon.moderation.usage.unmute": "/unmute <гравець>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "на %s",
"horizon.moderation.muted": "Заглушено %1$s %2$s",
"horizon.moderation.unmuted": "Знято заглушення з %s",
"horizon.moderation.not_muted": "%s не заглушено",
"horizon.tempban.usage": "/tempban <гравець> <тривалість> [причина]",
"horizon.tempban.invalid_duration": "%s — не тривалість: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Тимчасові бани потребують виділеного сервера",
"horizon.tempban.success": "%1$s на %2$s, до %3$s",
"horizon.tempban.list.empty": "Тимчасових банів немає",
"horizon.tempban.list.count": "Тимчасових банів: %s:",
"horizon.tempban.list.entry": "%1$s забанено гравцем %2$s %3$s (до %4$s)",
"horizon.options.pointer_speed": "Швидкість курсора в меню",
"horizon.options.scroll_speed": "Швидкість прокрутки в меню",
"horizon.options.repeat_delay": "Затримка повтору навігації",
"horizon.options.repeat_interval": "Інтервал повтору навігації",
"horizon.options.milliseconds": "%s мс",
"horizon.extras.cosmetics_solo": "В одиночній грі",
"horizon.extras.cosmetics_solo.tooltip": "Показувати вашу косметику й значок вашого ніка також в одиночних світах (сам нік — із «Власним ніком»). Вимкнено: лише на серверах.",
"horizon.extras.horizon_marks": "Логотипи Horizon",
"horizon.extras.horizon_marks.tooltip": "Показувати логотип Horizon біля гравців Horizon: маленький логотип перед іменами інших гравців, які грають у Horizon, над їхніми головами та в списку гравців. Його бачать лише гравці Horizon: сервери нічого не отримують.",
"horizon.extras.horizon_me": "Показувати мене як гравця Horizon",
"horizon.extras.horizon_me.tooltip": "Показувати мене як гравця Horizon: інші гравці Horizon бачать логотип біля вашого імені. Коли ввімкнено, сервіс відповідає кожному, хто про вас питає, що ви грали в Horizon за останні 3 години; коли вимкнено — ніколи про вас не повідомляє.",
"horizon.pages.pvp": "PvP та HUD",
"horizon.pages.pvp.tooltip": "Налаштування прицілу, HUD і PvP, усі типово вимкнені. Тут немає нічого, що показує більше, ніж ванільна гра.",
"horizon.pages.open.pvp": "PvP та HUD...",
"horizon.pvp.header.crosshair": "Приціл",
"horizon.pvp.crosshair": "Власний приціл",
"horizon.pvp.crosshair_shape": "Форма",
"horizon.pvp.crosshair_size": "Розмір",
"horizon.pvp.crosshair_gap": "Проміжок",
"horizon.pvp.crosshair_thickness": "Товщина",
"horizon.pvp.crosshair_colour": "Колір",
"horizon.pvp.crosshair_outline": "Обведення",
"horizon.pvp.crosshair_dot": "Точка в центрі",
"horizon.pvp.crosshair_hover": "Колір на сутностях",
"horizon.pvp.crosshair.tooltip": "Малює ваш власний приціл замість ванільного. Початкові налаштування виглядають як ванільний.",
"horizon.pvp.crosshair_shape.tooltip": "Хрест, T, X, коло, квадрат або лише точка.",
"horizon.pvp.crosshair_size.tooltip": "Довжина кожного променя в пікселях інтерфейсу.",
"horizon.pvp.crosshair_gap.tooltip": "Порожнє місце посередині.",
"horizon.pvp.crosshair_thickness.tooltip": "Ширина кожної лінії.",
"horizon.pvp.crosshair_colour.tooltip": "«Інвертований» показує колір під прицілом інвертованим, як ванільний приціл.",
"horizon.pvp.crosshair_outline.tooltip": "Тонкий чорний край, щоб приціл було видно на будь-якому фоні.",
"horizon.pvp.crosshair_dot.tooltip": "Точка посередині прицілу.",
"horizon.pvp.crosshair_hover.tooltip": "Інший колір, коли приціл наведено на моба чи гравця, якого можна вдарити, — як це визначає ванільний індикатор атаки.",
"horizon.pvp.colour.inverted": "Інвертований",
"horizon.pvp.colour.white": "Білий",
"horizon.pvp.colour.red": "Червоний",
"horizon.pvp.colour.orange": "Помаранчевий",
"horizon.pvp.colour.yellow": "Жовтий",
"horizon.pvp.colour.green": "Зелений",
"horizon.pvp.colour.cyan": "Бірюзовий",
"horizon.pvp.colour.blue": "Синій",
"horizon.pvp.colour.magenta": "Пурпуровий",
"horizon.pvp.colour.black": "Чорний",
"horizon.pvp.shape.cross": "Хрест",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Коло",
"horizon.pvp.shape.square": "Квадрат",
"horizon.pvp.shape.dot": "Точка",
"horizon.pvp.outline": "Власне обведення блока",
"horizon.pvp.outline.tooltip": "Змінює обведення навколо блока, на який ви дивитеся.",
"horizon.pvp.outline_colour": "Колір",
"horizon.pvp.outline_colour.tooltip": "Колір обведення. Чорний на 40% — ванільний.",
"horizon.pvp.outline_opacity": "Непрозорість",
"horizon.pvp.outline_opacity.tooltip": "Наскільки щільне обведення.",
"horizon.pvp.outline_thickness": "Товщина",
"horizon.pvp.outline_thickness.tooltip": "Ширина обведення, у разах від ванільної.",
"horizon.pvp.outline_fill": "Заливка",
"horizon.pvp.outline_fill.tooltip": "Злегка підфарбовує грані блока, на який ви дивитеся.",
"horizon.pvp.hit": "Власний колір удару",
"horizon.pvp.hit.tooltip": "Змінює колір, яким спалахують моби й гравці, коли їм завдають шкоди. Червоний на 30% — ванільний.",
"horizon.pvp.hit_colour": "Колір",
"horizon.pvp.hit_colour.tooltip": "Колір, яким спалахують поранені моби й гравці.",
"horizon.pvp.hit_opacity": "Непрозорість",
"horizon.pvp.hit_opacity.tooltip": "Наскільки колір їх покриває.",
"horizon.pvp.header.outline": "Обведення блока",
"horizon.pvp.header.hit": "Колір удару",
"horizon.pvp.hud_armour": "Броня",
"horizon.pvp.hud_armour.tooltip": "Броня, яку ви носите, починаючи з голови, і скільки використань лишилося в кожного предмета.",
"horizon.pvp.hud_armour_corner": "Кут броні",
"horizon.pvp.hud_armour_corner.tooltip": "Де показується броня.",
"horizon.pvp.hud_armour_percent": "Міцність у %",
"horizon.pvp.hud_armour_percent.tooltip": "Залишок використань у відсотках від максимуму предмета замість числа.",
"horizon.pvp.hud_effects": "Ефекти",
"horizon.pvp.hud_effects.tooltip": "Ваші ефекти з назвами, рівнями й часом, що лишився, замість значків угорі праворуч.",
"horizon.pvp.hud_effects_corner": "Кут ефектів",
"horizon.pvp.hud_effects_corner.tooltip": "Де показуються ефекти.",
"horizon.pvp.hud_held": "Кількість предмета в руці",
"horizon.pvp.hud_held.tooltip": "Скільки предметів, як у вашій руці, є в інвентарі; з луком чи арбалетом — стріл.",
"horizon.pvp.hud_held_corner": "Кут кількості",
"horizon.pvp.hud_held_corner.tooltip": "Де показується кількість.",
"horizon.pvp.hud_background": "Фон віджетів",
"horizon.pvp.hud_background.tooltip": "Темна підкладка під кожним віджетом.",
"horizon.pvp.hud_opacity": "Непрозорість фону",
"horizon.pvp.hud_opacity.tooltip": "Наскільки темні підкладки під віджетами.",
"horizon.pvp.hud_text_colour": "Колір тексту",
"horizon.pvp.hud_text_colour.tooltip": "Колір тексту віджетів. Міцність лишається від зеленого до червоного.",
"horizon.pvp.hud_shadow": "Тінь тексту",
"horizon.pvp.hud_shadow.tooltip": "Тінь під текстом віджетів, як у тексту ванільного HUD.",
"horizon.pvp.header.hud": "Віджети HUD",
"horizon.pvp.corner.top_left": "Угорі ліворуч",
"horizon.pvp.corner.top_right": "Угорі праворуч",
"horizon.pvp.corner.bottom_left": "Унизу ліворуч",
"horizon.pvp.corner.bottom_right": "Унизу праворуч",
"horizon.pvp.hud_ping": "Пінг",
"horizon.pvp.hud_ping.tooltip": "Затримка, яку сервер повідомляє для вас, у мілісекундах. Лише в мережевій грі.",
"horizon.pvp.hud_ping_corner": "Кут пінгу",
"horizon.pvp.hud_ping_corner.tooltip": "Де показується пінг.",
"horizon.pvp.hud_coords": "Координати",
"horizon.pvp.hud_coords.tooltip": "Ваша позиція в блоках і напрямок погляду, як їх показує F3. Приховано, коли світ чи сервер скорочує F3.",
"horizon.pvp.hud_coords_corner": "Кут координат",
"horizon.pvp.hud_coords_corner.tooltip": "Де показуються координати.",
"horizon.pvp.hud_server": "Адреса сервера",
"horizon.pvp.hud_server.tooltip": "Адреса сервера, на якому ви граєте. Лише в мережевій грі.",
"horizon.pvp.hud_server_corner": "Кут сервера",
"horizon.pvp.hud_server_corner.tooltip": "Де показується адреса.",
"horizon.pvp.hud_combo": "Лічильник комбо",
"horizon.pvp.hud_combo.tooltip": "Удари поспіль, підтверджені сервером, доки вас не поранять або не мине 3 секунди без удару.",
"horizon.pvp.hud_combo_corner": "Кут комбо",
"horizon.pvp.hud_combo_corner.tooltip": "Де показується комбо.",
"horizon.pvp.hud.ping": "Пінг: %s мс",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Напрямок: %s",
"horizon.pvp.hud.combo": "Комбо: %s",
"horizon.pvp.hud.direction.north": "Північ",
"horizon.pvp.hud.direction.south": "Південь",
"horizon.pvp.hud.direction.east": "Схід",
"horizon.pvp.hud.direction.west": "Захід",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Кадрів за останню секунду.",
"horizon.pvp.hud_fps_corner": "Кут FPS",
"horizon.pvp.hud_fps_corner.tooltip": "Де показується FPS.",
"horizon.pvp.hud_cps": "Кліки за секунду",
"horizon.pvp.hud_cps.tooltip": "Ваші ліві й праві кліки за останню секунду.",
"horizon.pvp.hud_cps_corner": "Кут CPS",
"horizon.pvp.hud_cps_corner.tooltip": "Де показуються кліки за секунду.",
"horizon.pvp.hud_keys": "Натискання клавіш",
"horizon.pvp.hud_keys.tooltip": "Клавіші руху, кнопки миші та стрибок, які підсвічуються, поки ви їх тримаєте, і кліки за секунду.",
"horizon.pvp.hud_keys_corner": "Кут натискань",
"horizon.pvp.hud_keys_corner.tooltip": "Де показуються натискання клавіш.",
"horizon.pvp.hud_clock": "Годинник",
"horizon.pvp.hud_clock.tooltip": "Час на вашому комп’ютері.",
"horizon.pvp.hud_clock_corner": "Кут годинника",
"horizon.pvp.hud_clock_corner.tooltip": "Де показується годинник.",
"horizon.pvp.hud_reach": "Дальність",
"horizon.pvp.hud_reach.tooltip": "На якій відстані від ваших очей влучив останній удар, протягом 3 секунд. Це лише вимірювання: ваша дальність лишається ванільною.",
"horizon.pvp.hud_reach_corner": "Кут дальності",
"horizon.pvp.hud_reach_corner.tooltip": "Де показується дальність.",
"horizon.pvp.hud_speed": "Швидкість",
"horizon.pvp.hud_speed.tooltip": "Як швидко ви рухаєтеся по землі, у блоках за секунду.",
"horizon.pvp.hud_speed_corner": "Кут швидкості",
"horizon.pvp.hud_speed_corner.tooltip": "Де показується швидкість.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Дальність: %s",
"horizon.pvp.hud.speed": "Швидкість: %s б/с",
"horizon.pvp.hud.keys.attack": "ЛКМ",
"horizon.pvp.hud.keys.use": "ПКМ",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Рівні чар числами",
"horizon.pvp.numeric_enchants.tooltip": "Підказки та чарувальний стіл показують «Гострота 5» замість «Гострота V».",
"horizon.pvp.header.items": "Предмети",
"horizon.pvp.fire_lower": "Нижчий вогонь",
"horizon.pvp.fire_lower.tooltip": "Опускає полум’я, яке ви бачите, коли горите, щоб воно менше закривало екран.",
"horizon.pvp.shield_size": "Розмір щита",
"horizon.pvp.shield_size.tooltip": "Розмір щита у вашій руці від першої особи.",
"horizon.pvp.totem_size": "Розмір анімації тотема",
"horizon.pvp.totem_size.tooltip": "Розмір тотема, що заповнює екран, коли рятує вас.",
"horizon.pvp.glint_colour": "Колір блиску чар",
"horizon.pvp.glint_colour.tooltip": "Колір мерехтіння на зачарованих предметах і броні. Вимкнено — ванільний фіолетовий.",
"horizon.pvp.clear_water": "Прозора вода",
"horizon.pvp.clear_water.tooltip": "Під водою одразу бачите так далеко, як ванільна гра дозволяє лише після 30 секунд під водою.",
"horizon.pvp.clear_lava": "Прозора лава",
"horizon.pvp.clear_lava.tooltip": "У лаві бачите так далеко, як дозволяє ванільна Вогнестійкість, без цього ефекту.",
"horizon.pvp.header.visual": "Візуальні зміни",
"horizon.pvp.view_time": "Час доби",
"horizon.pvp.view_time.tooltip": "Час доби, який ви бачите: лише небо та його світло. Час у світі йде далі, і на сервер нічого не надсилається.",
"horizon.pvp.view_weather": "Погода",
"horizon.pvp.view_weather.tooltip": "Погода, яку ви бачите: лише дощ, його світло й туман. Погода у світі не змінюється, і на сервер нічого не надсилається.",
"horizon.pvp.header.view": "Що ви бачите",
"horizon.pvp.view.world": "Як у світі",
"horizon.pvp.time.morning": "Ранок",
"horizon.pvp.time.noon": "Полудень",
"horizon.pvp.time.sunset": "Захід сонця",
"horizon.pvp.time.midnight": "Опівніч",
"horizon.pvp.weather.clear": "Ясно",
"horizon.pvp.weather.rain": "Дощ",
"horizon.pvp.weather.thunder": "Гроза",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Чат",
"horizon.chat.copied": "Повідомлення скопійовано",
"horizon.chat.search": "Пошук у чаті:",
"horizon.pvp.chat_timestamps": "Позначки часу",
"horizon.pvp.chat_timestamps.tooltip": "Перед кожним повідомленням — час, коли воно надійшло.",
"horizon.pvp.chat_compact": "Компактний чат",
"horizon.pvp.chat_compact.tooltip": "Повідомлення, повторене кілька разів поспіль, показується один раз із кількістю повторів: (x3).",
"horizon.pvp.chat_copy": "Копіювання клацанням",
"horizon.pvp.chat_copy.tooltip": "Клацання по повідомленню у відкритому чаті копіює його текст, якщо клацання не робить чогось іншого.",
"horizon.pvp.chat_search": "Пошук у чаті",
"horizon.pvp.chat_search.tooltip": "Ctrl+F у відкритому чаті: показуються лише повідомлення, що містять введений текст. Enter або повторне Ctrl+F завершує пошук.",
"horizon.pvp.preset": "PvP-пресет",
"horizon.pvp.preset.tooltip": "Вмикає власний приціл, віджети броні й ефектів, перемикання бігу та скрадання, нижчий вогонь, а також моди лічильника FPS і натискань клавіш, якщо їх встановлено. Ваші налаштування кожного з них зберігаються.",
"horizon.pvp.hud_editor_key": "Клавіша редактора HUD",
"horizon.pvp.hud_editor_key.tooltip": "Відкриває редактор HUD із гри. Клацніть, потім натисніть клавішу; Escape скасовує призначення.",
"horizon.pvp.editor": "Редагувати HUD",
"horizon.pvp.editor.tooltip": "Розставте віджети просто в грі: перетягуйте куди завгодно, змінюйте розмір коліщатком, клацніть правою кнопкою, щоб повернути віджет у його кут.",
"horizon.pvp.editor.hint": "Перетягуйте віджети · Коліщатко: розмір · Права кнопка: назад у кут",
"horizon.pvp.combat_feel": "Відчуття бою",
"horizon.pvp.combat_feel.tooltip": "«Класичне» показує бої як у 1.8.9: предмет у руці, його змах, їжу, лук і піднятий меч, поки ви тримаєте «використати», без просідання після кожного удару та без індикатора атаки. Лише вигляд: те, що ви надсилаєте серверу, не змінюється.",
"horizon.pvp.combat_indicator": "Індикатор атаки в «Класичному»",
"horizon.pvp.combat_indicator.tooltip": "Залишає в «Класичному» індикатор атаки, якого в 1.8.9 не було.",
"horizon.pvp.combat_feel.modern": "Сучасне",
"horizon.pvp.combat_feel.legacy": "Класичне (1.8.9)",
"horizon.pvp.header.combat": "Відчуття бою",
"horizon.pvp.combat_sounds": "Звуки ударів 1.8",
"horizon.pvp.combat_sounds.tooltip": "У «Класичному» прибирає звуки атаки, додані в 1.9 (сильний, слабкий, розмашистий, критичний удар...). Звуки поранення лишаються.",
"horizon.pvp.combat_rules": "Бій 1.8 у ваших світах",
"horizon.pvp.combat_rules.tooltip": "В одиночній грі та у світах, відкритих для LAN, бої йдуть за правилами 1.8.9: без перезарядки атаки й розмашистого удару, зі шкодою зброї, Силою, Гостротою і критичними ударами як у 1.8. Сервери, до яких ви приєднуєтесь, лишають свої правила.",
"Bindings": "Призначення",
"Play": "Гра",
"Device": "Пристрій",
"Look": "Огляд",
"Movement": "Рух",
"Menus": "Меню",
"Actions": "Дії",
"Reset": "Скинути",
"Detected": "Виявлено",
"Glyphs": "Значки кнопок",
"Feedback": "Віддача",
"Controller": "Геймпад",
"How the sticks turn and move you, and how menus repeat and scroll.": "Як стіки повертають і рухають вас, а меню повторюють і прокручують.",
"Which button does what. Each player has their own.": "Яка кнопка що робить. У кожного гравця — свої.",
"The controller the game sees, its button glyphs and its feedback.": "Геймпад, який бачить гра, значки його кнопок і віддача.",
"Press this row, then the button that should do it.": "Натисніть цей рядок, потім кнопку, яка має це робити.",
"Off, the game ignores every controller for this player.": "Вимкнено: гра ігнорує всі геймпади для цього гравця.",
"How fast the right stick turns the view.": "Як швидко правий стік повертає огляд.",
"Turn the view the other way, left and right.": "Повертати огляд у протилежний бік ліворуч і праворуч.",
"Turn the view the other way, up and down.": "Повертати огляд у протилежний бік угору і вниз.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Наскільки має відхилитися правий стік, перш ніж огляд почне повертатися. Збільште, якщо огляд сам зсувається.",
"Hold the button to sneak, or press it once to start and again to stop.": "Тримайте кнопку, щоб скрадатися, або натисніть раз, щоб почати, і ще раз, щоб припинити.",
"How far the left stick moves before you walk. Raise it if you drift.": "Наскільки має відхилитися лівий стік, перш ніж ви підете. Збільште, якщо вас самого зносить.",
"How far a trigger is pulled before it counts as pressed.": "Наскільки треба натиснути тригер, щоб це вважалося натисканням.",
"How fast the stick moves the crosshair in inventories.": "Як швидко стік рухає курсор в інвентарях.",
"How fast the right stick scrolls a list.": "Як швидко правий стік прокручує список.",
"How long a held direction waits before it repeats.": "Скільки утримуваний напрямок чекає перед першим повтором.",
"How long a held direction takes between repeats.": "Скільки часу минає між повторами утримуваного напрямку.",
"Which on-screen keyboard opens when a text field is pressed.": "Яка екранна клавіатура відкривається, коли натискаєте текстове поле.",
"Every action back on its default button.": "Повернути всі дії на типові кнопки.",
"These settings belong to this player's profile.": "Ці налаштування належать профілю цього гравця.",
"The controller driving this player, or else the first one connected.": "Геймпад, яким керує цей гравець, або інакше перший підключений.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Виробник і продукт USB, які повідомляє система. Знадобиться, коли повідомляєте про геймпад, що працює неправильно.",
"The glyph family the game picked from the controller's maker.": "Набір значків, який гра обрала за виробником геймпада.",
"Follow the controller, or always show one family's buttons.": "Як у підключеного геймпада або завжди кнопки одного набору.",
"The buttons menus show right now.": "Кнопки, які меню показують зараз.",
"Rumble when something happens to you.": "Вібрація, коли з вами щось стається.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Геймпад працює, навіть коли це вікно не у фокусі, а втрата фокусу не ставить гру на паузу.",
"A DualSense's triggers push back on bows and tools.": "Тригери DualSense чинять опір, коли ви стріляєте з лука чи працюєте інструментами.",
"What a DualSense's light shows, over USB or Bluetooth.": "Що показує підсвічування DualSense через USB або Bluetooth.",
"Player profile": "Профіль гравця",
"Vendor and product": "Виробник і продукт",
"Detected glyphs": "Виявлені значки",
"Glyphs in use": "Поточні значки",
"Unknown": "Невідомо",
"No controller": "Немає геймпада",
"Editing: %s": "Редагування: %s",
"Press the button for this. %s cancels.": "Натисніть кнопку для цього. %s — скасувати.",
"This button also does: %s": "Ця кнопка також робить: %s",
"horizon.pad.notice.connected.press": "Натисніть, щоб використовувати",
"horizon.recipe_transfer.missing": "Бракує предметів",
"horizon.recipe_transfer.no_room": "Інвентар заповнено",
"horizon.recipe_transfer.unavailable": "Рецепт не підходить для цього меню",
"horizon.recipe_transfer.unsupported": "Сервер вимагає відомого рецепта"
}
@@ -195,6 +195,7 @@
"horizon.pages.shader_options": "光影选项",
"horizon.pages.split_profiles": "谁在玩?",
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
"horizon.plain.official": "带启动器、模组和更新的官方 Horizon:playhorizon.dev",
"horizon.shader_options.profile": "档案",
"General": "常规",
"Advanced": "高级",
+12
View File
@@ -7,6 +7,18 @@ fn main() {
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Client)
.expect("generating native mod API");
// A workspace horizon-mixins changed holds native mods, whichever tool ran it (the launcher,
// or a mod's own script with no `native-mods.json` beside its game): `HORIZON_MIXIN_MODS`
// names them for `game::mods::built_with_native_mods`. Watched only once it exists: a
// bundle applied in place also edits `game/`, which `generate` watches.
let stage = root.join(horizon_mod_api_gen::MIXINS_STAGE);
if stage.is_file() {
println!("cargo:rerun-if-changed={}", stage.display());
}
let staged = horizon_mod_api_gen::staged_mods(&root);
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
let ico = manifest_dir
.join("../..")
@@ -1,7 +1,7 @@
//! Headless transport for the block-entity render parity gate: replays each
//! case (block state, light, partial tick and ticked block-entity fields)
//! through `BlockEntityRenderer` at the block origin and writes the quads
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
//! (texture, sprite-local UVs, tint, normal, light) and sign text calls.
#![forbid(unsafe_code)]
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_client::{
@@ -417,7 +417,8 @@ pub fn main() -> Result<()> {
])
})
.collect();
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light,
"normal": q[0].normal})
})
.collect();
let texts: Vec<Value> = texts
@@ -0,0 +1,96 @@
//! Brewing parity (tools/test_brewing_parity.py): reads "input reagent
//! candidate" lines of item SNBT and prints the three brewing-slot filters,
//! the matching recipe and its assembled result; "tick" lines build a
//! BrewingStand, run it for their tick count, and print the same state the
//! real BrewingStandBlockEntity.serverTick oracle prints.
//!
//! The first input line is `#resources RESOURCES`; usage: brewing_fixture INPUT OUTPUT
use horizon_client::{
brewing::{Brewing, BrewingStand, brewing_fuel},
item_data::{canon_stack, parse_stack},
items::{ItemStack, Items},
};
use horizon_client_render::providers::Providers;
use horizon_world::loot::PackOverlay;
use std::sync::Arc;
use std::{fmt::Write as _, fs};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
let root = input
.lines()
.find_map(|line| line.strip_prefix("#resources "))
.map(std::path::PathBuf::from)
.ok_or("no #resources line")?;
let items = Items::catalog(&root, &PackOverlay::NONE)?;
let brewing = Brewing::load(&root, &items, &PackOverlay::NONE);
let providers = Providers::load(&root);
let mut out = String::new();
for (n, line) in input
.lines()
.filter(|line| !line.starts_with('#'))
.enumerate()
{
let f: Vec<&str> = line.split(' ').collect();
if f[0] == "tick" {
let ticks: u32 = f[1].parse()?;
let mut slots: [Option<ItemStack>; 5] = Default::default();
for i in 0..5 {
slots[i] = parse_stack(f[2 + i])?;
}
let mut stand = BrewingStand::new();
stand.items = slots;
stand.brew_time = f[7].parse()?;
stand.fuel = f[8].parse()?;
stand.total_brew_time = f[9].parse()?;
stand.total_fuel = f[10].parse()?;
stand.speed = f[11].parse()?;
stand.ingredient = (f[12] != "-").then(|| Arc::<str>::from(f[12]));
let mut brews = 0;
for _ in 0..ticks {
brews += stand.tick(&brewing, &items, &providers).brewed as u32;
}
let bits: String = stand.items[..3]
.iter()
.map(|s| if s.is_some() { '1' } else { '0' })
.collect();
writeln!(
out,
"{n} s0={} s1={} s2={} s3={} s4={} time={} fuel={} total={} totalfuel={} speed={:.3} bits={bits} ing={} brews={brews}",
canon_stack(stand.items[0].as_ref()),
canon_stack(stand.items[1].as_ref()),
canon_stack(stand.items[2].as_ref()),
canon_stack(stand.items[3].as_ref()),
canon_stack(stand.items[4].as_ref()),
stand.brew_time,
stand.fuel,
stand.total_brew_time,
stand.total_fuel,
stand.speed,
stand.ingredient.as_deref().unwrap_or("-"),
)?;
continue;
}
let input = parse_stack(f[0])?;
let reagent = parse_stack(f[1])?;
let candidate = parse_stack(f[2])?;
let potion_slot = candidate.as_ref().is_some_and(|s| brewing.is_bottle(s));
let reagent_slot = candidate.as_ref().is_some_and(|s| brewing.is_reagent(s));
let fuel_slot = candidate
.as_ref()
.is_some_and(|s| brewing_fuel(&items, s).is_some());
let found = match (input.as_ref(), reagent.as_ref()) {
(Some(input), Some(reagent)) => brewing.find(input, reagent),
_ => None,
};
let result = found.map_or_else(|| "-".to_owned(), |r| canon_stack(Some(&r.output)));
let id = found.map_or_else(|| "-".to_owned(), |r| r.id.clone());
writeln!(
out,
"{n} {potion_slot} {reagent_slot} {fuel_slot} {result} {id}"
)?;
}
fs::write(args.get(2).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,79 @@
//! Rust side of tools/test_enchant_durability_parity.py: `Enchantments` value
//! effects for the `minecraft:item_damage` (Unbreaking) and
//! `minecraft:repair_with_xp` (Mending) components on one stack per case.
use horizon_client::{
enchant::{EffectContext, Enchantments},
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_core::random::LegacyRandom;
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{fs, io::Write, path::Path};
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
let root = input
.lines()
.find_map(|line| line.strip_prefix("#resources "))
.map(Path::new)
.ok_or("no #resources line")?;
let enchantments = Enchantments::load(root);
let items = Items::catalog(root, &PackOverlay::NONE)?;
let out = fs::File::create(args.get(2).ok_or("missing OUTPUT")?)?;
let mut out = std::io::BufWriter::new(out);
for line in input.lines() {
if line.starts_with("#resources ") || line.trim().is_empty() {
continue;
}
let case: Value = serde_json::from_str(line)?;
let item = case["item"].as_str().unwrap();
let level = case["level"].as_u64().unwrap() as u32;
let component = if case["mode"] == "unbreaking" {
"minecraft:item_damage"
} else {
"minecraft:repair_with_xp"
};
let stack = ItemStack {
item: item.into(),
count: 1,
damage: case["damage"].as_u64().unwrap() as u32,
extra: Some(Box::new(StackData {
enchantments: vec![(
component_enchantment(case["mode"].as_str().unwrap()).into(),
level,
)],
..Default::default()
})),
};
let ctx = EffectContext {
tool: Some(&stack.item),
items: Some(&items),
..Default::default()
};
let mut random = LegacyRandom::new(case["seed"].as_i64().unwrap());
let value = enchantments.modify_value(
Some(&stack),
component,
&ctx,
&mut random,
case["amount"].as_i64().unwrap() as f32,
);
let value = if case["mode"] == "mending" {
value.max(0.0)
} else {
value
};
writeln!(out, "{}", value as i64)?;
}
Ok(())
}
fn component_enchantment(mode: &str) -> &'static str {
if mode == "unbreaking" {
"minecraft:unbreaking"
} else {
"minecraft:mending"
}
}
@@ -0,0 +1,76 @@
//! Environment attribute entries for tools/test_environment_attribute_modifier_parity.py: each
//! case parses an entry for an attribute and applies it to a subject value.
use horizon_world::environment_attributes::{ATTRIBUTES, Attribute, Entry, Kind, Value, attribute};
use std::{error::Error, fmt::Write};
fn bits(f: f32) -> String {
format!("{:08x}", f.to_bits())
}
fn format(value: &Value) -> String {
match value {
Value::Bool(b) => b.to_string(),
Value::Float(f) => bits(*f),
Value::Rgb(v) => v.map(bits).join(" "),
Value::Argb(v) => v.map(bits).join(" "),
Value::Other(json) => json.to_string(),
}
}
fn run(attribute: &Attribute, subject: &str, entry: &str) -> Result<String, Box<dyn Error>> {
let subject = match Entry::parse(attribute, &serde_json::from_str(subject)?)? {
Entry::Override(value) => value,
_ => return Err("the subject is not a plain value".into()),
};
let Ok(entry) = Entry::parse(attribute, &serde_json::from_str(entry)?) else {
return Ok("error".into());
};
let value = entry.apply(&subject);
Ok(format!(
"{}\t{}",
format(&value),
format(&attribute.sanitize(value.clone()))
))
}
/// Every attribute in registration order: id, type, default, and how a float range clamps.
fn registry(output: &mut String) -> std::fmt::Result {
for a in &ATTRIBUTES {
let kind = match a.kind {
Kind::Boolean => "minecraft:boolean",
Kind::Float => "minecraft:float",
Kind::AngleDegrees => "minecraft:angle_degrees",
Kind::RgbColor => "minecraft:rgb_color",
Kind::ArgbColor => "minecraft:argb_color",
Kind::Other => "other",
};
let default = a.default_value();
let range = match default {
Some(Value::Float(_)) => format!(
"{} {}",
format(&a.sanitize(Value::Float(f32::NEG_INFINITY))),
format(&a.sanitize(Value::Float(f32::INFINITY)))
),
_ => "-".into(),
};
let default = default.map_or("unported".into(), |v| format(&v));
writeln!(output, "minecraft:{}\t{kind}\t{default}\t{range}", a.id)?;
}
Ok(())
}
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
if line == "registry" {
registry(&mut output)?;
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
let attribute = attribute(fields[0]).ok_or("unknown attribute")?;
writeln!(output, "{}", run(attribute, fields[1], fields[2])?)?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
Loaded 100 of 887 files, more files were not shown because too many files have changed in this diff. Show more