mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Merge main into fix-11722-old-pick-item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
fee1085bec
887 files changed
+84176
-15757
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<()>| {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¯os,
|
||||
&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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "高级",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user