mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
horizon-prepare: --java-home, log the jar read, document named inputs (#3545)
- Merges #3574 (and with it #3567 and #3551) for docs/api/launch.md. - --java-home names a Java the launcher manages (Options::java_home was there for horizon-launcher only). - The import logs the client jar it read, which --client-jar may have named outside the Minecraft folder. - named_asset_store_serves_the_asset_commands: with an empty Minecraft folder, an asset command fails without --assets-dir and copies from the store it names. - docs/api/launch.md and native-client.md: the named inputs, that an earlier verified import is reused instead, the server jar rule (Horizon never downloads it; a launcher only for an owner), --java-home. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
78db89ba16
430 files changed
+29282
-6781
No files matched your search
@@ -1,3 +1,5 @@
|
||||
<!-- Opened by an AI agent? First line: "> 🤖 Opened by <your GitHub login>'s AI agent (<tool, model>), not by <your GitHub login> personally." Use your own login, not someone else's. -->
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
|
||||
@@ -16,12 +16,14 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
run: |
|
||||
# Every head the pull request had, not only its last: a run-parity label added on an older head left that
|
||||
# run queued after the merge (#3104, 2026-10-29). Its branch in its own repository names its runs (another
|
||||
# fork can have a branch of the same name).
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
|
||||
--json databaseId,event,headSha \
|
||||
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
|
||||
gh api -X GET "repos/$REPO/actions/runs" -f branch="$BRANCH" -f event=pull_request -f status="$status" \
|
||||
-f per_page=100 --jq ".workflow_runs[] | select(.head_repository.full_name == \"$HEAD_REPO\") | .id"
|
||||
done | sort -u | while read -r id; do
|
||||
gh run cancel -R "$REPO" "$id" || true
|
||||
done
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
@@ -71,11 +71,11 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-mods/pack
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --features horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-mods/pack
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -37,9 +37,10 @@ permissions:
|
||||
jobs:
|
||||
exporters:
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
|
||||
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
|
||||
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
|
||||
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
|
||||
# goes to the Mac mini's nightly runner (macOS + horizon-nightly: ludovics-mac-mini-nightly, beside the one that
|
||||
# runs pull requests' parity jobs, so a nightly never waits behind their queue), never the release runner
|
||||
# (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
|
||||
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]') }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -103,7 +104,7 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }}
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
@@ -137,8 +138,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
@@ -266,9 +266,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
|
||||
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
|
||||
@@ -5,11 +5,15 @@ name: run-parity
|
||||
# skipped `verify`, and GitHub counts a skipped required check as passed.
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
# A push starts a run only to cancel the old head's (see concurrency): the job runs on the label alone.
|
||||
types: [labeled, synchronize]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: run-parity-${{ github.event.pull_request.number }}
|
||||
# The run-parity label and pushes share one group per pull request, so the newest run cancels the one before: a
|
||||
# pushed pull request's run tested a head the merge would no longer land. Any other label gets a group of its own:
|
||||
# in the shared one, adding needs-proof cancelled the parity run queued seconds before (run 40766180150).
|
||||
group: run-parity-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-parity' && format('-{0}', github.run_id) || '' }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
|
||||
@@ -13,13 +13,15 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
|
||||
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
|
||||
the tool: `> 🤖 Opened by <login>'s AI agent (<tool, model>), not by <login> personally.`, where `<login>`
|
||||
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
|
||||
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
|
||||
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Ludovic's agents use
|
||||
`Co-Authored-By: Claude <model> <[email protected]>` and
|
||||
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
`Co-Authored-By: Claude <model> <[email protected]>`, last line
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
@@ -74,7 +76,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
@@ -167,17 +169,18 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
|
||||
Generated
+3
@@ -1527,6 +1527,9 @@ name = "horizon-hdr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,15 +23,15 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -46,9 +46,9 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -139,6 +139,11 @@ average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
|
||||
them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
|
||||
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
|
||||
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
|
||||
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
|
||||
|
||||
+111
-30
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
|
||||
**Next**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -104,37 +104,49 @@ More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native mods, built by the launcher
|
||||
|
||||
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods that run on the server
|
||||
|
||||
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
|
||||
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Screens, input and drawing for mods
|
||||
|
||||
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
|
||||
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
|
||||
|
||||
</details>
|
||||
|
||||
#### Tools for people who make mods
|
||||
|
||||
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
|
||||
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
|
||||
|
||||
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -244,6 +256,20 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Minecraft Java instances in the launcher
|
||||
|
||||
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
|
||||
|
||||
More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
@@ -268,6 +294,16 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
|
||||
|
||||
</details>
|
||||
|
||||
#### Every vanilla method, tracked
|
||||
|
||||
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
|
||||
|
||||
</details>
|
||||
|
||||
## Next
|
||||
|
||||
### Performance
|
||||
@@ -444,6 +480,16 @@ A player's idea (#horizon-ideas). `horizon-server` already runs headless; left:
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
@@ -452,9 +498,9 @@ Mods that can see and change blocks, read inventories and items, and find and af
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
|
||||
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
|
||||
|
||||
More: [docs/modpacks.md](docs/modpacks.md)
|
||||
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -468,16 +514,6 @@ Block entities and menus: ticking machines with slots, server menu logic and the
|
||||
|
||||
</details>
|
||||
|
||||
#### Lua and other languages for mods
|
||||
|
||||
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
@@ -536,15 +572,13 @@ More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Microsoft sign-in (coming soon)
|
||||
#### A new launcher design
|
||||
|
||||
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
|
||||
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
|
||||
|
||||
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
|
||||
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -708,6 +742,18 @@ Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.
|
||||
|
||||
More: [#3023](https://github.com/horizon-rust/horizon/issues/3023)
|
||||
|
||||
</details>
|
||||
|
||||
#### Online-mode checks for Play with Friends
|
||||
|
||||
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
|
||||
@@ -720,6 +766,18 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Scripts for mods
|
||||
|
||||
Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
@@ -732,13 +790,13 @@ A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/
|
||||
|
||||
#### Native versions of popular client mods
|
||||
|
||||
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
|
||||
Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
|
||||
Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
More: [docs/extras.md](docs/extras.md) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -784,6 +842,16 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
|
||||
|
||||
</details>
|
||||
|
||||
#### Splitscreen
|
||||
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
@@ -834,7 +902,7 @@ More graphics options: multisample anti-aliasing, anisotropic filtering and an e
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -862,6 +930,18 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Windows ARM64
|
||||
|
||||
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.
|
||||
|
||||
More: [docs/release-flow.md](docs/release-flow.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
@@ -968,6 +1048,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
|
||||
@@ -11,13 +11,18 @@ pub fn parse_snbt(input: &str) -> Result<(Value, usize), String> {
|
||||
let mut p = Parser {
|
||||
s: input.as_bytes(),
|
||||
i: 0,
|
||||
depth: 0,
|
||||
};
|
||||
let v = p.value()?;
|
||||
Ok((v, p.i))
|
||||
}
|
||||
/// Nesting deeper than NBT's own limit (`NbtAccounter`'s 512) is refused
|
||||
/// rather than overflowing the stack.
|
||||
const SNBT_MAX_DEPTH: usize = 512;
|
||||
struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
}
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
@@ -41,57 +46,22 @@ impl Parser<'_> {
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b'{') => {
|
||||
self.i += 1;
|
||||
let mut map = Map::new();
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
loop {
|
||||
self.ws();
|
||||
let key = self.string()?;
|
||||
self.expect(b':')?;
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
_ => return Err("expected ',' or '}'".into()),
|
||||
}
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.object();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'[') => {
|
||||
self.i += 1;
|
||||
let mut list = Vec::new();
|
||||
self.ws();
|
||||
// Typed arrays [B;...], [I;...], [L;...].
|
||||
if matches!(
|
||||
self.s.get(self.i..self.i + 2),
|
||||
Some([b'B' | b'I' | b'L', b';'])
|
||||
) {
|
||||
self.i += 2;
|
||||
}
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
loop {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
_ => return Err("expected ',' or ']'".into()),
|
||||
}
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.array();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
|
||||
Some(_) => {
|
||||
@@ -105,10 +75,20 @@ impl Parser<'_> {
|
||||
_ => {
|
||||
let digits = word
|
||||
.trim_end_matches(['b', 'B', 's', 'S', 'l', 'L', 'f', 'F', 'd', 'D']);
|
||||
match (digits.parse::<i64>(), digits.parse::<f64>()) {
|
||||
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
|
||||
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
|
||||
_ => Value::String(word),
|
||||
// Only digits (and an exponent) make a number: `nan`
|
||||
// or `Infinity`, which Rust's parse would take, stays
|
||||
// a string.
|
||||
if digits
|
||||
.bytes()
|
||||
.any(|c| c.is_ascii_alphabetic() && c != b'e' && c != b'E')
|
||||
{
|
||||
Value::String(word)
|
||||
} else {
|
||||
match (digits.parse::<i64>(), digits.parse::<f64>()) {
|
||||
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
|
||||
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
|
||||
_ => Value::String(word),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -116,6 +96,59 @@ impl Parser<'_> {
|
||||
None => Err("expected a value".into()),
|
||||
}
|
||||
}
|
||||
fn object(&mut self) -> Result<Value, String> {
|
||||
self.i += 1;
|
||||
let mut map = Map::new();
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
loop {
|
||||
self.ws();
|
||||
let key = self.string()?;
|
||||
self.expect(b':')?;
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
_ => return Err("expected ',' or '}'".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn array(&mut self) -> Result<Value, String> {
|
||||
self.i += 1;
|
||||
let mut list = Vec::new();
|
||||
self.ws();
|
||||
// Typed arrays [B;...], [I;...], [L;...].
|
||||
if matches!(
|
||||
self.s.get(self.i..self.i + 2),
|
||||
Some([b'B' | b'I' | b'L', b';'])
|
||||
) {
|
||||
self.i += 2;
|
||||
}
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
loop {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
_ => return Err("expected ',' or ']'".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn unquoted(&mut self) -> String {
|
||||
let start = self.i;
|
||||
while let Some(c) = self.peek() {
|
||||
@@ -351,4 +384,22 @@ mod tests {
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.spans[0].style.font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snbt_nesting_is_bounded() {
|
||||
let nest = |n: usize| format!("{}1{}", "[".repeat(n), "]".repeat(n));
|
||||
assert!(parse_snbt(&nest(512)).is_ok());
|
||||
assert!(parse_snbt(&nest(513)).is_err());
|
||||
assert!(parse_snbt(&nest(100_000)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_words_that_look_numeric_stay_strings() {
|
||||
let (v, _) = parse_snbt("{text:nan}").unwrap();
|
||||
assert_eq!(v["text"], Value::String("nan".into()));
|
||||
let (v, _) = parse_snbt("{text:Infinity}").unwrap();
|
||||
assert_eq!(v["text"], Value::String("Infinity".into()));
|
||||
let (v, _) = parse_snbt("{n:1.5}").unwrap();
|
||||
assert_eq!(v["n"], Value::from(1.5));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -266,6 +269,11 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
let uk = Language::load(&[&empty], "uk_ua");
|
||||
assert_eq!(
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let key = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
@@ -102,7 +103,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ pub struct Registries {
|
||||
pub function_tags: Vec<String>,
|
||||
/// The block the source is looking at (relevant coordinates).
|
||||
pub target_block: Option<[i32; 3]>,
|
||||
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
|
||||
/// entity under the crosshair (`Entity.getStringUUID`), if any.
|
||||
pub selected_entities: Vec<String>,
|
||||
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
|
||||
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
|
||||
pub target_point: Option<[String; 3]>,
|
||||
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
|
||||
/// the server's registry access and reloadable registries hold them.
|
||||
pub by_registry: BTreeMap<String, RegistryIds>,
|
||||
|
||||
@@ -662,10 +662,22 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
|
||||
}
|
||||
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
|
||||
}
|
||||
/// `ServerPlayer.die`: whether a player's death message reaches the player
|
||||
/// themselves (the team broadcasts leave out the dying player).
|
||||
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
|
||||
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
|
||||
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
|
||||
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
|
||||
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
|
||||
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
|
||||
let Some(team) = board.players_team(dying) else {
|
||||
return true;
|
||||
};
|
||||
let same_team = board
|
||||
.players_team(other)
|
||||
.is_some_and(|t| t.name == team.name);
|
||||
match team.death_message_visibility {
|
||||
Visibility::Always => true,
|
||||
Visibility::Never => false,
|
||||
Visibility::HideForOtherTeams => same_team && other != dying,
|
||||
Visibility::HideForOwnTeam => !same_team,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- objectives and scores ---------------------------------------------
|
||||
|
||||
@@ -403,31 +403,7 @@ impl Text {
|
||||
continue;
|
||||
}
|
||||
let Some(code) = chars.next() else { break };
|
||||
let code = code.to_ascii_lowercase();
|
||||
let next = if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
|
||||
// Style.applyLegacyFormat: a colour clears the decorations.
|
||||
Style {
|
||||
color: Some(c.2),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underlined: Some(false),
|
||||
strikethrough: Some(false),
|
||||
obfuscated: Some(false),
|
||||
..style.clone()
|
||||
}
|
||||
} else {
|
||||
let mut s = style.clone();
|
||||
match code {
|
||||
'k' => s.obfuscated = Some(true),
|
||||
'l' => s.bold = Some(true),
|
||||
'm' => s.strikethrough = Some(true),
|
||||
'n' => s.underlined = Some(true),
|
||||
'o' => s.italic = Some(true),
|
||||
'r' => s = span.style.clone(),
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
};
|
||||
let next = legacy_format(&style, code, &span.style);
|
||||
if next != style {
|
||||
if !current.is_empty() {
|
||||
out.spans.push(Span {
|
||||
@@ -588,6 +564,35 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Style.applyLegacyFormat` for the code after a `§`, `§r` going back to
|
||||
/// `reset` (`StringDecomposer.iterateFormatted`); an unknown code changes nothing.
|
||||
pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
|
||||
let code = code.to_ascii_lowercase();
|
||||
if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
|
||||
// A colour clears the decorations.
|
||||
return Style {
|
||||
color: Some(c.2),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underlined: Some(false),
|
||||
strikethrough: Some(false),
|
||||
obfuscated: Some(false),
|
||||
..style.clone()
|
||||
};
|
||||
}
|
||||
let mut s = style.clone();
|
||||
match code {
|
||||
'k' => s.obfuscated = Some(true),
|
||||
'l' => s.bold = Some(true),
|
||||
'm' => s.strikethrough = Some(true),
|
||||
'n' => s.underlined = Some(true),
|
||||
'o' => s.italic = Some(true),
|
||||
'r' => s = reset.clone(),
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
|
||||
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
|
||||
/// shown verbatim.
|
||||
|
||||
@@ -60,6 +60,19 @@ pub fn edit_shortcut(
|
||||
})
|
||||
}
|
||||
|
||||
/// Windows reports AltGr as Ctrl plus Alt: the system presses a left Control
|
||||
/// in front of the right Alt.
|
||||
pub const ALTGR_IS_CTRL_ALT: bool = cfg!(windows);
|
||||
|
||||
/// Whether a key's text reaches the screen (`KeyboardHandler.textInput`, then
|
||||
/// `charTyped` per code point). The edit modifier's shortcuts type nothing,
|
||||
/// but `textInput` takes no modifiers: with AltGr held (Ctrl plus Alt where
|
||||
/// `altgr_is_ctrl_alt`) the layout's third-level characters are text, as `/`
|
||||
/// on ABNT2, `@`, `{` and `[` on German or the Polish letters.
|
||||
pub fn types_text(edit_modifier: bool, alt: bool, altgr_is_ctrl_alt: bool) -> bool {
|
||||
!edit_modifier || (altgr_is_ctrl_alt && alt)
|
||||
}
|
||||
|
||||
/// `StringUtil.isAllowedChatCharacter`.
|
||||
pub fn is_allowed_chat_character(c: char) -> bool {
|
||||
c != '§' && c >= ' ' && c != '\u{7f}'
|
||||
@@ -303,6 +316,22 @@ pub fn append_limited(value: &mut String, text: &str, max_chars: usize) -> bool
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn altgr_types_text_where_it_is_ctrl_alt() {
|
||||
// Plain keys and Shift type.
|
||||
assert!(types_text(false, false, true));
|
||||
assert!(types_text(false, false, false));
|
||||
// Ctrl (Cmd) shortcuts do not.
|
||||
assert!(!types_text(true, false, true));
|
||||
assert!(!types_text(true, false, false));
|
||||
// AltGr on Windows is Ctrl+Alt: ABNT2 `/`, German `@` type.
|
||||
assert!(types_text(true, true, true));
|
||||
// Where AltGr is its own modifier, Ctrl+Alt is a shortcut.
|
||||
assert!(!types_text(true, true, false));
|
||||
// Alt alone (macOS Option, Linux AltGr) types.
|
||||
assert!(types_text(false, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_need_the_edit_modifier_alone() {
|
||||
let c = Some('c');
|
||||
|
||||
@@ -300,9 +300,8 @@ impl Default for KeyBinds {
|
||||
}
|
||||
}
|
||||
/// Each default key once, in order: two mappings that share a default key
|
||||
/// (Advancements and the debug profiler both on L) are one press of it, not
|
||||
/// two (the second would close the screen the first opened). Vanilla clicks
|
||||
/// both mappings, but the profiler one only acts with F3 held.
|
||||
/// (Pick Block and the spectator hotbar both on the middle button) are one
|
||||
/// press of it, not two.
|
||||
fn distinct<T: PartialEq>(keys: Vec<T>) -> Vec<T> {
|
||||
let mut out = Vec::with_capacity(keys.len());
|
||||
for k in keys {
|
||||
@@ -368,6 +367,25 @@ impl KeyBinds {
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
}
|
||||
/// The mappings bound to `key` that [`KeyBinds::translate_all`] follows:
|
||||
/// all but the debug keys. Those act only with F3 held
|
||||
/// (`KeyboardHandler.handleDebugKeys`, `debug_keys.rs` reads the physical
|
||||
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
|
||||
/// would otherwise turn the key back into its old gameplay meaning.
|
||||
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.filter(|m| m.2 != "debug")
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
}
|
||||
/// Whether a mapping (other than a debug key) whose default is `key` was
|
||||
/// bound elsewhere.
|
||||
fn default_moved(&self, key: &str) -> bool {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
|
||||
}
|
||||
/// Translate a physical key into the default key of the mapping it is
|
||||
/// bound to, so game code written against vanilla defaults follows the
|
||||
/// player's bindings. `None` means the key now does nothing; mouse
|
||||
@@ -384,7 +402,7 @@ impl KeyBinds {
|
||||
return vec![Ok(key)];
|
||||
};
|
||||
let bound: Vec<_> = self
|
||||
.mappings_for(name)
|
||||
.translated_mappings(name)
|
||||
.filter_map(|mapping| {
|
||||
let default = Self::default_of(mapping);
|
||||
match key_code(default) {
|
||||
@@ -397,10 +415,7 @@ impl KeyBinds {
|
||||
return distinct(bound);
|
||||
}
|
||||
// An unbound key whose default meaning was moved elsewhere is inert.
|
||||
if MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, _)| *d == name && !self.is_default(n))
|
||||
{
|
||||
if self.default_moved(name) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Ok(key)]
|
||||
@@ -413,7 +428,7 @@ impl KeyBinds {
|
||||
pub fn translate_mouse_all(&self, button: MouseButton) -> Vec<Result<KeyCode, MouseButton>> {
|
||||
let name = mouse_name(button);
|
||||
let bound: Vec<_> = self
|
||||
.mappings_for(&name)
|
||||
.translated_mappings(&name)
|
||||
.filter_map(|mapping| {
|
||||
let default = Self::default_of(mapping);
|
||||
match mouse_button(default) {
|
||||
@@ -425,10 +440,7 @@ impl KeyBinds {
|
||||
if !bound.is_empty() {
|
||||
return distinct(bound);
|
||||
}
|
||||
if MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, _)| *d == name && !self.is_default(n))
|
||||
{
|
||||
if self.default_moved(&name) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Err(button)]
|
||||
@@ -508,6 +520,29 @@ mod tests {
|
||||
assert_eq!(k.conflicts("key.forward"), ["key.jump"]);
|
||||
}
|
||||
#[test]
|
||||
fn a_moved_key_keeps_no_default_action() {
|
||||
// #3158: the debug keys (F3+A, F3+1...) share these keys' defaults,
|
||||
// and must not hand them back their gameplay meaning.
|
||||
let mut k = KeyBinds::default();
|
||||
k.set("key.hotbar.1", "key.keyboard.q");
|
||||
assert_eq!(k.translate_all(KeyCode::Digit1), []);
|
||||
assert_eq!(
|
||||
k.translate_all(KeyCode::KeyQ),
|
||||
[Ok(KeyCode::KeyQ), Ok(KeyCode::Digit1)]
|
||||
);
|
||||
k.set("key.inventory", "key.keyboard.a");
|
||||
k.set("key.left", "key.keyboard.e");
|
||||
assert_eq!(k.translate_all(KeyCode::KeyA), [Ok(KeyCode::KeyE)]);
|
||||
assert_eq!(k.translate_all(KeyCode::KeyE), [Ok(KeyCode::KeyA)]);
|
||||
k.set("key.back", "key.keyboard.down");
|
||||
assert_eq!(k.translate_all(KeyCode::KeyS), []);
|
||||
// Keys left alone still act, and a moved debug key changes nothing.
|
||||
k.set("key.debug.fpsCharts", "key.keyboard.9");
|
||||
assert_eq!(k.translate_all(KeyCode::Digit2), [Ok(KeyCode::Digit2)]);
|
||||
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
|
||||
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
|
||||
}
|
||||
#[test]
|
||||
fn display_names() {
|
||||
assert_eq!(display_name("key.keyboard.w").plain(), "W");
|
||||
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Two offscreen frames presented in one window, without game resources or player data.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
|
||||
use horizon_client_render::gfx::compositor::Compositor;
|
||||
use std::sync::Arc;
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
struct Frames {
|
||||
window: Arc<Window>,
|
||||
compositor: Compositor,
|
||||
textures: [wgpu::Texture; 2],
|
||||
}
|
||||
|
||||
impl Frames {
|
||||
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
|
||||
let window = Arc::new(
|
||||
event_loop.create_window(
|
||||
Window::default_attributes()
|
||||
.with_title("Compositor: red and blue views")
|
||||
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
|
||||
)?,
|
||||
);
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
|
||||
Box::new(window.clone()),
|
||||
));
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
..Default::default()
|
||||
}))?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
|
||||
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
|
||||
let size = wgpu::Extent3d {
|
||||
width: 320,
|
||||
height: 360,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Offscreen view"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&pixel.repeat(320 * 360),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(320 * 4),
|
||||
rows_per_image: Some(360),
|
||||
},
|
||||
size,
|
||||
);
|
||||
texture
|
||||
});
|
||||
let compositor = Compositor::new(
|
||||
surface,
|
||||
&adapter,
|
||||
&device,
|
||||
&queue,
|
||||
window.inner_size().into(),
|
||||
)?;
|
||||
Ok(Self {
|
||||
window,
|
||||
compositor,
|
||||
textures,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Demo {
|
||||
frames: Option<Frames>,
|
||||
error: Option<String>,
|
||||
presented: usize,
|
||||
smoke: bool,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Demo {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.frames.is_none() {
|
||||
match Frames::new(event_loop) {
|
||||
Ok(frames) => self.frames = Some(frames),
|
||||
Err(error) => {
|
||||
self.error = Some(error.to_string());
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
|
||||
let Some(f) = &mut self.frames else { return };
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
|
||||
// Uncovered pixels are black; resize smaller to exercise clipping.
|
||||
WindowEvent::RedrawRequested
|
||||
if f.compositor
|
||||
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
|
||||
{
|
||||
self.presented += 1;
|
||||
if self.smoke && self.presented == 10 {
|
||||
f.compositor.set_vsync(false);
|
||||
f.compositor.resize(0, 0);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(u32::MAX, u32::MAX);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(400, 240);
|
||||
let _ = f
|
||||
.window
|
||||
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
|
||||
}
|
||||
if self.smoke && self.presented == 20 {
|
||||
println!("compositor: 20 frames, resize, suspension and clipping passed");
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
|
||||
if let Some(f) = &self.frames {
|
||||
f.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client_render::Result<()> {
|
||||
let mut demo = Demo {
|
||||
smoke: std::env::args().any(|a| a == "--smoke"),
|
||||
..Default::default()
|
||||
};
|
||||
EventLoop::new()?.run_app(&mut demo)?;
|
||||
if let Some(error) = demo.error {
|
||||
return Err(error.into());
|
||||
}
|
||||
if demo.smoke && demo.presented < 20 {
|
||||
return Err("smoke test closed early".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
|
||||
type Stream = std::convert::Infallible;
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
@@ -543,15 +544,48 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
/// Available audio output device names.
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
if output_disabled(|name| std::env::var_os(name)).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let mut names = Vec::new();
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for device in devices {
|
||||
if let Ok(name) = device.name()
|
||||
&& !names.contains(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
Self::with_device(root, "")
|
||||
}
|
||||
pub fn with_device(root: &Path, device_name: &str) -> Self {
|
||||
Self::with_output(root, true, device_name)
|
||||
}
|
||||
/// No output device: the dedicated server.
|
||||
pub fn silent(root: &Path) -> Self {
|
||||
Self::with_output(root, false)
|
||||
Self::with_output(root, false, "")
|
||||
}
|
||||
fn with_output(root: &Path, output: bool) -> Self {
|
||||
/// The output device asked for (`soundDevice`), empty for the system's default; the
|
||||
/// default plays when that one can't be opened, as in vanilla.
|
||||
pub fn device(&self) -> &str {
|
||||
&self.device_name
|
||||
}
|
||||
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
if output && registry.is_empty() {
|
||||
// The game then plays nothing on a working output (#1035, #1076).
|
||||
@@ -564,7 +598,7 @@ impl Audio {
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let opened = if output {
|
||||
Self::open(mixer.clone())
|
||||
Self::open_named(mixer.clone(), target_device)
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
@@ -581,6 +615,7 @@ impl Audio {
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
device_name: target_device.to_owned(),
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
@@ -602,10 +637,61 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
|
||||
Err("this build has no sound output (built without the `audio` feature)".into())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
if !target.is_empty()
|
||||
&& let Ok(devices) = host.output_devices()
|
||||
{
|
||||
for device in devices {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if name == target {
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: could not open audio device '{target}': {e}; falling back to default"
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::open(mixer)
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
|
||||
self.device_name = name.to_owned();
|
||||
return Ok(());
|
||||
}
|
||||
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
|
||||
self.device_name = name.to_owned();
|
||||
self.backend.live = true;
|
||||
self.engine.loaded = true;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
self.device_name = name.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
|
||||
@@ -52,20 +52,20 @@ impl AuthSession {
|
||||
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
|
||||
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
|
||||
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
|
||||
let name = field("name").ok_or("the session file has no name")?;
|
||||
let name = field("name").ok_or("the session has no name")?;
|
||||
if name.is_empty() || name.chars().count() > 16 {
|
||||
return Err("the session file's name is not a valid player name".into());
|
||||
return Err("the session's name is not a valid player name".into());
|
||||
}
|
||||
let uuid = field("uuid")
|
||||
.and_then(parse_uuid)
|
||||
.ok_or("the session file has no valid uuid")?;
|
||||
.ok_or("the session has no valid uuid")?;
|
||||
let access_token = field("access_token")
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or("the session file has no access token")?;
|
||||
.ok_or("the session has no access token")?;
|
||||
if field("user_type").is_some_and(|t| t != "msa") {
|
||||
return Err("the session file is not a Microsoft account session".into());
|
||||
return Err("the session is not a Microsoft account session".into());
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
|
||||
@@ -256,6 +256,10 @@ impl Biomes {
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// Whether the game has the biome `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
|
||||
@@ -654,10 +654,20 @@ fn sign_text_calls(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far the text of an outlined call sits in front of its outline, in
|
||||
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
|
||||
const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
|
||||
/// Draws a text call with the font atlas: 8x8 glyph cells, the outline as
|
||||
/// eight one-pixel offsets under the text.
|
||||
///
|
||||
/// `TextFeatureRenderer.renderText` draws the outline glyphs NORMAL and the
|
||||
/// text over them POLYGON_OFFSET, so the coplanar text always wins the depth
|
||||
/// test. Here both share one mesh, so the text is lifted a hair towards the
|
||||
/// reader instead (a twentieth of a font pixel, invisible) rather than
|
||||
/// z-fighting with the outline under it.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, argb: u32| {
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
@@ -672,7 +682,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, 0.0))
|
||||
.transform_point3(Vec3::new(cx, cy, z))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -697,10 +707,15 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
emit(dx, dy, call.outline);
|
||||
emit(dx, dy, 0.0, call.outline);
|
||||
}
|
||||
}
|
||||
emit(0.0, 0.0, call.color);
|
||||
let z = if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
emit(0.0, 0.0, z, call.color);
|
||||
}
|
||||
|
||||
/// Block-entity fields the level ticks, at one position (vanilla's own field
|
||||
@@ -2258,6 +2273,69 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
/// TextFeatureRenderer.renderText: an outlined call's text is drawn over
|
||||
/// its outline (Font.DisplayMode.POLYGON_OFFSET), never level with it,
|
||||
/// and plain text stays on the sign's plane.
|
||||
#[test]
|
||||
fn outlined_sign_text_sits_in_front_of_its_outline() {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let glyph = |_: char| {
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
(Some(rect), 6.0)
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "Hi".into(),
|
||||
color: 0xFFFF_FF00,
|
||||
outline: 0xFF33_3300,
|
||||
light: 240 | 240 << 8,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
// Eight outline copies, then the text, four corners a glyph.
|
||||
assert_eq!(out.len(), 9 * 2 * 4);
|
||||
let (outline, text) = out.split_at(8 * 2 * 4);
|
||||
let front = outline.iter().map(|v| v.pos[2]).fold(f32::MIN, f32::max);
|
||||
assert!(
|
||||
text.iter().all(|v| v.pos[2] > front),
|
||||
"text z {:?} not in front of the outline at {front}",
|
||||
text.iter().map(|v| v.pos[2]).collect::<Vec<_>>()
|
||||
);
|
||||
call.outline = 0;
|
||||
out.clear();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
assert_eq!(out.len(), 2 * 4);
|
||||
assert!(out.iter().all(|v| v.pos[2] == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
|
||||
@@ -14,14 +14,19 @@ pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
let secs = ticks / 20;
|
||||
format_tick_duration(ticks, 20.0)
|
||||
}
|
||||
/// `StringUtil.formatTickDuration`: mm:ss, or hh:mm:ss from an hour up, every
|
||||
/// field two digits.
|
||||
pub fn format_tick_duration(ticks: i32, tick_rate: f32) -> String {
|
||||
let secs = (ticks as f32 / tick_rate).floor() as i32;
|
||||
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
format!("{h:02}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
format!("{m:02}:{s:02}")
|
||||
}
|
||||
}
|
||||
/// `item.minecraft.potion.effect.<name>` style translation key for a potion item.
|
||||
@@ -317,10 +322,14 @@ mod tests {
|
||||
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
|
||||
"item.minecraft.splash_potion.effect.swiftness"
|
||||
);
|
||||
assert_eq!(format_duration(9600), "8:00");
|
||||
assert_eq!(format_duration(450), "0:22");
|
||||
assert_eq!(format_duration(9600), "08:00");
|
||||
assert_eq!(format_duration(450), "00:22");
|
||||
assert_eq!(format_duration(140), "00:07");
|
||||
assert_eq!(format_duration(1800), "01:30");
|
||||
assert_eq!(format_duration(72000), "01:00:00");
|
||||
assert_eq!(format_tick_duration(9600, 40.0), "04:00");
|
||||
let t = tooltip(Some("minecraft:strong_poison"), 1.0, &|_| None);
|
||||
assert_eq!(t[0].0, "poison II (0:21)");
|
||||
assert_eq!(t[0].0, "poison II (00:21)");
|
||||
assert_eq!(POTIONS.len(), 46);
|
||||
}
|
||||
|
||||
@@ -377,4 +386,23 @@ mod tests {
|
||||
stand.tick(&brewing, &items, &providers);
|
||||
assert_eq!(stand.brew_time, 0);
|
||||
}
|
||||
|
||||
/// #3312: `PotionContents.getPotionDescription` names the effects with
|
||||
/// translatable components, so a Russian game reads them from ru_ru.json
|
||||
/// (the prepared one; skipped without it), as the tooltip's other lines.
|
||||
#[test]
|
||||
fn effect_lines_read_the_selected_language() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("assets/minecraft/lang/ru_ru.json").exists() {
|
||||
return;
|
||||
}
|
||||
let ru = crate::lang::Language::load(&[&root], "ru_ru");
|
||||
let translate = |key: &str| ru.get(key).map(str::to_owned);
|
||||
let lines = tooltip(Some("minecraft:turtle_master"), 0.125, &translate);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
|
||||
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
|
||||
}
|
||||
}
|
||||
@@ -653,18 +653,57 @@ pub fn modify_fov_based_on_death_or_fluid(
|
||||
fov
|
||||
}
|
||||
|
||||
/// `ClientAvatarState`: the walked distance and view bob of a player.
|
||||
/// `ClientAvatarState`: the walked distance, view bob and cloak of a player.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct AvatarBob {
|
||||
pub walk_dist: f32,
|
||||
pub walk_dist_o: f32,
|
||||
pub bob: f32,
|
||||
pub bob_o: f32,
|
||||
/// `xCloak`, `yCloak`, `zCloak`: a point trailing the player, which the cape leans from.
|
||||
pub cloak: [f64; 3],
|
||||
pub cloak_o: [f64; 3],
|
||||
}
|
||||
impl AvatarBob {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
|
||||
pub fn tick(&mut self) {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`)
|
||||
/// with the player's position then: `moveCloak` eases the cloak a quarter
|
||||
/// of the way there, or puts it there from 10 blocks away.
|
||||
pub fn tick(&mut self, position: [f64; 3]) {
|
||||
self.walk_dist_o = self.walk_dist;
|
||||
self.cloak_o = self.cloak;
|
||||
for i in 0..3 {
|
||||
let d = position[i] - self.cloak[i];
|
||||
if d > 10.0 || d < -10.0 {
|
||||
self.cloak[i] = position[i];
|
||||
self.cloak_o[i] = position[i];
|
||||
} else {
|
||||
self.cloak[i] += d * 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AvatarRenderer.extractCapeState`: the cape's `capeFlap`, `capeLean`
|
||||
/// and `capeLean2` (degrees) for the player at `position` (interpolated)
|
||||
/// with its body turned `body_yaw` and its `fallFlyingScale`.
|
||||
pub fn cape(
|
||||
&self,
|
||||
partial: f32,
|
||||
position: [f64; 3],
|
||||
body_yaw: f32,
|
||||
fall_flying_scale: f32,
|
||||
) -> [f32; 3] {
|
||||
let d: [f64; 3] = std::array::from_fn(|i| {
|
||||
lerp_d(f64::from(partial), self.cloak_o[i], self.cloak[i]) - position[i]
|
||||
});
|
||||
let rad = f64::from(body_yaw * DEG_TO_RAD);
|
||||
let (sin, cos) = (f64::from(trig::sin(rad)), f64::from(-trig::cos(rad)));
|
||||
let mut flap = (d[1] as f32 * 10.0).clamp(-6.0, 32.0);
|
||||
let lean = ((d[0] * sin + d[2] * cos) as f32 * 100.0 * (1.0 - fall_flying_scale))
|
||||
.clamp(0.0, 150.0);
|
||||
let lean2 = ((d[0] * cos - d[2] * sin) as f32 * 100.0).clamp(-20.0, 20.0);
|
||||
let bob = self.interpolated_bob(partial);
|
||||
let walk = lerp(partial, self.walk_dist_o, self.walk_dist);
|
||||
flap += trig::sin(f64::from(walk * 6.0)) * 32.0 * bob;
|
||||
[flap, lean, lean2]
|
||||
}
|
||||
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
|
||||
/// with the float horizontal displacement.
|
||||
@@ -795,6 +834,23 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
|
||||
/// cape leans back as the player walks forward (south, body yaw 0).
|
||||
#[test]
|
||||
fn the_cloak_trails_the_player() {
|
||||
let mut a = super::AvatarBob::default();
|
||||
a.tick([0.0, 64.0, 0.0]);
|
||||
assert_eq!((a.cloak, a.cloak_o), ([0.0, 64.0, 0.0], [0.0, 64.0, 0.0]));
|
||||
a.tick([0.0, 64.0, 1.0]);
|
||||
assert_eq!(a.cloak, [0.0, 64.0, 0.25]);
|
||||
let [flap, lean, lean2] = a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 0.0);
|
||||
assert_eq!((flap, lean, lean2), (0.0, 75.0, 0.0));
|
||||
// Falling lifts the flap, at most 32 degrees.
|
||||
assert_eq!(a.cape(1.0, [0.0, 60.0, 0.25], 0.0, 0.0)[0], 32.0);
|
||||
// Gliding takes the lean away.
|
||||
assert_eq!(a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 1.0)[1], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_far_covers_the_cloud_range() {
|
||||
// renderDistance 2 with the default Cloud Distance (128 chunks).
|
||||
|
||||
@@ -653,6 +653,42 @@ pub struct Commands<'a> {
|
||||
pub registries: &'a Registries,
|
||||
}
|
||||
|
||||
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
|
||||
/// parse and where they are drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SuggestLayout {
|
||||
/// `commandsOnly`: the input is a command without a leading `/`.
|
||||
pub commands_only: bool,
|
||||
/// `onlyShowIfCursorPastError`: completions from the command's start,
|
||||
/// not only past the `/`.
|
||||
pub only_past_error: bool,
|
||||
/// `suggestionLineLimit`.
|
||||
pub line_limit: usize,
|
||||
/// `anchorToBottom`: the list and usage over a box at the bottom, else
|
||||
/// under one at the top (y 72).
|
||||
pub anchor_to_bottom: bool,
|
||||
/// `fillColor`: behind the list and the usage lines.
|
||||
pub fill: i32,
|
||||
}
|
||||
impl SuggestLayout {
|
||||
/// ChatScreen.init's `CommandSuggestions`.
|
||||
pub const CHAT: Self = Self {
|
||||
commands_only: false,
|
||||
only_past_error: false,
|
||||
line_limit: 10,
|
||||
anchor_to_bottom: true,
|
||||
fill: FILL,
|
||||
};
|
||||
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
|
||||
pub const COMMAND_BLOCK: Self = Self {
|
||||
commands_only: true,
|
||||
only_past_error: true,
|
||||
line_limit: 7,
|
||||
anchor_to_bottom: false,
|
||||
fill: i32::MIN,
|
||||
};
|
||||
}
|
||||
|
||||
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
|
||||
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
|
||||
/// the suggestion list, Tab completion, history).
|
||||
@@ -680,6 +716,7 @@ pub struct ChatScreen {
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
pub layout: SuggestLayout,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -750,6 +787,43 @@ impl ChatScreen {
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
layout: SuggestLayout::CHAT,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
|
||||
/// focused, holding `command`, with suggestions from the start.
|
||||
pub fn command_block(
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
command: &str,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
|
||||
input.max_length = 32500;
|
||||
input.focused = true;
|
||||
input.set_value(m, command);
|
||||
let mut s = Self {
|
||||
input,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
history_pos: 0,
|
||||
parse: None,
|
||||
pending: None,
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
allow_suggestions: true,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities: ChatAbilities::new(ChatVisibility::Full),
|
||||
layout: SuggestLayout::COMMAND_BLOCK,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -768,7 +842,11 @@ impl ChatScreen {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -814,11 +892,18 @@ impl ChatScreen {
|
||||
self.usage.clear();
|
||||
let cursor = self.input.cursor;
|
||||
let cursor_b = byte_at(&command, cursor);
|
||||
if command.starts_with('/') {
|
||||
let slash = command.starts_with('/');
|
||||
if self.layout.commands_only || slash {
|
||||
let start = usize::from(slash);
|
||||
if self.parse.is_none() {
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
|
||||
}
|
||||
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let from = if self.layout.only_past_error {
|
||||
start
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let parse = self.parse.as_ref().expect("parsed above");
|
||||
let (range, list, ask) =
|
||||
c.dispatcher
|
||||
@@ -1002,7 +1087,11 @@ impl ChatScreen {
|
||||
0,
|
||||
self.input.screen_x(m, 0) + self.input.inner_width() - width,
|
||||
);
|
||||
let y = self.height - 12;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
self.height - 12
|
||||
} else {
|
||||
72
|
||||
};
|
||||
// sortSuggestions: those starting with the word at the cursor first.
|
||||
let partial = &value[..byte_at(&value, self.input.cursor)];
|
||||
let word = partial[last_word_index(partial)..].to_lowercase();
|
||||
@@ -1011,9 +1100,16 @@ impl ChatScreen {
|
||||
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
|
||||
});
|
||||
first.extend(rest);
|
||||
let n = first.len().min(10) as i32;
|
||||
// SuggestionsList: beside a bordered box, or over an unbordered one.
|
||||
let n = first.len().min(self.layout.line_limit) as i32;
|
||||
let bordered = i32::from(self.input.bordered);
|
||||
let top = if self.layout.anchor_to_bottom {
|
||||
y - 3 - n * 12
|
||||
} else {
|
||||
y - bordered
|
||||
};
|
||||
let mut l = SuggestionList {
|
||||
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
|
||||
rect: [x - (1 - bordered), top, width + 1, n * 12],
|
||||
original: value,
|
||||
list: first,
|
||||
offset: 0,
|
||||
@@ -1042,11 +1138,12 @@ impl ChatScreen {
|
||||
}
|
||||
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
|
||||
self.select(l, l.current as isize + dir);
|
||||
let max = l.list.len().saturating_sub(10);
|
||||
let limit = self.layout.line_limit;
|
||||
let max = l.list.len().saturating_sub(limit);
|
||||
if l.current < l.offset {
|
||||
l.offset = l.current.min(max);
|
||||
} else if l.current > l.offset + 9 {
|
||||
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
|
||||
} else if l.current >= l.offset + limit {
|
||||
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
|
||||
}
|
||||
}
|
||||
/// `SuggestionsList.useSuggestion`.
|
||||
@@ -1074,7 +1171,57 @@ impl ChatScreen {
|
||||
ctrl: bool,
|
||||
) -> ChatAction {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
// CommandSuggestions.keyPressed.
|
||||
if self.suggestions_key(m, c, key, shift) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
self.field_key(m, c, key, shift, ctrl);
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
|
||||
/// following an edit: whether the box took the key.
|
||||
pub fn field_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
ctrl: bool,
|
||||
) -> bool {
|
||||
match self.input.key(m, key, shift, ctrl) {
|
||||
Some(changed) => {
|
||||
if changed {
|
||||
self.edited(m, c);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
|
||||
/// and Tab to show it. True when the suggestions took the key.
|
||||
pub fn suggestions_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
let handled = match key {
|
||||
K::ArrowUp | K::ArrowDown => {
|
||||
@@ -1091,37 +1238,18 @@ impl ChatScreen {
|
||||
}
|
||||
K::Escape => {
|
||||
self.input.suggestion = None;
|
||||
return ChatAction::None;
|
||||
return true;
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.suggestions = Some(l);
|
||||
if handled {
|
||||
return ChatAction::None;
|
||||
}
|
||||
handled
|
||||
} else if key == K::Tab {
|
||||
self.show_suggestions(m);
|
||||
return ChatAction::None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
if self.input.key(m, key, shift, ctrl) == Some(true) {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// `ChatScreen.moveInHistory`.
|
||||
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
|
||||
@@ -1175,7 +1303,7 @@ impl ChatScreen {
|
||||
if !l.contains(mouse.0, mouse.1) {
|
||||
return false;
|
||||
}
|
||||
let max = l.list.len().saturating_sub(10) as i32;
|
||||
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
@@ -1246,7 +1374,9 @@ impl ChatScreen {
|
||||
) -> Vec<Draw> {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let mut out = Vec::new();
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
if self.layout.anchor_to_bottom {
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
}
|
||||
let format = |s: &str, o: usize| self.format(s, o);
|
||||
self.input.draw(&mut out, m, blink_ms, Some(&format));
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
@@ -1254,14 +1384,18 @@ impl ChatScreen {
|
||||
self.suggestions = Some(l);
|
||||
} else {
|
||||
for (i, line) in self.usage.iter().enumerate() {
|
||||
let y = h - 27 - 12 * i as i32;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
h - 27 - 12 * i as i32
|
||||
} else {
|
||||
72 + 12 * i as i32
|
||||
};
|
||||
d::fill(
|
||||
&mut out,
|
||||
self.usage_position - 1,
|
||||
y,
|
||||
self.usage_position + self.usage_width + 1,
|
||||
y + 12,
|
||||
FILL,
|
||||
self.layout.fill,
|
||||
);
|
||||
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
|
||||
}
|
||||
@@ -1275,7 +1409,7 @@ impl ChatScreen {
|
||||
l: &mut SuggestionList,
|
||||
mouse: (i32, i32),
|
||||
) {
|
||||
let limit = l.list.len().min(10);
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
@@ -1284,8 +1418,8 @@ impl ChatScreen {
|
||||
l.last_mouse = mouse;
|
||||
}
|
||||
if has_prev || has_next {
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
|
||||
if has_prev {
|
||||
for x in (0..rw).step_by(2) {
|
||||
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
|
||||
@@ -1300,7 +1434,7 @@ impl ChatScreen {
|
||||
let mut hovered = false;
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, FILL);
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
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);
|
||||
|
||||
@@ -42,6 +42,45 @@ pub fn recipe_used_key(id: &str) -> String {
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
|
||||
/// translation key of its menu's title when it has no custom name. Copper
|
||||
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
|
||||
/// `ShulkerBoxBlockEntity`.
|
||||
pub fn default_name_key(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
|
||||
n if n.ends_with("copper_chest") => "container.chest",
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
"minecraft:dispenser" => "container.dispenser",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
"minecraft:hopper" => "container.hopper",
|
||||
"minecraft:furnace" => "container.furnace",
|
||||
"minecraft:smoker" => "container.smoker",
|
||||
"minecraft:blast_furnace" => "container.blast_furnace",
|
||||
"minecraft:brewing_stand" => "container.brewing",
|
||||
"minecraft:crafter" => "container.crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
|
||||
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
|
||||
/// name (`CustomName`, a literal), else its default name.
|
||||
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
|
||||
match custom {
|
||||
Some(name) => horizon_protocol::text::literal(name),
|
||||
None => horizon_protocol::text::translatable(default_key, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
|
||||
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
|
||||
/// `FIRST`), else the second's, else `container.chestDouble`.
|
||||
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
|
||||
display_name(first.or(second), "container.chestDouble")
|
||||
}
|
||||
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
@@ -378,6 +417,58 @@ impl BlockEntity {
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
|
||||
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
|
||||
/// chests, trapped chests and copper chests in every weathering state, waxed
|
||||
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
|
||||
/// `EnderChestBlock`s and always single.
|
||||
pub fn is_chest_block(name: &str) -> bool {
|
||||
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
|
||||
}
|
||||
|
||||
/// A chest block's name with its `type` and `facing` properties.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChestHalf<'a> {
|
||||
pub block: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub facing: &'a str,
|
||||
}
|
||||
|
||||
impl ChestHalf<'_> {
|
||||
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
|
||||
/// half: clockwise from the facing for a left half, counter-clockwise for
|
||||
/// a right one. None for a single chest.
|
||||
pub fn connected_step(&self) -> Option<(i32, i32)> {
|
||||
let step = |facing: &str| match facing {
|
||||
"north" => Some((0, -1)),
|
||||
"south" => Some((0, 1)),
|
||||
"west" => Some((-1, 0)),
|
||||
"east" => Some((1, 0)),
|
||||
_ => None,
|
||||
};
|
||||
let (x, z) = step(self.facing)?;
|
||||
// Turning clockwise seen from above takes (x, z) to (-z, x).
|
||||
match self.kind {
|
||||
"left" => Some((-z, x)),
|
||||
"right" => Some((z, -x)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
|
||||
/// it: `other`, the block in this half's connected direction, is its other
|
||||
/// half when it is the same block, a half of the other side and faces the
|
||||
/// same way. Copper chests in different weathering states never join
|
||||
/// (`CopperChestBlock` keeps both halves in one state instead).
|
||||
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
|
||||
is_chest_block(self.block)
|
||||
&& self.kind != "single"
|
||||
&& other.block == self.block
|
||||
&& other.kind != "single"
|
||||
&& other.kind != self.kind
|
||||
&& other.facing == self.facing
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
pub fn create_experience(
|
||||
@@ -395,6 +486,24 @@ pub fn create_experience(
|
||||
xp
|
||||
}
|
||||
|
||||
/// The `MenuType` the block entity of a one-block storage container opens
|
||||
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
|
||||
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
|
||||
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
|
||||
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
|
||||
pub fn block_menu_type(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
|
||||
"minecraft:generic_9x3"
|
||||
}
|
||||
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Written to `crash-reports/crash-<yyyy-MM-dd_HH.mm.ss>-client.txt` in the game folder when
|
||||
//! the main thread panics or `main` gets an error back. It leaves the machine only scrubbed, and
|
||||
//! only if the player allows crash reports (`telemetry`).
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
@@ -14,6 +15,51 @@ use chrono::{DateTime, Local};
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
static REPORTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
thread_local! {
|
||||
/// How many `contain` calls this thread is inside.
|
||||
static CONTAINED: Cell<u32> = const { Cell::new(0) };
|
||||
/// The message of the last panic `contain` caught on this thread, from the hook.
|
||||
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
|
||||
/// it end the game. For work that is not the game's own and whose failure the caller recovers
|
||||
/// from, such as handing a shader pack's programs to the GPU driver's compiler: the panic goes
|
||||
/// to the log with its stack, and no crash report is made for it.
|
||||
pub fn contain<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
CONTAINED.with(|c| c.set(c.get() + 1));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
CONTAINED.with(|c| c.set(c.get() - 1));
|
||||
result.map_err(|payload| {
|
||||
CAUGHT
|
||||
.with(|c| c.borrow_mut().take())
|
||||
.unwrap_or_else(|| payload_text(&*payload))
|
||||
})
|
||||
}
|
||||
|
||||
/// For the hook: a panic inside `contain` is logged with its stack and kept for `contain` to
|
||||
/// return. False for any other panic.
|
||||
fn contained_panic(info: &std::panic::PanicHookInfo) -> bool {
|
||||
if CONTAINED.with(Cell::get) == 0 {
|
||||
return false;
|
||||
}
|
||||
let message = panic_message(info);
|
||||
let stack = stack_lines(
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
);
|
||||
log::error!(
|
||||
"Recovered from an error: {message}\n{}",
|
||||
stack
|
||||
.lines()
|
||||
.map(|l| format!("\tat {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
CAUGHT.with(|c| *c.borrow_mut() = Some(message));
|
||||
true
|
||||
}
|
||||
|
||||
/// Sets one line of the report's details (replacing the last value under `key`).
|
||||
pub fn set_detail(key: &'static str, value: impl Into<String>) {
|
||||
let mut details = DETAILS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -274,6 +320,10 @@ fn thread_name() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
|
||||
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
|
||||
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
@@ -297,7 +347,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
match report.save(home) {
|
||||
Ok(path) => {
|
||||
eprintln!("Horizon: crash report saved to {}", path.display());
|
||||
eprintln!("{SAVED_LINE}{}", path.display());
|
||||
Some(path)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -308,13 +358,35 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let payload = info.payload();
|
||||
let text = payload
|
||||
/// The report for a hung server tick, written by the watchdog thread
|
||||
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
|
||||
/// the report carries none.
|
||||
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
side,
|
||||
details: system_details(),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into());
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into())
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let text = payload_text(info.payload());
|
||||
match info.location() {
|
||||
Some(at) => format!("panicked at {at}:\n{text}"),
|
||||
None => format!("panicked:\n{text}"),
|
||||
@@ -417,10 +489,13 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic.
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if contained_panic(info) {
|
||||
return;
|
||||
}
|
||||
default(info);
|
||||
let main = std::thread::current().name() == Some("main");
|
||||
if main {
|
||||
@@ -609,6 +684,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contained_panics_come_back_as_errors() {
|
||||
assert_eq!(contain(|| 7), Ok(7));
|
||||
let error = contain(|| -> u8 { panic!("a pack's program") }).unwrap_err();
|
||||
assert!(error.contains("a pack's program"), "{error}");
|
||||
// Nested, and the thread is no longer inside one after.
|
||||
let outer = contain(|| contain(|| -> u8 { panic!("inner") }));
|
||||
assert!(outer.unwrap().unwrap_err().contains("inner"));
|
||||
assert_eq!(CONTAINED.with(Cell::get), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_debug_crash_is_reported_as_vanillas() {
|
||||
let error: Box<dyn std::error::Error> = DebugCrash::new().into();
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! the parts of them commands use: `.mcfunction` functions (with macros and
|
||||
//! line continuation), function tags and predicates.
|
||||
use crate::zip::Zip;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::overrides::{self, PackFile, WorldgenOverrides},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -125,6 +128,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
packs
|
||||
}
|
||||
|
||||
// ---- Create World ------------------------------------------------------------
|
||||
|
||||
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
|
||||
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
|
||||
/// built-in one (`resources` holds its `pack.png`), which can be unselected
|
||||
/// and stays at the bottom. Horizon has no experiments, so their feature
|
||||
/// packs are not offered.
|
||||
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
|
||||
use crate::{
|
||||
pack_screen::{Compat, PackInfo, Source, load_icon},
|
||||
packs::Position,
|
||||
text::{Style, Text},
|
||||
};
|
||||
let format = horizon_core::version::DATA_PACK_FORMAT;
|
||||
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
|
||||
.into_iter()
|
||||
.map(|p| PackInfo {
|
||||
icon: p.icon_png().as_deref().and_then(load_icon),
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.raw_description,
|
||||
description_style: p.description_style,
|
||||
source: Source::World,
|
||||
compat: p.compatibility,
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
})
|
||||
.collect();
|
||||
infos.push(PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("dataPack.vanilla.name", &[]),
|
||||
description: Text::translate("dataPack.vanilla.description", &[]),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: std::fs::read(resources.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(load_icon),
|
||||
required: false,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
});
|
||||
infos
|
||||
}
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
/// What's in a folder of packs, to notice packs added or removed while the
|
||||
/// screen shows it (`PackSelectionScreen.Watcher`).
|
||||
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
|
||||
let mut stamp: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let modified = e.metadata().and_then(|m| m.modified()).ok();
|
||||
(e.file_name().to_string_lossy().into_owned(), modified)
|
||||
})
|
||||
.collect();
|
||||
stamp.sort();
|
||||
stamp
|
||||
}
|
||||
/// Create World's packs into the new world before it opens
|
||||
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
|
||||
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
|
||||
/// (lowest priority first) and disables the rest.
|
||||
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
|
||||
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
|
||||
let target = world.join("datapacks");
|
||||
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
|
||||
let mut disabled = Vec::new();
|
||||
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
|
||||
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
|
||||
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
|
||||
if !selected.contains(&pack.id) {
|
||||
disabled.push(pack.id);
|
||||
}
|
||||
}
|
||||
if !selected.iter().any(|id| id == "vanilla") {
|
||||
disabled.push("vanilla".into());
|
||||
}
|
||||
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// A file, or a folder and everything in it.
|
||||
fn copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
|
||||
if !from.is_dir() {
|
||||
return std::fs::copy(from, to).map(drop).map_err(err);
|
||||
}
|
||||
std::fs::create_dir_all(to).map_err(err)?;
|
||||
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
|
||||
copy(&e.path(), &to.join(e.file_name()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- functions ---------------------------------------------------------------
|
||||
|
||||
/// A `.mcfunction`: plain command lines and macro lines.
|
||||
@@ -421,9 +532,138 @@ fn hash_normalize(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
|
||||
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
|
||||
/// order, then every pack it lists neither way, in name order.
|
||||
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
|
||||
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let list = |key: &str| -> Vec<String> {
|
||||
saved
|
||||
.as_ref()
|
||||
.and_then(|v| v[key].as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|s| s.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let disabled = list("disabled");
|
||||
let mut enabled: Vec<String> = list("enabled")
|
||||
.into_iter()
|
||||
.filter(|id| packs.iter().any(|p| &p.id == id))
|
||||
.collect();
|
||||
for p in packs {
|
||||
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
|
||||
enabled.push(p.id.clone());
|
||||
}
|
||||
}
|
||||
enabled
|
||||
}
|
||||
|
||||
// ---- worldgen ----------------------------------------------------------------
|
||||
|
||||
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
|
||||
/// left out: the prepared bundles are it) for the chunk generator, and what
|
||||
/// it leaves out, for the log. `biomes` says which biomes the game has: a
|
||||
/// pack's new biome is left out (the game has no colours, sounds or id for it).
|
||||
pub fn worldgen_overrides(
|
||||
packs: &[&DataPack],
|
||||
biomes: impl Fn(&str) -> bool,
|
||||
) -> (WorldgenOverrides, Vec<String>) {
|
||||
let mut out = WorldgenOverrides::default();
|
||||
let mut not_read: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&overrides::PACK_KINDS) {
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
|
||||
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
|
||||
the dimension keeps its vanilla build height",
|
||||
pack.id
|
||||
)),
|
||||
PackFile::NotRead(kind) => {
|
||||
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
|
||||
}
|
||||
PackFile::Unreadable => warnings.push(format!("{}: {file}: unreadable", pack.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
warnings.extend(out.finish(biomes));
|
||||
for (kind, files) in not_read {
|
||||
warnings.push(format!("{kind}: {files} files the generator does not read"));
|
||||
}
|
||||
(out, warnings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join("datapacks").join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(
|
||||
pack,
|
||||
"data/minecraft/tags/worldgen/biome/is_hill.json",
|
||||
"{\"values\":[]}",
|
||||
);
|
||||
}
|
||||
write("a", "data/minecraft/worldgen/biome/new_peaks.json", "{}");
|
||||
write("a", "data/minecraft/worldgen/biome/plains.json", "{}");
|
||||
write(
|
||||
"a",
|
||||
"data/minecraft/worldgen/world_preset/normal.json",
|
||||
"{}",
|
||||
);
|
||||
write(
|
||||
"b",
|
||||
"data/minecraft/dimension_type/overworld.json",
|
||||
"{\"min_y\":-64,\"height\":2096}",
|
||||
);
|
||||
// `b` is enabled first, so `a` (enabled after it) wins.
|
||||
std::fs::write(
|
||||
dir.join("datapacks.json"),
|
||||
"{\"enabled\":[\"file/b\",\"file/a\"],\"disabled\":[\"file/off\"]}",
|
||||
)
|
||||
.unwrap();
|
||||
let packs = discover(&dir);
|
||||
let enabled = enabled_ids(&dir, &packs);
|
||||
assert_eq!(enabled, ["file/b", "file/a"]);
|
||||
let enabled: Vec<&DataPack> = enabled
|
||||
.iter()
|
||||
.map(|id| packs.iter().find(|p| &p.id == id).unwrap())
|
||||
.collect();
|
||||
let (o, warnings) = worldgen_overrides(&enabled, |b| b == "minecraft:plains");
|
||||
assert_eq!(
|
||||
o.entries("noises").collect::<Vec<_>>(),
|
||||
[("minecraft:cave", "a")]
|
||||
);
|
||||
assert_eq!(
|
||||
o.entries("biomes").collect::<Vec<_>>(),
|
||||
[("minecraft:plains", "{}")]
|
||||
);
|
||||
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
|
||||
assert_eq!(tag_packs, ["file/b", "file/a"]);
|
||||
assert_eq!(warnings.len(), 3, "{warnings:?}");
|
||||
assert!(warnings[0].contains("height 2096"));
|
||||
assert!(warnings[1].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[2].contains("worldgen/world_preset"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
@@ -566,4 +806,51 @@ mod tests {
|
||||
assert_eq!(p.list_kinds(&kinds), wanted);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_world_installs_its_packs() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (dir, world) = (root.join("mcworld"), root.join("world"));
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let infos = screen_packs(&dir, &root);
|
||||
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
|
||||
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
|
||||
use crate::pack_screen::Compat;
|
||||
assert_eq!(
|
||||
compat,
|
||||
[
|
||||
Compat::Compatible,
|
||||
Compat::Compatible,
|
||||
Compat::TooOld,
|
||||
Compat::Compatible
|
||||
]
|
||||
);
|
||||
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
|
||||
install(&dir, &world, &selected).unwrap();
|
||||
let packs = discover(&world);
|
||||
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
saved,
|
||||
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
|
||||
@@ -72,9 +72,8 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now
|
||||
/// (`moved_extras`): their old settings stay here until the game has
|
||||
/// installed the mod for a player who had one on.
|
||||
/// The FPS counter and keystrokes extras are official mods now: their
|
||||
/// old settings stay here, kept as they were.
|
||||
pub fps: bool,
|
||||
pub fps_corner: Corner,
|
||||
/// Also show the minimum and 1% low.
|
||||
@@ -83,8 +82,8 @@ pub struct Extras {
|
||||
pub keystrokes_corner: Corner,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub keystrokes_cps: bool,
|
||||
/// The official mods installed for the extras they replace, by mod id
|
||||
/// (`movedToMods`): each is done once.
|
||||
/// The official mods once installed for the extras they replace, by mod
|
||||
/// id (`movedToMods`).
|
||||
pub moved: Vec<String>,
|
||||
pub minimap: bool,
|
||||
pub minimap_corner: Corner,
|
||||
@@ -97,6 +96,8 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// The player's own name tag over its model in third person.
|
||||
pub own_name_tag: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
@@ -137,6 +138,7 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
own_name_tag: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
@@ -211,6 +213,7 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
@@ -225,7 +228,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -251,6 +254,7 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.own_name_tag),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
@@ -282,7 +286,7 @@ impl Extras {
|
||||
}
|
||||
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`native_zoom`). A
|
||||
/// zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
@@ -378,6 +382,7 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -396,6 +401,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -410,6 +417,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::OwnNameTag => "Own name tag",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
@@ -426,7 +434,7 @@ impl Item {
|
||||
Some(match self {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
@@ -439,6 +447,9 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::OwnNameTag => {
|
||||
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
|
||||
}
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
@@ -471,7 +482,9 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -480,6 +493,7 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
@@ -506,6 +520,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
@@ -534,7 +549,7 @@ mod tests {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
@@ -558,6 +573,8 @@ mod tests {
|
||||
e.minimap_rotate = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.auto_reconnect = true;
|
||||
Item::OwnNameTag.cycle(&mut e, false);
|
||||
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
|
||||
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
/// The camera entity is a spectator (`LavaFogEnvironment`).
|
||||
pub spectator: bool,
|
||||
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
|
||||
pub fire_resistance: bool,
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
let brighten = if eye_in_water {
|
||||
water_vision
|
||||
} else {
|
||||
input.night_vision
|
||||
};
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
color = color.map(|c| c + (c * scale - c) * brighten);
|
||||
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
};
|
||||
match input.fog_type {
|
||||
FogType::Lava => {
|
||||
fog.environmental_start = 0.25;
|
||||
fog.environmental_end = 1.0;
|
||||
fog.sky_end = 1.0;
|
||||
fog.cloud_end = 1.0;
|
||||
// LavaFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else if input.fire_resistance {
|
||||
(0.0, 5.0)
|
||||
} else {
|
||||
(0.25, 1.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
@@ -362,6 +379,22 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
fog
|
||||
}
|
||||
/// Horizon's Fog option turned off (`horizon-graphics.txt` `fog:false`):
|
||||
/// no distance fog, in any dimension: the dimension's and biome's own fog
|
||||
/// (`visual/fog_start_distance`, `fog_end_distance`: the Nether's ends at 96
|
||||
/// blocks) and the render-distance fog. The fog of the fluid or powder snow
|
||||
/// the camera is in, blindness, darkness and a boss's world fog keep their
|
||||
/// limits. Vanilla has no such option.
|
||||
pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
|
||||
if input.fog_type == FogType::Atmospheric
|
||||
&& input.darkness.is_none()
|
||||
&& !input.boss_world_fog
|
||||
&& input.blindness.is_none()
|
||||
{
|
||||
(fog.render_distance_start, fog.render_distance_end) = (1e9, 2e9);
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
}
|
||||
/// `AtmosphericFogEnvironment.updateRainFogState`.
|
||||
pub fn update_rain_fog(
|
||||
state: &mut FogState,
|
||||
@@ -405,6 +438,9 @@ mod tests {
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
spectator: false,
|
||||
fire_resistance: false,
|
||||
night_vision: 0.0,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -432,6 +468,56 @@ mod tests {
|
||||
assert_eq!(water.environmental_start, -8.0);
|
||||
}
|
||||
#[test]
|
||||
fn fog_off_clears_the_nether_fog_too() {
|
||||
// The Nether's visual/fog_start_distance and fog_end_distance (10, 96)
|
||||
// end before the render distance; the Fog option still clears them.
|
||||
let nether = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&nether);
|
||||
input.skybox = false;
|
||||
let mut fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.environmental_end, 96.0);
|
||||
clear_distance_fog(&mut fog, &input);
|
||||
assert!(fog.environmental_start >= 1e9 && fog.render_distance_start >= 1e9);
|
||||
}
|
||||
#[test]
|
||||
fn fog_off_keeps_fluid_and_effect_fog() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let kept = |input: &FogInputs| {
|
||||
let mut fog = setup_fog(&tables, &FogState::default(), input);
|
||||
let before = fog;
|
||||
clear_distance_fog(&mut fog, input);
|
||||
fog == before
|
||||
};
|
||||
for fog_type in [FogType::Lava, FogType::PowderSnow, FogType::Water] {
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = fog_type;
|
||||
assert!(kept(&input), "{fog_type:?}");
|
||||
}
|
||||
let mut input = inputs(&sky);
|
||||
input.blindness = Some((100, false));
|
||||
assert!(kept(&input));
|
||||
let mut input = inputs(&sky);
|
||||
input.darkness = Some(0.5);
|
||||
assert!(kept(&input));
|
||||
let mut input = inputs(&sky);
|
||||
input.boss_world_fog = true;
|
||||
assert!(kept(&input));
|
||||
}
|
||||
#[test]
|
||||
fn boss_darkening_dims_fog_color() {
|
||||
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
|
||||
let sky = Sky {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! offsets), styled drawing and StringSplitter-style line wrapping.
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
text::{Style, Text},
|
||||
text::{Span, Style, Text, legacy_format},
|
||||
zip::Zip,
|
||||
};
|
||||
use ab_glyph::{Font as _, ScaleFont as _};
|
||||
@@ -966,6 +966,83 @@ impl Font {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `Font.split`: `StringSplitter.splitLines(text, width, Style.EMPTY)` over
|
||||
/// the text's parts with their `§` codes in. A code styles what follows
|
||||
/// (`§r` back to no style while measuring), and the part a line breaks in
|
||||
/// goes on in the style at the break. Lines keep their codes for drawing.
|
||||
pub fn split_formatted(&self, t: &Text, max_width: f32) -> Vec<Text> {
|
||||
let max_width = max_width.max(1.0);
|
||||
let mut parts: Vec<(Vec<char>, Style)> = t
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| !s.text.is_empty())
|
||||
.map(|s| (s.text.chars().collect(), s.style.clone()))
|
||||
.collect();
|
||||
let mut flat: Vec<char> = parts.iter().flat_map(|p| p.0.iter().copied()).collect();
|
||||
let mut out = Vec::new();
|
||||
let mut force_new_line = false;
|
||||
// One LineBreakFinder per line, over the parts left.
|
||||
while let Some((at, split_style)) = self.line_break(&parts, max_width) {
|
||||
let tail = flat[at];
|
||||
let new_line = tail == '\n';
|
||||
let skip = usize::from(new_line || tail == ' ');
|
||||
force_new_line = new_line;
|
||||
out.push(split_at(&mut parts, at, skip, &split_style));
|
||||
flat.drain(..at + skip);
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
let mut rest = Text::default();
|
||||
rest.spans = parts
|
||||
.into_iter()
|
||||
.map(|(text, style)| Span {
|
||||
text: text.into_iter().collect(),
|
||||
style,
|
||||
})
|
||||
.collect();
|
||||
out.push(rest);
|
||||
} else if force_new_line {
|
||||
out.push(Text::empty());
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `StringSplitter.LineBreakFinder` fed by `StringDecomposer.iterateFormatted`:
|
||||
/// where the parts break (a character index) and the style there.
|
||||
fn line_break(&self, parts: &[(Vec<char>, Style)], max_width: f32) -> Option<(usize, Style)> {
|
||||
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
|
||||
let mut offset = 0;
|
||||
for (contents, part_style) in parts {
|
||||
let mut style = part_style.clone();
|
||||
let mut i = 0;
|
||||
while i < contents.len() {
|
||||
let c = contents[i];
|
||||
if c == '§' {
|
||||
// A `§` that ends its part ends only that part.
|
||||
let Some(&code) = contents.get(i + 1) else {
|
||||
break;
|
||||
};
|
||||
style = legacy_format(&style, code, &Style::default());
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let at = offset + i;
|
||||
if c == '\n' {
|
||||
return Some((at, style));
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some((at, style.clone()));
|
||||
}
|
||||
let advance = self.advance(c, &style);
|
||||
width += advance;
|
||||
if had_visible && width > max_width {
|
||||
return Some(last_space.unwrap_or((at, style)));
|
||||
}
|
||||
had_visible |= advance != 0.0;
|
||||
i += 1;
|
||||
}
|
||||
offset += contents.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
|
||||
/// indented by an unstyled space; one empty line for empty text.
|
||||
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
|
||||
@@ -1059,6 +1136,47 @@ impl Font {
|
||||
}
|
||||
}
|
||||
|
||||
/// `StringSplitter.FlatComponents.splitAt`: the parts before `at` as a line,
|
||||
/// `skip` characters dropped, and the part it ends in restyled as `style`.
|
||||
fn split_at(parts: &mut Vec<(Vec<char>, Style)>, at: usize, skip: usize, style: &Style) -> Text {
|
||||
let mut line = Text::default();
|
||||
let mut push = |text: &[char], style: &Style| {
|
||||
line.spans.push(Span {
|
||||
text: text.iter().collect(),
|
||||
style: style.clone(),
|
||||
})
|
||||
};
|
||||
// Each part done is removed, so the one at hand is always the first.
|
||||
let (mut position, mut in_skip) = (at, false);
|
||||
while let Some((text, part_style)) = parts.first() {
|
||||
let size = text.len();
|
||||
if !in_skip {
|
||||
if position > size {
|
||||
push(text, part_style);
|
||||
parts.remove(0);
|
||||
position -= size;
|
||||
continue;
|
||||
}
|
||||
if position > 0 {
|
||||
push(&text[..position], part_style);
|
||||
}
|
||||
position += skip;
|
||||
in_skip = true;
|
||||
}
|
||||
if position <= size {
|
||||
if position == size {
|
||||
parts.remove(0);
|
||||
} else {
|
||||
parts[0] = (text[position..].to_vec(), style.clone());
|
||||
}
|
||||
break;
|
||||
}
|
||||
parts.remove(0);
|
||||
position -= size;
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1221,6 +1339,37 @@ mod tests {
|
||||
assert!(!font.take_patches().is_empty());
|
||||
}
|
||||
|
||||
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
|
||||
/// part only; the next part is still measured and can break.
|
||||
#[test]
|
||||
fn a_section_sign_ending_a_part_still_breaks_the_next() {
|
||||
let sheet = png(16, 8, |x, _| x < 2);
|
||||
let default = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"section-sign",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/minecraft/textures/font/a.png", &sheet),
|
||||
],
|
||||
);
|
||||
let font = load(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut text = Text::default();
|
||||
text.spans = ["a§", "a a"]
|
||||
.map(|part| Span {
|
||||
text: part.into(),
|
||||
style: Style::default(),
|
||||
})
|
||||
.into();
|
||||
// 3 + 3 + 4 + 3 is past 12: the line breaks at the space.
|
||||
let lines: Vec<String> = font
|
||||
.split_formatted(&text, 12.0)
|
||||
.iter()
|
||||
.map(Text::plain)
|
||||
.collect();
|
||||
assert_eq!(lines, ["a§a", "a"]);
|
||||
}
|
||||
|
||||
/// A renderer rebuilt over the same font (the loading screen's handoff)
|
||||
/// makes its atlas texture from the textures alone, so the glyphs it had
|
||||
/// rasterized into the previous texture must be placed into the new one.
|
||||
|
||||
@@ -39,9 +39,11 @@ mod entity_outline;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
|
||||
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
WATER_MASK_OVERLAY,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -68,6 +70,7 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
|
||||
|
||||
mod animate;
|
||||
mod arena;
|
||||
pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
@@ -91,11 +94,14 @@ pub mod pack_uniforms;
|
||||
mod pack_weather;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod stars;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
pub(crate) mod upload;
|
||||
mod view;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
|
||||
@@ -188,6 +194,8 @@ struct Globals {
|
||||
outline_params: [f32; 4],
|
||||
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
|
||||
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
struct SectionGpu {
|
||||
@@ -349,6 +357,14 @@ struct BorderUniform {
|
||||
color: [f32; 4],
|
||||
offset: [f32; 4],
|
||||
}
|
||||
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
|
||||
/// or the icon the Select World list shows.
|
||||
pub struct ScreenshotRequest {
|
||||
pub path: PathBuf,
|
||||
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
|
||||
/// and average it down to this size. `None` saves the frame as drawn.
|
||||
pub size: Option<[u32; 2]>,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -376,6 +392,11 @@ pub struct Renderer {
|
||||
pub last_wait: std::time::Duration,
|
||||
/// CPU time of the last `render`, waits excluded.
|
||||
pub last_encode: std::time::Duration,
|
||||
/// What the system says about the window's display, watched only while HDR output
|
||||
/// is on; before `window`, so it's dropped first.
|
||||
display: DisplayWatch,
|
||||
/// The watch was just made: the settings' Auto values take what it reported.
|
||||
display_fresh: bool,
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
@@ -425,6 +446,7 @@ pub struct Renderer {
|
||||
lightning_pipeline: wgpu::RenderPipeline,
|
||||
lightning: lightning::Renderer,
|
||||
see_through_pipeline: wgpu::RenderPipeline,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
@@ -533,7 +555,7 @@ pub struct Renderer {
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
/// Resolution scale, FXAA and TAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
@@ -561,6 +583,8 @@ pub struct Renderer {
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
/// Where a screenshot's frame is drawn when the swapchain cannot be read back.
|
||||
readback: readback::Readback,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
pub suspended: bool,
|
||||
pub drawn_sections: usize,
|
||||
@@ -570,8 +594,8 @@ pub struct Renderer {
|
||||
pub section_draw_calls: usize,
|
||||
pub uploaded_bytes: u64,
|
||||
started: Instant,
|
||||
/// Write the next presented frame to this PNG path (control socket).
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// The next frame to write to a PNG path (control socket, F2, world icon).
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
@@ -765,8 +789,14 @@ fn mip_chain(
|
||||
/// pass is also what passes each draw's first instance to shaders reading
|
||||
/// `instance_index`, and DirectX 12 is the fallback, where working matters
|
||||
/// more than CPU time.
|
||||
fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
fn instance_descriptor(
|
||||
backends: wgpu::Backends,
|
||||
window: Option<&Arc<Window>>,
|
||||
) -> wgpu::InstanceDescriptor {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
if let Some(window) = window {
|
||||
descriptor = descriptor.with_display_handle(Box::new(WindowDisplay(window.clone())));
|
||||
}
|
||||
descriptor.backends = backends.with_env();
|
||||
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
|
||||
if !cfg!(debug_assertions) && !dx12 {
|
||||
@@ -776,6 +806,21 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
}
|
||||
descriptor.with_env()
|
||||
}
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
|
||||
/// cannot present to a window; the backend then reports the surface as
|
||||
/// incompatible and the game has no OpenGL to fall back to (`gl not
|
||||
/// compatible with provided surface`).
|
||||
#[derive(Debug, Clone)]
|
||||
struct WindowDisplay(Arc<Window>);
|
||||
impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
|
||||
fn display_handle(
|
||||
&self,
|
||||
) -> std::result::Result<wgpu::rwh::DisplayHandle<'_>, wgpu::rwh::HandleError> {
|
||||
wgpu::rwh::HasDisplayHandle::display_handle(&*self.0)
|
||||
}
|
||||
}
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
@@ -853,6 +898,14 @@ fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
|
||||
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
|
||||
/// minimized window. Past `max`: Windows lets a program size a window up to 65535 pixels
|
||||
/// (`SetWindowPos`), a 984x65496 client area, when an RTX 5080 takes 32768. Vanilla's
|
||||
/// `Window.onFramebufferResize` likewise keeps the last size for one that is not positive.
|
||||
fn drawable_size(width: u32, height: u32, max: u32) -> bool {
|
||||
(1..=max).contains(&width) && (1..=max).contains(&height)
|
||||
}
|
||||
/// The swapchain format: a non-sRGB target, because vanilla blends and tints in gamma
|
||||
/// space, and 8-bit, because wgpu gives `Rgba16Float` the linear `ExtendedSrgb` color
|
||||
/// space: the compositor would read these gamma-encoded values as linear light and wash
|
||||
@@ -890,7 +943,7 @@ async fn open_device(
|
||||
if attempt > 0 {
|
||||
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
|
||||
}
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends));
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
@@ -1100,9 +1153,14 @@ impl Renderer {
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let max_side = device.limits().max_texture_dimension_2d;
|
||||
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
|
||||
.get_default_config(
|
||||
&adapter,
|
||||
size.width.clamp(1, max_side),
|
||||
size.height.clamp(1, max_side),
|
||||
)
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
config.format = surface_format(&caps.formats, config.format);
|
||||
@@ -1128,14 +1186,22 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone(), hdr::output(&caps))
|
||||
let hdr_output = hdr::output(&caps);
|
||||
crate::graphics::offer_hdr(hdr_output.is_some());
|
||||
if previous.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: HDR output {}",
|
||||
hdr_output.map_or("not offered by this display", hdr::Output::name)
|
||||
);
|
||||
}
|
||||
(config, caps.present_modes.clone(), hdr_output)
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
format,
|
||||
width: size.width.max(1),
|
||||
height: size.height.max(1),
|
||||
width: size.width.clamp(1, max_side),
|
||||
height: size.height.clamp(1, max_side),
|
||||
present_mode: wgpu::PresentMode::Immediate,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
@@ -2016,6 +2082,21 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.TEXT_POLYGON_OFFSET: a text display's text and
|
||||
// background win against the surface they lie on (bias 1, 10).
|
||||
let text_polygon_offset_pipeline = entity_pipeline(
|
||||
"Polygon offset text",
|
||||
None,
|
||||
true,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let entity_pipeline = entity_pipeline(
|
||||
"Entities",
|
||||
None,
|
||||
@@ -2519,6 +2600,8 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
display: DisplayWatch::default(),
|
||||
display_fresh: false,
|
||||
instance,
|
||||
adapter,
|
||||
surface,
|
||||
@@ -2573,6 +2656,7 @@ impl Renderer {
|
||||
lightning_pipeline,
|
||||
lightning: lightning::Renderer::new(),
|
||||
see_through_pipeline,
|
||||
text_polygon_offset_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
@@ -2660,8 +2744,9 @@ impl Renderer {
|
||||
pack_terrain_flat: false,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
readback: readback::Readback::default(),
|
||||
info,
|
||||
suspended: size.width == 0 || size.height == 0,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
@@ -2821,8 +2906,11 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Follows the window to `width` x `height`. A size the swapchain can't take
|
||||
/// ([`drawable_size`]) keeps the last one and draws nothing until the next.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.suspended = width == 0 || height == 0;
|
||||
let max = self.device.limits().max_texture_dimension_2d;
|
||||
self.suspended = !drawable_size(width, height, max);
|
||||
if self.suspended {
|
||||
return;
|
||||
}
|
||||
@@ -3176,7 +3264,7 @@ impl Renderer {
|
||||
) {
|
||||
surface.configure(device, config);
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak);
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
}
|
||||
}
|
||||
fn configure_surface(&self) {
|
||||
@@ -3191,7 +3279,7 @@ impl Renderer {
|
||||
}
|
||||
fn send_hdr_metadata(&self) {
|
||||
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak);
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
@@ -3206,9 +3294,30 @@ impl Renderer {
|
||||
}
|
||||
config
|
||||
}
|
||||
/// The F3 screen's HDR line while the setting is on: the swapchain HDR presents to with
|
||||
/// its paper white and peak, or that the surface offers none.
|
||||
pub fn hdr_line(&self) -> String {
|
||||
match &self.hdr {
|
||||
Some(hdr) => format!(
|
||||
"HDR: {}, {}/{} nits",
|
||||
hdr.output.name(),
|
||||
hdr.paper_white.round(),
|
||||
hdr.peak.round()
|
||||
),
|
||||
None => "HDR: not offered by this display".into(),
|
||||
}
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
|
||||
pub fn set_hdr(
|
||||
&mut self,
|
||||
on: bool,
|
||||
paper_white: f32,
|
||||
peak: f32,
|
||||
black: f32,
|
||||
ui: f32,
|
||||
marker: &std::path::Path,
|
||||
) {
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
// If the game dies before HDR output shows a frame, the marker is still there at
|
||||
@@ -3219,21 +3328,61 @@ impl Renderer {
|
||||
} else if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
// Only HDR output asks the system about the display, so with HDR off the
|
||||
// game never waits on the compositor for it.
|
||||
self.display = if output.is_some() {
|
||||
let (watch, now) = DisplayWatch::new(&self.window);
|
||||
crate::graphics::report(now);
|
||||
self.display_fresh = true;
|
||||
watch
|
||||
} else {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
|
||||
output.name(),
|
||||
output.format()
|
||||
),
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.ui = ui;
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
|
||||
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
|
||||
{
|
||||
hdr.paper_white = paper_white;
|
||||
hdr.peak = peak;
|
||||
hdr.black = black;
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool) {
|
||||
/// Whether what the system reports for the display changed (another output, the
|
||||
/// player recalibrating the desktop): the settings' Auto values follow it then, so
|
||||
/// apply them again.
|
||||
pub fn display_changed(&mut self) -> bool {
|
||||
flush_display_log();
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(now) = self.display.changed() else {
|
||||
return false;
|
||||
};
|
||||
crate::graphics::report(now);
|
||||
true
|
||||
}
|
||||
/// `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) {
|
||||
let taa = taa && self.world_post.supports_taa();
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias;
|
||||
self.world_post.antialias = antialias && !taa;
|
||||
self.world_post.taa = taa;
|
||||
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
|
||||
@@ -3429,14 +3578,14 @@ impl Renderer {
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
}
|
||||
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
|
||||
self.projection(
|
||||
/// The views `render` draws `frame` from.
|
||||
pub fn views(&self, frame: &Frame) -> ViewSet {
|
||||
ViewSet::single(self.screen_view(
|
||||
frame.fov_degrees,
|
||||
frame.yaw,
|
||||
frame.pitch,
|
||||
frame.bob * frame.spin,
|
||||
frame.depth_far,
|
||||
)
|
||||
))
|
||||
}
|
||||
/// The view frustum `render` will cull with, for culling geometry built
|
||||
/// before the frame (entities, block entities) the same way.
|
||||
@@ -3448,13 +3597,14 @@ impl Renderer {
|
||||
bob: Mat4,
|
||||
depth_far: f32,
|
||||
) -> Frustum {
|
||||
let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far));
|
||||
Frustum {
|
||||
planes: frustum(self.projection(fov_degrees, yaw, pitch, bob, depth_far)),
|
||||
planes: frustum(views.cull().view_proj(bob)),
|
||||
}
|
||||
}
|
||||
fn projection(&self, fov_degrees: f32, yaw: f32, pitch: f32, bob: Mat4, far: f32) -> Mat4 {
|
||||
fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View {
|
||||
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
|
||||
world_projection(fov_degrees, aspect, far) * bob * crate::camera::view_matrix(yaw, pitch)
|
||||
View::screen(yaw, pitch, fov_degrees, aspect, far)
|
||||
}
|
||||
fn update_lightmap(
|
||||
&mut self,
|
||||
@@ -3586,7 +3736,12 @@ impl Renderer {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
(Some(f), None) => f.texture.clone(),
|
||||
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
|
||||
// into a texture that has it, then onto the swapchain.
|
||||
(Some(f), None) => self
|
||||
.readback
|
||||
.target(&self.device, &f.texture, self.screenshot.is_some())
|
||||
.unwrap_or_else(|| f.texture.clone()),
|
||||
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
|
||||
};
|
||||
// One command buffer for the frame, after the section copies staged
|
||||
@@ -3614,7 +3769,19 @@ impl Renderer {
|
||||
frame.block_flicker,
|
||||
frame.boss_darken,
|
||||
);
|
||||
let view_proj = self.view_projection(frame);
|
||||
let views = self.views(frame);
|
||||
let pose = frame.bob * frame.spin;
|
||||
let view_proj = views.primary().view_proj(pose);
|
||||
// TAA runs where the world post pass does, not under a shader pack's own.
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let jittered = self.world_post.jitter(
|
||||
[self.config.width, self.config.height],
|
||||
taa,
|
||||
view_proj,
|
||||
frame.camera,
|
||||
) * view_proj;
|
||||
if frame.title {
|
||||
self.panorama.prepare(
|
||||
&self.queue,
|
||||
@@ -3624,8 +3791,7 @@ impl Renderer {
|
||||
frame.pitch,
|
||||
);
|
||||
}
|
||||
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let view = views.primary().view;
|
||||
let sun_angle = frame.sky.sun_angle.to_radians();
|
||||
// Celestial bodies rotate around the X axis; angle 0 = noon.
|
||||
let sun = Vec3::new(0.0, sun_angle.cos(), -sun_angle.sin());
|
||||
@@ -3666,7 +3832,7 @@ impl Renderer {
|
||||
)
|
||||
};
|
||||
let globals = Globals {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
view_proj: jittered.to_cols_array_2d(),
|
||||
fog_color,
|
||||
fog,
|
||||
sky_color: [
|
||||
@@ -3678,8 +3844,8 @@ impl Renderer {
|
||||
misc: [
|
||||
1.0 - frame.sky.sky_light_factor,
|
||||
frame.game_time,
|
||||
aspect,
|
||||
(frame.fov_degrees.to_radians() / 2.0).tan(),
|
||||
views.primary().aspect,
|
||||
views.primary().tan_half_fov,
|
||||
],
|
||||
inverse_view: view.inverse().to_cols_array_2d(),
|
||||
sunrise: frame.sky.sunrise_color,
|
||||
@@ -3706,6 +3872,7 @@ impl Renderer {
|
||||
}
|
||||
lights
|
||||
},
|
||||
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
@@ -3741,14 +3908,15 @@ impl Renderer {
|
||||
bytemuck::bytes_of(&crosshair_globals),
|
||||
);
|
||||
// Frustum culling and camera-relative origins.
|
||||
let cull_proj = views.cull().view_proj(pose);
|
||||
let view = Frustum {
|
||||
planes: frustum(view_proj),
|
||||
planes: frustum(cull_proj),
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera);
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection.
|
||||
let eye = projection_center(view_proj).filter(|_| self.sides_on);
|
||||
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
|
||||
let culling = !frame.title && frame.cave_culling;
|
||||
if culling {
|
||||
crate::spans::time("occlusion", || {
|
||||
@@ -4208,6 +4376,7 @@ impl Renderer {
|
||||
}
|
||||
let [
|
||||
entity_quads,
|
||||
text_quads,
|
||||
crumbling_quads,
|
||||
translucent_crumbling_quads,
|
||||
water_mask_quads,
|
||||
@@ -4220,6 +4389,7 @@ impl Renderer {
|
||||
.update(entities, frame.additive, frame.hand, frame.see_through);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads
|
||||
@@ -4298,7 +4468,7 @@ impl Renderer {
|
||||
self.stars.prepare(
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
view_proj * rotation,
|
||||
jittered * rotation,
|
||||
brightness,
|
||||
);
|
||||
}
|
||||
@@ -4471,6 +4641,8 @@ impl Renderer {
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -4622,8 +4794,7 @@ impl Renderer {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
|
||||
store: if cull.is_some() || pack_reads_world {
|
||||
store: if cull.is_some() || keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
@@ -4698,7 +4869,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_reads_world,
|
||||
keep_depth,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -4728,7 +4899,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_reads_world,
|
||||
keep_depth,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -4757,6 +4928,13 @@ impl Renderer {
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 {
|
||||
pass.set_pipeline(&self.text_polygon_offset_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 {
|
||||
pass.set_pipeline(&self.translucent_item_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -4776,7 +4954,11 @@ impl Renderer {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..crumbling_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.draw_indexed(
|
||||
0..crumbling_quads * 6,
|
||||
((entity_quads + text_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if shadow_quads > 0 {
|
||||
@@ -4802,7 +4984,8 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..water_mask_quads * 6,
|
||||
((entity_quads + crumbling_quads + translucent_crumbling_quads) * 4) as i32,
|
||||
((entity_quads + text_quads + crumbling_quads + translucent_crumbling_quads)
|
||||
* 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -4820,6 +5003,7 @@ impl Renderer {
|
||||
pass.draw_indexed(
|
||||
0..swirl_quads * 6,
|
||||
((entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads)
|
||||
@@ -4913,7 +5097,7 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_crumbling_quads * 6,
|
||||
((entity_quads + crumbling_quads) * 4) as i32,
|
||||
((entity_quads + text_quads + crumbling_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
@@ -5041,7 +5225,8 @@ impl Renderer {
|
||||
native.timing.as_mut(),
|
||||
);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
self.world_post
|
||||
.finish(&self.queue, &mut encoder, &view_target);
|
||||
}
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder);
|
||||
@@ -5078,6 +5263,10 @@ impl Renderer {
|
||||
((world_quads + additive_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(&mut encoder);
|
||||
}
|
||||
}
|
||||
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
|
||||
self.entity_outline.draw(
|
||||
@@ -5086,6 +5275,13 @@ impl Renderer {
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
|
||||
// runs right after `renderLevel`).
|
||||
let readback = self
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -5189,24 +5385,20 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
.take()
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
|
||||
});
|
||||
self.uploads.finish(&encoder);
|
||||
if has_section_copies {
|
||||
self.section_copy_bytes = 0;
|
||||
self.section_uploads.finish(&encoder);
|
||||
}
|
||||
if let Some(f) = &surface_frame {
|
||||
self.readback
|
||||
.present(&self.device, &mut encoder, &f.texture);
|
||||
}
|
||||
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
|
||||
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
|
||||
}
|
||||
@@ -5227,9 +5419,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
if let Some((request, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
@@ -5279,43 +5471,68 @@ impl Renderer {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
|
||||
fn padded_row(width: u32) -> u32 {
|
||||
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
/// Encodes the copy of `texture`'s `size` pixels into a mappable buffer.
|
||||
fn copy_frame(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
[width, height]: [u32; 2],
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon screenshot"),
|
||||
size: u64::from(padded_row(width)) * u64::from(height),
|
||||
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(padded_row(width)),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
fn padded_row(&self) -> u32 {
|
||||
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
fn copy_frame(
|
||||
&self,
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon screenshot"),
|
||||
size: u64::from(self.padded_row()) * u64::from(self.config.height),
|
||||
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(self.padded_row()),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.config.width,
|
||||
height: self.config.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return Err("this surface does not support frame readback".into());
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
@@ -5330,7 +5547,7 @@ impl Renderer {
|
||||
let (width, height, row) = (
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
self.padded_row() as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
@@ -5350,8 +5567,18 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
@@ -5361,6 +5588,42 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
let (side, cx, cy) = if width > height {
|
||||
(height, (width - height) / 2, 0)
|
||||
} else {
|
||||
(width, 0, (height - width) / 2)
|
||||
};
|
||||
let [tw, th] = size;
|
||||
let mut out = vec![0u8; (tw * th * 4) as usize];
|
||||
for ty in 0..th {
|
||||
let y0 = cy + ty * side / th;
|
||||
let y1 = cy + (ty + 1) * side / th;
|
||||
for tx in 0..tw {
|
||||
let x0 = cx + tx * side / tw;
|
||||
let x1 = cx + (tx + 1) * side / tw;
|
||||
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = ((y * width + x) * 4) as usize;
|
||||
r += u32::from(pixels[p]);
|
||||
g += u32::from(pixels[p + 1]);
|
||||
b += u32::from(pixels[p + 2]);
|
||||
}
|
||||
}
|
||||
let count = (x1 - x0) * (y1 - y0);
|
||||
let o = ((ty * tw + tx) * 4) as usize;
|
||||
out[o..o + 4].copy_from_slice(&[
|
||||
(r / count) as u8,
|
||||
(g / count) as u8,
|
||||
(b / count) as u8,
|
||||
255,
|
||||
]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -5452,6 +5715,23 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn drawable_size_skips_minimized_and_oversized_windows() {
|
||||
use super::drawable_size;
|
||||
assert!(drawable_size(1280, 720, 8192));
|
||||
assert!(drawable_size(1, 1, 8192));
|
||||
assert!(drawable_size(8192, 8192, 8192));
|
||||
// Minimized.
|
||||
assert!(!drawable_size(0, 720, 8192));
|
||||
assert!(!drawable_size(1280, 0, 8192));
|
||||
assert!(!drawable_size(0, 0, 8192));
|
||||
// One past the device's largest texture.
|
||||
assert!(!drawable_size(8193, 720, 8192));
|
||||
assert!(!drawable_size(1280, 8193, 8192));
|
||||
// The crash: a window `SetWindowPos` made 65535 pixels tall, on an RTX 5080 (32768).
|
||||
assert!(!drawable_size(984, 65496, 32768));
|
||||
assert!(!drawable_size(984, u32::MAX, 32768));
|
||||
}
|
||||
#[test]
|
||||
fn surface_is_8_bit_and_not_srgb() {
|
||||
use super::surface_format;
|
||||
@@ -5516,6 +5796,7 @@ mod tests {
|
||||
(11, 2),
|
||||
(15, 10),
|
||||
(7, 3),
|
||||
(35, 11),
|
||||
(4, 4),
|
||||
(12, 7),
|
||||
]
|
||||
@@ -5525,7 +5806,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 0, 0, 1, 1, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -5533,7 +5814,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|q| q[0].light)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3, 4, 2, 7, 9, 10, 5, 6, 8]
|
||||
[0, 1, 3, 4, 11, 2, 7, 9, 10, 5, 6, 8]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -5704,7 +5985,7 @@ mod tests {
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends)
|
||||
super::instance_descriptor(backends, None)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
|
||||
};
|
||||
@@ -5873,3 +6154,101 @@ mod sprite_upload_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the system says about the window's display, kept up to date: on Wayland, the
|
||||
/// compositor's preferred image description of the surface.
|
||||
#[derive(Default)]
|
||||
struct DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: Option<horizon_hdr::wayland::Watch>,
|
||||
}
|
||||
impl DisplayWatch {
|
||||
/// Starts watching, with what the system reports now.
|
||||
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
|
||||
let watch = Self {
|
||||
wayland: Some(wayland),
|
||||
};
|
||||
return (watch, now.map(reported));
|
||||
}
|
||||
let _ = window;
|
||||
(Self::default(), None)
|
||||
}
|
||||
/// What the system reports, if that changed since the last call.
|
||||
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(wayland) = &mut self.wayland {
|
||||
return wayland.changed().map(|now| now.map(reported));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The log of what the system reports about the display: one line per change. The
|
||||
/// renderer is made anew a few times while the game starts and each asks again, and a
|
||||
/// slider dragged in the desktop's settings changes the values many times a second, so
|
||||
/// a change within [`DISPLAY_LOG_QUIET`] of the last line waits until the values have
|
||||
/// held that long, and only the one they settled on is logged.
|
||||
struct DisplayLog {
|
||||
last: String,
|
||||
at: Option<std::time::Instant>,
|
||||
pending: Option<(String, std::time::Instant)>,
|
||||
}
|
||||
static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(DisplayLog {
|
||||
last: String::new(),
|
||||
at: None,
|
||||
pending: None,
|
||||
});
|
||||
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
fn log_display(line: String) {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if line == log.last {
|
||||
log.pending = None;
|
||||
} else if log.at.is_none_or(|at| now - at >= DISPLAY_LOG_QUIET) {
|
||||
log::info!("{line}");
|
||||
(log.last, log.at, log.pending) = (line, Some(now), None);
|
||||
} else {
|
||||
log.pending = Some((line, now));
|
||||
}
|
||||
}
|
||||
/// Logs a change [`log_display`] held back, once the values have held for
|
||||
/// [`DISPLAY_LOG_QUIET`].
|
||||
fn flush_display_log() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((_, since)) = &log.pending
|
||||
&& now - *since >= DISPLAY_LOG_QUIET
|
||||
&& let Some((line, _)) = log.pending.take()
|
||||
{
|
||||
log::info!("{line}");
|
||||
(log.last, log.at) = (line, Some(now));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a compositor's luminances mean in HDR output's scale.
|
||||
///
|
||||
/// KWin maps content's reference white (203 nits in PQ, BT.2408) onto its own, so the
|
||||
/// desktop's white is 203 there and the peak is the target's maximum over that ratio.
|
||||
/// Checked on KWin. Hyprland (0.57) reports the standard 203, and shows HDR windows below
|
||||
/// the SDR white of its desktop (which it doesn't report), so Auto can't match its desktop
|
||||
/// there. Other compositors are unchecked. The peak is kept within the settings' lowest and
|
||||
/// PQ's top, so the metadata never ends below the black level.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
let peak = (l.target_max * 203.0 / l.reference.max(1.0)).round() as u32;
|
||||
log_display(format!(
|
||||
"Horizon renderer: the compositor shows this window with reference white {} nits, \
|
||||
{}..{} nits: peak {peak} nits in HDR output's scale",
|
||||
l.reference, l.target_min, l.target_max,
|
||||
));
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,11 @@ impl SpriteAnimator {
|
||||
let mut counts: Vec<usize> = Vec::new();
|
||||
let mut sprites = Vec::with_capacity(animations.len());
|
||||
for animation in animations {
|
||||
let chains: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| animation.pixels(frame))
|
||||
.collect();
|
||||
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
|
||||
let (chains, edge) = source_chains(&pixels, size, settings, animation.layer);
|
||||
let bytes: u64 = chains.iter().map(|chain| chain.len() as u64).sum();
|
||||
if bytes > limit {
|
||||
log::warn!(
|
||||
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
|
||||
@@ -124,7 +125,7 @@ impl SpriteAnimator {
|
||||
let data = &mut frames[buffer];
|
||||
let offsets = chains
|
||||
.iter()
|
||||
.map(|(chain, _)| {
|
||||
.map(|chain| {
|
||||
let offset = (data.len() / 4) as u32;
|
||||
data.extend_from_slice(chain);
|
||||
offset
|
||||
@@ -142,7 +143,7 @@ impl SpriteAnimator {
|
||||
shown: None,
|
||||
layer: animation.layer % split,
|
||||
frames: offsets,
|
||||
edge: chains[0].1,
|
||||
edge,
|
||||
group,
|
||||
});
|
||||
}
|
||||
@@ -424,15 +425,63 @@ fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
/// larger levels: that chain, which the shader repeats. Any other sprite: its
|
||||
/// chain at the array's size.
|
||||
#[cfg(test)]
|
||||
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
|
||||
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
|
||||
let edge = if native.is_power_of_two() && native < size {
|
||||
native
|
||||
let (mut chains, edge) = source_chains(&[pixels], size, settings, layer);
|
||||
(chains.swap_remove(0), edge)
|
||||
}
|
||||
|
||||
/// Every frame's chain, laid out as one frame's `source_chain`, but mipmapped as
|
||||
/// vanilla's `SpriteContents.increaseMipLevel` does: over the whole animation
|
||||
/// sheet (the frames stacked), so `solidify` and the alpha-to-coverage scale
|
||||
/// of each level are the sheet's, not each frame's. A frame alone has a
|
||||
/// single texel square at its 2x2 level, and its coverage scale could round
|
||||
/// a whole far frame transparent (kelp blinking out at distance).
|
||||
fn source_chains(
|
||||
frames: &[&[u8]],
|
||||
size: u32,
|
||||
settings: &[Settings],
|
||||
layer: u32,
|
||||
) -> (Vec<Vec<u8>>, u32) {
|
||||
let s = settings.get(layer as usize).copied().unwrap_or_default();
|
||||
let edge = if s.native_size.is_power_of_two() && s.native_size < size {
|
||||
s.native_size
|
||||
} else {
|
||||
size
|
||||
};
|
||||
let frame = crate::sprites::resample(pixels, size, edge);
|
||||
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
|
||||
let mut sheet = crate::mipmap::Image {
|
||||
width: edge,
|
||||
height: edge * frames.len() as u32,
|
||||
pixels: Vec::with_capacity((edge * edge) as usize * frames.len()),
|
||||
};
|
||||
for frame in frames {
|
||||
let frame = crate::sprites::resample(frame, size, edge);
|
||||
sheet.pixels.extend_from_slice(frame.as_chunks::<4>().0);
|
||||
}
|
||||
let has_transparent = s.has_transparent || sheet.pixels.iter().any(|p| p[3] == 0);
|
||||
let levels = crate::mipmap::generate(
|
||||
sheet,
|
||||
edge.trailing_zeros(),
|
||||
Settings {
|
||||
has_transparent,
|
||||
..s
|
||||
},
|
||||
);
|
||||
let chains = (0..frames.len())
|
||||
.map(|frame| {
|
||||
let mut chain = Vec::new();
|
||||
for level in &levels {
|
||||
let texels = (level.width * level.width) as usize;
|
||||
chain.extend(
|
||||
level.pixels[frame * texels..(frame + 1) * texels]
|
||||
.iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
chain
|
||||
})
|
||||
.collect();
|
||||
(chains, edge)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -572,6 +621,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kelp_frames_share_the_sheet_mips() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root
|
||||
.join("assets/minecraft/textures/block/kelp.png")
|
||||
.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in ["kelp", "kelp_plant", "seagrass", "tall_seagrass_top"] {
|
||||
sprites
|
||||
.insert(&root, &format!("minecraft:block/{name}"))
|
||||
.unwrap();
|
||||
}
|
||||
sprites.finish();
|
||||
// A level drawn nowhere: no texel passes the cutout's alpha test.
|
||||
let blank = |chain: &[u8], edge: u32| {
|
||||
let mut start = 0;
|
||||
let mut level = edge;
|
||||
let mut blank = Vec::new();
|
||||
while level > 0 {
|
||||
let bytes = (level * level * 4) as usize;
|
||||
let texels = &chain[start..start + bytes];
|
||||
blank.push(texels.as_chunks::<4>().0.iter().all(|p| p[3] < 128));
|
||||
start += bytes;
|
||||
level >>= 1;
|
||||
}
|
||||
blank
|
||||
};
|
||||
let mut blinked = 0;
|
||||
for animation in &sprites.animations {
|
||||
let size = sprites.size;
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|f| animation.pixels(f))
|
||||
.collect();
|
||||
let (chains, edge) = source_chains(&pixels, size, &sprites.mips, animation.layer);
|
||||
for (frame, chain) in chains.iter().enumerate() {
|
||||
// The 1x1 level has no texel square to measure coverage on
|
||||
// (vanilla leaves it unscaled); every larger level shows.
|
||||
let levels = blank(chain, edge);
|
||||
assert!(
|
||||
!levels[..levels.len() - 1].contains(&true),
|
||||
"layer {} frame {frame}: {levels:?}",
|
||||
animation.layer
|
||||
);
|
||||
let (alone, _) = source_chain(pixels[frame], size, &sprites.mips, animation.layer);
|
||||
let alone = blank(&alone, edge);
|
||||
blinked += usize::from(alone[..alone.len() - 1].contains(&true));
|
||||
}
|
||||
}
|
||||
// Mipmapped frame by frame, some kelp frames vanished at the 2x2 level.
|
||||
assert!(blinked > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_matches_f32_mix_but_for_exact_halves() {
|
||||
// The CPU blend this replaced: f32 mix, then rounded. They differ
|
||||
@@ -730,8 +836,11 @@ mod tests {
|
||||
queue.submit([encoder.finish()]);
|
||||
for animation in &animations {
|
||||
let blend = animation.blend(tick);
|
||||
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
|
||||
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|f| animation.pixels(f))
|
||||
.collect();
|
||||
let (chains, edge) = source_chains(&pixels, size, &mips, animation.layer);
|
||||
let (current, next) = (&chains[blend.frame], &chains[blend.next]);
|
||||
let layers = read_back(
|
||||
&device,
|
||||
&queue,
|
||||
@@ -739,7 +848,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
layers[(animation.layer % split) as usize],
|
||||
reference(¤t, &next, blend.millis, edge, size),
|
||||
reference(current, next, blend.millis, edge, size),
|
||||
"layer {} at tick {tick}",
|
||||
animation.layer
|
||||
);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Splitscreen: several players' renderers draw offscreen on one device, and the
|
||||
//! compositor copies each frame into its place on a window's surface. A window holds views but
|
||||
//! never owns them, so a view can later move to another window (another monitor, detached).
|
||||
use super::Renderer;
|
||||
use crate::Result;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
pub struct Compositor {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
present_modes: Vec<wgpu::PresentMode>,
|
||||
suspended: bool,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// A compositor for `window` on this renderer's device, presenting on the surface it gave up.
|
||||
pub fn compositor(&self, window: Arc<Window>) -> Result<Compositor> {
|
||||
// The surface this renderer set aside for its successor (`release_surface`), else a new
|
||||
// one: a window can't keep two.
|
||||
let spare = self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
let surface = match spare {
|
||||
Some(surface) => surface,
|
||||
None => self.instance.create_surface(window.clone())?,
|
||||
};
|
||||
Compositor::new(
|
||||
surface,
|
||||
&self.adapter,
|
||||
&self.device,
|
||||
&self.queue,
|
||||
window.inner_size().into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The last offscreen frame (`--offscreen`), for the compositor.
|
||||
pub fn offscreen_frame(&self) -> Option<&wgpu::Texture> {
|
||||
self.offscreen.as_ref()
|
||||
}
|
||||
|
||||
/// Splitscreen turned off: a compositor's surface, set aside for the renderer that draws on
|
||||
/// the window again (it takes it, as after `release_surface`).
|
||||
pub fn take_back(&self, compositor: Compositor) {
|
||||
*self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(compositor.surface);
|
||||
}
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Present offscreen BGRA frames on a compatible surface using their existing device.
|
||||
/// The surface must support COPY_DST and Bgra8Unorm; unsupported surfaces return an error.
|
||||
pub fn new(
|
||||
surface: wgpu::Surface<'static>,
|
||||
adapter: &wgpu::Adapter,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
) -> Result<Self> {
|
||||
let caps = surface.get_capabilities(adapter);
|
||||
// ponytail: a plain texture copy needs the offscreen frames' format; a blit pass would
|
||||
// take any surface format (Rgba8Unorm on some Linux drivers).
|
||||
if !caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
|
||||
return Err("surface does not support copying offscreen frames".into());
|
||||
}
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
if !caps.formats.contains(&format) {
|
||||
return Err(format!(
|
||||
"splitscreen needs a {format:?} surface; this one offers {:?}",
|
||||
caps.formats
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
|
||||
format,
|
||||
width: size[0].clamp(1, max),
|
||||
height: size[1].clamp(1, max),
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
surface.configure(device, &config);
|
||||
Ok(Compositor {
|
||||
surface,
|
||||
device: device.clone(),
|
||||
queue: queue.clone(),
|
||||
config,
|
||||
present_modes: caps.present_modes,
|
||||
suspended: !super::drawable_size(size[0], size[1], max),
|
||||
})
|
||||
}
|
||||
|
||||
/// Vsync on: presents wait for the display. Off: they don't (Mailbox, else Immediate).
|
||||
pub fn set_vsync(&mut self, vsync: bool) {
|
||||
let mode = [wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate]
|
||||
.into_iter()
|
||||
.find(|m| !vsync && self.present_modes.contains(m))
|
||||
.unwrap_or(wgpu::PresentMode::Fifo);
|
||||
if mode != self.config.present_mode {
|
||||
self.config.present_mode = mode;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.suspended =
|
||||
!super::drawable_size(width, height, self.device.limits().max_texture_dimension_2d);
|
||||
if !self.suspended {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy each view's frame to its top-left corner on the window and present. Frames that
|
||||
/// don't fit are clipped; parts of the window no view covers (a view before its first frame,
|
||||
/// one still at its old size after a resize) are black. Nothing when the window has no frame
|
||||
/// to give (occluded, resized, timed out). Returns true when a frame was submitted.
|
||||
pub fn present(&mut self, views: &[(&wgpu::Texture, [u32; 2])]) -> bool {
|
||||
if self.suspended {
|
||||
return false;
|
||||
}
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(f)
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
|
||||
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return false;
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Splitscreen compositor"),
|
||||
});
|
||||
let target = frame.texture.create_view(&Default::default());
|
||||
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Splitscreen compositor clear"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
}));
|
||||
for &(texture, [x, y]) in views {
|
||||
let [width, height] = clipped_size(
|
||||
[texture.width(), texture.height()],
|
||||
[x, y],
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
if width == 0 || height == 0 {
|
||||
continue;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
origin: wgpu::Origin3d { x, y, z: 0 },
|
||||
..frame.texture.as_image_copy()
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.queue.submit([encoder.finish()]);
|
||||
self.queue.present(frame);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies never reach past the surface, including while a resized view still has its old frame.
|
||||
fn clipped_size(view: [u32; 2], at: [u32; 2], surface: [u32; 2]) -> [u32; 2] {
|
||||
[
|
||||
view[0].min(surface[0].saturating_sub(at[0])),
|
||||
view[1].min(surface[1].saturating_sub(at[1])),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frames_are_clipped_to_the_window_during_resize() {
|
||||
assert_eq!(clipped_size([640, 480], [0, 0], [1280, 720]), [640, 480]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [1000, 600], [1280, 720]),
|
||||
[280, 120]
|
||||
);
|
||||
assert_eq!(clipped_size([640, 480], [1280, 720], [1280, 720]), [0, 0]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [u32::MAX, 0], [1280, 720]),
|
||||
[0, 480]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
use super::Vertex;
|
||||
|
||||
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), energy swirls
|
||||
/// (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
|
||||
/// ordinary entities, world text drawn with a depth bias (35,
|
||||
/// `RenderPipelines.TEXT_POLYGON_OFFSET`), breaking cracks (11), translucent
|
||||
/// breaking cracks (12), the boat water mask (13, `RenderPipelines.WATER_MASK`),
|
||||
/// energy swirls (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
|
||||
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
|
||||
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
|
||||
/// and translucent item quads (32, drawn without depth writes).
|
||||
const MATERIALS: usize = 8;
|
||||
const MATERIALS: usize = 9;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
|
||||
@@ -24,17 +25,21 @@ pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
|
||||
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
|
||||
/// A translucent item with an enchantment glint.
|
||||
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
|
||||
/// drawn like an ordinary entity, pulled toward the camera.
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32) -> usize {
|
||||
match layer {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
ENERGY_SWIRL_OVERLAY => 4,
|
||||
SHADOW_OVERLAY => 5,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 6,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
WATER_MASK_OVERLAY => 4,
|
||||
ENERGY_SWIRL_OVERLAY => 5,
|
||||
SHADOW_OVERLAY => 6,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 7,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -102,9 +107,10 @@ mod tests {
|
||||
) -> (Vec<Vertex>, [u32; MATERIALS]) {
|
||||
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
|
||||
let mut counts = [0; MATERIALS];
|
||||
for (i, layer) in [0, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
|
||||
for (i, layer) in [0, 35, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
let material = match quad[0].layer {
|
||||
35 => 35,
|
||||
11 => 11,
|
||||
12 => 12,
|
||||
13 => 13,
|
||||
@@ -130,7 +136,7 @@ mod tests {
|
||||
pos: [index as f32, 2.0, 3.0],
|
||||
uv: [index as f32, 5.0],
|
||||
layer: if mixed {
|
||||
[7, 11, 14, 12, 4, 13, 15, 0][index / 4 % 8]
|
||||
[7, 11, 14, 35, 12, 4, 13, 15, 0][index / 4 % 9]
|
||||
} else {
|
||||
0
|
||||
},
|
||||
|
||||
@@ -14,6 +14,12 @@ pub enum Output {
|
||||
}
|
||||
|
||||
impl Output {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pq => "HDR10 (PQ)",
|
||||
Self::ScRgb => "scRGB",
|
||||
}
|
||||
}
|
||||
pub fn format(self) -> wgpu::TextureFormat {
|
||||
match self {
|
||||
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
@@ -57,6 +63,12 @@ struct Targets {
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
|
||||
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
|
||||
clear_alpha: wgpu::RenderPipeline,
|
||||
/// Scales the frame's alpha down to 1/255 where the hand drew (`mark_hand`), so HDR
|
||||
/// output keeps the hand at the world's brightness.
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
@@ -67,6 +79,10 @@ pub struct Hdr {
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
|
||||
pub black: f32,
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
@@ -132,6 +148,48 @@ impl Hdr {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&device.create_pipeline_layout(&Default::default())),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_clear_alpha"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let clear_alpha = alpha_pass("Horizon HDR UI mask", None);
|
||||
// Alpha times the blend constant (1/255): the hand's 1 becomes 1/255, the world's 0
|
||||
// stays 0.
|
||||
let scale_alpha = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Constant,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let hand_mark = alpha_pass(
|
||||
"Horizon HDR hand mark",
|
||||
Some(wgpu::BlendState {
|
||||
color: scale_alpha,
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 32,
|
||||
@@ -141,6 +199,8 @@ impl Hdr {
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params,
|
||||
output,
|
||||
format,
|
||||
@@ -148,6 +208,8 @@ impl Hdr {
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
ui: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +266,9 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it.
|
||||
/// 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.
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
@@ -214,6 +278,56 @@ impl Hdr {
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR UI mask"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.clear_alpha);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR hand mark"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.hand_mark);
|
||||
let a = 1.0 / 255.0;
|
||||
pass.set_blend_constant(wgpu::Color {
|
||||
r: a,
|
||||
g: a,
|
||||
b: a,
|
||||
a,
|
||||
});
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Writes the finished frame to the swapchain image `target`.
|
||||
@@ -234,8 +348,8 @@ impl Hdr {
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
0.0,
|
||||
0.0,
|
||||
self.black.clamp(0.0, paper),
|
||||
self.ui.clamp(0.25, 1.0),
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
|
||||
@@ -15,10 +15,19 @@ pub struct Bolt {
|
||||
#[derive(Default)]
|
||||
pub struct Input<'a> {
|
||||
pub bolts: &'a [Bolt],
|
||||
/// Current atlas white texel; a changed rect invalidates retained vertices.
|
||||
/// Current atlas white texel ([`white_uv`]); a changed rect invalidates
|
||||
/// retained vertices.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
/// The atlas point an untextured quad (`RenderTypes.lightning` has no
|
||||
/// texture) samples: the middle of the white sprite. Its corner (`u0`, `v0`)
|
||||
/// is on the sprite's border, where the atlas's nearest sampling picks the
|
||||
/// neighbouring, transparent texels for most fragments.
|
||||
pub fn white_uv(white: &crate::hud::Rect) -> [f32; 2] {
|
||||
[(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0]
|
||||
}
|
||||
|
||||
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
|
||||
pub(super) const QUADS: u32 = 4 * 14 * 4;
|
||||
|
||||
|
||||
@@ -496,3 +496,253 @@ fn retained_gpu_draw_matches_transient_additive_draw() {
|
||||
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon/white` (4x4 texels) at (40, 12) of a 64x32 atlas.
|
||||
const ATLAS: (u32, u32) = (64, 32);
|
||||
fn white_sprite() -> crate::hud::Rect {
|
||||
let (w, h) = (ATLAS.0 as f32, ATLAS.1 as f32);
|
||||
crate::hud::Rect {
|
||||
u0: 40.0 / w,
|
||||
v0: 12.0 / h,
|
||||
u1: 44.0 / w,
|
||||
v1: 16.0 / h,
|
||||
w: 4,
|
||||
h: 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The white sprite's corner is on its border with the next atlas texels,
|
||||
/// which nearest sampling picks for most of a bolt's fragments (#3171): the
|
||||
/// point sampled is at least a texel inside the sprite.
|
||||
#[test]
|
||||
fn white_uv_is_inside_the_white_sprite() {
|
||||
let [u, v] = white_uv(&white_sprite());
|
||||
let (x, y) = (u * ATLAS.0 as f32, v * ATLAS.1 as f32);
|
||||
assert!((41.0..=43.0).contains(&x), "u at texel {x}");
|
||||
assert!((13.0..=15.0).contains(&y), "v at texel {y}");
|
||||
}
|
||||
|
||||
/// A bolt seen from 14 blocks through the atlas as the game binds it (nearest
|
||||
/// sampling, only the white sprite opaque) draws the same pixels as through a
|
||||
/// texture that is white everywhere (#3171: the sprite's corner left a few
|
||||
/// scattered pixels). Run explicitly, like the test above.
|
||||
#[test]
|
||||
#[ignore = "requires GPU; run separately with --ignored --exact"]
|
||||
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let offsets = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &offsets,
|
||||
}),
|
||||
];
|
||||
// The lightning pipeline (gfx.rs): additive, into RGBA8.
|
||||
let additive = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_lightning"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: additive,
|
||||
alpha: additive,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// The game's camera: 70 degrees, reversed depth, the bolt 14 blocks ahead.
|
||||
let globals = crate::gfx::Globals {
|
||||
view_proj: (glam::Mat4::perspective_rh(70f32.to_radians(), 1.0, 400.0, 0.05)
|
||||
* glam::Mat4::look_at_rh(glam::Vec3::ZERO, glam::Vec3::Z, glam::Vec3::Y))
|
||||
.to_cols_array_2d(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::bytes_of(&globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let texture = |(width, height): (u32, u32), data: &[u8]| {
|
||||
device
|
||||
.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
data,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let white = texture((1, 1), &[255; 4]);
|
||||
// Transparent but for the white sprite.
|
||||
let mut texels = vec![0u8; (ATLAS.0 * ATLAS.1 * 4) as usize];
|
||||
for y in 12..16 {
|
||||
for x in 40..44 {
|
||||
let i = ((y * ATLAS.0 + x) * 4) as usize;
|
||||
texels[i..i + 4].copy_from_slice(&[255; 4]);
|
||||
}
|
||||
}
|
||||
let atlas = texture(ATLAS, &texels);
|
||||
// The atlas sampler (gfx.rs): wgpu's default, nearest.
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS);
|
||||
let size = 256;
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let draw = |view: &wgpu::TextureView, uv: [f32; 2]| -> Vec<u8> {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&white),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let bolts = [Bolt {
|
||||
seed: 123,
|
||||
at: [0.0, -1.62, 14.0],
|
||||
}];
|
||||
let input = Input { bolts: &bolts, uv };
|
||||
let mut renderer = Renderer::new();
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
renderer.prepare(&device, &mut uploads, &mut encoder, &input, DVec3::ZERO);
|
||||
let color = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
{
|
||||
let target = color.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
renderer.draw(&mut pass, &input);
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(size * size * 4),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
read.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
};
|
||||
let lit = |pixels: &[u8]| pixels.chunks(4).filter(|p| p[3] != 0).count();
|
||||
let expected = draw(&white, [0.5; 2]);
|
||||
let drawn = draw(&atlas, white_uv(&white_sprite()));
|
||||
assert!(lit(&expected) > 1000, "{} pixels", lit(&expected));
|
||||
assert_eq!(
|
||||
lit(&drawn),
|
||||
lit(&expected),
|
||||
"pixels drawn through the atlas"
|
||||
);
|
||||
assert!(drawn == expected, "the bolt's colours through the atlas");
|
||||
}
|
||||
@@ -476,10 +476,12 @@ fn sampler(
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `create` and returns its validation error, if any.
|
||||
/// Runs `create` and returns its validation error, if any. A panic while it runs is an error
|
||||
/// too: naga's backends turn a module they cannot write into a panic as the driver gets the
|
||||
/// pipeline (#3525), which a pack's program must not turn into a crash.
|
||||
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let value = create();
|
||||
let value = crate::crash::contain(create);
|
||||
match pollster::block_on(scope.pop()) {
|
||||
Some(e) => {
|
||||
// The details are in the error's description or its sources.
|
||||
@@ -494,7 +496,7 @@ fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, St
|
||||
}
|
||||
Err(message)
|
||||
}
|
||||
None => Ok(value),
|
||||
None => value.map_err(|panic| format!("the shader compiler failed: {panic}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2629,6 +2631,38 @@ mod tests {
|
||||
assert_eq!(used, [1, 2, 3]);
|
||||
}
|
||||
|
||||
/// #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]
|
||||
fn a_shader_compiler_panic_skips_the_pass() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
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 failed = checked(&device, || -> wgpu::Buffer {
|
||||
panic!("internal error: entered unreachable code: Expression [89] is not cached!")
|
||||
});
|
||||
let error = failed.expect_err("the panic comes back as an error");
|
||||
assert!(error.contains("Expression [89] is not cached!"), "{error}");
|
||||
let buffer = |usage| {
|
||||
checked(&device, || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 16,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
};
|
||||
// The device still makes things, and its errors still come back.
|
||||
assert!(buffer(wgpu::BufferUsages::UNIFORM).is_ok());
|
||||
assert!(buffer(wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::MAP_WRITE).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
|
||||
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
|
||||
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
|
||||
//! then drawn onto the swapchain image as a fullscreen triangle.
|
||||
|
||||
/// The texture a frame to read back is drawn into, and the pass that puts it
|
||||
/// on the swapchain afterwards.
|
||||
#[derive(Default)]
|
||||
pub struct Readback {
|
||||
texture: Option<wgpu::Texture>,
|
||||
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
|
||||
/// A frame drew into `texture` and still has to reach the swapchain.
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl Readback {
|
||||
/// The texture this frame draws into instead of the swapchain image
|
||||
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
|
||||
/// image cannot be copied from.
|
||||
pub fn target(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Texture,
|
||||
screenshot: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return None;
|
||||
}
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self
|
||||
.texture
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != surface.size() || t.format() != format)
|
||||
{
|
||||
self.texture = Some(device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon screenshot frame"),
|
||||
size: surface.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
}));
|
||||
}
|
||||
self.drawn = true;
|
||||
self.texture.clone()
|
||||
}
|
||||
|
||||
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
|
||||
/// when this frame drew straight into it.
|
||||
pub fn present(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
if !std::mem::take(&mut self.drawn) {
|
||||
return;
|
||||
}
|
||||
let Some(texture) = &self.texture else {
|
||||
return;
|
||||
};
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
|
||||
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
|
||||
}
|
||||
let target = surface.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
let source = texture.create_view(&Default::default());
|
||||
if let Some((_, blitter)) = &self.blitter {
|
||||
blitter.copy(device, encoder, &source, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn texture(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 70,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: wgpu::Color) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(color),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// The last pixel of the last row of a 70x3 frame read back by `copy_frame`.
|
||||
fn last_pixel(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
let pixels = read(device, buffer);
|
||||
let row = 70usize * 4;
|
||||
let last = row.next_multiple_of(256) * 2 + row - 4;
|
||||
pixels[last..last + 4].to_vec()
|
||||
}
|
||||
|
||||
/// The renderer's screenshot path on a surface like OpenGL's (#3510): the
|
||||
/// frame is drawn into `target`, read back with `copy_frame` and drawn on
|
||||
/// the swapchain, with no validation error (the copy used to need the
|
||||
/// swapchain's missing `COPY_SRC`), and the swapchain shows the frame.
|
||||
#[test]
|
||||
fn screenshot_of_a_surface_without_copy_src() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
// Sampled only so the test can look at what reached it.
|
||||
let surface = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let check = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
|
||||
let mut readback = Readback::default();
|
||||
let colors = [
|
||||
(1.0, [0, 0, 255, 255]),
|
||||
// A second screenshot reuses the texture.
|
||||
(0.0, [255, 0, 0, 255]),
|
||||
];
|
||||
for (r, bgra) in colors {
|
||||
let color = wgpu::Color {
|
||||
r,
|
||||
g: 0.0,
|
||||
b: 1.0 - r,
|
||||
a: 1.0,
|
||||
};
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback
|
||||
.target(&device, &surface, true)
|
||||
.unwrap_or_else(|| surface.clone());
|
||||
clear(&mut encoder, &frame, color);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
|
||||
readback.present(&device, &mut encoder, &surface);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
panic!("{error}");
|
||||
}
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
}
|
||||
assert!(!readback.drawn);
|
||||
}
|
||||
|
||||
/// A surface that can be copied from, or a frame nobody reads back,
|
||||
/// draws straight into the swapchain image.
|
||||
#[test]
|
||||
fn frames_draw_straight_to_a_readable_surface() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut readback = Readback::default();
|
||||
let readable = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
assert!(readback.target(&device, &readable, true).is_none());
|
||||
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
assert!(readback.target(&device, &surface, false).is_none());
|
||||
assert!(readback.texture.is_none() && !readback.drawn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The sunrise/sunset fan (`SkyRenderer.renderSunriseAndSunset`) as the sky
|
||||
//! shader tests it. The fan's 16 triangles depend only on the sunrise alpha
|
||||
//! and the horizon the sun is on, so their vertices are built here once a
|
||||
//! frame instead of in every sky pixel (#3143: with the per-pixel build, four
|
||||
//! `sin`/`cos` per triangle, the sky pass took 10.2 ms at dawn at 1080p on an
|
||||
//! Intel UHD 730 against 0.6 ms by day; now 1.8 ms).
|
||||
//!
|
||||
//! Möller-Trumbore from the camera reduces to triple products: with apex A
|
||||
//! and rim vertices r0, r1, a ray d hits at barycentrics proportional to
|
||||
//! (`[d, r0, r1]`, `[A, d, r1]`, `[A, r0, d]`), each a dot of d with a
|
||||
//! constant normal, and their sum S is the negated determinant. A hit needs
|
||||
//! S on the side of `[A, r0, r1]`, so only the triangles whose rim normals'
|
||||
//! dots change sign there are tested in full.
|
||||
use glam::Vec3;
|
||||
|
||||
pub const TRIANGLES: usize = 16;
|
||||
|
||||
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
|
||||
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
|
||||
/// Then per rim vertex: xyz `r × A`.
|
||||
pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
let mut out = [[0.0; 4]; 2 * TRIANGLES];
|
||||
for i in 0..TRIANGLES {
|
||||
let (r0, r1) = (rim[i], rim[(i + 1) % TRIANGLES]);
|
||||
out[i] = r0.cross(r1).extend(apex.dot(r0.cross(r1))).to_array();
|
||||
out[TRIANGLES + i] = r0.cross(apex).extend(0.0).to_array();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The fan's apex and rim in camera space, as vanilla builds them: the
|
||||
/// fan turned to the horizon the sun sets on (or rises from), its rim
|
||||
/// tilted by the alpha.
|
||||
fn vertices(alpha: f32, sun: Vec3) -> (Vec3, [Vec3; TRIANGLES]) {
|
||||
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
|
||||
let flip = if -sun.z < 0.0 { 180f32 } else { 0.0 };
|
||||
let (s, c) = (flip + 90.0).to_radians().sin_cos();
|
||||
let rotate_x90 = |v: Vec3| Vec3::new(v.x, -v.z, v.y);
|
||||
let apex = rotate_x90(Vec3::new(-100.0 * s, 100.0 * c, 0.0));
|
||||
let rim = std::array::from_fn(|i| {
|
||||
let a = i as f32 * std::f32::consts::TAU / TRIANGLES as f32;
|
||||
let l = Vec3::new(a.sin() * 120.0, a.cos() * 120.0, -a.cos() * 40.0 * alpha);
|
||||
rotate_x90(Vec3::new(l.x * c - l.y * s, l.x * s + l.y * c, l.z))
|
||||
});
|
||||
(apex, rim)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// terrain.wgsl's `sunrise_weight` over the normals.
|
||||
fn weight(normals: &[[f32; 4]; 2 * TRIANGLES], d: Vec3) -> f32 {
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let mut best = 0.0f32;
|
||||
let mut k0 = d.dot(n(TRIANGLES));
|
||||
for i in 0..TRIANGLES {
|
||||
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
|
||||
let w = normals[i][3];
|
||||
if k0 * w <= 0.0 && k1 * w >= 0.0 {
|
||||
let apex = d.dot(n(i));
|
||||
let s = apex + k1 - k0;
|
||||
if s.abs() >= 1e-6
|
||||
&& s * w > 0.0
|
||||
&& apex * s >= 0.0
|
||||
&& k1 * s >= 0.0
|
||||
&& k0 * s <= 0.0
|
||||
{
|
||||
best = best.max(apex / s);
|
||||
}
|
||||
}
|
||||
k0 = k1;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
|
||||
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
let mut best = 0.0f32;
|
||||
for i in 0..TRIANGLES {
|
||||
let e1 = rim[i] - apex;
|
||||
let e2 = rim[(i + 1) % TRIANGLES] - apex;
|
||||
let pv = d.cross(e2);
|
||||
let det = e1.dot(pv);
|
||||
if det.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let tv = -apex;
|
||||
let u = tv.dot(pv) / det;
|
||||
let qv = tv.cross(e1);
|
||||
let v = d.dot(qv) / det;
|
||||
let t = e2.dot(qv) / det;
|
||||
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
|
||||
best = best.max(1.0 - u - v);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
|
||||
let mut hits = 0;
|
||||
for (alpha, sun_z) in [(1.0f32, -0.2f32), (0.6, 0.3), (0.05, -0.9), (0.9, 0.05)] {
|
||||
let sun = Vec3::new(0.0, (1.0 - sun_z * sun_z).sqrt(), sun_z);
|
||||
let normals = normals(alpha, sun);
|
||||
for yaw in 0..180 {
|
||||
for pitch in -45..=45 {
|
||||
let (yaw, pitch) = (
|
||||
(yaw as f32 * 2.0).to_radians(),
|
||||
(pitch as f32 * 2.0).to_radians(),
|
||||
);
|
||||
let d = Vec3::new(
|
||||
yaw.cos() * pitch.cos(),
|
||||
pitch.sin(),
|
||||
yaw.sin() * pitch.cos(),
|
||||
);
|
||||
let (got, want) = (weight(&normals, d), reference(alpha, sun, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"{alpha} {sun_z} {d}: {got} vs {want}"
|
||||
);
|
||||
hits += usize::from(want > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The fan covers part of the sky, not none of it.
|
||||
assert!(hits > 1000, "{hits}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The views a frame is drawn from. A flat screen has one; a headset has
|
||||
//! one per eye. Every view shares the frame's camera position, so the
|
||||
//! camera-relative world (section origins, entity offsets) is built once and
|
||||
//! only each view's rotation and lens differ.
|
||||
|
||||
use glam::Mat4;
|
||||
|
||||
/// The most views one frame draws.
|
||||
const MAX_VIEWS: usize = 2;
|
||||
|
||||
/// One eye's view of the camera-relative world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct View {
|
||||
/// Camera-relative world to eye: a rotation, plus the eye's offset from
|
||||
/// the camera on a headset.
|
||||
pub view: Mat4,
|
||||
/// Reversed-Z perspective: the near plane at depth 1, far at 0.
|
||||
pub projection: Mat4,
|
||||
/// Width over height of the image this view fills.
|
||||
pub aspect: f32,
|
||||
/// `tan(fov_y / 2)`, for shaders rebuilding view rays from NDC.
|
||||
pub tan_half_fov: f32,
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// The flat-screen view: vanilla's camera rotation and a symmetric
|
||||
/// vertical field of view.
|
||||
pub fn screen(yaw: f32, pitch: f32, fov_degrees: f32, aspect: f32, far: f32) -> Self {
|
||||
Self {
|
||||
view: crate::camera::view_matrix(yaw, pitch),
|
||||
projection: super::world_projection(fov_degrees, aspect, far),
|
||||
aspect,
|
||||
tan_half_fov: (fov_degrees.to_radians() / 2.0).tan(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip space from the camera-relative world, with `pose` (view bobbing,
|
||||
/// nausea spin) between the lens and the rotation.
|
||||
pub fn view_proj(&self, pose: Mat4) -> Mat4 {
|
||||
self.projection * pose * self.view
|
||||
}
|
||||
}
|
||||
|
||||
/// Every view of a frame, and the one geometry is culled with.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ViewSet {
|
||||
views: [View; MAX_VIEWS],
|
||||
len: usize,
|
||||
cull: View,
|
||||
}
|
||||
|
||||
impl ViewSet {
|
||||
/// A frame drawn from `view` alone, culled with it.
|
||||
pub fn single(view: View) -> Self {
|
||||
Self {
|
||||
views: [view; MAX_VIEWS],
|
||||
len: 1,
|
||||
cull: view,
|
||||
}
|
||||
}
|
||||
|
||||
/// The view the HUD, hand and screen-space effects follow.
|
||||
pub fn primary(&self) -> &View {
|
||||
&self.views[0]
|
||||
}
|
||||
|
||||
pub fn views(&self) -> &[View] {
|
||||
&self.views[..self.len]
|
||||
}
|
||||
|
||||
/// The view whose frustum covers every other view's.
|
||||
pub fn cull(&self) -> &View {
|
||||
&self.cull
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The formulas `Renderer` used before views existed, kept to pin the
|
||||
/// screen view to them bit for bit.
|
||||
fn legacy_view_proj(fov: f32, yaw: f32, pitch: f32, pose: Mat4, aspect: f32, far: f32) -> Mat4 {
|
||||
super::super::world_projection(fov, aspect, far)
|
||||
* pose
|
||||
* crate::camera::view_matrix(yaw, pitch)
|
||||
}
|
||||
|
||||
fn poses() -> [Mat4; 3] {
|
||||
[
|
||||
Mat4::IDENTITY,
|
||||
Mat4::from_translation(glam::Vec3::new(0.013, -0.027, 0.0))
|
||||
* Mat4::from_rotation_z(0.021),
|
||||
Mat4::from_rotation_y(0.4) * Mat4::from_scale(glam::Vec3::new(1.1, 0.9, 1.0)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_screen_view_matches_the_legacy_projection_bit_for_bit() {
|
||||
for (fov, yaw, pitch, aspect, far) in [
|
||||
(70.0, 0.0, 0.0, 16.0 / 9.0, 512.0),
|
||||
(110.0, -137.25, 61.5, 21.0 / 9.0, 8192.0),
|
||||
(30.0, 359.9, -90.0, 4.0 / 3.0, 96.0),
|
||||
(82.333, 12.5, 7.75, 1.0, 1024.0),
|
||||
] {
|
||||
let view = View::screen(yaw, pitch, fov, aspect, far);
|
||||
for pose in poses() {
|
||||
let old = legacy_view_proj(fov, yaw, pitch, pose, aspect, far);
|
||||
let new = view.view_proj(pose);
|
||||
assert_eq!(
|
||||
old.to_cols_array().map(f32::to_bits),
|
||||
new.to_cols_array().map(f32::to_bits),
|
||||
"fov {fov} yaw {yaw} pitch {pitch}"
|
||||
);
|
||||
}
|
||||
assert_eq!(view.view, crate::camera::view_matrix(yaw, pitch));
|
||||
assert_eq!(
|
||||
view.tan_half_fov.to_bits(),
|
||||
(fov.to_radians() / 2.0).tan().to_bits()
|
||||
);
|
||||
assert_eq!(view.aspect.to_bits(), aspect.to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_view_set_draws_and_culls_with_its_view() {
|
||||
let view = View::screen(45.0, 10.0, 70.0, 16.0 / 9.0, 512.0);
|
||||
let set = ViewSet::single(view);
|
||||
assert_eq!(set.views(), &[view]);
|
||||
assert_eq!(*set.primary(), view);
|
||||
assert_eq!(*set.cull(), view);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,53 @@
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! Resolution scale, FXAA and TAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With neither on,
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With none on,
|
||||
//! the world renders straight to the screen.
|
||||
//!
|
||||
//! TAA jitters the world's projection by a sub-pixel offset each frame and blends
|
||||
//! the frame into a history reprojected through the depth buffer: its motion
|
||||
//! vectors are the camera's, so what moves on its own leans on the neighbourhood
|
||||
//! clamp alone.
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec2};
|
||||
|
||||
/// The TAA history format: 8 bits would band as frames accumulate.
|
||||
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
color: wgpu::TextureView,
|
||||
depth: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
taa: Option<History>,
|
||||
}
|
||||
|
||||
/// TAA's two history textures at the output size (the world's, times the upscale), written
|
||||
/// in turn: `resolve[i]` writes `history[i]` from the world and `history[1 - i]`; `show[i]`
|
||||
/// copies `history[i]` to the screen.
|
||||
struct History {
|
||||
size: [u32; 2],
|
||||
history: [wgpu::TextureView; 2],
|
||||
resolve: [wgpu::BindGroup; 2],
|
||||
show: [wgpu::BindGroup; 2],
|
||||
}
|
||||
|
||||
/// `world_post.wgsl`'s `Taa`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct TaaUniform {
|
||||
/// This frame's unjittered clip space to the previous frame's.
|
||||
reproject: [[f32; 4]; 4],
|
||||
/// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
|
||||
jitter: [f32; 4],
|
||||
}
|
||||
|
||||
/// TAA's GPU resources, absent when the device cannot compile the shader
|
||||
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
|
||||
/// from depth textures).
|
||||
struct TaaResources {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
@@ -19,7 +59,19 @@ pub struct Post {
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
pub taa: bool,
|
||||
/// Output pixels per rendered pixel while TAA is on (1: native): the world renders
|
||||
/// this much smaller and TAA rebuilds it at the resolution scale's size.
|
||||
pub upscale: f32,
|
||||
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
|
||||
taa_on: bool,
|
||||
target: Option<WorldTarget>,
|
||||
taa_resources: Option<TaaResources>,
|
||||
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
|
||||
taa_frame: u32,
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
taa_previous: Option<(DMat4, DVec3)>,
|
||||
taa_this_frame: TaaUniform,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -49,15 +101,26 @@ impl Post {
|
||||
},
|
||||
],
|
||||
});
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str| {
|
||||
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
layout: Some(&pipeline_layout),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
@@ -81,6 +144,88 @@ impl Post {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The OpenGL backend's GLSL translator does not support textureLoad
|
||||
// from depth textures: on devices running through OpenGL (such as older
|
||||
// Intel GPUs on Linux), TAA cannot compile its pipeline.
|
||||
let taa_resources = {
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let taa_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let taa_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: Some(&taa_pipeline_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_taa"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: HISTORY_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => {
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Some(TaaResources {
|
||||
layout: taa_layout,
|
||||
pipeline: taa_pipeline,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: temporal anti-aliasing unavailable ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
@@ -88,41 +233,100 @@ impl Post {
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
taa: false,
|
||||
upscale: 1.0,
|
||||
taa_on: false,
|
||||
target: None,
|
||||
taa_resources,
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether TAA is supported on this GPU/backend.
|
||||
pub fn supports_taa(&self) -> bool {
|
||||
self.taa_resources.is_some()
|
||||
}
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
let upscale = if self.taa_on { self.upscale } else { 1.0 };
|
||||
(
|
||||
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
|
||||
self.taa_on = on && self.taa && self.taa_resources.is_some();
|
||||
if !self.taa_on {
|
||||
self.taa_previous = None;
|
||||
return Mat4::IDENTITY;
|
||||
}
|
||||
self.taa_frame = self.taa_frame.wrapping_add(1);
|
||||
let (size, _) = self.sizes(window);
|
||||
// Halton (2, 3) over 8 frames per output pixel (FSR's count), in rendered
|
||||
// pixels around the centre.
|
||||
let phases = (8.0 * self.upscale * self.upscale).ceil() as u32;
|
||||
let i = self.taa_frame % phases + 1;
|
||||
let offset = Vec2::new(halton(i, 2), halton(i, 3)) - 0.5;
|
||||
let jitter = offset * 2.0 / Vec2::new(size[0] as f32, size[1] as f32);
|
||||
let current = view_proj.as_dmat4();
|
||||
let (reproject, reset) = match self.taa_previous {
|
||||
Some((previous, previous_camera)) => (
|
||||
reprojection(previous, previous_camera, current, camera),
|
||||
0.0,
|
||||
),
|
||||
None => (DMat4::IDENTITY, 1.0),
|
||||
};
|
||||
self.taa_previous = Some((current, camera));
|
||||
self.taa_this_frame = TaaUniform {
|
||||
reproject: reproject.as_mat4().to_cols_array_2d(),
|
||||
jitter: [jitter.x, jitter.y, reset, 0.0],
|
||||
};
|
||||
Mat4::from_translation(jitter.extend(0.0))
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
/// `always`: a target even at scale 1 without FXAA (a shader pack reads it).
|
||||
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
always: bool,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !always {
|
||||
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let make_tex = |label, format, usage| {
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
|
||||
{
|
||||
let make_tex = |label, size: [u32; 2], format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent,
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
@@ -131,25 +335,69 @@ impl Post {
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = make_tex(
|
||||
"Scaled world",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
// TEXTURE_BINDING: occlusion culling reads it.
|
||||
let sampled =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = make_tex("Scaled world", size, self.format, sampled)
|
||||
.create_view(&Default::default());
|
||||
// TEXTURE_BINDING: occlusion culling and TAA read it.
|
||||
let depth = make_tex(
|
||||
"Scaled world depth",
|
||||
size,
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
sampled,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
let taa = history_size.and_then(|output| {
|
||||
let res = self.taa_resources.as_ref()?;
|
||||
let history = [0, 1].map(|_| {
|
||||
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
|
||||
.create_view(&Default::default())
|
||||
});
|
||||
let resolve = [0, 1].map(|i| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: &res.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&history[1 - i]),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: res.uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let show = [0, 1].map(|i| self.bind(device, &history[i]));
|
||||
Some(History {
|
||||
size: output,
|
||||
history,
|
||||
resolve,
|
||||
show,
|
||||
})
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
color,
|
||||
depth,
|
||||
group,
|
||||
taa,
|
||||
});
|
||||
}
|
||||
self.target
|
||||
@@ -180,10 +428,43 @@ impl Post {
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the scaled world onto `screen`.
|
||||
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
|
||||
/// Draw the scaled world onto `screen`, through TAA when this frame's `jitter` had it on.
|
||||
pub fn finish(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
let Some(target) = &self.target else { return };
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let Some(res) = &self.taa_resources else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&res.pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
self.finish_with(encoder, screen, &taa.show[i]);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
@@ -216,3 +497,68 @@ impl Post {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
|
||||
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
|
||||
}
|
||||
|
||||
/// The radical inverse of `i` in `base`: a low-discrepancy sequence in 0..1.
|
||||
fn halton(mut i: u32, base: u32) -> f32 {
|
||||
let (mut f, mut r) = (1.0, 0.0);
|
||||
while i > 0 {
|
||||
f /= base as f32;
|
||||
r += f * (i % base) as f32;
|
||||
i /= base;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reprojection_finds_where_a_block_was_last_frame() {
|
||||
let projection = DMat4::perspective_rh(1.2, 16.0 / 9.0, 1024.0, 0.05);
|
||||
let previous = projection * DMat4::from_rotation_y(0.3);
|
||||
let current = projection * DMat4::from_rotation_y(0.35);
|
||||
let (previous_camera, camera) = (DVec3::new(10.0, 64.0, 5.0), DVec3::new(10.5, 64.2, 4.0));
|
||||
let block = DVec3::new(14.0, 66.0, -8.0);
|
||||
let clip = |m: DMat4, eye: DVec3| m * (block - eye).extend(1.0);
|
||||
let now = clip(current, camera);
|
||||
let then = reprojection(previous, previous_camera, current, camera) * (now / now.w);
|
||||
let expected = clip(previous, previous_camera);
|
||||
assert!((then.truncate() / then.w - expected.truncate() / expected.w).length() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_inside_the_pixel() {
|
||||
for i in 1..=8 {
|
||||
let (x, y) = (halton(i, 2), halton(i, 3));
|
||||
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
|
||||
}
|
||||
assert_eq!(
|
||||
[halton(1, 2), halton(2, 2), halton(3, 2)],
|
||||
[0.5, 0.25, 0.75]
|
||||
);
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_post_pipelines_compile_or_fall_back() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
|
||||
// Copy and FXAA pipelines always compile; TAA compiles if supported by backend.
|
||||
let _ = post.supports_taa();
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,39 @@
|
||||
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
|
||||
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
|
||||
//! player makes here depart from it.
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
|
||||
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
|
||||
pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
|
||||
/// What the system reports for the display HDR output goes to, in nits in HDR output's
|
||||
/// scale: where its SDR white sits (the desktop's white) and the peak.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
}
|
||||
|
||||
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
|
||||
|
||||
/// Sets what the system reports (the renderer, when it opens its surface).
|
||||
pub fn report(reported: Option<Reported>) {
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
REPORTED[0].store(paper_white, Ordering::Relaxed);
|
||||
REPORTED[1].store(peak, Ordering::Relaxed);
|
||||
}
|
||||
/// What the system reports, if it does.
|
||||
pub fn reported() -> Option<Reported> {
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Preset {
|
||||
@@ -96,9 +122,9 @@ impl PresentMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphics API the renderer runs on. Only Windows has a choice: the
|
||||
/// other systems always use their one backend (Vulkan on Linux, Metal on
|
||||
/// macOS). Applies at the next start.
|
||||
/// The graphics API the renderer runs on. Windows picks Vulkan, DirectX 12 or
|
||||
/// OpenGL; Linux picks Vulkan or OpenGL; macOS always uses its one backend
|
||||
/// (Metal). Applies at the next start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GraphicsApi {
|
||||
/// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device.
|
||||
@@ -116,6 +142,26 @@ impl GraphicsApi {
|
||||
GraphicsApi::Dx12,
|
||||
GraphicsApi::OpenGl,
|
||||
];
|
||||
/// The APIs this system offers, in the order the setting cycles through
|
||||
/// them: DirectX 12 is Windows' third backend, and macOS has no choice.
|
||||
pub fn choices() -> &'static [GraphicsApi] {
|
||||
const WINDOWS: [GraphicsApi; 4] = [
|
||||
GraphicsApi::Auto,
|
||||
GraphicsApi::Vulkan,
|
||||
GraphicsApi::Dx12,
|
||||
GraphicsApi::OpenGl,
|
||||
];
|
||||
const LINUX: [GraphicsApi; 3] =
|
||||
[GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::OpenGl];
|
||||
const MACOS: [GraphicsApi; 1] = [GraphicsApi::Auto];
|
||||
if cfg!(windows) {
|
||||
&WINDOWS
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&MACOS
|
||||
} else {
|
||||
&LINUX
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GraphicsApi::Auto => "auto",
|
||||
@@ -200,6 +246,61 @@ impl FarDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
|
||||
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Upscaling {
|
||||
#[default]
|
||||
Native,
|
||||
Quality,
|
||||
Balanced,
|
||||
Performance,
|
||||
UltraPerformance,
|
||||
}
|
||||
impl Upscaling {
|
||||
pub const ALL: [Upscaling; 5] = [
|
||||
Upscaling::Native,
|
||||
Upscaling::Quality,
|
||||
Upscaling::Balanced,
|
||||
Upscaling::Performance,
|
||||
Upscaling::UltraPerformance,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Upscaling::Native => "native",
|
||||
Upscaling::Quality => "quality",
|
||||
Upscaling::Balanced => "balanced",
|
||||
Upscaling::Performance => "performance",
|
||||
Upscaling::UltraPerformance => "ultraPerformance",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Upscaling::Native => "Native",
|
||||
Upscaling::Quality => "Quality",
|
||||
Upscaling::Balanced => "Balanced",
|
||||
Upscaling::Performance => "Performance",
|
||||
Upscaling::UltraPerformance => "Ultra Performance",
|
||||
}
|
||||
}
|
||||
/// Output pixels per rendered pixel, along each axis.
|
||||
pub fn ratio(self) -> f32 {
|
||||
match self {
|
||||
Upscaling::Native => 1.0,
|
||||
Upscaling::Quality => 1.5,
|
||||
Upscaling::Balanced => 1.7,
|
||||
Upscaling::Performance => 2.0,
|
||||
Upscaling::UltraPerformance => 3.0,
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|u| u.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
static REDUCED_SUGGESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the settings suggest Reduced far detail: set when the renderer
|
||||
/// starts, on the GPUs `lod_capped` names (far terrain slows them most).
|
||||
@@ -210,6 +311,16 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
pub fn offer_hdr(on: bool) {
|
||||
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -262,12 +373,24 @@ pub struct Graphics {
|
||||
pub resolution_scale: u32,
|
||||
/// FXAA over the world.
|
||||
pub fxaa: bool,
|
||||
/// TAA over the world (FXAA is off while it is on).
|
||||
pub taa: bool,
|
||||
/// TAA upscaling; anything but `Native` comes with `taa` on.
|
||||
pub upscaling: Upscaling,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
|
||||
pub hdr_paper_white: u32,
|
||||
/// HDR peak brightness the world's highlights reach, in nits.
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// what the system reports for the display ([`Graphics::peak_nits`]).
|
||||
pub hdr_peak: u32,
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
|
||||
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -299,9 +422,13 @@ impl Default for Graphics {
|
||||
overlay: Overlay::Off,
|
||||
resolution_scale: 100,
|
||||
fxaa: false,
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
hdr: false,
|
||||
hdr_paper_white: 200,
|
||||
hdr_peak: 1000,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -361,9 +488,25 @@ impl Graphics {
|
||||
"workers" => g.workers = number(g.workers).min(64),
|
||||
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
|
||||
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(80, 480),
|
||||
}
|
||||
}
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
n => n.clamp(200, 2000),
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -381,7 +524,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -406,9 +549,13 @@ impl Graphics {
|
||||
},
|
||||
self.resolution_scale,
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
@@ -417,6 +564,22 @@ impl Graphics {
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
|
||||
/// system reports it, else 203 (BT.2408's reference white).
|
||||
pub fn paper_white_nits(&self) -> u32 {
|
||||
match self.hdr_paper_white {
|
||||
0 => reported().map_or(203, |r| r.paper_white),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The peak the world's highlights reach, in nits: the player's, or on Auto
|
||||
/// what the system reports, else 1000.
|
||||
pub fn peak_nits(&self) -> u32 {
|
||||
match self.hdr_peak {
|
||||
0 => reported().map_or(1000, |r| r.peak),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
if self.workers > 0 {
|
||||
@@ -486,6 +649,21 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_api_choice_matches_the_system() {
|
||||
let choices = GraphicsApi::choices();
|
||||
if cfg!(windows) {
|
||||
assert_eq!(choices, GraphicsApi::ALL);
|
||||
} else if cfg!(target_os = "macos") {
|
||||
assert_eq!(choices, [GraphicsApi::Auto]);
|
||||
} else {
|
||||
assert_eq!(
|
||||
choices,
|
||||
[GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::OpenGl]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let g = Graphics {
|
||||
@@ -506,15 +684,24 @@ mod tests {
|
||||
overlay: Overlay::Graph,
|
||||
resolution_scale: 75,
|
||||
fxaa: true,
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
|
||||
@@ -130,8 +130,9 @@ impl Style {
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Horizon's graphics settings screens, Simple and Advanced: which settings
|
||||
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
settings::{Opt, Settings},
|
||||
@@ -14,6 +14,8 @@ use crate::{
|
||||
pub enum Level {
|
||||
Simple,
|
||||
Advanced,
|
||||
/// HDR output's own settings, opened from Advanced while HDR is on.
|
||||
Hdr,
|
||||
}
|
||||
|
||||
/// Horizon's own settings (`graphics::Graphics`).
|
||||
@@ -36,9 +38,14 @@ pub enum H {
|
||||
Overlay,
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
Hdr,
|
||||
HdrPaperWhite,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
BatteryMode,
|
||||
GraphicsApi,
|
||||
}
|
||||
@@ -119,6 +126,34 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Hdr => vec![
|
||||
Row::Header("HDR"),
|
||||
h(
|
||||
H::Hdr,
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"How bright highlights get. Auto: the peak your system reports.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"How bright white paper and menus look. Auto: your desktop's white.",
|
||||
),
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -221,23 +256,18 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::Antialiasing,
|
||||
Medium,
|
||||
"FXAA smooths jagged edges of the world; slightly softer image.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
H::Upscaling,
|
||||
High,
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::HdrSettings,
|
||||
None,
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"HDR: how bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"HDR: how bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
@@ -276,7 +306,7 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::GraphicsApi,
|
||||
None,
|
||||
"Vulkan, or DirectX 12 where Vulkan fails. Next start.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
|
||||
),
|
||||
h(
|
||||
H::UploadBudget,
|
||||
@@ -291,8 +321,8 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
),
|
||||
],
|
||||
};
|
||||
// Only Windows has more than one graphics API.
|
||||
if !cfg!(windows) {
|
||||
// Only Windows and Linux have more than one graphics API.
|
||||
if cfg!(target_os = "macos") {
|
||||
rows.retain(
|
||||
|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::GraphicsApi)),
|
||||
);
|
||||
@@ -303,9 +333,13 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
|
||||
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
|
||||
fn extra_rows(level: Level) -> Vec<Row> {
|
||||
/// Upscaling is only there while TAA is, which does it.
|
||||
fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
|
||||
let mut rows = rows(level);
|
||||
rows.retain(|row| !matches!(row, Row::Setting(s) if matches!(s.item, Item::Vanilla(_))));
|
||||
if !settings.horizon.taa {
|
||||
rows.retain(|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::Upscaling)));
|
||||
}
|
||||
// Drop headings whose settings were all on the parent screens.
|
||||
let mut has_setting = false;
|
||||
rows.reverse();
|
||||
@@ -341,8 +375,18 @@ 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::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
|
||||
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0),
|
||||
// Auto, then 80..480 by 10.
|
||||
H::HdrPaperWhite => Some(match g.hdr_paper_white {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 70.0) / 410.0,
|
||||
}),
|
||||
// Auto, then 200..2000 by 50.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 150.0) / 1850.0,
|
||||
}),
|
||||
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
|
||||
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -383,10 +427,33 @@ impl H {
|
||||
n => labelled("Worker Threads", n),
|
||||
},
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
H::Antialiasing => labelled(
|
||||
"Anti-aliasing",
|
||||
match (g.fxaa, g.taa) {
|
||||
(_, true) => "TAA",
|
||||
(true, false) => "FXAA",
|
||||
(false, false) => "Off",
|
||||
},
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
|
||||
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
format!("Auto ({} nits)", g.paper_white_nits()),
|
||||
),
|
||||
n => labelled("Paper White", format!("{n} nits")),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
|
||||
n => labelled("Peak Brightness", format!("{n} nits")),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -409,8 +476,20 @@ 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::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
|
||||
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
|
||||
H::HdrPaperWhite => {
|
||||
g.hdr_paper_white = match (v * 41.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 70 + step * 10,
|
||||
}
|
||||
}
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 150 + step * 50,
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -441,10 +520,25 @@ impl H {
|
||||
H::Weather => g.weather = !g.weather,
|
||||
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
|
||||
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
|
||||
H::Antialiasing => g.fxaa = !g.fxaa,
|
||||
// Off, FXAA, TAA.
|
||||
H::Antialiasing => {
|
||||
let i = if g.taa { 2 } else { usize::from(g.fxaa) };
|
||||
let i = (i + if back { 2 } else { 1 }) % 3;
|
||||
(g.fxaa, g.taa) = (i == 1, i == 2);
|
||||
if !g.taa {
|
||||
g.upscaling = Upscaling::Native;
|
||||
}
|
||||
}
|
||||
// Upscaling is TAA's: picking a ratio turns it on.
|
||||
H::Upscaling => {
|
||||
g.upscaling = step(&Upscaling::ALL, g.upscaling, back);
|
||||
if g.upscaling != Upscaling::Native {
|
||||
(g.fxaa, g.taa) = (false, true);
|
||||
}
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back),
|
||||
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
|
||||
H::Pacing => {
|
||||
g.pacing = match g.pacing {
|
||||
Pacing::Efficient => Pacing::Precise,
|
||||
@@ -471,6 +565,25 @@ impl Item {
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// Why a control can't be used now, when it can't: HDR's screen opens only where
|
||||
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
}
|
||||
match self {
|
||||
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
@@ -481,7 +594,6 @@ impl Item {
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::Antialiasing) => g.fxaa,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
|
||||
@@ -570,8 +682,10 @@ impl Item {
|
||||
pub enum Action {
|
||||
ButtonClick,
|
||||
Changed,
|
||||
SliderDrag,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -591,20 +705,25 @@ struct Widget {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GraphicsUi {
|
||||
scroll: f32,
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
pub fn reset(&mut self) {
|
||||
self.scroll = 0.0;
|
||||
self.scroll = ui::Scroll::default();
|
||||
self.dragging = None;
|
||||
}
|
||||
/// The scrollbar's column: right of the descriptions.
|
||||
fn scrollbar_x(w: f32) -> f32 {
|
||||
STYLE.row_x(w) + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0
|
||||
}
|
||||
fn bounds(h: f32) -> (f32, f32) {
|
||||
(STYLE.band, h - STYLE.band)
|
||||
}
|
||||
fn content_height(level: Level) -> f32 {
|
||||
extra_rows(level).iter().fold(4.0, |height, row| {
|
||||
fn content_height(level: Level, s: &Settings) -> f32 {
|
||||
extra_rows(level, s).iter().fold(4.0, |height, row| {
|
||||
height
|
||||
+ match row {
|
||||
Row::Header(_) => STYLE.header_height,
|
||||
@@ -616,17 +735,18 @@ impl GraphicsUi {
|
||||
fn layout(
|
||||
&mut self,
|
||||
level: Level,
|
||||
s: &Settings,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) -> (Vec<Widget>, Vec<(f32, &'static str)>, f32) {
|
||||
let content = Self::content_height(level);
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, _) = Self::bounds(h);
|
||||
let x = STYLE.row_x(w);
|
||||
let mut y = top + 4.0 - self.scroll;
|
||||
let mut y = top + 4.0 - self.scroll.amount;
|
||||
let mut widgets = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
for row in extra_rows(level) {
|
||||
for row in extra_rows(level, s) {
|
||||
match row {
|
||||
Row::Header(name) => {
|
||||
headers.push((y, name));
|
||||
@@ -649,6 +769,17 @@ impl GraphicsUi {
|
||||
let other = match level {
|
||||
Level::Simple => Action::OpenAdvanced,
|
||||
Level::Advanced => Action::OpenSimple,
|
||||
// One Done, back to Advanced.
|
||||
Level::Hdr => {
|
||||
widgets.push(Widget {
|
||||
x: cx - 100.0,
|
||||
y: by,
|
||||
w: 200.0,
|
||||
target: Target::Button(Action::Done),
|
||||
list: false,
|
||||
});
|
||||
return (widgets, headers, content);
|
||||
}
|
||||
};
|
||||
widgets.push(Widget {
|
||||
x: cx - 155.0,
|
||||
@@ -668,7 +799,7 @@ impl GraphicsUi {
|
||||
}
|
||||
fn clamp_scroll(&mut self, h: f32, content: f32) {
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll = self.scroll.clamp(0.0, (content - (bottom - top)).max(0.0));
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
}
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
@@ -680,7 +811,7 @@ impl GraphicsUi {
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let (widgets, headers, content) = self.layout(level, w, h);
|
||||
let (widgets, headers, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
STYLE.background(batch, atlas, top, bottom, in_world);
|
||||
let shown = |y: f32, height: f32| y >= top && y + height <= bottom;
|
||||
@@ -690,7 +821,7 @@ impl GraphicsUi {
|
||||
STYLE.header(batch, atlas, &Text::literal(name), x, y);
|
||||
}
|
||||
}
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level)
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
Row::Setting(s) => Some((s.cost, s.text)),
|
||||
@@ -710,12 +841,10 @@ impl GraphicsUi {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let live = item.available();
|
||||
if hover && !live {
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
if hover && let Some(why) = blocked {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -725,6 +854,7 @@ impl GraphicsUi {
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
@@ -765,15 +895,16 @@ impl GraphicsUi {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0,
|
||||
Self::scrollbar_x(w),
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
self.scroll,
|
||||
self.scroll.amount,
|
||||
);
|
||||
let title = match level {
|
||||
Level::Simple => "Graphics",
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
if let Some(lines) = tooltip {
|
||||
@@ -791,22 +922,34 @@ impl GraphicsUi {
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Action> {
|
||||
let (widgets, _, _) = self.layout(level, w, h);
|
||||
let (widgets, _, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
if drag {
|
||||
// AbstractScrollArea.mouseDragged: the held scrollbar follows the pointer.
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, top, bottom, content);
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(item) = self.dragging
|
||||
&& let Some(wd) = widgets
|
||||
.iter()
|
||||
.find(|wd| matches!(wd.target, Target::Item(i) if i == item))
|
||||
{
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
return vec![Action::Changed];
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
// AbstractScrollArea.updateScrolling: a press on the scrollbar holds it, silently.
|
||||
if self
|
||||
.scroll
|
||||
.press((x, y), Self::scrollbar_x(w), top, bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
for wd in widgets {
|
||||
if !ui::hovered((x, y), wd.x, wd.y, wd.w, 20.0) {
|
||||
continue;
|
||||
@@ -817,29 +960,34 @@ impl GraphicsUi {
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
// A disabled control does nothing: no sound, no preset change.
|
||||
Target::Item(item) if !item.available() => Vec::new(),
|
||||
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
vec![Action::SliderDrag]
|
||||
} else {
|
||||
item.cycle(s, right, framebuffer);
|
||||
return vec![Action::ButtonClick, Action::Changed];
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
}
|
||||
vec![Action::Changed]
|
||||
}
|
||||
};
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
/// Lets go of a slider (true: it plays its click) or the scrollbar.
|
||||
pub fn release(&mut self) -> bool {
|
||||
self.scroll.release();
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
pub fn scroll(&mut self, level: Level, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level);
|
||||
self.clamp_scroll(h, content);
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(h, content);
|
||||
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level, s);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
self.scroll.wheel(amount, 18.0, top, bottom, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,12 +995,59 @@ impl GraphicsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hdr_settings_open_where_hdr_is_offered() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 2000.0);
|
||||
let at = |ui: &mut GraphicsUi, s: &Settings, level, item| {
|
||||
let (widgets, _, _) = ui.layout(level, s, size.0, size.1);
|
||||
let w = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
(w.x + 1.0, w.y + 1.0)
|
||||
};
|
||||
let button = at(&mut ui, &s, Level::Advanced, Item::Horizon(H::HdrSettings));
|
||||
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
|
||||
ui.click(level, s, point, size, [480, 2000], false, false)
|
||||
};
|
||||
crate::graphics::offer_hdr(false);
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
// Not even with HDR saved on, from another computer.
|
||||
s.horizon.hdr = true;
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
s.horizon.hdr = false;
|
||||
crate::graphics::offer_hdr(true);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Advanced, button),
|
||||
[Action::ButtonClick, Action::OpenHdr]
|
||||
);
|
||||
// On HDR's screen, the sliders wait for the switch above them.
|
||||
let peak = at(&mut ui, &s, Level::Hdr, Item::Horizon(H::HdrPeak));
|
||||
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
|
||||
s.horizon.hdr = true;
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Hdr, peak),
|
||||
[Action::SliderDrag]
|
||||
);
|
||||
let on = |level, item| {
|
||||
extra_rows(level, &s)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
|
||||
};
|
||||
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
|
||||
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
|
||||
for item in [H::HdrBlackLevel, H::HdrPaperWhite, H::HdrUiBrightness] {
|
||||
assert!(on(Level::Hdr, item) && !on(Level::Advanced, item));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 320.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let button = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Button(_)))
|
||||
@@ -897,7 +1092,7 @@ mod tests {
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::Changed]
|
||||
vec![Action::SliderDrag]
|
||||
);
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
@@ -909,7 +1104,7 @@ mod tests {
|
||||
true,
|
||||
false
|
||||
),
|
||||
vec![Action::Changed]
|
||||
vec![Action::SliderDrag]
|
||||
);
|
||||
assert!(ui.release());
|
||||
assert!(!ui.release());
|
||||
@@ -917,53 +1112,94 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scrolling_stops_at_both_limits_before_layout() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
let h = 2.0 * STYLE.band + 10.0;
|
||||
let (top, bottom) = GraphicsUi::bounds(h);
|
||||
let max = GraphicsUi::content_height(level) - (bottom - top);
|
||||
let max = GraphicsUi::content_height(level, &s) - (bottom - top);
|
||||
assert!(max > 4.5);
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, h, 1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, h, -1000.0);
|
||||
assert_eq!(ui.scroll, max);
|
||||
ui.scroll(level, &s, h, -1000.0);
|
||||
assert_eq!(ui.scroll.amount, max);
|
||||
}
|
||||
ui.scroll(level, h, 0.25);
|
||||
assert_eq!(ui.scroll, max - 4.5);
|
||||
ui.scroll(level, h, 1000.0);
|
||||
ui.scroll(level, h, -0.25);
|
||||
assert_eq!(ui.scroll, 4.5);
|
||||
ui.scroll(level, &s, h, 0.25);
|
||||
assert_eq!(ui.scroll.amount, max - 4.5);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
ui.scroll(level, &s, h, -0.25);
|
||||
assert_eq!(ui.scroll.amount, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_can_be_held_and_dragged() {
|
||||
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
|
||||
// column, mouseDragged moves the list with the pointer, onRelease lets go.
|
||||
let mut s = Settings::default();
|
||||
let size = (480.0, 2.0 * STYLE.band + 60.0);
|
||||
let (top, bottom) = GraphicsUi::bounds(size.1);
|
||||
// On the scrollbar column, as `draw` paints it.
|
||||
let x =
|
||||
STYLE.row_x(size.0) + STYLE.control_width + STYLE.gap + STYLE.text_width(size.0) + 8.0;
|
||||
let first_row = |ui: &mut GraphicsUi| {
|
||||
ui.layout(Level::Advanced, &Settings::default(), size.0, size.1)
|
||||
.0[0]
|
||||
.y
|
||||
};
|
||||
let mut ui = GraphicsUi::default();
|
||||
let start = first_row(&mut ui);
|
||||
let at = |y| (x, y);
|
||||
let press = |ui: &mut GraphicsUi, s: &mut Settings, y, drag| {
|
||||
ui.click(Level::Advanced, s, at(y), size, [854, 480], drag, false)
|
||||
};
|
||||
// The press itself makes no sound and changes nothing.
|
||||
assert!(press(&mut ui, &mut s, top + 5.0, false).is_empty());
|
||||
assert!(press(&mut ui, &mut s, top + 15.0, true).is_empty());
|
||||
let moved = first_row(&mut ui);
|
||||
assert!(moved < start, "dragging down scrolls the list down");
|
||||
// Past the bottom of the list: the last row.
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
let content = GraphicsUi::content_height(Level::Advanced, &s);
|
||||
assert_eq!(first_row(&mut ui), start - (content - (bottom - top)));
|
||||
// Above the list: the first row again.
|
||||
press(&mut ui, &mut s, top - 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
// Released: drags no longer scroll, and no slider click plays.
|
||||
assert!(!ui.release());
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_clamps_before_positioning_graphics_rows() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
ui.scroll(level, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
assert!(ui.scroll > 0.0);
|
||||
let (widgets, headers, _) = ui.layout(level, 480.0, 4000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
assert!(ui.scroll.amount > 0.0);
|
||||
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
if let Some((y, _)) = headers.first() {
|
||||
assert_eq!(*y, STYLE.band + 4.0);
|
||||
}
|
||||
let (expected, _, _) = GraphicsUi::default().layout(level, 480.0, 4000.0);
|
||||
let (expected, _, _) = GraphicsUi::default().layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(
|
||||
widgets.iter().map(|w| w.y).collect::<Vec<_>>(),
|
||||
expected.iter().map(|w| w.y).collect::<Vec<_>>()
|
||||
);
|
||||
ui.scroll(level, 4000.0, -1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
ui.scroll(level, &s, 4000.0, -1000.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_submenus_only_add_horizon_settings_without_empty_groups() {
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let rows = extra_rows(level);
|
||||
let rows = extra_rows(level, &Settings::default());
|
||||
assert!(rows.iter().any(|row| matches!(row, Row::Setting(_))));
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
match row {
|
||||
@@ -1051,6 +1287,37 @@ mod tests {
|
||||
assert_eq!(s.horizon.upload_budget_kib, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn antialiasing_cycles_off_fxaa_taa() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::Antialiasing);
|
||||
for label in ["FXAA", "TAA", "Off"] {
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(item.label(&s).plain(), format!("Anti-aliasing: {label}"));
|
||||
}
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!((s.horizon.fxaa, s.horizon.taa), (false, true));
|
||||
|
||||
// Upscaling needs TAA: a ratio turns it on, leaving TAA goes back to Native.
|
||||
s.horizon.taa = false;
|
||||
let upscaling = Item::Horizon(H::Upscaling);
|
||||
upscaling.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(upscaling.label(&s).plain(), "Upscaling: Quality");
|
||||
assert!(s.horizon.taa);
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.upscaling, Upscaling::Native);
|
||||
|
||||
// The Upscaling row is only on the screen while TAA is on.
|
||||
let shown = |s: &Settings| {
|
||||
extra_rows(Level::Advanced, s)
|
||||
.iter()
|
||||
.any(|row| matches!(row, Row::Setting(r) if r.item == upscaling))
|
||||
};
|
||||
assert!(!shown(&s));
|
||||
s.horizon.taa = true;
|
||||
assert!(shown(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
@@ -1067,7 +1334,7 @@ mod tests {
|
||||
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
|
||||
@@ -16,6 +16,10 @@ struct Params {
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
// The display's black level in nits; 0 leaves the picture as it is.
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
@@ -56,20 +60,51 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
|
||||
}
|
||||
|
||||
// ST 2084's inverse: the PQ signal to nits.
|
||||
fn pq_nits(e: vec3<f32>) -> vec3<f32> {
|
||||
let p = pow(clamp(e, vec3(0.0), vec3(1.0)), vec3(1.0 / 78.84375));
|
||||
let y = max(p - 0.8359375, vec3(0.0)) / (18.8515625 - 18.6875 * p);
|
||||
return 10000.0 * pow(y, vec3(1.0 / 0.1593017578125));
|
||||
}
|
||||
|
||||
// ITU-R BT.2390's black level lift (its EETF without the knee) on the PQ signal
|
||||
// `e`: black moves up to the display's black level, the peak stays, and the lift
|
||||
// fades out by the fourth power on the way up, so only the shadows move. A display
|
||||
// that can't go darker than its black level would crush everything below it.
|
||||
fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
let top = pq(vec3(params.peak * params.paper * 80.0)).x;
|
||||
let n = min(e / top, vec3(1.0));
|
||||
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: once the world is kept, the frame's alpha starts at 0 again.
|
||||
@fragment fn fs_clear_alpha() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let c = textureLoad(frame, at, 0).rgb;
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
// (terrain.wgsl, the celestial pipeline): 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.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
|
||||
&& shows_world(c, w.rgb) {
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
@@ -84,9 +119,15 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
|
||||
}
|
||||
if params.black > 0.0 {
|
||||
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
@@ -1001,6 +1001,35 @@ impl HudBatch {
|
||||
y: f32,
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, false)
|
||||
}
|
||||
/// `text_styled` for text drawn in the world (`TextRenderable.render`
|
||||
/// with `flat` false): each quad's `depth` is its z in text units, the
|
||||
/// glyphs `Font.SHADOW_DEPTH` in front of their shadow, a bold copy 0.001
|
||||
/// in front of its glyph and the lines 0.01 (`BakedSheetGlyph`), so they
|
||||
/// do not fight over the same pixels.
|
||||
pub fn text_styled_world(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, true)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn styled_text(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
world: bool,
|
||||
) -> f32 {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
@@ -1011,7 +1040,13 @@ impl HudBatch {
|
||||
let alpha = (color >> 24) as u8;
|
||||
let mut glyphs = Vec::new();
|
||||
let mut cursor = x;
|
||||
let mut previous = None;
|
||||
for (i, (ch, style)) in text.chars().enumerate() {
|
||||
// Font.PreparedTextBuilder.accept: lines start 1 left of a
|
||||
// sequence's first character. Language.getVisualOrder makes a
|
||||
// sequence of each run of one style, which world text has.
|
||||
let first = i == 0 || world && previous != Some(style);
|
||||
previous = Some(style);
|
||||
// The style's font (`Style.getFont`), the default one unless set.
|
||||
let mut g = font.glyph_styled(ch, style);
|
||||
// Font.StringRenderOutput.accept: obfuscated characters draw a
|
||||
@@ -1032,7 +1067,7 @@ impl HudBatch {
|
||||
style.is_italic(),
|
||||
style.underlined == Some(true),
|
||||
style.strikethrough == Some(true),
|
||||
i == 0,
|
||||
first,
|
||||
advance,
|
||||
));
|
||||
cursor += advance;
|
||||
@@ -1040,19 +1075,25 @@ impl HudBatch {
|
||||
self.patches.extend(font.take_patches());
|
||||
let white = font.white();
|
||||
let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the shadow's colour.
|
||||
let shade = |c: u32| {
|
||||
let b = c.to_le_bytes();
|
||||
rgba(
|
||||
(f32::from(b[0]) * 0.25) as u8,
|
||||
(f32::from(b[1]) * 0.25) as u8,
|
||||
(f32::from(b[2]) * 0.25) as u8,
|
||||
b[3],
|
||||
)
|
||||
};
|
||||
for &is_shadow in passes {
|
||||
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
|
||||
// In the world a glyph whose shadow is 0 has none (hasShadow).
|
||||
let has_shadow = shadow && !(world && shade(c) == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
}
|
||||
let (dx, col) = if is_shadow {
|
||||
let b = c.to_le_bytes();
|
||||
(
|
||||
g.offset,
|
||||
rgba(
|
||||
(f32::from(b[0]) * 0.25) as u8,
|
||||
(f32::from(b[1]) * 0.25) as u8,
|
||||
(f32::from(b[2]) * 0.25) as u8,
|
||||
b[3],
|
||||
),
|
||||
)
|
||||
(g.offset, shade(c))
|
||||
} else {
|
||||
(0.0, c)
|
||||
};
|
||||
@@ -1092,21 +1133,62 @@ impl HudBatch {
|
||||
}
|
||||
None => {}
|
||||
};
|
||||
let base = if has_shadow && !is_shadow { 0.03 } else { 0.0 };
|
||||
let lift = |batch: &mut Self, start: usize, z: f32| {
|
||||
if world {
|
||||
for v in &mut batch.vertices[start..] {
|
||||
v.depth = z;
|
||||
}
|
||||
}
|
||||
};
|
||||
let start = self.vertices.len();
|
||||
draw(self, 0.0);
|
||||
lift(self, start, base);
|
||||
if bold {
|
||||
let start = self.vertices.len();
|
||||
draw(self, g.offset);
|
||||
lift(self, start, base + 0.001);
|
||||
}
|
||||
let x0 = if first { px - 1.0 } else { px };
|
||||
let start = self.vertices.len();
|
||||
if strike {
|
||||
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
|
||||
}
|
||||
if underline {
|
||||
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
|
||||
}
|
||||
lift(self, start, base + 0.01);
|
||||
}
|
||||
}
|
||||
cursor - x
|
||||
}
|
||||
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
|
||||
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
|
||||
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
|
||||
/// shadow), over `background` (RGBA) unless its alpha is 0. Returns how
|
||||
/// many vertices at the start of the batch are the background.
|
||||
pub fn name_tag(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
y: f32,
|
||||
color: u32,
|
||||
background: u32,
|
||||
) -> usize {
|
||||
let x = -atlas.font.width_text(text) / 2.0;
|
||||
let start = self.vertices.len();
|
||||
let advance = self.text_styled(atlas, text, x, y, color, false);
|
||||
if background >> 24 == 0 {
|
||||
return 0;
|
||||
}
|
||||
// PreparedTextBuilder.markBackground: one pixel round the advances,
|
||||
// from a pixel above the line to 9 below its top.
|
||||
let glyphs = self.vertices.split_off(start);
|
||||
self.fill(atlas, x - 1.0, y - 1.0, advance + 1.0, 10.0, background);
|
||||
let behind = self.vertices.len() - start;
|
||||
self.vertices.extend(glyphs);
|
||||
behind
|
||||
}
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
@@ -1822,6 +1904,65 @@ mod tests {
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
|
||||
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
|
||||
// 'й' (height 12, ascent 10: the breve above the line), 7 wide.
|
||||
let root = std::env::temp_dir().join(format!("horizon-name-tag-{}", std::process::id()));
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
|
||||
fs::create_dir_all(&textures).unwrap();
|
||||
fs::write(
|
||||
root.join("assets/minecraft/font/default.json"),
|
||||
r#"{"providers":[
|
||||
{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["a"]},
|
||||
{"type":"bitmap","file":"minecraft:font/tall.png","height":12,"ascent":10,"chars":["й"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (name, (w, h), used) in [("a", (8, 8), 4), ("tall", (9, 12), 7)] {
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| [255, 255, 255, if i % w < used { 255 } else { 0 }])
|
||||
.collect();
|
||||
let file = fs::File::create(textures.join(format!("{name}.png"))).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let text = crate::text::Text::literal("aй").bold();
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let behind = batch.name_tag(&atlas, &text, 0.0, u32::MAX, 0x3F00_0000);
|
||||
// Each quad's box: x0, y0, x1, y1.
|
||||
let boxes: Vec<[f32; 4]> = batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| [q[0].pos[0], q[0].pos[1], q[2].pos[0], q[2].pos[1]])
|
||||
.collect();
|
||||
// Advances 5 and 8, plus 1 each for bold: Font.width 15, centred.
|
||||
let x = -7.5;
|
||||
// Each glyph twice (bold), the copy a pixel right; 'й' in its 9x12
|
||||
// box from 3 pixels above the line, each bold quad 0.1 larger.
|
||||
let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4);
|
||||
let expected = [
|
||||
[x - 0.1, -0.1, x + 8.1, 8.1],
|
||||
[x + 0.9, -0.1, x + 9.1, 8.1],
|
||||
[x + 5.9, -3.1, x + 15.1, 9.1],
|
||||
[x + 6.9, -3.1, x + 16.1, 9.1],
|
||||
];
|
||||
for (got, want) in boxes[1..].iter().zip(expected) {
|
||||
assert!(near(*got, want), "{got:?} != {want:?}");
|
||||
}
|
||||
assert_eq!(boxes.len(), 1 + expected.len());
|
||||
// The background first: a pixel round the advances, 10 pixels tall.
|
||||
assert_eq!(behind, 6);
|
||||
assert_eq!(boxes[0], [x - 1.0, -1.0, x + 15.0, 9.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_icons_are_painted_from_the_mob_effect_directory() {
|
||||
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
|
||||
|
||||
@@ -59,13 +59,7 @@ pub fn panels(
|
||||
translate("effect.duration.infinite").unwrap_or_else(|| "∞".to_owned())
|
||||
} else {
|
||||
// StringUtil.formatTickDuration uses the level's current rate.
|
||||
let seconds = (effect.duration as f32 / tick_rate) as i32;
|
||||
let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
|
||||
if hours > 0 {
|
||||
format!("{hours:02}:{minutes:02}:{seconds:02}")
|
||||
} else {
|
||||
format!("{minutes:02}:{seconds:02}")
|
||||
}
|
||||
crate::brewing::format_tick_duration(effect.duration, tick_rate)
|
||||
};
|
||||
let width = max_width.min(39 + text_width(&name).max(text_width(&duration)));
|
||||
EffectPanel {
|
||||
|
||||
@@ -70,6 +70,7 @@ pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -25,6 +25,16 @@ const MAX_SAVED: usize = 1024;
|
||||
/// (`Lod::observing`).
|
||||
pub const OBSERVE_AHEAD: usize = 8;
|
||||
const SAMPLE_JOBS: usize = 4;
|
||||
/// Threads that sample far tiles beside the mesher. Machines with 12 or more
|
||||
/// threads use up to 6 (measured: a cold 1024-chunk LoD loads about a quarter
|
||||
/// sooner than with 3); the rest keep a quarter of their threads, at most 3.
|
||||
fn sampling_threads(cores: usize) -> usize {
|
||||
if cores >= 12 {
|
||||
(cores / 2).min(6)
|
||||
} else {
|
||||
(cores / 4).clamp(1, 3)
|
||||
}
|
||||
}
|
||||
/// While the near terrain loads on a machine with four threads or fewer, far
|
||||
/// terrain starts one sample job at most this often and no detail: it would
|
||||
/// otherwise take the cores the near chunks and the frame need.
|
||||
@@ -250,8 +260,17 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
let state = BlockStateId(i as u32);
|
||||
let f = facts.get(state);
|
||||
let fluid = matches!(&*f.name, "minecraft:water" | "minecraft:lava");
|
||||
// Chorus is thin and non-solid but a visible part of the far End.
|
||||
let chorus = matches!(
|
||||
&*f.name,
|
||||
"minecraft:chorus_plant" | "minecraft:chorus_flower"
|
||||
);
|
||||
let kept = !facts.is_air(state)
|
||||
&& (f.solid_render || f.full_collision || f.name.ends_with("_leaves") || fluid);
|
||||
&& (f.solid_render
|
||||
|| f.full_collision
|
||||
|| f.name.ends_with("_leaves")
|
||||
|| chorus
|
||||
|| fluid);
|
||||
(u8::from(kept) * KEPT)
|
||||
| (u8::from(fluid) * FLUID)
|
||||
| (u8::from(state == facts.air) * AIR)
|
||||
@@ -416,6 +435,28 @@ impl Plan {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
|
||||
/// at level 0, also `filled`) rather than by a coarser ancestor standing
|
||||
/// in for it, or is past the far terrain.
|
||||
fn replaces(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
ready: &FxHashMap<TileKey, impl Sized>,
|
||||
filled: impl Fn(TileKey) -> bool,
|
||||
) -> bool {
|
||||
let Some(key) = (0..=MAX_LEVEL)
|
||||
.map(|level| TileKey {
|
||||
x: (x * 16).div_euclid(16 << level),
|
||||
z: (z * 16).div_euclid(16 << level),
|
||||
level,
|
||||
})
|
||||
.find(|key| self.nodes.contains(key))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
// A split node's child there is out of range: nothing is drawn.
|
||||
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
|
||||
}
|
||||
}
|
||||
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
|
||||
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
|
||||
@@ -515,6 +556,8 @@ struct Shared {
|
||||
kinds: std::sync::OnceLock<Vec<u8>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Reduced far detail: samplers approximate wide cells.
|
||||
approximate: AtomicBool,
|
||||
}
|
||||
/// One background worker and at most two outstanding sample jobs. Only the
|
||||
/// current view's quadtree and a bounded set of visited summaries are retained.
|
||||
@@ -541,6 +584,8 @@ pub struct Lod {
|
||||
detail_request_at: std::time::Instant,
|
||||
/// Four threads or fewer: far work waits for the near terrain (`hold`).
|
||||
small: bool,
|
||||
/// Sample jobs that may be queued or running at once.
|
||||
sample_jobs: usize,
|
||||
/// The near terrain is loading (`set_near_loading`).
|
||||
near_loading: bool,
|
||||
/// When `request_samples` last started a job while held.
|
||||
@@ -550,8 +595,17 @@ pub struct Lod {
|
||||
active: FxHashSet<TileKey>,
|
||||
dirty: FxHashSet<TileKey>,
|
||||
removed: Vec<TileKey>,
|
||||
/// The columns left out: `full` and `retiring`.
|
||||
covered: FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
/// The full-detail columns `update` was last given.
|
||||
full: FxHashSet<(i32, i32)>,
|
||||
/// Columns that left the full-detail area, still left out (their
|
||||
/// full-detail meshes still drawn) until the tile planned there is ready
|
||||
/// (`Plan::replaces`). Tiles inside that area are never sampled: when the
|
||||
/// render distance shrinks, their coarsest ancestors would otherwise
|
||||
/// stand in for it for minutes.
|
||||
retiring: FxHashSet<(i32, i32)>,
|
||||
/// The covered columns the active meshes leave out, when a mesh job
|
||||
/// has finished since the last `take_cutout`.
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
@@ -561,12 +615,14 @@ pub struct Lod {
|
||||
}
|
||||
impl Lod {
|
||||
pub fn new(source: Arc<dyn LodSource>, context: Arc<WorkerContext>) -> Self {
|
||||
let cores = thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let shared = Arc::new(Shared {
|
||||
jobs: Mutex::new(VecDeque::new()),
|
||||
observed: Mutex::new(FxHashMap::default()),
|
||||
kinds: std::sync::OnceLock::new(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
approximate: AtomicBool::new(false),
|
||||
});
|
||||
let worker = shared.clone();
|
||||
let mesh_context = context.clone();
|
||||
@@ -574,10 +630,10 @@ impl Lod {
|
||||
let handle = thread::Builder::new()
|
||||
.name("Far terrain".into())
|
||||
.spawn(move || {
|
||||
// Ticks and frames come first (horizon_threads).
|
||||
horizon_threads::behind_server();
|
||||
let mut sampler = source.sampler();
|
||||
let count = thread::available_parallelism()
|
||||
.map_or(1, |n| n.get() / 4)
|
||||
.clamp(1, 3);
|
||||
let count = sampling_threads(cores);
|
||||
let mut sampling = Vec::new();
|
||||
for _ in 0..count {
|
||||
let Some(mut sample) = sampler.fork() else {
|
||||
@@ -589,6 +645,7 @@ impl Lod {
|
||||
thread::Builder::new()
|
||||
.name("Far sampling".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
loop {
|
||||
let key = {
|
||||
let mut jobs = shared.jobs.lock().unwrap();
|
||||
@@ -610,6 +667,9 @@ impl Lod {
|
||||
}
|
||||
};
|
||||
let [x, _, z] = key.origin();
|
||||
sample.set_approximate(
|
||||
shared.approximate.load(Ordering::Relaxed),
|
||||
);
|
||||
let samples =
|
||||
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
@@ -672,6 +732,7 @@ impl Lod {
|
||||
}
|
||||
Job::Sample(key) => {
|
||||
let [x, _, z] = key.origin();
|
||||
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
|
||||
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
Done::Sample { key, samples }
|
||||
}
|
||||
@@ -737,7 +798,8 @@ impl Lod {
|
||||
detail_masks: FxHashMap::default(),
|
||||
detail_request_pending: false,
|
||||
detail_request_at: std::time::Instant::now(),
|
||||
small: thread::available_parallelism().map_or(1, |n| n.get()) <= 4,
|
||||
small: cores <= 4,
|
||||
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
|
||||
near_loading: false,
|
||||
held_sample_at: std::time::Instant::now(),
|
||||
ready: FxHashMap::default(),
|
||||
@@ -746,6 +808,8 @@ impl Lod {
|
||||
removed: Vec::new(),
|
||||
covered: FxHashSet::default(),
|
||||
covered_bounds: None,
|
||||
full: FxHashSet::default(),
|
||||
retiring: FxHashSet::default(),
|
||||
cutout: None,
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
@@ -967,9 +1031,11 @@ impl Lod {
|
||||
self.near_loading = loading;
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
|
||||
/// replans when it changes.
|
||||
/// replans when it changes, and tiles sampled from then on approximate
|
||||
/// wide cells (`GeneratorWorker::lod_grid`).
|
||||
pub fn set_reduced_detail(&mut self, reduced: bool) {
|
||||
self.reduced_detail = reduced;
|
||||
self.shared.approximate.store(reduced, Ordering::Relaxed);
|
||||
}
|
||||
fn held(&self) -> bool {
|
||||
self.small && self.near_loading
|
||||
@@ -1005,7 +1071,8 @@ impl Lod {
|
||||
(ox + (index as i32 % 16) * stride).div_euclid(16),
|
||||
(oz + (index as i32 / 16) * stride).div_euclid(16),
|
||||
);
|
||||
if self.covered.contains(&chunk) {
|
||||
// A retiring column's tile is drawn soon: it needs its detail.
|
||||
if self.full.contains(&chunk) {
|
||||
self.detail_masks.entry(key).or_default()[index / 64] |= 1 << (index % 64);
|
||||
} else {
|
||||
chunks.insert(chunk);
|
||||
@@ -1127,31 +1194,13 @@ impl Lod {
|
||||
}
|
||||
/// Takes the new full-detail columns, when they changed (`None`: not).
|
||||
fn cover(&mut self, covered: Option<FxHashSet<(i32, i32)>>) {
|
||||
if let Some(covered) = covered
|
||||
&& self.covered != covered
|
||||
if let Some(full) = covered
|
||||
&& self.full != full
|
||||
{
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
self.covered = covered;
|
||||
self.replanned = true;
|
||||
self.reselect = true;
|
||||
self.retiring.extend(self.full.difference(&full).copied());
|
||||
self.retiring.retain(|column| !full.contains(column));
|
||||
self.full = full;
|
||||
self.leave_out();
|
||||
}
|
||||
// Complete resident columns need no duplicate noise work. Their real
|
||||
// summaries are retained when the full-detail chunks leave the view.
|
||||
@@ -1165,12 +1214,55 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Leaves out the full-detail and retiring columns: the tiles touching
|
||||
/// one that changed are meshed again.
|
||||
fn leave_out(&mut self) {
|
||||
let covered: FxHashSet<_> = self.full.union(&self.retiring).copied().collect();
|
||||
if self.covered == covered {
|
||||
return;
|
||||
}
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
// Only new covered columns need stand-ins.
|
||||
self.replanned |= !covered.is_subset(&self.covered);
|
||||
self.covered = covered;
|
||||
self.reselect = true;
|
||||
}
|
||||
/// Chooses the tiles to draw and meshes those that changed, when the
|
||||
/// plan, the samples, the covered columns or the active tiles changed.
|
||||
fn select(&mut self) {
|
||||
if !std::mem::take(&mut self.reselect) && self.dirty.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.retiring.is_empty() {
|
||||
let (plan, ready, saved, masks) =
|
||||
(&self.plan, &self.ready, &self.saved, &self.detail_masks);
|
||||
// A level-0 tile there is an empty stand-in (`cover`) until the
|
||||
// chunk's summary or generated detail fills it (`apply_fine`).
|
||||
self.retiring.retain(|&column| {
|
||||
!plan.replaces(column, ready, |key| {
|
||||
saved.contains_key(&column) || masks.get(&key) == Some(&[u64::MAX; 4])
|
||||
})
|
||||
});
|
||||
self.leave_out();
|
||||
}
|
||||
{
|
||||
let mut selected = Vec::new();
|
||||
for &key in &self.plan.roots {
|
||||
@@ -1234,7 +1326,13 @@ impl Lod {
|
||||
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
|
||||
)
|
||||
} else {
|
||||
SAMPLE_JOBS.saturating_sub(self.pending.len())
|
||||
// The extra sampling threads wait until the near terrain is in.
|
||||
let slots = if self.near_loading {
|
||||
SAMPLE_JOBS
|
||||
} else {
|
||||
self.sample_jobs
|
||||
};
|
||||
slots.saturating_sub(self.pending.len())
|
||||
};
|
||||
if free == 0 {
|
||||
return;
|
||||
@@ -1282,8 +1380,8 @@ impl Lod {
|
||||
}
|
||||
// Only the first `free` keys not already pending are taken. A key is
|
||||
// listed at most twice (as a root and as a node), so the best
|
||||
// `2 * (SAMPLE_JOBS + pending)` entries hold them, in order.
|
||||
let best = 2 * (SAMPLE_JOBS + self.pending.len());
|
||||
// `2 * (sample_jobs + pending)` entries hold them, in order.
|
||||
let best = 2 * (self.sample_jobs + self.pending.len());
|
||||
if wanted.len() > best {
|
||||
wanted.select_nth_unstable(best);
|
||||
wanted.truncate(best);
|
||||
@@ -1581,6 +1679,48 @@ mod tests {
|
||||
assert_eq!(selected, parent.children());
|
||||
}
|
||||
#[test]
|
||||
fn a_shrunk_render_distance_keeps_columns_until_their_planned_tiles_are_ready() {
|
||||
// Render distance 64 under LOD 64: every tile inside it is a level-0
|
||||
// stand-in for a covered column, and only the roots are sampled.
|
||||
let camera = [8, 8];
|
||||
let before = Plan::new(camera, 64, 64);
|
||||
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
|
||||
let mut ready: FxHashMap<TileKey, ()> = before
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| key.level == 0 || before.roots.contains(key))
|
||||
.map(|&key| (key, ()))
|
||||
.collect();
|
||||
ready.retain(|key, _| after.nodes.contains(key));
|
||||
let contains = |key: &TileKey, (x, z): (i32, i32)| {
|
||||
let [ox, _, oz] = key.origin();
|
||||
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
|
||||
};
|
||||
// 24 chunks out, now past the render distance: its planned tile was
|
||||
// split before, so never sampled, and a root stands in for it.
|
||||
let column = (24, 0);
|
||||
let mut selected = Vec::new();
|
||||
for &root in &after.roots {
|
||||
after.select(root, &ready, &mut selected);
|
||||
}
|
||||
let drawn = selected.iter().filter(|key| contains(key, column));
|
||||
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
|
||||
assert!(!after.replaces(column, &ready, |_| true));
|
||||
let planned = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 1,
|
||||
};
|
||||
assert!(after.nodes.contains(&planned) && before.split.contains(&planned));
|
||||
ready.insert(planned, ());
|
||||
assert!(after.replaces(column, &ready, |_| true));
|
||||
// 12 chunks out, its level-0 tile is the stand-in until filled.
|
||||
assert!(!after.replaces((12, 0), &ready, |_| false));
|
||||
assert!(after.replaces((12, 0), &ready, |_| true));
|
||||
// Past the far terrain nothing waits.
|
||||
assert!(after.replaces((100, 0), &ready, |_| false));
|
||||
}
|
||||
#[test]
|
||||
fn detail_survives_movement_across_a_split_threshold() {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
@@ -1810,6 +1950,23 @@ mod tests {
|
||||
for y in 55..64 {
|
||||
chunk.set(4, y, 4, water.0);
|
||||
}
|
||||
// Chorus is thin and non-solid but must survive the visit summary.
|
||||
let end_stone = assets
|
||||
.registry
|
||||
.default_state("minecraft:end_stone")
|
||||
.unwrap();
|
||||
let chorus = assets
|
||||
.registry
|
||||
.default_state("minecraft:chorus_plant")
|
||||
.unwrap();
|
||||
let flower = assets
|
||||
.registry
|
||||
.default_state("minecraft:chorus_flower")
|
||||
.unwrap();
|
||||
chunk.set(8, 70, 8, end_stone.0);
|
||||
chunk.set(8, 71, 8, chorus.0);
|
||||
chunk.set(8, 72, 8, chorus.0);
|
||||
chunk.set(8, 73, 8, flower.0);
|
||||
lod.observe(Arc::new(chunk.clone()), &facts);
|
||||
receive_observed(&mut lod);
|
||||
let observed = &lod.saved[&(-1, -1)];
|
||||
@@ -1817,6 +1974,16 @@ mod tests {
|
||||
assert_eq!(observed[0].surface.unwrap().state, stone);
|
||||
assert_eq!(observed[68].surface.unwrap().height, 55);
|
||||
assert_eq!(observed[68].surface.unwrap().water, Some((64, water)));
|
||||
let chorus_column = &observed[8 * 16 + 8];
|
||||
assert_eq!(chorus_column.surface.unwrap().state, flower);
|
||||
assert_eq!(
|
||||
chorus_column
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| (span.min, span.max, span.state))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
|
||||
);
|
||||
|
||||
let fine = TileKey {
|
||||
x: -1,
|
||||
@@ -1964,6 +2131,7 @@ mod tests {
|
||||
lod.ready
|
||||
.insert(parent, Arc::new(vec![Column::default(); 256]));
|
||||
lod.active = [parent].into_iter().collect();
|
||||
lod.sample_jobs = SAMPLE_JOBS;
|
||||
lod.pending = (0..SAMPLE_JOBS - 1)
|
||||
.map(|i| TileKey {
|
||||
x: 999 + i as i32,
|
||||
@@ -2022,6 +2190,7 @@ mod tests {
|
||||
}
|
||||
lod.active = [near, far].into_iter().collect();
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.sample_jobs = SAMPLE_JOBS;
|
||||
lod.pending = (0..SAMPLE_JOBS - 1)
|
||||
.map(|i| TileKey {
|
||||
x: 999 + i as i32,
|
||||
@@ -2055,6 +2224,34 @@ mod tests {
|
||||
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
|
||||
"one unfinished fine tile must not starve neighbouring requests"
|
||||
);
|
||||
|
||||
// The extra sampling threads' slots open once the near terrain is in.
|
||||
lod.small = false;
|
||||
lod.sample_jobs = SAMPLE_JOBS + 3;
|
||||
lod.plan = Plan::new([-256, -256], 2, 64);
|
||||
lod.ready.clear();
|
||||
lod.active.clear();
|
||||
lod.pending.clear();
|
||||
lod.meshing = true;
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
let queued = |lod: &mut Lod, near_loading: bool| {
|
||||
lod.set_near_loading(near_loading);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.pending.clear();
|
||||
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
|
||||
let jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.iter()
|
||||
.filter(|job| matches!(job, Job::Sample(_)))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(queued(&mut lod, true), SAMPLE_JOBS);
|
||||
assert_eq!(queued(&mut lod, false), SAMPLE_JOBS + 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_sampling_threads_only_on_large_machines() {
|
||||
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 2, 2]);
|
||||
assert_eq!([12, 16, 64].map(sampling_threads), [6, 6, 6]);
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
|
||||
/// platform data folder (launcher/src/backend.rs `game_home`).
|
||||
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
|
||||
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Makes `game_home` return `home` for the rest of the run (`--home`).
|
||||
pub fn set_game_home(home: PathBuf) {
|
||||
let _ = HOME.set(home);
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
|
||||
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
|
||||
pub fn game_home() -> PathBuf {
|
||||
if let Some(home) = HOME.get() {
|
||||
return home.clone();
|
||||
}
|
||||
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon"),
|
||||
|
||||
@@ -36,11 +36,8 @@ pub struct Installed {
|
||||
pub description: String,
|
||||
/// Why it is not running, when it failed.
|
||||
pub problem: Option<String>,
|
||||
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
|
||||
/// Loaded with `--dev-mod`: always on.
|
||||
pub dev: bool,
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
/// A native mod (`game/native_hooks.rs`): built into the game, on until
|
||||
/// the player turns it off, and its settings page.
|
||||
pub native: Option<NativePage>,
|
||||
@@ -178,7 +175,6 @@ mod tests {
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
let mut list = ModList {
|
||||
|
||||
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
|
||||
];
|
||||
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
|
||||
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
|
||||
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
|
||||
/// view distance tops out at 127 anyway.
|
||||
pub const MAX_RENDER_DISTANCE: i32 = 128;
|
||||
const INACTIVITY: &[&str] = &["minimized", "afk"];
|
||||
const CLOUDS: &[&str] = &["false", "fast", "true"];
|
||||
const MAIN_HAND: &[&str] = &["left", "right"];
|
||||
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
|
||||
b("reducedDebugInfo", false),
|
||||
def("renderClouds", O(Clouds), V::Int(2)),
|
||||
i("cloudRange", 2, 128, 128),
|
||||
i("renderDistance", 2, 64, 12),
|
||||
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
|
||||
i("simulationDistance", 5, 32, 12),
|
||||
u("screenEffectScale", 1.0),
|
||||
def("soundDevice", O(AnyStr), V::Str(String::new())),
|
||||
@@ -570,6 +573,15 @@ impl OptionsFile {
|
||||
self.values[i] = V::Int(p as i32);
|
||||
}
|
||||
}
|
||||
/// `Options.modelCustomisation`: the PlayerModelPart bits the player shows
|
||||
/// (cape first, as `modelPart_*` is in `MODEL_PARTS`).
|
||||
pub fn model_customisation(&self) -> u8 {
|
||||
MODEL_PARTS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, part)| self.bool(&format!("modelPart_{part}")))
|
||||
.fold(0, |bits, (bit, _)| bits | 1 << bit)
|
||||
}
|
||||
/// Key mapping value by mapping name.
|
||||
pub fn key_binding(&self, name: &str) -> String {
|
||||
self.string(&format!("key_{name}"))
|
||||
|
||||
@@ -45,6 +45,16 @@ pub(crate) enum Kind {
|
||||
Slider(f32),
|
||||
Cycle,
|
||||
}
|
||||
/// `PlayerModelPart` keys, in vanilla's bit order (`modelPart_*` in options.txt).
|
||||
const MODEL_PART_KEYS: [&str; 7] = [
|
||||
"modelPart_cape",
|
||||
"modelPart_jacket",
|
||||
"modelPart_left_sleeve",
|
||||
"modelPart_right_sleeve",
|
||||
"modelPart_left_pants_leg",
|
||||
"modelPart_right_pants_leg",
|
||||
"modelPart_hat",
|
||||
];
|
||||
/// The Accessibility Settings page:
|
||||
/// `AccessibilityOptionsScreen.options`, with the Controls link inserted separately.
|
||||
pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
@@ -74,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
Opt::HighContrastBlockOutlines,
|
||||
];
|
||||
|
||||
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
|
||||
/// their vanilla order. The macOS-only Control-click switch is omitted on
|
||||
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
|
||||
/// does in Java.
|
||||
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
|
||||
let mut options = vec![
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::ToggleAttack,
|
||||
Opt::ToggleUse,
|
||||
Opt::AutoJump,
|
||||
Opt::SprintWindow,
|
||||
Opt::QuitShortcuts,
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
options.push(Opt::CtrlClickEmulatesRightClick);
|
||||
}
|
||||
options.push(Opt::OperatorItemsTab);
|
||||
options
|
||||
}
|
||||
|
||||
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
|
||||
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
|
||||
Opt::Sensitivity,
|
||||
@@ -97,13 +128,24 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::DistortionEffects
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::SharePresence
|
||||
| Opt::DirectionalAudio
|
||||
| Opt::MusicToast
|
||||
// Saved, but nothing in rendering reads them yet.
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs, the quit keys and the mouse
|
||||
// don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
| Opt::QuitShortcuts
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
fn file_key(self) -> Option<&'static str> {
|
||||
@@ -128,6 +170,17 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::SoundDevice => "soundDevice",
|
||||
Opt::OperatorItemsTab => "operatorItemsTab",
|
||||
Opt::QuitShortcuts => "quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
|
||||
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
|
||||
Opt::AllowServerListing => "allowServerListing",
|
||||
Opt::RealmsNotifications => "realmsNotifications",
|
||||
Opt::InGameNotification => "inGameNotification",
|
||||
Opt::SharePresence => "sharePresence",
|
||||
Opt::DirectionalAudio => "directionalAudio",
|
||||
Opt::MusicToast => "musicToast",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -144,6 +197,7 @@ impl Opt {
|
||||
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
|
||||
Opt::AllowCursorChanges => "options.allowCursorChanges",
|
||||
Opt::Volume(_) => "soundCategory.master",
|
||||
Opt::SoundDevice => "options.audioDevice",
|
||||
Opt::GuiScale => "options.guiScale",
|
||||
Opt::ViewBobbing => "options.viewBobbing",
|
||||
Opt::Gamma => "options.gamma",
|
||||
@@ -171,7 +225,13 @@ impl Opt {
|
||||
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
|
||||
Opt::ToggleCrouch => "key.sneak",
|
||||
Opt::ToggleSprint => "key.sprint",
|
||||
Opt::ToggleAttack => "key.attack",
|
||||
Opt::ToggleUse => "key.use",
|
||||
Opt::AutoJump => "options.autoJump",
|
||||
Opt::SprintWindow => "options.sprintWindow",
|
||||
Opt::QuitShortcuts => "options.quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
|
||||
Opt::OperatorItemsTab => "options.operatorItemsTab",
|
||||
Opt::ChatVisibility => "options.chat.visibility",
|
||||
Opt::ChatColors => "options.chat.color",
|
||||
Opt::ChatLinks => "options.chat.links",
|
||||
@@ -225,6 +285,22 @@ impl Opt {
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadMenuNav => "horizon.options.menu_navigation",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
"options.modelPart.jacket",
|
||||
"options.modelPart.left_sleeve",
|
||||
"options.modelPart.right_sleeve",
|
||||
"options.modelPart.left_pants_leg",
|
||||
"options.modelPart.right_pants_leg",
|
||||
"options.modelPart.hat",
|
||||
][i],
|
||||
Opt::MainHand => "options.mainHand",
|
||||
Opt::AllowServerListing => "options.allowServerListing",
|
||||
Opt::RealmsNotifications => "options.realmsNotifications.button",
|
||||
Opt::InGameNotification => "options.inGameNotification",
|
||||
Opt::SharePresence => "options.sharePresence",
|
||||
Opt::DirectionalAudio => "options.directionalAudio",
|
||||
Opt::MusicToast => "options.musicToast",
|
||||
}
|
||||
}
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
@@ -239,7 +315,11 @@ impl Opt {
|
||||
| Opt::GlintStrength
|
||||
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
|
||||
Opt::Fov => unit(s.fov, 30.0, 110.0),
|
||||
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
|
||||
Opt::RenderDistance => unit(
|
||||
s.render_distance as f32,
|
||||
2.0,
|
||||
crate::options_file::MAX_RENDER_DISTANCE as f32,
|
||||
),
|
||||
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
|
||||
Opt::Sensitivity => Kind::Slider(s.sensitivity),
|
||||
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
|
||||
@@ -269,6 +349,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -317,6 +398,14 @@ impl Opt {
|
||||
}
|
||||
}
|
||||
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
|
||||
Opt::SprintWindow => {
|
||||
let value = if s.sprint_window == 0 {
|
||||
tr("options.off")
|
||||
} else {
|
||||
Text::tr("options.value", &[&s.sprint_window])
|
||||
};
|
||||
generic(c, value)
|
||||
}
|
||||
Opt::Fov => match s.fov as i32 {
|
||||
70 => generic(c, tr("options.fov.min")),
|
||||
110 => generic(c, tr("options.fov.max")),
|
||||
@@ -460,9 +549,29 @@ impl Opt {
|
||||
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
|
||||
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
|
||||
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
|
||||
Opt::HideMatchedNames | Opt::OnlySecureChat | Opt::SaveChatDrafts => {
|
||||
on_off(s.file.bool(self.file_key().unwrap()))
|
||||
}
|
||||
Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::ModelPart(_)
|
||||
| Opt::AllowServerListing
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio => on_off(s.file.bool(self.file_key().unwrap())),
|
||||
Opt::MainHand => tr(if s.main_hand_left {
|
||||
"options.mainHand.left"
|
||||
} else {
|
||||
"options.mainHand.right"
|
||||
}),
|
||||
Opt::SharePresence => tr([
|
||||
"options.sharePresence.none",
|
||||
"options.sharePresence.limited",
|
||||
"options.sharePresence.all",
|
||||
][s.file.int("sharePresence").clamp(0, 2) as usize]),
|
||||
Opt::MusicToast => tr([
|
||||
"options.musicToast.never",
|
||||
"options.musicToast.pauseMenu",
|
||||
"options.musicToast.pauseMenuAndToast",
|
||||
][s.file.int("musicToast").clamp(0, 2) as usize]),
|
||||
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
|
||||
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
|
||||
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
|
||||
@@ -514,7 +623,20 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleAttack => tr(if s.toggle_attack {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleUse => tr(if s.toggle_use {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::AutoJump => on_off(s.auto_jump),
|
||||
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
|
||||
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
|
||||
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
|
||||
Opt::ChatVisibility => tr([
|
||||
"options.chat.visibility.full",
|
||||
"options.chat.visibility.system",
|
||||
@@ -527,6 +649,13 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
|
||||
Opt::HighContrast => on_off(s.high_contrast),
|
||||
Opt::ShowSubtitles => on_off(s.show_subtitles),
|
||||
Opt::SoundDevice => {
|
||||
if s.sound_device.is_empty() {
|
||||
tr("options.audioDevice.default")
|
||||
} else {
|
||||
Text::literal(&s.sound_device)
|
||||
}
|
||||
}
|
||||
Opt::MusicFrequency => tr([
|
||||
"options.music_frequency.default",
|
||||
"options.music_frequency.frequent",
|
||||
@@ -579,14 +708,19 @@ impl Opt {
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v).powi(2)),
|
||||
),
|
||||
Opt::DamageTilt | Opt::PanoramaSpeed | Opt::GlintSpeed | Opt::GlintStrength => {
|
||||
s.file.set(
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
)
|
||||
}
|
||||
Opt::DistortionEffects
|
||||
| Opt::DamageTilt
|
||||
| Opt::PanoramaSpeed
|
||||
| Opt::GlintSpeed
|
||||
| Opt::GlintStrength => s.file.set(
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
),
|
||||
Opt::Fov => s.fov = step(30.0, 110.0),
|
||||
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
|
||||
Opt::RenderDistance => {
|
||||
s.render_distance =
|
||||
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
|
||||
}
|
||||
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
|
||||
Opt::Sensitivity => s.sensitivity = v,
|
||||
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
|
||||
@@ -613,6 +747,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -669,7 +804,12 @@ impl Opt {
|
||||
| Opt::HighContrastBlockOutlines
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts => {
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::ModelPart(_)
|
||||
| Opt::AllowServerListing
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio => {
|
||||
let key = self.file_key().unwrap();
|
||||
s.file
|
||||
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
|
||||
@@ -704,7 +844,14 @@ impl Opt {
|
||||
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
|
||||
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
|
||||
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
|
||||
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
|
||||
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
|
||||
Opt::AutoJump => s.auto_jump = !s.auto_jump,
|
||||
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
|
||||
Opt::CtrlClickEmulatesRightClick => {
|
||||
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
|
||||
}
|
||||
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
|
||||
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
|
||||
Opt::ChatColors => s.chat_colors = !s.chat_colors,
|
||||
Opt::ChatLinks => s.chat_links = !s.chat_links,
|
||||
@@ -713,11 +860,39 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
|
||||
Opt::HighContrast => s.high_contrast = !s.high_contrast,
|
||||
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
|
||||
Opt::SoundDevice => {
|
||||
let devices = crate::audio::output_devices();
|
||||
let mut all = vec![String::new()];
|
||||
for d in devices {
|
||||
if !all.contains(&d) {
|
||||
all.push(d);
|
||||
}
|
||||
}
|
||||
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
|
||||
let count = all.len();
|
||||
let next_idx = if back {
|
||||
(current_idx + count - 1) % count
|
||||
} else {
|
||||
(current_idx + 1) % count
|
||||
};
|
||||
s.sound_device = all[next_idx].clone();
|
||||
}
|
||||
Opt::MusicFrequency => {
|
||||
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
.set("musicFrequency", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::MainHand => s.main_hand_left = !s.main_hand_left,
|
||||
Opt::SharePresence => {
|
||||
let n = next(s.file.int("sharePresence").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
.set("sharePresence", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::MusicToast => {
|
||||
let n = next(s.file.int("musicToast").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
.set("musicToast", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::ForceUnicode => s.force_unicode = !s.force_unicode,
|
||||
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
|
||||
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
|
||||
@@ -844,6 +1019,7 @@ pub enum Page {
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
GraphicsHdr,
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
@@ -855,6 +1031,10 @@ pub enum Page {
|
||||
Horizon,
|
||||
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
|
||||
Agent,
|
||||
/// SkinCustomizationScreen: the skin layers and Main Hand.
|
||||
Skin,
|
||||
/// OnlineOptionsScreen: server listing, Realms and friends notifications.
|
||||
Online,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
@@ -874,8 +1054,7 @@ enum Act {
|
||||
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
|
||||
PickShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
/// The installed native mod's settings page.
|
||||
ModSettings(usize),
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
@@ -923,22 +1102,28 @@ pub enum Event {
|
||||
ButtonClick,
|
||||
/// Close the options screens (back to the pause menu or title).
|
||||
Close,
|
||||
/// Settings changed: apply them.
|
||||
/// Settings changed: apply and save them.
|
||||
Changed,
|
||||
/// A slider is being dragged: apply settings live but defer saving.
|
||||
SliderDrag,
|
||||
/// The language changed (reload translations).
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
Packs(Vec<String>),
|
||||
/// Create World's Data Packs screen closed with a new selection.
|
||||
DataPacks(Vec<String>),
|
||||
OpenPackFolder,
|
||||
/// Data Packs' Open Pack Folder: create the folder if needed and open it.
|
||||
OpenDataPackFolder(std::path::PathBuf),
|
||||
OpenModFolder,
|
||||
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
|
||||
ListShaderPacks,
|
||||
/// A shader pack was picked or turned off: save and apply it.
|
||||
ShaderPack,
|
||||
OpenShaderPackFolder,
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Native mod `.0`'s page opened: the game asks the mod for its labels
|
||||
/// again (they were made with the list, maybe in another language).
|
||||
NativeSettings(usize),
|
||||
/// Button `.1` of native mod `.0`'s page was pressed: the game tells the
|
||||
/// mod and refreshes the page.
|
||||
NativeButton(usize, usize),
|
||||
@@ -992,6 +1177,13 @@ pub struct OptionsUi {
|
||||
pub pack_selection: Vec<String>,
|
||||
/// The open pack screen, made at its first frame.
|
||||
pub pack_screen: Option<crate::pack_screen::PackScreen>,
|
||||
/// The Packs page is Create World's Data Packs screen on this folder.
|
||||
data_pack_dir: Option<std::path::PathBuf>,
|
||||
/// The game's resources, for the built-in data pack's icon.
|
||||
data_pack_resources: std::path::PathBuf,
|
||||
/// The folder's entries when its packs were listed, and when they were
|
||||
/// last compared with it.
|
||||
data_pack_stamp: (Vec<(String, Option<std::time::SystemTime>)>, Instant),
|
||||
/// A reload failure, dismissed before returning to the current page.
|
||||
pub resource_error: Option<String>,
|
||||
/// When the pack screen's search box took the focus (its cursor blink).
|
||||
@@ -1026,6 +1218,9 @@ impl Default for OptionsUi {
|
||||
pack_infos: Vec::new(),
|
||||
pack_selection: Vec::new(),
|
||||
pack_screen: None,
|
||||
data_pack_dir: None,
|
||||
data_pack_resources: Default::default(),
|
||||
data_pack_stamp: (Vec::new(), Instant::now()),
|
||||
resource_error: None,
|
||||
pack_focus: Instant::now(),
|
||||
in_world: false,
|
||||
@@ -1133,9 +1328,48 @@ impl OptionsUi {
|
||||
pub fn open(&mut self, in_world: bool) {
|
||||
self.open_at(in_world, Page::Main);
|
||||
}
|
||||
/// Create World's Data Packs (`openDataPackSelectionScreen`): the Packs
|
||||
/// page on the new world's pack folder `dir`, `selected` chosen;
|
||||
/// `resources` holds the built-in pack's icon.
|
||||
pub fn open_data_packs(
|
||||
&mut self,
|
||||
dir: std::path::PathBuf,
|
||||
selected: Vec<String>,
|
||||
resources: std::path::PathBuf,
|
||||
) {
|
||||
self.open_at(false, Page::Packs);
|
||||
self.pack_infos = crate::datapack::screen_packs(&dir, &resources);
|
||||
self.data_pack_resources = resources;
|
||||
self.pack_selection = selected;
|
||||
self.data_pack_stamp = (crate::datapack::folder_stamp(&dir), Instant::now());
|
||||
self.data_pack_dir = Some(dir);
|
||||
}
|
||||
/// `PackSelectionScreen.tick`'s watcher: once a second, the Data Packs
|
||||
/// folder's packs are listed again if it changed, the selection kept
|
||||
/// (the search text and scroll start over).
|
||||
fn watch_data_packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) {
|
||||
let Some(dir) = &self.data_pack_dir else {
|
||||
return;
|
||||
};
|
||||
if self.data_pack_stamp.1.elapsed().as_secs() < 1 {
|
||||
return;
|
||||
}
|
||||
let stamp = crate::datapack::folder_stamp(dir);
|
||||
let changed = stamp != self.data_pack_stamp.0;
|
||||
self.data_pack_stamp = (stamp, Instant::now());
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
self.pack_infos = crate::datapack::screen_packs(dir, &self.data_pack_resources);
|
||||
if let Some(p) = self.pack_screen.take() {
|
||||
self.pack_selection = p.commit();
|
||||
self.packs(atlas, size).changed = p.changed;
|
||||
}
|
||||
}
|
||||
/// Open a page whose Done/Escape returns directly to the calling screen.
|
||||
pub fn open_at(&mut self, in_world: bool, page: Page) {
|
||||
self.stack.clear();
|
||||
self.data_pack_dir = None;
|
||||
self.push(page);
|
||||
self.in_world = in_world;
|
||||
self.selected_key = None;
|
||||
@@ -1156,7 +1390,10 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
|
||||
if matches!(
|
||||
page,
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
}
|
||||
@@ -1165,6 +1402,7 @@ impl OptionsUi {
|
||||
match page {
|
||||
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
|
||||
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
|
||||
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1173,6 +1411,8 @@ impl OptionsUi {
|
||||
Page::Main => "options.title",
|
||||
Page::Video => "options.videoTitle",
|
||||
Page::Sound => "options.sounds.title",
|
||||
Page::Skin => "options.skinCustomisation.title",
|
||||
Page::Online => "options.online.title",
|
||||
Page::Controls => "controls.title",
|
||||
Page::KeyBinds => "controls.keybinds.title",
|
||||
Page::Mouse => "options.mouse_settings.title",
|
||||
@@ -1184,7 +1424,7 @@ impl OptionsUi {
|
||||
Page::Mods => "Mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
@@ -1432,13 +1672,30 @@ impl OptionsUi {
|
||||
self.filter_option_rows(rows)
|
||||
}
|
||||
Page::Sound => {
|
||||
// SoundOptionsScreen, without Audio Device: Horizon opens the
|
||||
// output device itself and cannot switch it.
|
||||
let mut rows = vec![Row::Big(Opt::Volume(0))];
|
||||
let others: Vec<Opt> = (1..SOUND_SOURCES.len()).map(Opt::Volume).collect();
|
||||
rows.extend(pairs(&others));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles]));
|
||||
// SoundOptionsScreen: Music Frequency starts the next row (with
|
||||
// Music Toast, not in Horizon yet).
|
||||
rows.extend(pairs(&[Opt::MusicFrequency]));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
|
||||
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
|
||||
rows.push(Row::Big(Opt::SoundDevice));
|
||||
rows
|
||||
}
|
||||
Page::Skin => {
|
||||
// SkinCustomizationScreen: the seven layers, then Main Hand.
|
||||
let mut opts: Vec<Opt> = (0..MODEL_PART_KEYS.len()).map(Opt::ModelPart).collect();
|
||||
opts.push(Opt::MainHand);
|
||||
pairs(&opts)
|
||||
}
|
||||
Page::Online => {
|
||||
// OnlineOptionsScreen without the Xbox and friends-list parts.
|
||||
let mut rows = vec![Row::Header("options.online.friends.header")];
|
||||
rows.extend(pairs(&[Opt::InGameNotification, Opt::SharePresence]));
|
||||
rows.push(Row::Header("options.online.servers.header"));
|
||||
rows.push(Row::Big(Opt::AllowServerListing));
|
||||
rows.push(Row::Header("options.online.realms.header"));
|
||||
rows.push(Row::Big(Opt::RealmsNotifications));
|
||||
rows
|
||||
}
|
||||
Page::Controls => {
|
||||
@@ -1458,11 +1715,8 @@ impl OptionsUi {
|
||||
Act::Push(Page::KeyBinds),
|
||||
)),
|
||||
)];
|
||||
rows.extend(pairs(&[
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::AutoJump,
|
||||
]));
|
||||
let controls = vanilla_control_options();
|
||||
rows.extend(pairs(&controls));
|
||||
// Horizon's controller page, after vanilla's rows.
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
@@ -1557,9 +1811,9 @@ impl OptionsUi {
|
||||
label = label.append(" (Dev)");
|
||||
}
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
// `ui`) gets its own button beside the toggle.
|
||||
if installed.has_settings || installed.native.is_some() {
|
||||
// A native mod gets its settings page's button beside
|
||||
// the toggle.
|
||||
if installed.native.is_some() {
|
||||
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
|
||||
let settings = button(
|
||||
0.0,
|
||||
@@ -1633,15 +1887,15 @@ impl OptionsUi {
|
||||
list: false,
|
||||
});
|
||||
out.push(option_widget(s, Opt::Fov, cx - 154.0, 29.0, 150.0));
|
||||
out.push(inactive(button(
|
||||
out.push(button(
|
||||
cx + 4.0,
|
||||
29.0,
|
||||
150.0,
|
||||
tr("options.online"),
|
||||
Act::None,
|
||||
)));
|
||||
Act::Push(Page::Online),
|
||||
));
|
||||
let entries = [
|
||||
("options.skinCustomisation", None),
|
||||
("options.skinCustomisation", Some(Page::Skin)),
|
||||
("options.sounds", Some(Page::Sound)),
|
||||
("options.video", Some(Page::Video)),
|
||||
("options.controls", Some(Page::Controls)),
|
||||
@@ -2169,6 +2423,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
self.watch_data_packs(atlas, (w, h));
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
let blink = self.pack_focus.elapsed().as_millis() as u64;
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
@@ -2427,7 +2682,9 @@ impl OptionsUi {
|
||||
match action {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -2463,7 +2720,10 @@ impl OptionsUi {
|
||||
self.pack_focus = Instant::now();
|
||||
}
|
||||
match action {
|
||||
crate::pack_screen::Action::OpenFolder => events.push(Event::OpenPackFolder),
|
||||
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
|
||||
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
|
||||
None => Event::OpenPackFolder,
|
||||
}),
|
||||
crate::pack_screen::Action::Done => events.extend(self.back(s)),
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
@@ -2507,7 +2767,7 @@ impl OptionsUi {
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
events.push(Event::SliderDrag);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -2610,11 +2870,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
events.push(Event::ButtonClick);
|
||||
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
|
||||
self.push(Page::NativeMod(i));
|
||||
} else {
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
@@ -2643,7 +2900,7 @@ impl OptionsUi {
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
events.push(Event::SliderDrag);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
@@ -2676,7 +2933,7 @@ impl OptionsUi {
|
||||
let slider = self.dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
vec![Event::ButtonClick]
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -2692,7 +2949,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics.scroll(level, size.1, amount);
|
||||
self.graphics.scroll(level, s, size.1, amount);
|
||||
return;
|
||||
}
|
||||
match &mut self.pack_screen {
|
||||
@@ -2714,8 +2971,14 @@ impl OptionsUi {
|
||||
}
|
||||
None => {
|
||||
self.pack_focus = Instant::now();
|
||||
let title = if self.data_pack_dir.is_some() {
|
||||
"dataPack.title"
|
||||
} else {
|
||||
"resourcePack.title"
|
||||
};
|
||||
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
|
||||
&m,
|
||||
crate::text::Text::translate(title, &[]),
|
||||
w,
|
||||
h,
|
||||
self.in_world,
|
||||
@@ -2747,13 +3010,18 @@ impl OptionsUi {
|
||||
}
|
||||
Page::Packs => {
|
||||
// PackSelectionScreen.onClose: PackSelectionModel.commit.
|
||||
let data = self.data_pack_dir.take();
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
&& p.changed
|
||||
{
|
||||
let ids = p.commit();
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
if data.is_some() {
|
||||
events.push(Event::DataPacks(ids));
|
||||
} else {
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3049,10 +3317,48 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_match_java_26_3_option_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Controls);
|
||||
let options =
|
||||
ui.list_rows(&Settings::default())
|
||||
.into_iter()
|
||||
.fold(Vec::new(), |mut options, row| {
|
||||
match row {
|
||||
Row::Big(opt) => options.push(opt),
|
||||
Row::Pair(a, b) => {
|
||||
options.push(a);
|
||||
options.extend(b);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
options
|
||||
});
|
||||
assert_eq!(options, vanilla_control_options());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_window_and_new_control_switches_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
set_slider(Opt::SprintWindow, &mut settings, 0.5);
|
||||
settings.operator_items_tab = true;
|
||||
settings.quit_shortcuts = false;
|
||||
settings.ctrl_click_emulates_right_click = true;
|
||||
settings.toggle_attack = true;
|
||||
settings.toggle_use = true;
|
||||
let loaded = Settings::parse(&settings.serialize());
|
||||
assert_eq!(loaded.sprint_window, 5);
|
||||
assert!(loaded.operator_items_tab);
|
||||
assert!(!loaded.quit_shortcuts);
|
||||
assert!(loaded.ctrl_click_emulates_right_click);
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_distance_slider_round_trips_through_extended_options_range() {
|
||||
let mut s = Settings::default();
|
||||
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
|
||||
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
|
||||
set_slider(Opt::RenderDistance, &mut s, position);
|
||||
assert_eq!(s.render_distance, distance);
|
||||
let reloaded = Settings::parse(&s.serialize());
|
||||
@@ -3063,10 +3369,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let mut file = crate::options_file::OptionsFile::default();
|
||||
file.set_text("renderDistance", "64").unwrap();
|
||||
assert!(file.set_text("renderDistance", "65").is_err());
|
||||
file.set_text("renderDistance", "128").unwrap();
|
||||
assert!(file.set_text("renderDistance", "129").is_err());
|
||||
assert!(file.set_text("renderDistance", "1").is_err());
|
||||
assert_eq!(file.int("renderDistance"), 64);
|
||||
assert_eq!(file.int("renderDistance"), 128);
|
||||
}
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
@@ -3174,7 +3480,6 @@ mod tests {
|
||||
description: "May show HUD panels.".into(),
|
||||
problem: id.is_empty().then(|| "Invalid package".into()),
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
});
|
||||
}
|
||||
@@ -3230,6 +3535,49 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::Main);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_native_mods_page_asks_the_game_for_its_labels_again() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
id: "native".into(),
|
||||
title: "Native 1.0".into(),
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
native: Some(crate::mod_list::NativePage::default()),
|
||||
});
|
||||
ui.show(Page::Mods);
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let settings = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ModSettings(0),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("a native mod's row has a Settings button");
|
||||
let events = ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
settings.x + 1.0,
|
||||
settings.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
// Its labels were made with the list, maybe in another language.
|
||||
assert_eq!(events.last(), Some(&Event::NativeSettings(0)));
|
||||
assert_eq!(ui.page(), Page::NativeMod(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_tooltip_keys_follow_value_dependent_descriptions() {
|
||||
let mut s = Settings::default();
|
||||
@@ -3666,22 +4014,22 @@ mod tests {
|
||||
let (x, y) = (slider.x + slider.w - 1.0, slider.y + 1.0);
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
assert_eq!(s.master_volume, 1.0);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
assert!(ui.release().is_empty());
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
for cx in [-100.0, 250.0, 1000.0] {
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
}
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
assert!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], true, false)
|
||||
.is_empty()
|
||||
@@ -4272,4 +4620,29 @@ mod tests {
|
||||
ui.stack = vec![Page::Main, Page::Controls];
|
||||
assert!(pushes(&mut ui).contains(&Page::Controller));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_device_button_in_sound_page_and_cycles() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Sound);
|
||||
let rows = ui.list_rows(&s);
|
||||
assert!(
|
||||
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
|
||||
"Sound page should contain Row::Big(Opt::SoundDevice)"
|
||||
);
|
||||
assert_eq!(s.sound_device, "");
|
||||
let devices = crate::audio::output_devices();
|
||||
let count = 1 + devices.len();
|
||||
// Cycling forward 'count' times wraps around back to the initial selection ("")
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
// Cycling backward 'count' times also wraps around back to ""
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,8 @@ pub enum Action {
|
||||
/// The pack screen and its state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackScreen {
|
||||
/// `resourcePack.title`, or `dataPack.title` from Create World.
|
||||
pub title: Text,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub in_world: bool,
|
||||
@@ -342,6 +344,7 @@ impl PackScreen {
|
||||
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
|
||||
pub fn new(
|
||||
m: &Measure,
|
||||
title: Text,
|
||||
width: i32,
|
||||
height: i32,
|
||||
in_world: bool,
|
||||
@@ -360,6 +363,7 @@ impl PackScreen {
|
||||
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
|
||||
.collect();
|
||||
let mut s = Self {
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
in_world,
|
||||
@@ -382,7 +386,7 @@ impl PackScreen {
|
||||
}
|
||||
/// The header layout: title, drop info and search box, centred.
|
||||
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
|
||||
let tw = m.width(&tr("resourcePack.title"));
|
||||
let tw = m.width(&self.title);
|
||||
let dw = m.width(&tr("pack.dropInfo"));
|
||||
let col = tw.max(dw).max(200);
|
||||
let frame = self.width.max(col);
|
||||
@@ -837,7 +841,7 @@ impl PackScreen {
|
||||
return out;
|
||||
}
|
||||
let [(tx, ty), (dx, dy), _] = self.header(m);
|
||||
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
|
||||
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
|
||||
d::string_widget(
|
||||
&mut out,
|
||||
m,
|
||||
@@ -1105,7 +1109,7 @@ mod icon_tests {
|
||||
pack("high_contrast", Some(contrast.clone())),
|
||||
pack("file/Iconless", None),
|
||||
];
|
||||
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
|
||||
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0);
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, &atlas, &mut patches);
|
||||
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
|
||||
let title = tr("resourcePack.title");
|
||||
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
|
||||
assert!(!screen.button_at(&m, 0.0, 0.0));
|
||||
assert!(!screen.button_at(
|
||||
&m,
|
||||
|
||||
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
|
||||
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
|
||||
/// when the metadata names no format.
|
||||
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
|
||||
compatibility_for(meta, RESOURCE_FORMAT)
|
||||
}
|
||||
/// `compatibility` against `current`, the resource or the data pack format.
|
||||
pub fn compatibility_for(
|
||||
meta: &serde_json::Value,
|
||||
current: (i64, i64),
|
||||
) -> crate::pack_screen::Compat {
|
||||
use crate::pack_screen::Compat;
|
||||
let pack = &meta["pack"];
|
||||
let current = RESOURCE_FORMAT;
|
||||
let range = if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
@@ -275,6 +281,10 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
/// A pack whose compatibility is against `format`.
|
||||
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
Some(Pack {
|
||||
@@ -284,7 +294,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
raw_description,
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility(&meta),
|
||||
compatibility: compatibility_for(&meta, format),
|
||||
overlays: overlays_of(&meta),
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
@@ -305,7 +315,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
|
||||
out.extend(folder(packs_dir, RESOURCE_FORMAT));
|
||||
out
|
||||
}
|
||||
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
|
||||
/// finds them; their compatibility is against `format`.
|
||||
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
|
||||
let mut user: Vec<Pack> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
@@ -319,12 +335,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
pack(format!("file/{name}"), Text::literal(name), source)
|
||||
pack_for(format!("file/{name}"), Text::literal(name), source, format)
|
||||
})
|
||||
.collect();
|
||||
user.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out.extend(user);
|
||||
out
|
||||
user
|
||||
}
|
||||
/// A file a pack provides: its path (folder packs) and its name inside the pack.
|
||||
type Provided = (Option<PathBuf>, String);
|
||||
@@ -380,11 +395,45 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
if target.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
fs::copy(target, at).map(|_| ())
|
||||
// The mirror only reads these files, and unix links them: a hardlink gives
|
||||
// the same view without writing the 639 MB prepared root again, which was
|
||||
// 33 s of a cold Windows launch with a pack on. Another volume (or a
|
||||
// filesystem without hardlinks) still copies.
|
||||
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
|
||||
}
|
||||
}
|
||||
/// A directory junction, which Windows creates without privileges (a directory symlink needs
|
||||
/// developer mode). The mirror falls back to copying the tree when it fails.
|
||||
#[cfg(windows)]
|
||||
fn junction(target: &Path, at: &Path) -> io::Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an
|
||||
// unquoted path would split or change the command. A Windows path cannot hold `"`.
|
||||
let quoted = |path: &Path| -> io::Result<std::ffi::OsString> {
|
||||
let mut quoted = std::ffi::OsString::from("\"");
|
||||
quoted.push(std::path::absolute(path)?);
|
||||
quoted.push("\"");
|
||||
Ok(quoted)
|
||||
};
|
||||
let status = std::process::Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.raw_arg(quoted(at)?)
|
||||
.raw_arg(quoted(target)?)
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()?;
|
||||
status
|
||||
.success()
|
||||
.then_some(())
|
||||
.ok_or_else(|| io::Error::other("mklink /J failed"))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn junction(_: &Path, _: &Path) -> io::Result<()> {
|
||||
Err(io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
/// Build `out` as `vanilla` with the packs' files layered on top (packs in
|
||||
/// load order, last wins). Untouched directories are symlinks into vanilla.
|
||||
/// load order, last wins). Untouched directories are links into vanilla.
|
||||
pub fn build_overlay(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()> {
|
||||
cache::build(vanilla, packs, out)
|
||||
}
|
||||
@@ -417,6 +466,22 @@ fn build_overlay_uncached(
|
||||
files.insert(name, (i, provided));
|
||||
}
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
let meta = format!("{name}.mcmeta");
|
||||
match files.get(&meta) {
|
||||
Some((meta_owner, _)) if meta_owner >= owner => None,
|
||||
_ => Some(meta),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
// Vanilla sits below every pack, so filters hide its files too.
|
||||
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
@@ -468,6 +533,7 @@ fn build_overlay_uncached(
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
@@ -504,6 +570,9 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
fs::write(out.join(&child), bytes)?;
|
||||
} else if let Some((owner, (path, inner))) = files.get(&child) {
|
||||
@@ -516,11 +585,17 @@ fn build_overlay_uncached(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if filters.is_empty()
|
||||
&& !touched.contains(&child)
|
||||
&& vanilla.join(&child).is_dir()
|
||||
&& junction(&vanilla.join(&child), &out.join(&child)).is_ok()
|
||||
{
|
||||
// Linked whole, like the unix symlink: no file in it is visited or copied.
|
||||
} else if touched.contains(&child)
|
||||
// Windows cannot link a directory the way unix does (`link` above), so an
|
||||
// untouched one has to be mirrored: skipping it hid textures/font and the
|
||||
// title panorama from the overlay, and a font json that names those sheets
|
||||
// then failed to load (#1688).
|
||||
// Without a junction Windows cannot link a directory the way unix does (`link`
|
||||
// above), so an untouched one has to be mirrored: skipping it hid textures/font
|
||||
// and the title panorama from the overlay, and a font json that names those
|
||||
// sheets then failed to load (#1688).
|
||||
|| (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty()))
|
||||
{
|
||||
mirror(
|
||||
@@ -528,6 +603,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
touched,
|
||||
hidden,
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
@@ -551,6 +627,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
&touched_dirs,
|
||||
&hidden,
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
@@ -741,7 +818,9 @@ impl SelectionModel {
|
||||
/// `available` in any order; `selected` as options.txt lists them,
|
||||
/// bottom first.
|
||||
pub fn new(mut available: Vec<Selectable>, selected: &[String]) -> Self {
|
||||
// PackRepository.available is a TreeMap by id (String.compareTo).
|
||||
// PackRepository.discoverAvailable: every source's packs go into one
|
||||
// TreeMap by id (String.compareTo), so `file/` packs sort among the
|
||||
// built-in ones.
|
||||
available.sort_by(|a, b| a.id.encode_utf16().cmp(b.id.encode_utf16()));
|
||||
available.dedup_by(|a, b| a.id == b.id);
|
||||
let mut m = Self {
|
||||
@@ -939,7 +1018,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn overlay_layers_packs_over_vanilla() {
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-{}", std::process::id()));
|
||||
// `&` would split an unquoted `cmd /C mklink` line.
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", 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();
|
||||
@@ -960,6 +1040,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(vanilla.join("facts.json"), b"[]").unwrap();
|
||||
// A directory no pack touches (#1688: a font json names sheets in it).
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
|
||||
fs::write(
|
||||
vanilla.join("assets/minecraft/textures/font/ascii.png"),
|
||||
b"font",
|
||||
)
|
||||
.unwrap();
|
||||
let pack_dir = base.join("packs/Mine");
|
||||
fs::create_dir_all(pack_dir.join("assets/minecraft/textures/block")).unwrap();
|
||||
fs::write(
|
||||
@@ -997,6 +1084,17 @@ mod tests {
|
||||
br#"{"a":"vanilla","b":"pack"}"#
|
||||
);
|
||||
assert_eq!(fs::read(out.join("facts.json")).unwrap(), b"[]");
|
||||
assert_eq!(
|
||||
fs::read(out.join("assets/minecraft/textures/font/ascii.png")).unwrap(),
|
||||
b"font"
|
||||
);
|
||||
// Linked, not mirrored: a junction on Windows, a symlink on unix.
|
||||
assert!(
|
||||
fs::symlink_metadata(out.join("assets/minecraft/textures/font"))
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
use super::{Pack, Source, build_overlay_uncached};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(any(test, not(unix)))]
|
||||
use std::io::Read;
|
||||
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
|
||||
// Bump when overlay/merge semantics change. Markers are disposable cache data.
|
||||
// Version 4 mirrored every untouched directory file by file on Windows.
|
||||
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
|
||||
const VERSION: u32 = 4;
|
||||
const VERSION: u32 = 5;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
@@ -59,7 +59,6 @@ fn field(hash: &mut Sha256, bytes: &[u8]) {
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
}
|
||||
#[cfg(any(test, not(unix)))]
|
||||
fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
hash.update(file.metadata()?.len().to_le_bytes());
|
||||
@@ -237,7 +236,10 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
|
||||
}
|
||||
Source::Zip(path) => {
|
||||
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
|
||||
stamp(&mut hash, path)?;
|
||||
// By its bytes, not its stamp: a server pack found in the download cache is
|
||||
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
|
||||
// time rebuilt the whole overlay each time. One file reads in a moment.
|
||||
content(&mut hash, path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,6 +350,27 @@ mod tests {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
/// Waits for the filesystem's clock to move on, as it does between a build and a
|
||||
/// person's next edit. On Unix the cache compares stamps (length, mtime, ctime, inode),
|
||||
/// and Linux takes them from a coarse clock (a few milliseconds a tick): an edit in the
|
||||
/// tick of the build before kept them all, and the tests failed at random.
|
||||
fn next_tick(&self) {
|
||||
let probe = self.0.with_extension("tick");
|
||||
// New bytes each time: NTFS keeps the time of a write that changes nothing.
|
||||
let stamp = |n: u32| {
|
||||
fs::write(&probe, n.to_le_bytes()).unwrap();
|
||||
fs::metadata(&probe).unwrap().modified().unwrap()
|
||||
};
|
||||
let start = stamp(0);
|
||||
// A second at most: no clock is that coarse, and a test never hangs on one.
|
||||
for n in 1..1000 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
if stamp(n) != start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&probe);
|
||||
}
|
||||
fn dir_pack(&self, name: &str) -> Pack {
|
||||
self.write(
|
||||
&format!("{name}/pack.mcmeta"),
|
||||
@@ -370,6 +393,7 @@ mod tests {
|
||||
output_signature(&cached).unwrap(),
|
||||
output_signature(&self.0.join("reference")).unwrap()
|
||||
);
|
||||
self.next_tick();
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
@@ -646,6 +670,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A ZIP whose bytes are the same keeps its overlay when only its time changes: a server
|
||||
/// pack found in the download cache is marked used on every join
|
||||
/// (`HttpUtil.updateModificationTime`), and rebuilding the overlay took most of a minute.
|
||||
#[test]
|
||||
fn a_zip_touched_but_unchanged_keeps_its_overlay() {
|
||||
let f = Fixture::new();
|
||||
f.write("vanilla/assets/minecraft/textures/b", b"vanilla");
|
||||
let files = vec![
|
||||
(
|
||||
"pack.mcmeta",
|
||||
br#"{"pack":{"description":"test","pack_format":97}}"#.to_vec(),
|
||||
),
|
||||
("assets/minecraft/textures/a", b"aaa".to_vec()),
|
||||
];
|
||||
f.write("pack.zip", &archive(&files, false));
|
||||
let zip = f.0.join("pack.zip");
|
||||
let pack = packs::pack("zip".into(), Text::empty(), Source::Zip(zip.clone())).unwrap();
|
||||
let (vanilla, out) = (f.0.join("vanilla"), f.0.join("cached"));
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
// A rebuild empties the output first: this file survives a cache hit only.
|
||||
fs::write(out.join("kept"), b"").unwrap();
|
||||
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&zip)
|
||||
.unwrap()
|
||||
.set_modified(later)
|
||||
.unwrap();
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
assert!(out.join("kept").exists(), "the overlay was rebuilt");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unrelated_directory_pack_symlink_cycle_disables_reuse() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! The option screens live in `options_ui`.
|
||||
use crate::keybinds::KeyBinds;
|
||||
use crate::options_file::{OptionsFile, V};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::sound_source::SOUND_SOURCES;
|
||||
@@ -100,6 +101,8 @@ pub struct Settings {
|
||||
pub master_volume: f32,
|
||||
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
|
||||
pub volumes: [f32; 11],
|
||||
/// Empty for system default, or a specific audio device name.
|
||||
pub sound_device: String,
|
||||
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
|
||||
pub gui_scale: u32,
|
||||
pub view_bobbing: bool,
|
||||
@@ -173,6 +176,12 @@ pub struct Settings {
|
||||
pub toggle_use: bool,
|
||||
/// Taps of the sprint key counted as double taps (sprintWindow).
|
||||
pub sprint_window: i32,
|
||||
/// Show the operator items tab in a creative inventory.
|
||||
pub operator_items_tab: bool,
|
||||
/// Allow platform quit shortcuts to close the game window.
|
||||
pub quit_shortcuts: bool,
|
||||
/// On macOS, turn Control-click into a right click.
|
||||
pub ctrl_click_emulates_right_click: bool,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
@@ -194,6 +203,7 @@ impl Default for Settings {
|
||||
sensitivity: 0.5,
|
||||
master_volume: 1.0,
|
||||
volumes: [1.0; 11],
|
||||
sound_device: String::new(),
|
||||
gui_scale: 0,
|
||||
view_bobbing: true,
|
||||
gamma: 0.5,
|
||||
@@ -255,6 +265,9 @@ impl Default for Settings {
|
||||
toggle_attack: false,
|
||||
toggle_use: false,
|
||||
sprint_window: 7,
|
||||
operator_items_tab: false,
|
||||
quit_shortcuts: true,
|
||||
ctrl_click_emulates_right_click: false,
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -382,6 +395,7 @@ impl Settings {
|
||||
sensitivity: f.double("mouseSensitivity") as f32,
|
||||
master_volume: volumes[0],
|
||||
volumes,
|
||||
sound_device: f.string("soundDevice"),
|
||||
gui_scale: f.int("guiScale").max(0) as u32,
|
||||
view_bobbing: f.bool("bobView"),
|
||||
gamma: f.double("gamma") as f32,
|
||||
@@ -417,6 +431,9 @@ impl Settings {
|
||||
toggle_attack: f.bool("toggleAttack"),
|
||||
toggle_use: f.bool("toggleUse"),
|
||||
sprint_window: f.int("sprintWindow"),
|
||||
operator_items_tab: f.bool("operatorItemsTab"),
|
||||
quit_shortcuts: f.bool("quitShortcuts"),
|
||||
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -448,6 +465,16 @@ impl Settings {
|
||||
file: f,
|
||||
}
|
||||
}
|
||||
/// `Options.createInformation`: what the server learns about this player.
|
||||
pub fn client_information(&self, view_distance: i8) -> ClientInformation {
|
||||
ClientInformation {
|
||||
chat_visibility: self.chat_visibility as i32,
|
||||
model_customisation: self.file.model_customisation(),
|
||||
main_hand: i32::from(!self.main_hand_left),
|
||||
allows_listing: self.file.bool("allowServerListing"),
|
||||
..ClientInformation::new(view_distance)
|
||||
}
|
||||
}
|
||||
/// The options file reflecting the typed fields; values the game did not
|
||||
/// change keep their stored form.
|
||||
pub fn to_file(&self) -> OptionsFile {
|
||||
@@ -464,6 +491,7 @@ impl Settings {
|
||||
};
|
||||
put_f(&mut f, &format!("soundCategory_{source}"), v);
|
||||
}
|
||||
put_s(&mut f, "soundDevice", &self.sound_device);
|
||||
put_i(&mut f, "guiScale", self.gui_scale as i32);
|
||||
put_b(&mut f, "bobView", self.view_bobbing);
|
||||
put_f(&mut f, "gamma", self.gamma);
|
||||
@@ -507,6 +535,13 @@ impl Settings {
|
||||
put_b(&mut f, "toggleAttack", self.toggle_attack);
|
||||
put_b(&mut f, "toggleUse", self.toggle_use);
|
||||
put_i(&mut f, "sprintWindow", self.sprint_window);
|
||||
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
|
||||
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
|
||||
put_b(
|
||||
&mut f,
|
||||
"ctrlClickEmulatesRightClick",
|
||||
self.ctrl_click_emulates_right_click,
|
||||
);
|
||||
put_b(&mut f, "autoJump", self.auto_jump);
|
||||
put_s(&mut f, "lang", &self.language);
|
||||
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
|
||||
@@ -626,6 +661,7 @@ pub enum Opt {
|
||||
InvertY,
|
||||
AllowCursorChanges,
|
||||
Volume(usize),
|
||||
SoundDevice,
|
||||
GuiScale,
|
||||
ViewBobbing,
|
||||
Gamma,
|
||||
@@ -653,7 +689,13 @@ pub enum Opt {
|
||||
MenuBlur,
|
||||
ToggleCrouch,
|
||||
ToggleSprint,
|
||||
ToggleAttack,
|
||||
ToggleUse,
|
||||
AutoJump,
|
||||
SprintWindow,
|
||||
QuitShortcuts,
|
||||
CtrlClickEmulatesRightClick,
|
||||
OperatorItemsTab,
|
||||
ChatVisibility,
|
||||
ChatColors,
|
||||
ChatLinks,
|
||||
@@ -706,6 +748,15 @@ pub enum Opt {
|
||||
PadGlyphs,
|
||||
PadMenuNav,
|
||||
PadKeyboard,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
MainHand,
|
||||
AllowServerListing,
|
||||
RealmsNotifications,
|
||||
InGameNotification,
|
||||
SharePresence,
|
||||
DirectionalAudio,
|
||||
MusicToast,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -800,4 +851,15 @@ mod tests {
|
||||
1.0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sound_device_option() {
|
||||
let s = Settings::parse("soundDevice:\"Headphones\"\n");
|
||||
assert_eq!(s.sound_device, "Headphones");
|
||||
let serialized = s.serialize();
|
||||
assert!(serialized.contains("soundDevice:\"Headphones\""));
|
||||
let again = Settings::parse(&serialized);
|
||||
assert_eq!(again.sound_device, "Headphones");
|
||||
let empty = Settings::parse("");
|
||||
assert_eq!(empty.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -491,15 +491,69 @@ pub fn texture_hash(url: &str) -> Option<String> {
|
||||
/// `SkinManager.TextureCache.registerTexture`: the texture id of a skin,
|
||||
/// `skins/` and the SHA-1 of its hash's UTF-16 code units.
|
||||
pub fn skin_location(hash: &str) -> String {
|
||||
location("skins/", hash)
|
||||
}
|
||||
/// `registerTexture` in the cape cache: `capes/` and the same SHA-1.
|
||||
pub fn cape_location(hash: &str) -> String {
|
||||
location("capes/", hash)
|
||||
}
|
||||
fn location(prefix: &str, hash: &str) -> String {
|
||||
let units: Vec<u8> = hash.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let digest = sha1(&units);
|
||||
let mut out = String::from("skins/");
|
||||
let mut out = String::from(prefix);
|
||||
for b in digest {
|
||||
out.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `PlayerSkin.cape`: the cape texture of textures whose lookup gave a skin
|
||||
/// (every download ready), if they name one.
|
||||
pub fn cape(textures: &Textures) -> Option<String> {
|
||||
textures
|
||||
.cape
|
||||
.as_deref()
|
||||
.and_then(texture_hash)
|
||||
.map(|hash| cape_location(&hash))
|
||||
}
|
||||
|
||||
/// A cape in a 64x64 skin slot: the 64x32 picture (or the older 22x17,
|
||||
/// which has the same layout) in its top half. Vanilla draws a cape of any
|
||||
/// size; a larger one is scaled down to 64x32 here.
|
||||
pub fn process_cape(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
|
||||
return None;
|
||||
}
|
||||
let mut img = vec![0u8; 64 * 64 * 4];
|
||||
let (fit_w, fit_h) = if w <= 64 && h <= 32 { (w, h) } else { (64, 32) };
|
||||
for y in 0..fit_h {
|
||||
for x in 0..fit_w {
|
||||
let (sx, sy) = (x * w / fit_w, y * h / fit_h);
|
||||
let (s, d) = ((sy * w + sx) * 4, (y * 64 + x) * 4);
|
||||
img[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
|
||||
}
|
||||
}
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// The cape the launcher gives an offline name (`HORIZON_OFFLINE_CAPE`, a
|
||||
/// PNG's path; launcher/src/skins.rs), read once as `process_cape` lays it out.
|
||||
pub fn offline_cape() -> Option<&'static [u8]> {
|
||||
static CAPE: std::sync::OnceLock<Option<Vec<u8>>> = std::sync::OnceLock::new();
|
||||
CAPE.get_or_init(|| {
|
||||
let path = std::env::var_os("HORIZON_OFFLINE_CAPE").filter(|p| !p.is_empty())?;
|
||||
let image = std::fs::read(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| crate::hud::decode_bytes(&bytes).map_err(|e| e.to_string()))
|
||||
.and_then(|(w, h, rgba)| process_cape(w, h, &rgba).ok_or("not an image".into()));
|
||||
image
|
||||
.inspect_err(|e| eprintln!("Horizon: no offline cape ({}): {e}", path.display()))
|
||||
.ok()
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// Where a texture download stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Download {
|
||||
@@ -650,9 +704,10 @@ impl Downloader {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
let state = match image {
|
||||
Some(rgba) => {
|
||||
if key.0 == Kind::Skin {
|
||||
let location = skin_location(&key.1);
|
||||
self.queue(location, rgba);
|
||||
match key.0 {
|
||||
Kind::Skin => self.queue(skin_location(&key.1), rgba),
|
||||
Kind::Cape => self.queue(cape_location(&key.1), rgba),
|
||||
Kind::Elytra => {}
|
||||
}
|
||||
Download::Ready
|
||||
}
|
||||
@@ -685,7 +740,8 @@ impl Downloader {
|
||||
.and_then(|b| crate::hud::decode_bytes(&b).ok())
|
||||
.and_then(|(w, h, rgba)| match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
_ => Some(rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
});
|
||||
let _ = send.send((key, image));
|
||||
});
|
||||
@@ -716,16 +772,21 @@ impl Downloader {
|
||||
if self.queued.contains(location) {
|
||||
return true;
|
||||
}
|
||||
let Some(digest) = location.strip_prefix("skins/") else {
|
||||
return false;
|
||||
};
|
||||
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
|
||||
if let Some(digest) = location.strip_prefix("skins/") {
|
||||
(digest, process_legacy_skin)
|
||||
} else if let Some(digest) = location.strip_prefix("capes/") {
|
||||
(digest, process_cape)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let image = self.images.get(location).cloned().or_else(|| {
|
||||
let file = self
|
||||
.root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(digest);
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(&std::fs::read(&file).ok()?).ok()?;
|
||||
process_legacy_skin(w, h, &rgba)
|
||||
process(w, h, &rgba)
|
||||
});
|
||||
let Some(image) = image else {
|
||||
return false;
|
||||
@@ -961,6 +1022,31 @@ mod tests {
|
||||
assert_eq!(d.take_ready(), vec![("skins/def".to_owned(), vec![4])]);
|
||||
}
|
||||
|
||||
/// A cape fills the top half of a 64x64 slot; a larger one is scaled to 64x32.
|
||||
#[test]
|
||||
fn capes_fill_the_top_half_of_a_slot() {
|
||||
let at = |x: usize, y: usize| (y * 64 + x) * 4;
|
||||
let legacy = super::process_cape(22, 17, &[7; 22 * 17 * 4]).unwrap();
|
||||
assert_eq!(legacy.len(), 64 * 64 * 4);
|
||||
assert_eq!(legacy[at(21, 16)], 7);
|
||||
assert_eq!(legacy[at(22, 16)], 0);
|
||||
let mut hd = vec![0u8; 128 * 64 * 4];
|
||||
hd[(62 * 128 + 126) * 4] = 9;
|
||||
let hd = super::process_cape(128, 64, &hd).unwrap();
|
||||
assert_eq!(hd[at(63, 31)], 9);
|
||||
assert_eq!(hd[at(0, 32)], 0);
|
||||
assert!(super::process_cape(64, 32, &[0; 4]).is_none());
|
||||
let textures = super::Textures {
|
||||
cape: Some("http://textures.minecraft.net/texture/abc".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let cape = super::cape(&textures).unwrap();
|
||||
assert_eq!(
|
||||
cape["capes/".len()..],
|
||||
super::skin_location("abc")["skins/".len()..]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_components_supply_unsigned_head_skins() {
|
||||
let value = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYzEyMyJ9fX0=";
|
||||
|
||||
@@ -58,6 +58,8 @@ pub enum Ticking {
|
||||
Bee { bee: u32, aggressive: bool },
|
||||
/// `GuardianAttackSoundInstance`.
|
||||
GuardianAttack { guardian: u32 },
|
||||
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
|
||||
Elytra { time: i32 },
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -95,6 +97,12 @@ pub struct TickContext {
|
||||
pub mobs: Vec<MobSound>,
|
||||
/// `!tickRateManager().runsNormally()`.
|
||||
pub frozen: bool,
|
||||
/// The player's position.
|
||||
pub player_position: [f64; 3],
|
||||
/// `getDeltaMovement().lengthSqr()` of the player.
|
||||
pub player_speed_sqr: f64,
|
||||
/// `isFallFlying()` of the player.
|
||||
pub player_fall_flying: bool,
|
||||
}
|
||||
/// `AbstractSoundInstance` and its subclasses' fields.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -390,6 +398,24 @@ impl SoundInstance {
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new ElytraOnPlayerSoundInstance(player)`: looping at volume 0.1 until
|
||||
/// its first tick.
|
||||
pub fn elytra(position: [f64; 3], random: LegacyRandom) -> Self {
|
||||
let mut s = Self::new(
|
||||
"minecraft:item.elytra.flying",
|
||||
PLAYERS,
|
||||
0.1,
|
||||
1.0,
|
||||
random,
|
||||
true,
|
||||
0,
|
||||
Attenuation::Linear,
|
||||
position,
|
||||
false,
|
||||
);
|
||||
s.ticking = Ticking::Elytra { time: 0 };
|
||||
s
|
||||
}
|
||||
pub fn tickable(&self) -> bool {
|
||||
self.ticking != Ticking::No
|
||||
}
|
||||
@@ -494,6 +520,37 @@ impl SoundInstance {
|
||||
self.volume = 0.0;
|
||||
}
|
||||
}
|
||||
Ticking::Elytra { time } => {
|
||||
*time += 1;
|
||||
let time = *time;
|
||||
if ctx.player_removed || (time > 20 && !ctx.player_fall_flying) {
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = ctx.player_position;
|
||||
self.position = [
|
||||
f64::from(x as f32),
|
||||
f64::from(y as f32),
|
||||
f64::from(z as f32),
|
||||
];
|
||||
let speed = ctx.player_speed_sqr as f32;
|
||||
self.volume = if speed >= 1.0e-7 {
|
||||
(speed / 4.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Silent for a second, then a second's fade in.
|
||||
if time < 20 {
|
||||
self.volume = 0.0;
|
||||
} else if time < 40 {
|
||||
self.volume *= (time - 20) as f32 / 20.0;
|
||||
}
|
||||
self.pitch = if self.volume > 0.8 {
|
||||
1.0 + (self.volume - 0.8)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
Ticking::GuardianAttack { guardian } => {
|
||||
let id = *guardian;
|
||||
// The client's guardian never has a target of its own, so
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
//! instance's random.
|
||||
use horizon_core::random::RandomSource;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// `SoundManager.EMPTY_SOUND_LOCATION`.
|
||||
pub const EMPTY: &str = "minecraft:empty";
|
||||
@@ -224,6 +228,34 @@ fn parse_sound(v: &Value) -> Option<Sound> {
|
||||
sentinel: Sentinel::None,
|
||||
})
|
||||
}
|
||||
/// The files under a namespace's `sounds/`, as `sounds/<path>` with `/`.
|
||||
fn sound_files(namespace: &Path) -> HashSet<String> {
|
||||
let mut files = HashSet::new();
|
||||
let mut stack = vec![namespace.join("sounds")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
// The listing's own type; a link is followed, as `is_file` does.
|
||||
let Ok(kind) = e.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let (dir, file) = if kind.is_symlink() {
|
||||
(path.is_dir(), path.is_file())
|
||||
} else {
|
||||
(kind.is_dir(), kind.is_file())
|
||||
};
|
||||
if dir {
|
||||
stack.push(path);
|
||||
} else if file && let Ok(rel) = path.strip_prefix(namespace) {
|
||||
files.insert(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
impl SoundRegistry {
|
||||
/// Every `assets/<namespace>/sounds.json` under a resource root, files
|
||||
/// validated against `assets/<namespace>/sounds/`.
|
||||
@@ -236,13 +268,19 @@ impl SoundRegistry {
|
||||
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
||||
.collect();
|
||||
namespaces.sort();
|
||||
// Each namespace's sound files are listed once, as the resource manager
|
||||
// lists a pack, instead of one file lookup per sound (thousands).
|
||||
let files: HashMap<&str, HashSet<String>> = namespaces
|
||||
.iter()
|
||||
.map(|ns| (ns.as_str(), sound_files(&assets.join(ns))))
|
||||
.collect();
|
||||
let exists = |ns: &str, path: &str| files.get(ns).is_some_and(|f| f.contains(path));
|
||||
let mut registry = Self::default();
|
||||
for namespace in namespaces {
|
||||
let Ok(text) = fs::read(assets.join(&namespace).join("sounds.json")) else {
|
||||
for namespace in &namespaces {
|
||||
let Ok(text) = fs::read(assets.join(namespace).join("sounds.json")) else {
|
||||
continue;
|
||||
};
|
||||
let exists = |ns: &str, path: &str| assets.join(ns).join(path).is_file();
|
||||
registry.add_file(&namespace, &text, &exists);
|
||||
registry.add_file(namespace, &text, &exists);
|
||||
}
|
||||
registry
|
||||
}
|
||||
@@ -369,6 +407,24 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
fn load_keeps_the_sound_files_a_root_holds() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sounds-{}", std::process::id()));
|
||||
let ns = root.join("assets/minecraft");
|
||||
fs::create_dir_all(ns.join("sounds/block/note")).unwrap();
|
||||
fs::write(ns.join("sounds/block/note/harp.ogg"), b"").unwrap();
|
||||
fs::write(
|
||||
ns.join("sounds.json"),
|
||||
br#"{"note": {"sounds": ["block/note/harp", "block/note/gone"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let r = SoundRegistry::load(&root);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let sounds = &r.get("minecraft:note").unwrap().sounds;
|
||||
let kept: Vec<&str> = sounds.iter().map(|s| s.location.as_str()).collect();
|
||||
assert_eq!(kept, ["minecraft:block/note/harp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_weigh_as_much_as_their_event() {
|
||||
let mut r = SoundRegistry::default();
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
|
||||
//! its window (`place`).
|
||||
use winit::dpi::PhysicalSize;
|
||||
|
||||
/// Players at most: P1 and fifteen more.
|
||||
pub const MAX_PLAYERS: usize = 16;
|
||||
|
||||
/// A player's view: its window (an index into the windows), its corner there and its size.
|
||||
pub type View = (usize, [u32; 2], PhysicalSize<u32>);
|
||||
|
||||
/// Rows of five to sixteen players sharing a window, widest first (columns on a tall window): every
|
||||
/// view stays near a screen's shape, where a full grid would leave a long thin one.
|
||||
const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[3, 2],
|
||||
&[3, 3],
|
||||
&[3, 2, 2],
|
||||
&[3, 3, 2],
|
||||
&[3, 3, 3],
|
||||
&[4, 3, 3],
|
||||
&[4, 4, 3],
|
||||
&[4, 4, 4],
|
||||
&[4, 3, 3, 3],
|
||||
&[4, 4, 3, 3],
|
||||
&[4, 4, 4, 3],
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
let mut out: Vec<usize> = Vec::with_capacity(n);
|
||||
for &i in order {
|
||||
if i < n && !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out.extend((0..n).filter(|i| !order.contains(i)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `place` with the players sharing a window in `order` (`ordered`): the first left or on top.
|
||||
/// The views are still by player.
|
||||
pub fn place_ordered(
|
||||
windows: &[usize],
|
||||
order: &[usize],
|
||||
sizes: &[PhysicalSize<u32>],
|
||||
two: u8,
|
||||
three: u8,
|
||||
) -> Vec<View> {
|
||||
let order = ordered(order, windows.len());
|
||||
let in_order: Vec<usize> = order.iter().map(|&i| windows[i]).collect();
|
||||
let placed = place(&in_order, sizes, two, three);
|
||||
let mut views = placed.clone();
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
views[i] = placed[k];
|
||||
}
|
||||
views
|
||||
}
|
||||
|
||||
/// Each player's view, given the window each is in and the windows' sizes. Players in one window
|
||||
/// share it in the order they come: two take halves (`two`: 0 side by side when wider than
|
||||
/// tall, 1 side by side, 2 top and bottom); three (`three`: 0 the first wide on top and two below,
|
||||
/// 1 the first tall on the left and two on the right, 2 three columns); four its quarters; five
|
||||
/// to sixteen in balanced rows (`ROWS`).
|
||||
pub fn place(windows: &[usize], sizes: &[PhysicalSize<u32>], two: u8, three: u8) -> Vec<View> {
|
||||
windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &window)| {
|
||||
let here = windows.iter().filter(|&&w| w == window).count();
|
||||
let slot = windows[..i].iter().filter(|&&w| w == window).count() as u32;
|
||||
let size = sizes
|
||||
.get(window)
|
||||
.copied()
|
||||
.unwrap_or(PhysicalSize::new(1, 1));
|
||||
let (w, h) = (size.width, size.height);
|
||||
let side_by_side = match two {
|
||||
1 => true,
|
||||
2 => false,
|
||||
_ => w >= h,
|
||||
};
|
||||
// The view's cell as fractions of the window: (x0, x1, y0, y1) in halves or thirds.
|
||||
let cell = |x0: u32, x1: u32, y0: u32, y1: u32, of: u32| {
|
||||
[w * x0 / of, w * x1 / of, h * y0 / of, h * y1 / of]
|
||||
};
|
||||
let [x0, x1, y0, y1] = match (here, slot) {
|
||||
(1, _) => cell(0, 1, 0, 1, 1),
|
||||
(2, s) if side_by_side => cell(s, s + 1, 0, 2, 2),
|
||||
(2, s) => cell(0, 2, s, s + 1, 2),
|
||||
(3, s) => match (three, s) {
|
||||
(2, s) => cell(s, s + 1, 0, 3, 3),
|
||||
(1, 0) => cell(0, 1, 0, 2, 2),
|
||||
(1, s) => cell(1, 2, s - 1, s, 2),
|
||||
(_, 0) => cell(0, 2, 0, 1, 2),
|
||||
(_, s) => cell(s - 1, s, 1, 2, 2),
|
||||
},
|
||||
(4, s) => cell(s % 2, s % 2 + 1, s / 2, s / 2 + 1, 2),
|
||||
(n, s) => rows(n, s, w, h),
|
||||
};
|
||||
(
|
||||
window,
|
||||
[x0, y0],
|
||||
PhysicalSize::new((x1 - x0).max(1), (y1 - y0).max(1)),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Five to sixteen players: `slot`'s cell `[x0, x1, y0, y1]` in balanced rows (`ROWS`), or
|
||||
/// in columns when the window is taller than wide.
|
||||
fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
let counts = ROWS[n.clamp(5, MAX_PLAYERS) - 5];
|
||||
let bands = counts.len() as u32;
|
||||
let (mut band, mut k) = (0, slot);
|
||||
while band + 1 < bands && k >= counts[band as usize] {
|
||||
k -= counts[band as usize];
|
||||
band += 1;
|
||||
}
|
||||
let across = counts[band as usize];
|
||||
// Along: the long side, divided between a band's views; down: the bands.
|
||||
let (along, down) = if w >= h { (w, h) } else { (h, w) };
|
||||
let (a0, a1) = (along * k / across, along * (k + 1) / across);
|
||||
let (d0, d1) = (down * band / bands, down * (band + 1) / bands);
|
||||
if w >= h {
|
||||
[a0, a1, d0, d1]
|
||||
} else {
|
||||
[d0, d1, a0, a1]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
|
||||
PhysicalSize::new(w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_on_one_screen_share_it() {
|
||||
let wide = [size(1920, 1080)];
|
||||
assert_eq!(
|
||||
place(&[0, 0], &wide, 0, 0)[1],
|
||||
(0, [960, 0], size(960, 1080))
|
||||
);
|
||||
assert_eq!(
|
||||
place(&[0, 0], &wide, 2, 0)[1],
|
||||
(0, [0, 540], size(1920, 540))
|
||||
);
|
||||
// Auto on a tall screen: top and bottom.
|
||||
assert_eq!(place(&[0, 0], &[size(1080, 1920)], 0, 0)[1].1, [0, 960]);
|
||||
assert_eq!(
|
||||
place(&[0; 4], &wide, 0, 0)[3],
|
||||
(0, [960, 540], size(960, 540))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desk_and_couch() {
|
||||
// P1 alone at the desk; P2 and P3 share the TV.
|
||||
let screens = [size(3024, 1964), size(3840, 2160)];
|
||||
let views = place(&[0, 1, 1], &screens, 0, 0);
|
||||
assert_eq!(views[0], (0, [0, 0], size(3024, 1964)));
|
||||
assert_eq!(views[1], (1, [0, 0], size(1920, 2160)));
|
||||
assert_eq!(views[2], (1, [1920, 0], size(1920, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_on_a_screen() {
|
||||
let tv = [size(1920, 1080)];
|
||||
// One wide on top, two below.
|
||||
let top = place(&[0; 3], &tv, 0, 0);
|
||||
assert_eq!(top[0], (0, [0, 0], size(1920, 540)));
|
||||
assert_eq!(top[2], (0, [960, 540], size(960, 540)));
|
||||
// One tall on the left, two stacked on the right.
|
||||
let left = place(&[0; 3], &tv, 0, 1);
|
||||
assert_eq!(left[0], (0, [0, 0], size(960, 1080)));
|
||||
assert_eq!(left[2], (0, [960, 540], size(960, 540)));
|
||||
// Three columns.
|
||||
assert_eq!(
|
||||
place(&[0; 3], &tv, 0, 2)[2],
|
||||
(0, [1280, 0], size(640, 1080))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_windows_two_players_each() {
|
||||
// P1 and P2 at the desk, P3 and P4 on the TV: halves in each window.
|
||||
let screens = [size(3024, 1964), size(3840, 2160)];
|
||||
let views = place(&[0, 0, 1, 1], &screens, 0, 0);
|
||||
assert_eq!(views[1], (0, [1512, 0], size(1512, 1964)));
|
||||
assert_eq!(views[2], (1, [0, 0], size(1920, 2160)));
|
||||
assert_eq!(views[3], (1, [1920, 0], size(1920, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_to_eight_in_rows_of_three_and_two() {
|
||||
let tv = [size(1920, 1080)];
|
||||
let eight = place(&[0; 8], &tv, 0, 0);
|
||||
assert_eq!(eight[0], (0, [0, 0], size(640, 360)));
|
||||
assert_eq!(eight[5], (0, [1280, 360], size(640, 360)));
|
||||
assert_eq!(eight[7], (0, [960, 720], size(960, 360)));
|
||||
let seven = place(&[0; 7], &tv, 0, 0);
|
||||
assert_eq!(seven[3], (0, [0, 360], size(960, 360)));
|
||||
// Every count tiles the window exactly: no gap, no overlap.
|
||||
for n in 5..=MAX_PLAYERS {
|
||||
for screen in [size(1920, 1080), size(1080, 1920), size(1711, 997)] {
|
||||
let views = place(&vec![0; n], &[screen], 0, 0);
|
||||
for (i, (_, [x, y], size)) in views.iter().enumerate() {
|
||||
assert!(x + size.width <= screen.width && y + size.height <= screen.height);
|
||||
for (_, [ox, oy], other) in &views[..i] {
|
||||
assert!(
|
||||
x + size.width <= *ox
|
||||
|| ox + other.width <= *x
|
||||
|| y + size.height <= *oy
|
||||
|| oy + other.height <= *y,
|
||||
"overlapping views for {n} in {screen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let area: u64 = views
|
||||
.iter()
|
||||
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
|
||||
.sum();
|
||||
assert_eq!(
|
||||
area,
|
||||
u64::from(screen.width) * u64::from(screen.height),
|
||||
"{n} in {screen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A tall window: columns.
|
||||
let tall = place(&[0; 5], &[size(1080, 1920)], 0, 0);
|
||||
assert_eq!(tall[0], (0, [0, 0], size(540, 640)));
|
||||
assert_eq!(tall[4], (0, [540, 960], size(540, 960)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_players_form_a_four_by_four_grid() {
|
||||
let views = place(&[0; 16], &[size(3840, 2160)], 0, 0);
|
||||
for (i, view) in views.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*view,
|
||||
(
|
||||
0,
|
||||
[(i as u32 % 4) * 960, (i as u32 / 4) * 540],
|
||||
size(960, 540)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_names_each_player_once_and_the_others_come_last() {
|
||||
assert_eq!(ordered(&[], 3), [0, 1, 2]);
|
||||
assert_eq!(ordered(&[2, 0], 3), [2, 0, 1]);
|
||||
// Gone players and repeats are dropped.
|
||||
assert_eq!(ordered(&[5, 1, 1], 3), [1, 0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_sharing_a_window_fill_it_in_their_order() {
|
||||
let wide = [size(2000, 1000)];
|
||||
// P2 first: P2 on the left, P1 on the right.
|
||||
let views = place_ordered(&[0, 0], &[1, 0], &wide, 0, 0);
|
||||
assert_eq!(views[1].1, [0, 0]);
|
||||
assert_eq!(views[0].1, [1000, 0]);
|
||||
// P3 first of three: the wide view on top is P3's.
|
||||
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
|
||||
assert_eq!(views[2].2, size(2000, 500));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,10 @@ use horizon_core::biome::BiomeZoom;
|
||||
use horizon_world::block_render::FaceVisibility;
|
||||
use horizon_world::worldgen::decoration_order::DecorationOrder;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashSet},
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, RwLock,
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
};
|
||||
@@ -99,6 +98,9 @@ pub trait LodSampler: Send {
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
||||
/// approximate wide cells.
|
||||
fn set_approximate(&mut self, _approximate: bool) {}
|
||||
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
|
||||
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
|
||||
crate::lod::Column::from_surface(self.sample(x, z))
|
||||
@@ -168,6 +170,9 @@ enum Job {
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
/// Meshes the renderer is done with (`Streaming::free`): their memory
|
||||
/// is given back here, not in the frame.
|
||||
Free(Vec<((i32, i32, i32), SectionMesh)>),
|
||||
}
|
||||
pub enum Done {
|
||||
Generated(Chunk),
|
||||
@@ -214,11 +219,52 @@ struct Shared {
|
||||
/// What meshing reads (block models and sprites), swapped when resource
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
}
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
/// half way through a send, and a worker descheduled there on a busy machine
|
||||
/// held the frame for a scheduler quantum (10 to 30 ms, the RD 32 ocean
|
||||
/// flight's 1% lows). The frame takes what is there without waiting; while a
|
||||
/// worker holds the lock, it takes it the next frame.
|
||||
#[derive(Default)]
|
||||
struct Mailbox {
|
||||
done: Mutex<VecDeque<Done>>,
|
||||
arrived: Condvar,
|
||||
}
|
||||
impl Mailbox {
|
||||
fn send(&self, done: Done) {
|
||||
self.done.lock().unwrap().push_back(done);
|
||||
self.arrived.notify_one();
|
||||
}
|
||||
fn try_lock(&self) -> Option<MutexGuard<'_, VecDeque<Done>>> {
|
||||
match self.done.try_lock() {
|
||||
Ok(done) => Some(done),
|
||||
Err(TryLockError::WouldBlock) => None,
|
||||
Err(TryLockError::Poisoned(e)) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
/// Every finished job, or none while a worker is sending.
|
||||
fn take(&self) -> VecDeque<Done> {
|
||||
self.try_lock()
|
||||
.map(|mut done| std::mem::take(&mut *done))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn try_recv(&self) -> Option<Done> {
|
||||
self.try_lock()?.pop_front()
|
||||
}
|
||||
fn recv_timeout(&self, timeout: std::time::Duration) -> Option<Done> {
|
||||
let done = self.done.lock().unwrap();
|
||||
let (mut done, _) = self
|
||||
.arrived
|
||||
.wait_timeout_while(done, timeout, |done| done.is_empty())
|
||||
.unwrap();
|
||||
done.pop_front()
|
||||
}
|
||||
}
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
results: mpsc::Receiver<Done>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
@@ -455,14 +501,13 @@ impl Streaming {
|
||||
ready: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
});
|
||||
let (send, results) = mpsc::channel();
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
.map(|i| {
|
||||
let shared = shared.clone();
|
||||
let send = send.clone();
|
||||
let generator = generator.clone();
|
||||
let light = context.light.clone();
|
||||
thread::Builder::new()
|
||||
@@ -510,6 +555,10 @@ impl Streaming {
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
drop(meshes);
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), &light, sky), seq)
|
||||
}
|
||||
@@ -541,9 +590,7 @@ impl Streaming {
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
if send.send(done).is_err() {
|
||||
return;
|
||||
}
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
.expect("spawn worker")
|
||||
@@ -556,7 +603,6 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
results,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
@@ -660,6 +706,17 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Give back meshes the renderer has copied: a worker frees them, ahead of
|
||||
/// other jobs, as vanilla's compile task closes its mesh data on its own
|
||||
/// thread once copied (`SectionRenderDispatcher.RenderSection.CompileTask.doTask`).
|
||||
/// Freed in the frame, the hundreds of meshes a chunk border brings waited
|
||||
/// on the allocator behind every busy worker: up to 20 ms of a frame each
|
||||
/// time while flying at render distance 32.
|
||||
pub fn free(&mut self, meshes: Vec<((i32, i32, i32), SectionMesh)>) {
|
||||
if !meshes.is_empty() {
|
||||
self.submit(None, 0, Job::Free(meshes));
|
||||
}
|
||||
}
|
||||
/// A column the server unloaded (ClientboundForgetLevelChunkPacket).
|
||||
pub fn forget(&mut self, world: &mut World, x: i32, z: i32) -> Option<Arc<Chunk>> {
|
||||
self.lit.remove(&(x, z));
|
||||
@@ -676,7 +733,7 @@ impl Streaming {
|
||||
}
|
||||
/// Queue a job for the column `at`, nearest first: its distance (`distance`)
|
||||
/// times four plus `bias`, which orders a column's jobs. A job without a
|
||||
/// column (a save) goes ahead of every other.
|
||||
/// column (a save, meshes to free) goes ahead of every other.
|
||||
fn submit(&mut self, at: Option<(i32, i32)>, bias: i64, job: Job) {
|
||||
self.order += 1;
|
||||
let priority = at.map_or(i64::MIN, |(x, z)| self.distance(x, z) * 4 + bias);
|
||||
@@ -909,8 +966,10 @@ impl Streaming {
|
||||
self.requeue();
|
||||
self.relight_edits(world);
|
||||
let deferred = std::mem::take(&mut self.deferred);
|
||||
let received = std::iter::from_fn(|| self.results.try_recv().ok());
|
||||
for done in deferred.into_iter().chain(received.collect::<Vec<_>>()) {
|
||||
let received = self.shared.done.take();
|
||||
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
|
||||
let mut stale = Vec::new();
|
||||
for done in deferred.into_iter().chain(received) {
|
||||
match done {
|
||||
Done::Proto(x, z, proto, decorated) => {
|
||||
self.generation_scan.exhausted = false;
|
||||
@@ -966,12 +1025,14 @@ impl Streaming {
|
||||
}
|
||||
Done::Meshed(x, sy, z, mesh, seq) => {
|
||||
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
|
||||
stale.push(((x, sy, z), mesh));
|
||||
continue;
|
||||
}
|
||||
// Its column unloaded while it was meshed: nothing would
|
||||
// ever remove the uploaded section.
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
stale.push(((x, sy, z), mesh));
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
@@ -980,6 +1041,7 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.free(stale);
|
||||
let mut removed = Vec::new();
|
||||
if sweep {
|
||||
// Unload far columns.
|
||||
@@ -1418,12 +1480,12 @@ impl Streaming {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Ok(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Ok(other) => self.deferred.push(other),
|
||||
Err(_) => {
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Some(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Some(other) => self.deferred.push(other),
|
||||
None => {
|
||||
log::warn!("Horizon: chunk generation did not finish before saving");
|
||||
break;
|
||||
}
|
||||
@@ -1445,7 +1507,7 @@ impl Streaming {
|
||||
/// in `save(true)`, so older generation cannot overwrite the final chunks.
|
||||
pub fn poll_save(&mut self, world: &mut World) -> bool {
|
||||
let started = std::time::Instant::now();
|
||||
while let Ok(done) = self.results.try_recv() {
|
||||
while let Some(done) = self.shared.done.try_recv() {
|
||||
match done {
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
@@ -1530,7 +1592,7 @@ impl Streaming {
|
||||
/// Wait up to `timeout` for a worker to finish a job; the next `update`
|
||||
/// takes it. A server pumps generation with this between its ticks.
|
||||
pub fn wait(&mut self, timeout: std::time::Duration) {
|
||||
if let Ok(done) = self.results.recv_timeout(timeout) {
|
||||
if let Some(done) = self.shared.done.recv_timeout(timeout) {
|
||||
self.deferred.push(done);
|
||||
}
|
||||
}
|
||||
@@ -1811,10 +1873,10 @@ impl Drop for Streaming {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Saved(_)) => self.saving -= 1,
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Saved(_)) => self.saving -= 1,
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
@@ -1852,6 +1914,37 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The frame never waits for a worker: while one holds the mailbox, the
|
||||
/// frame takes nothing and finds the results, in order, the next time.
|
||||
#[test]
|
||||
fn the_frame_takes_finished_jobs_without_waiting() {
|
||||
let mailbox = Mailbox::default();
|
||||
let lit = |seq| Done::Lit(0, 0, Vec::new(), seq);
|
||||
mailbox.send(lit(1));
|
||||
mailbox.send(lit(2));
|
||||
let sending = mailbox.done.lock().unwrap();
|
||||
assert!(mailbox.take().is_empty());
|
||||
assert!(mailbox.try_recv().is_none());
|
||||
drop(sending);
|
||||
let seqs: Vec<u64> = mailbox
|
||||
.take()
|
||||
.into_iter()
|
||||
.map(|done| match done {
|
||||
Done::Lit(_, _, _, seq) => seq,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(seqs, [1, 2]);
|
||||
assert!(mailbox.take().is_empty());
|
||||
let waiting = std::time::Instant::now();
|
||||
assert!(
|
||||
mailbox
|
||||
.recv_timeout(std::time::Duration::from_millis(10))
|
||||
.is_none()
|
||||
);
|
||||
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -24,6 +24,9 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
|
||||
// triangle's volume with the camera; then each rim vertex's normal against the apex.
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -197,39 +200,26 @@ fn sky_fog(color: vec3<f32>, dist: vec2<f32>) -> vec3<f32> {
|
||||
let f = max(linear_fog(dist.x, 0.0, end), linear_fog(dist.y, end, end));
|
||||
return mix(color, globals.fog_color.rgb, f * globals.fog_color.a);
|
||||
}
|
||||
fn rotate_x90(v: vec3<f32>) -> vec3<f32> { return vec3(v.x, -v.z, v.y); }
|
||||
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed).
|
||||
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed):
|
||||
// Moller-Trumbore from the origin along d, over normals built once a frame
|
||||
// (gfx/sunrise_fan.rs). The barycentrics are apex/s, k1/s and -k0/s; s is -det.
|
||||
fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
let alpha = globals.sunrise.a;
|
||||
if alpha <= 0.001 { return 0.0; }
|
||||
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
|
||||
let flip = select(0.0, 180.0, -globals.sun.z < 0.0);
|
||||
let theta = radians(flip + 90.0);
|
||||
let c = cos(theta);
|
||||
let s = sin(theta);
|
||||
if globals.sunrise.a <= 0.001 { return 0.0; }
|
||||
var best = 0.0;
|
||||
let apex = rotate_x90(vec3(-100.0 * s, 100.0 * c, 0.0));
|
||||
var k0 = dot(d, globals.sunrise_fan[16].xyz);
|
||||
for (var i = 0; i < 16; i++) {
|
||||
let a0 = f32(i) * 2.0 * PI / 16.0;
|
||||
let a1 = f32(i + 1) * 2.0 * PI / 16.0;
|
||||
let l0 = vec3(sin(a0) * 120.0, cos(a0) * 120.0, -cos(a0) * 40.0 * alpha);
|
||||
let l1 = vec3(sin(a1) * 120.0, cos(a1) * 120.0, -cos(a1) * 40.0 * alpha);
|
||||
let r0 = rotate_x90(vec3(l0.x * c - l0.y * s, l0.x * s + l0.y * c, l0.z));
|
||||
let r1 = rotate_x90(vec3(l1.x * c - l1.y * s, l1.x * s + l1.y * c, l1.z));
|
||||
// Moller-Trumbore from the origin along d.
|
||||
let e1 = r0 - apex;
|
||||
let e2 = r1 - apex;
|
||||
let pv = cross(d, e2);
|
||||
let det = dot(e1, pv);
|
||||
if abs(det) < 1e-6 { continue; }
|
||||
let tv = -apex;
|
||||
let u = dot(tv, pv) / det;
|
||||
let qv = cross(tv, e1);
|
||||
let v = dot(d, qv) / det;
|
||||
let t = dot(e2, qv) / det;
|
||||
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
|
||||
best = max(best, 1.0 - u - v);
|
||||
let triangle = globals.sunrise_fan[i];
|
||||
let k1 = dot(d, globals.sunrise_fan[16 + (i + 1) % 16].xyz);
|
||||
// A hit needs s on the side of w, so k0 and k1 on either side of 0.
|
||||
if k0 * triangle.w <= 0.0 && k1 * triangle.w >= 0.0 {
|
||||
let apex = dot(d, triangle.xyz);
|
||||
let s = apex + k1 - k0;
|
||||
if abs(s) >= 1e-6 && s * triangle.w > 0.0 && apex * s >= 0.0 && k1 * s >= 0.0
|
||||
&& k0 * s <= 0.0 {
|
||||
best = max(best, apex / s);
|
||||
}
|
||||
}
|
||||
k0 = k1;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@@ -79,27 +79,31 @@ pub fn load_metadata(root: &Path) {
|
||||
}
|
||||
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
|
||||
}
|
||||
/// The sprite's nine-slice metadata; without any (no resources loaded), its
|
||||
/// GUI size and the widgets' usual borders.
|
||||
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
/// The sprite's nine-slice metadata, when its `.png.mcmeta` has any.
|
||||
fn nine_slice_metadata(sprite: &str) -> Option<NineSlice> {
|
||||
NINE_SLICE
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(sprite).copied())
|
||||
.unwrap_or(NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle"
|
||||
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
|
||||
_ => [1; 4],
|
||||
},
|
||||
stretch_inner: false,
|
||||
})
|
||||
}
|
||||
/// The sprite's nine-slice metadata; without any (no resources loaded), its
|
||||
/// GUI size and the widgets' usual borders.
|
||||
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
nine_slice_metadata(sprite).unwrap_or(NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
|
||||
[2, 2, 2, 3]
|
||||
}
|
||||
_ => [1; 4],
|
||||
},
|
||||
stretch_inner: false,
|
||||
})
|
||||
}
|
||||
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
|
||||
/// own units (`TextureAtlasSprite.getU(x / width)`).
|
||||
@@ -152,6 +156,35 @@ pub fn nine_slice(
|
||||
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
/// `GuiGraphicsExtractor.blitSprite`: a sprite over `w` × `h` as its
|
||||
/// `gui.scaling` says, nine-sliced, or stretched when it has no metadata.
|
||||
/// (`tile`, which no vanilla sprite uses, isn't read yet: it stretches.)
|
||||
pub fn blit_sprite(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
sprite: &str,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
color: u32,
|
||||
) {
|
||||
if let Some(r) = atlas.sprites.get(sprite) {
|
||||
draw_sprite(batch, *r, nine_slice_metadata(sprite), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
fn draw_sprite(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
nine_slice: Option<NineSlice>,
|
||||
[x, y, w, h]: [f32; 4],
|
||||
color: u32,
|
||||
) {
|
||||
match nine_slice {
|
||||
Some(n) => draw_nine_slice(batch, r, n, [x, y, w, h], color),
|
||||
None => batch.rect_uv(x, y, w, h, r, color),
|
||||
}
|
||||
}
|
||||
fn draw_nine_slice(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
@@ -227,28 +260,33 @@ pub fn tiled(
|
||||
h: f32,
|
||||
size: f32,
|
||||
color: u32,
|
||||
) {
|
||||
tiled_sized(batch, atlas, sprite, x, y, w, h, [size, size], color);
|
||||
}
|
||||
/// Repeat a texture using its width and height in GUI pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tiled_sized(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
sprite: &str,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
size: [f32; 2],
|
||||
color: u32,
|
||||
) {
|
||||
let Some(r) = atlas.sprites.get(sprite).copied() else {
|
||||
return;
|
||||
};
|
||||
let mut yy = 0.0;
|
||||
while yy < h {
|
||||
let th = size.min(h - yy);
|
||||
let mut xx = 0.0;
|
||||
while xx < w {
|
||||
let tw = size.min(w - xx);
|
||||
batch.rect_uv(
|
||||
x + xx,
|
||||
y + yy,
|
||||
tw,
|
||||
th,
|
||||
sub(r, [size, size], [0.0, 0.0, tw, th]),
|
||||
color,
|
||||
);
|
||||
xx += size;
|
||||
}
|
||||
yy += size;
|
||||
}
|
||||
tile(
|
||||
batch,
|
||||
r,
|
||||
size,
|
||||
[0.0, 0.0, size[0], size[1]],
|
||||
[x, y, w, h],
|
||||
color,
|
||||
);
|
||||
}
|
||||
/// Text drawn at `scale` times its size (a scaled pose).
|
||||
pub fn text_scaled(
|
||||
@@ -434,7 +472,7 @@ pub fn separators(
|
||||
("gui/header_separator", "gui/footer_separator")
|
||||
};
|
||||
let w = batch.width;
|
||||
tiled(
|
||||
tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
header,
|
||||
@@ -442,10 +480,10 @@ pub fn separators(
|
||||
header_bottom - 2.0,
|
||||
w,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
tiled(
|
||||
tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
footer,
|
||||
@@ -453,7 +491,7 @@ pub fn separators(
|
||||
footer_top,
|
||||
w,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
@@ -1291,6 +1329,22 @@ mod tests {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
tile(
|
||||
&mut batch,
|
||||
sprite(32, 2),
|
||||
[32.0, 2.0],
|
||||
[0.0, 0.0, 32.0, 2.0],
|
||||
[0.0, 5.0, 40.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 2);
|
||||
assert_quad(q[0], ([0.0, 5.0, 32.0, 7.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
assert_quad(q[1], ([32.0, 5.0, 40.0, 7.0], [0.0, 0.0, 0.25, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_button_slices_in_its_gui_units() {
|
||||
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
|
||||
let n = faithful(
|
||||
@@ -1357,6 +1411,40 @@ mod tests {
|
||||
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn blit_sprite_nine_slices_a_sprite_whose_metadata_says_so() {
|
||||
// Vanilla's tooltip/frame: 100x100, border 10, stretched centre.
|
||||
let n = faithful(
|
||||
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":10,"stretch_inner":true}}}"#,
|
||||
);
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_sprite(
|
||||
&mut batch,
|
||||
sprite(100, 100),
|
||||
Some(n),
|
||||
[10.0, 10.0, 160.0, 26.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 9);
|
||||
// The corners keep their 10 pixels instead of stretching with the box.
|
||||
assert_quad(q[0], ([10.0, 10.0, 20.0, 20.0], [0.0, 0.0, 0.1, 0.1]));
|
||||
assert_quad(q[5], ([160.0, 26.0, 170.0, 36.0], [0.9, 0.9, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn blit_sprite_stretches_a_sprite_without_metadata() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_sprite(
|
||||
&mut batch,
|
||||
sprite(9, 9),
|
||||
None,
|
||||
[0.0, 0.0, 27.0, 18.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 1);
|
||||
assert_quad(q[0], ([0.0, 0.0, 27.0, 18.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn borders_shrink_to_half_the_drawn_size() {
|
||||
let n = NineSlice {
|
||||
width: 20,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Resident client world: streamed chunk columns plus the bookkeeping the game
|
||||
//! loop needs to relight and remesh after edits.
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Sections (x, section index, z) waiting to be remeshed, kept by column
|
||||
/// (a bit per section index), so the mesh scheduler looks at each column once
|
||||
@@ -65,14 +63,14 @@ pub struct World {
|
||||
/// Saved entities of chunks that just loaded, spawned by the game.
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
|
||||
/// Chunks changed since the last save.
|
||||
pub modified: HashSet<(i32, i32)>,
|
||||
pub modified: FxHashSet<(i32, i32)>,
|
||||
/// Block entities, and the order they tick in.
|
||||
pub block_entities: crate::block_entities::BlockEntities,
|
||||
/// Each chest's `ContainerOpenersCounter`: opener count and largest
|
||||
/// interaction range.
|
||||
pub chest_openers: HashMap<(i32, i32, i32), (i32, f64)>,
|
||||
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
|
||||
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
|
||||
pub block_names: HashMap<(i32, i32, i32), String>,
|
||||
pub block_names: FxHashMap<(i32, i32, i32), String>,
|
||||
/// States (by id) whose writes are recorded in `watched_writes` (sculk
|
||||
/// blocks, which need a block entity).
|
||||
pub watched: Vec<bool>,
|
||||
@@ -80,7 +78,7 @@ pub struct World {
|
||||
pub watched_writes: Vec<((i32, i32, i32), u32, u32)>,
|
||||
/// `ChunkAccess.inhabitedTime` of resident chunks: ticks a player spent
|
||||
/// within spawning range (local difficulty).
|
||||
pub inhabited: HashMap<(i32, i32), i64>,
|
||||
pub inhabited: FxHashMap<(i32, i32), i64>,
|
||||
pub game_time: i64,
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
@@ -177,13 +175,13 @@ impl World {
|
||||
dirty_light: Default::default(),
|
||||
light_edits: Vec::new(),
|
||||
loaded_entities: Vec::new(),
|
||||
modified: HashSet::new(),
|
||||
modified: FxHashSet::default(),
|
||||
block_entities: Default::default(),
|
||||
chest_openers: HashMap::new(),
|
||||
block_names: HashMap::new(),
|
||||
chest_openers: FxHashMap::default(),
|
||||
block_names: FxHashMap::default(),
|
||||
watched: Vec::new(),
|
||||
watched_writes: Vec::new(),
|
||||
inhabited: HashMap::new(),
|
||||
inhabited: FxHashMap::default(),
|
||||
game_time: 0,
|
||||
day_time: 1000,
|
||||
weather: Weather::default(),
|
||||
@@ -274,20 +272,40 @@ impl World {
|
||||
log.push((x, y, z));
|
||||
}
|
||||
self.modified.insert((x >> 4, z >> 4));
|
||||
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
|
||||
// the sections it can reach: this section, the one above/below
|
||||
// only on a section face, and a neighbour column only on a chunk
|
||||
// border (the diagonal only where both borders meet).
|
||||
let sy = (y - MIN_Y) >> 4;
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
||||
let s = (by - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
continue;
|
||||
}
|
||||
self.dirty_sections.insert((bx >> 4, s, bz >> 4));
|
||||
}
|
||||
let local_y = y & 15;
|
||||
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
|
||||
dirty.insert((bx >> 4, sy, bz >> 4));
|
||||
if local_y == 0 && sy > 0 {
|
||||
dirty.insert((bx >> 4, sy - 1, bz >> 4));
|
||||
}
|
||||
if local_y == 15 && sy + 1 < SECTIONS as i32 {
|
||||
dirty.insert((bx >> 4, sy + 1, bz >> 4));
|
||||
}
|
||||
};
|
||||
let (bx, bz) = (x & 15, z & 15);
|
||||
mark(&mut self.dirty_sections, x, z);
|
||||
if bx == 0 {
|
||||
mark(&mut self.dirty_sections, x - 1, z);
|
||||
} else if bx == 15 {
|
||||
mark(&mut self.dirty_sections, x + 1, z);
|
||||
}
|
||||
if bz == 0 {
|
||||
mark(&mut self.dirty_sections, x, z - 1);
|
||||
} else if bz == 15 {
|
||||
mark(&mut self.dirty_sections, x, z + 1);
|
||||
}
|
||||
if (bx == 0 || bx == 15) && (bz == 0 || bz == 15) {
|
||||
mark(
|
||||
&mut self.dirty_sections,
|
||||
if bx == 0 { x - 1 } else { x + 1 },
|
||||
if bz == 0 { z - 1 } else { z + 1 },
|
||||
);
|
||||
}
|
||||
let _ = sy;
|
||||
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
|
||||
self.light_edits.push(((x, y, z), previous, state.0));
|
||||
} else {
|
||||
@@ -407,11 +425,59 @@ impl Default for HealthRules {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::World;
|
||||
use crate::chunk::Chunk;
|
||||
use super::{DirtySections, World};
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Every block write marks the same section set the 27-cell loop did, but
|
||||
/// only the distinct keys: one in the interior, a neighbour section on a
|
||||
/// section face, a neighbour column on a chunk border and the diagonal at
|
||||
/// a corner (eight keys at a corner on a section face).
|
||||
#[test]
|
||||
fn writes_mark_exactly_the_reachable_sections() {
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
for (x, y, z) in [
|
||||
(3, 70, 3),
|
||||
(0, 70, 3),
|
||||
(15, 70, 3),
|
||||
(3, 70, 0),
|
||||
(0, 70, 0),
|
||||
(15, 70, 15),
|
||||
(0, MIN_Y, 0),
|
||||
(0, 80, 0),
|
||||
(15, MIN_Y + SECTIONS as i32 * 16 - 1, 15),
|
||||
] {
|
||||
world.dirty_sections.clear();
|
||||
world.set(x, y, z, BlockStateId(1)).unwrap();
|
||||
let mut expected = DirtySections::default();
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
||||
let s = (by - MIN_Y) >> 4;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
expected.insert((bx >> 4, s, bz >> 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut got: Vec<_> = world.dirty_sections.columns().collect();
|
||||
let mut want: Vec<_> = expected.columns().collect();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
assert_eq!(got, want, "write at {x} {y} {z}");
|
||||
if (x & 15, z & 15) == (3, 3) {
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
}
|
||||
if (x & 15, z & 15, y & 15) == (0, 0, 0) && y > MIN_Y {
|
||||
// Four columns, each with the section below and this one.
|
||||
assert_eq!(world.dirty_sections.len(), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A box misses a state only where no section it reaches lists it, and
|
||||
/// unloaded columns or heights past the world count as their air.
|
||||
#[test]
|
||||
|
||||
@@ -55,3 +55,79 @@ fn luma(c: vec3<f32>) -> f32 {
|
||||
}
|
||||
return vec4(b, 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.
|
||||
struct Taa {
|
||||
reproject: mat4x4<f32>,
|
||||
// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
|
||||
jitter: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var history: texture_2d<f32>;
|
||||
@group(0) @binding(3) var depth: texture_depth_2d;
|
||||
@group(0) @binding(4) var<uniform> taa: Taa;
|
||||
|
||||
// Catmull-Rom over the history in 5 bilinear taps: sharper than one under motion.
|
||||
fn sample_history(uv: vec2<f32>) -> vec3<f32> {
|
||||
let size = vec2<f32>(textureDimensions(history));
|
||||
let pos = uv * size;
|
||||
let centre = floor(pos - 0.5) + 0.5;
|
||||
let f = pos - centre;
|
||||
let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
let w3 = f * f * (-0.5 + 0.5 * f);
|
||||
let w12 = w1 + w2;
|
||||
let t0 = (centre - 1.0) / size;
|
||||
let t3 = (centre + 2.0) / size;
|
||||
let t12 = (centre + w2 / w12) / size;
|
||||
let c = textureSampleLevel(history, world_sampler, vec2(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, t12, 0.0).rgb * (w12.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
|
||||
let weight = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y;
|
||||
return max(c / weight, vec3(0.0));
|
||||
}
|
||||
|
||||
// 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.
|
||||
@fragment fn fs_taa(in: PostOut) -> @location(0) vec4<f32> {
|
||||
let size = vec2<f32>(textureDimensions(world));
|
||||
// The jitter moved the scene by `jitter` in clip space: where this pixel's
|
||||
// point was drawn, in world pixels.
|
||||
let drawn = (in.uv + taa.jitter.xy * vec2(0.5, -0.5)) * size;
|
||||
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;
|
||||
// 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;
|
||||
lo = min(lo, c);
|
||||
hi = max(hi, c);
|
||||
near = max(near, textureLoad(depth, q, 0));
|
||||
}
|
||||
}
|
||||
let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||
let previous = taa.reproject * vec4(ndc, near, 1.0);
|
||||
let uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
|
||||
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 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.
|
||||
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
|
||||
let share = 0.1 * 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);
|
||||
}
|
||||
@@ -14,11 +14,13 @@ use zip::{CompressionMethod, ZipArchive};
|
||||
/// bomb stops here.
|
||||
const MAX_ENTRY: usize = 1 << 30;
|
||||
|
||||
/// One archive with its entries indexed by name (`\` read as `/`, folders
|
||||
/// left out).
|
||||
/// One archive with its entries indexed by name (`\` read as `/`), files
|
||||
/// and folders apart.
|
||||
pub struct Zip {
|
||||
archive: ZipArchive<Cursor<Arc<[u8]>>>,
|
||||
entries: BTreeMap<String, usize>,
|
||||
/// Folder entries (`name/`) by their name without the slash.
|
||||
folders: BTreeMap<String, usize>,
|
||||
/// The archive's length: no size an entry claims can be bigger.
|
||||
len: usize,
|
||||
}
|
||||
@@ -33,26 +35,44 @@ impl Zip {
|
||||
normalize_end(&mut data);
|
||||
let len = data.len();
|
||||
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
|
||||
let entries = (0..archive.len())
|
||||
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
|
||||
.filter(|(name, _)| !name.ends_with('/'))
|
||||
.collect();
|
||||
let (mut entries, mut folders) = (BTreeMap::new(), BTreeMap::new());
|
||||
for i in 0..archive.len() {
|
||||
let Some(name) = archive.name_for_index(i) else {
|
||||
continue;
|
||||
};
|
||||
let name = name.replace('\\', "/");
|
||||
match name.strip_suffix('/') {
|
||||
Some(folder) => folders.insert(folder.to_owned(), i),
|
||||
None => entries.insert(name, i),
|
||||
};
|
||||
}
|
||||
Ok(Self {
|
||||
archive,
|
||||
entries,
|
||||
folders,
|
||||
len,
|
||||
})
|
||||
}
|
||||
/// The files, as `FilePackResources.listResources` lists them (folder
|
||||
/// entries skipped).
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
/// `FilePackResources.getResource` through `ZipFile.getEntry`: the file
|
||||
/// `name`, else the folder entry `name/`, whose bytes Java reads like a
|
||||
/// file's (Loka's pack stores its `pack.mcmeta` and `pack.png` that way).
|
||||
fn index(&self, name: &str) -> Option<usize> {
|
||||
self.entries
|
||||
.get(name)
|
||||
.or_else(|| self.folders.get(name))
|
||||
.copied()
|
||||
}
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.entries.contains_key(name)
|
||||
self.index(name).is_some()
|
||||
}
|
||||
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
|
||||
let &index = self
|
||||
.entries
|
||||
.get(name)
|
||||
let index = self
|
||||
.index(name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
|
||||
// A clone shares the bytes and the central directory: reading needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
@@ -133,13 +153,16 @@ mod tests {
|
||||
use super::*;
|
||||
/// A hand-built archive with one stored and one deflated entry.
|
||||
fn archive() -> Vec<u8> {
|
||||
let files: [(&str, &[u8], bool); 2] = [
|
||||
archive_of(&[
|
||||
("pack.mcmeta", b"{\"pack\":{}}", false),
|
||||
("a/b.txt", b"hello hello hello hello", true),
|
||||
];
|
||||
])
|
||||
}
|
||||
/// A hand-built archive of `(name, body, deflated)` entries.
|
||||
fn archive_of(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, body, deflate) in files {
|
||||
for &(name, body, deflate) in files {
|
||||
let data = if deflate {
|
||||
miniz_oxide::deflate::compress_to_vec(body, 6)
|
||||
} else {
|
||||
@@ -174,8 +197,8 @@ mod tests {
|
||||
out.extend(¢ral);
|
||||
out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
out.extend([0; 4]);
|
||||
out.extend(2u16.to_le_bytes());
|
||||
out.extend(2u16.to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((central.len() as u32).to_le_bytes());
|
||||
out.extend(start.to_le_bytes());
|
||||
out.extend([0; 2]);
|
||||
@@ -191,6 +214,29 @@ mod tests {
|
||||
assert!(zip.read("missing").is_err());
|
||||
}
|
||||
|
||||
/// Loka's server pack (#3413) names its metadata `pack.mcmeta/`.
|
||||
/// `ZipFile.getEntry("pack.mcmeta")` falls back to that folder entry and
|
||||
/// vanilla reads its bytes; a file of the exact name still comes first.
|
||||
#[test]
|
||||
fn a_name_falls_back_to_its_folder_entry() {
|
||||
let zip = Zip::from_bytes(archive_of(&[
|
||||
("pack.mcmeta/", b"{\"pack\":{\"pack_format\":75}}", true),
|
||||
("pack.png/", b"png", false),
|
||||
("a/", b"folder", false),
|
||||
("a", b"file", false),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(zip.names().collect::<Vec<_>>(), ["a"]);
|
||||
assert!(zip.contains("pack.mcmeta"));
|
||||
assert_eq!(
|
||||
zip.read("pack.mcmeta").unwrap(),
|
||||
b"{\"pack\":{\"pack_format\":75}}"
|
||||
);
|
||||
assert_eq!(zip.read("pack.png").unwrap(), b"png");
|
||||
assert_eq!(zip.read("a").unwrap(), b"file");
|
||||
assert!(!zip.contains("pack"));
|
||||
}
|
||||
|
||||
/// A pack as zip tools write it: folder entries, a nested pack root,
|
||||
/// stored and deflated files, and a Windows-style name.
|
||||
fn written() -> Vec<u8> {
|
||||
|
||||
@@ -91,9 +91,6 @@ default = ["audio", "controllers"]
|
||||
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"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
# (release.yml); the tests and probes don't need it. Content mods load without it.
|
||||
mods = ["horizon-mods/runtime"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Геймпад",
|
||||
"horizon.options.vibration": "Вібрація",
|
||||
"horizon.options.adaptive_triggers": "Адаптивні тригери",
|
||||
"horizon.options.lightbar": "Світлова панель",
|
||||
"horizon.options.look_sensitivity": "Чутливість огляду",
|
||||
"horizon.options.invert_x": "Інвертувати огляд по X",
|
||||
"horizon.options.invert_y": "Інвертувати огляд по Y",
|
||||
"horizon.options.sneak_button": "Кнопка присідання",
|
||||
"horizon.options.move_deadzone": "Мертва зона руху",
|
||||
"horizon.options.look_deadzone": "Мертва зона огляду",
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
|
||||
}
|
||||
@@ -302,7 +302,7 @@ fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
let name = op[0].as_str().unwrap();
|
||||
match name {
|
||||
"tick" => state.tick(),
|
||||
"tick" => state.tick([0.0; 3]),
|
||||
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
|
||||
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
|
||||
"reset" => state.reset_bob(),
|
||||
|
||||
@@ -43,6 +43,7 @@ pub fn registries(r: &Value, permission: u8) -> Registries {
|
||||
reg.timelines = strings(&r["timelines"]);
|
||||
reg.dimensions = strings(&r["dimensions"]);
|
||||
reg.players = strings(&r["players"]);
|
||||
reg.selected_entities = strings(&r["selected_entities"]);
|
||||
reg.teams = strings(&r["teams"]);
|
||||
reg.objectives = strings(&r["objectives"]);
|
||||
reg.tracked = strings(&r["tracked"]);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Double chests for tools/test_chest_pair_parity.py: reads one JSON case per
|
||||
//! line (see VanillaChestPairOracle), a chest's block state at the origin and
|
||||
//! another block state at the offset "at", and prints what
|
||||
//! `horizon_client::containers::ChestHalf` pairs them into, as the game's
|
||||
//! menus and hoppers do: "double" with the right half's x,z then the left
|
||||
//! half's, or "single".
|
||||
//!
|
||||
//! usage: chest_pair_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::{ChestHalf, is_chest_block};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
/// `minecraft:chest[facing=north,type=left]` as its name and properties.
|
||||
fn parse(state: &str) -> Option<(&str, BTreeMap<&str, &str>)> {
|
||||
let (name, props) = match state.split_once('[') {
|
||||
Some((name, rest)) => (name, rest.strip_suffix(']')?),
|
||||
None => (state, ""),
|
||||
};
|
||||
let props = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| p.split_once('='))
|
||||
.collect::<Option<_>>()?;
|
||||
Some((name, props))
|
||||
}
|
||||
|
||||
fn half<'a>(name: &'a str, props: &BTreeMap<&str, &'a str>) -> Option<ChestHalf<'a>> {
|
||||
Some(ChestHalf {
|
||||
block: name,
|
||||
kind: props.get("type")?,
|
||||
facing: props.get("facing")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let (name, props) = parse(case["state"].as_str()?)?;
|
||||
let (other_name, other_props) = parse(case["other"].as_str()?)?;
|
||||
let at = (
|
||||
i32::try_from(case["at"][0].as_i64()?).ok()?,
|
||||
i32::try_from(case["at"][1].as_i64()?).ok()?,
|
||||
);
|
||||
if !is_chest_block(name) {
|
||||
return None;
|
||||
}
|
||||
let own = half(name, &props)?;
|
||||
let paired = own.connected_step() == Some(at)
|
||||
&& half(other_name, &other_props).is_some_and(|other| own.pairs_with(&other));
|
||||
Some(if !paired {
|
||||
"single".to_owned()
|
||||
} else if own.kind == "right" {
|
||||
format!("double 0,0 {},{}", at.0, at.1)
|
||||
} else {
|
||||
format!("double {},{} 0,0", at.0, at.1)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: chest_pair_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -178,6 +178,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
reg.timelines = strings(&r["timelines"]);
|
||||
reg.dimensions = strings(&r["dimensions"]);
|
||||
reg.players = strings(&r["players"]);
|
||||
reg.selected_entities = strings(&r["selected_entities"]);
|
||||
reg.teams = strings(&r["teams"]);
|
||||
reg.objectives = strings(&r["objectives"]);
|
||||
reg.tracked = strings(&r["tracked"]);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Container menu titles for tools/test_container_title_parity.py: reads one
|
||||
//! JSON case per line (see VanillaContainerTitleOracle) and prints the title
|
||||
//! the server opens the container's menu with, as canonical NBT. A single
|
||||
//! block entity is placed as the game places it (`block_entity_item::place`,
|
||||
//! whose `CustomName` the game keeps in `World::block_names`), then named by
|
||||
//! `containers::display_name`; a double chest's halves by
|
||||
//! `containers::double_chest_name`.
|
||||
//!
|
||||
//! usage: container_title_fixture INPUT OUTPUT
|
||||
use horizon_client::block_entity_item::place;
|
||||
use horizon_client::block_entity_nbt::canonical;
|
||||
use horizon_client::containers::{default_name_key, display_name, double_chest_name};
|
||||
use horizon_client::save::text;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
/// ParityData.canon of a text component: sorted keys.
|
||||
fn canon(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, v)) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&k);
|
||||
out.push(':');
|
||||
canon(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Tag::List(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canon(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snbt(value: &Value) -> Option<Tag> {
|
||||
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
|
||||
}
|
||||
|
||||
/// The custom name the game keeps for a block entity saved as `value`.
|
||||
fn custom_name(value: &Value) -> Option<Option<String>> {
|
||||
let saved = canonical(&snbt(value)?)?;
|
||||
Some(text(saved.get("CustomName")))
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let title = if let Some(first) = case.get("first") {
|
||||
let first = custom_name(first)?;
|
||||
let second = custom_name(&case["second"])?;
|
||||
double_chest_name(first.as_deref(), second.as_deref())
|
||||
} else {
|
||||
let saved = canonical(&snbt(&case["nbt"])?)?;
|
||||
// BlockItem.updateBlockEntityComponents, as Game::apply_placed_item runs it.
|
||||
let placed = match case.get("patch") {
|
||||
Some(p) => match snbt(p)? {
|
||||
Tag::Compound(patch) => place(&saved, &patch, true),
|
||||
_ => return None,
|
||||
},
|
||||
None => saved,
|
||||
};
|
||||
let key = default_name_key(case["block"].as_str()?)?;
|
||||
display_name(text(placed.get("CustomName")).as_deref(), key)
|
||||
};
|
||||
let mut out = String::new();
|
||||
canon(&title, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: container_title_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,9 +24,11 @@ mod breaking_fixture;
|
||||
mod camera_probe;
|
||||
mod cauldron_fixture;
|
||||
mod chat_screen_fixture;
|
||||
mod chest_pair_fixture;
|
||||
mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod container_title_fixture;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
@@ -86,10 +88,12 @@ mod sound_event_fixture;
|
||||
mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod storage_menu_fixture;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod text_display_render_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -115,9 +119,11 @@ probes! {
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
chest_pair_fixture,
|
||||
cloud_fixture,
|
||||
command_parse_fixture,
|
||||
container_screen_probe,
|
||||
container_title_fixture,
|
||||
creative_search_fixture,
|
||||
emitter_particles_probe,
|
||||
enchanting_fixture,
|
||||
@@ -174,12 +180,14 @@ probes! {
|
||||
sound_event_fixture,
|
||||
spawn_rules_fixture,
|
||||
sprint_probe,
|
||||
storage_menu_fixture,
|
||||
subtitle_fixture,
|
||||
tab_list_fixture,
|
||||
team_rules_fixture,
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
text_display_render_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -67,8 +67,9 @@ struct Scenario {
|
||||
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
|
||||
hurts: Vec<(i32, usize)>,
|
||||
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
|
||||
/// (tick, mob index); the scenario only feeds each animal its food.
|
||||
feeds: Vec<(i32, usize)>,
|
||||
/// (tick, mob index, None); the scenario only feeds each animal its food.
|
||||
/// `interact` uses another item on a horse: (tick, mob index, Some(item)).
|
||||
feeds: Vec<(i32, usize, Option<String>)>,
|
||||
hand: Option<String>,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
@@ -108,7 +109,14 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
}
|
||||
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s
|
||||
.feeds
|
||||
.push((f[1].parse().unwrap(), f[2].parse().unwrap(), None)),
|
||||
"interact" => s.feeds.push((
|
||||
f[1].parse().unwrap(),
|
||||
f[2].parse().unwrap(),
|
||||
Some(f[3].to_owned()),
|
||||
)),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
@@ -725,6 +733,15 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
|
||||
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
|
||||
let _ = write!(out, "\tcg {} {held} {opened}", m.extra.variant3);
|
||||
}
|
||||
if m.species() == Species::Wolf {
|
||||
// NeutralMob anger left (the angry texture), -1 when calm.
|
||||
let anger = if m.extra.anger > 0 {
|
||||
i64::from(m.extra.anger)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
let _ = write!(out, "\twolf {anger}");
|
||||
}
|
||||
if let Some(b) = horizon_client::mobs::bee::get(m) {
|
||||
let anger = if b.anger_end > 0 {
|
||||
b.anger_end - now
|
||||
@@ -1209,10 +1226,22 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
events.append(&mut mobs[i].outbox);
|
||||
}
|
||||
for &(tick, i) in &s.feeds {
|
||||
for (tick, i, item) in &s.feeds {
|
||||
let (tick, i) = (*tick, *i);
|
||||
if tick != t || i >= mobs.len() || mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
// Horse.mobInteract's held-item branch (makeMad).
|
||||
if let Some(item) = item {
|
||||
horizon_client::mobs::equine::held_item(
|
||||
&mut mobs[i],
|
||||
item,
|
||||
false,
|
||||
item == "minecraft:golden_dandelion",
|
||||
&mut events,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
{
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.collect();
|
||||
let mut screen = PackScreen::new(
|
||||
&m,
|
||||
Text::translate(c["title"].as_str().unwrap_or("resourcePack.title"), &[]),
|
||||
int(&c["width"]),
|
||||
int(&c["height"]),
|
||||
c["inWorld"] == true,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Storage block menus for tools/test_storage_menu_parity.py: reads one
|
||||
//! block id per line and prints the menu type the server opens it with
|
||||
//! (`horizon_client::containers::block_menu_type`, as `lan_open_container`
|
||||
//! sends it) and whether that menu's slots take a shulker box: the server
|
||||
//! refuses them in a `minecraft:shulker_box` menu (`ShulkerBoxSlot.mayPlace`,
|
||||
//! `lan::containers`).
|
||||
//!
|
||||
//! usage: storage_menu_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::block_menu_type;
|
||||
use std::fs;
|
||||
|
||||
fn case(block: &str) -> Option<String> {
|
||||
let menu = block_menu_type(block.trim())?;
|
||||
let accepts = if menu == "minecraft:shulker_box" {
|
||||
"refuses"
|
||||
} else {
|
||||
"accepts"
|
||||
};
|
||||
Some(format!("{menu} {accepts} shulker_box"))
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: storage_menu_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! A text display's text for differential tests against vanilla's
|
||||
//! `TextDisplayRenderer.submitInner` (tools/test_text_display_render_parity.py):
|
||||
//! decodes each case's entity data from vanilla's wire bytes, lays the text out
|
||||
//! with `horizon_client::text_display` and prints the pose and every quad in
|
||||
//! text units [x, y, z, ARGB], as VanillaTextDisplayRenderOracle does.
|
||||
//!
|
||||
//! usage: text_display_render_fixture RESOURCES INPUTS OUTPUT
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
hud_layout::background_color,
|
||||
text_display::{self, Lines, TextDisplayData},
|
||||
};
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use serde_json::{Value, json};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let [_, resources, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: text_display_render_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let atlas = HudAtlas::build(std::path::Path::new(resources))?;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
|
||||
for line in std::fs::read_to_string(inputs)?.lines() {
|
||||
let input: Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap_or_default();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
return Err("not entity data".into());
|
||||
};
|
||||
let mut d = TextDisplayData::default();
|
||||
d.apply(&packet.values);
|
||||
let lines = Lines::split(&atlas.font, &d.text, d.line_width);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let default_background = background_color(
|
||||
input["chat_only"].as_bool().unwrap_or(false),
|
||||
input["background_opacity"].as_f64().unwrap_or(0.0),
|
||||
0.25,
|
||||
);
|
||||
text_display::draw(&mut batch, &atlas, &d, &lines, default_background);
|
||||
let quads: Vec<Value> = batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let corners: Vec<Value> = [0, 1, 2, 5]
|
||||
.map(|i| {
|
||||
let v = q[i];
|
||||
let [r, g, b, a] = v.color.to_le_bytes().map(u32::from);
|
||||
let argb = (a << 24 | r << 16 | g << 8 | b) as i32;
|
||||
json!([v.pos[0], v.pos[1], v.depth, argb])
|
||||
})
|
||||
.into();
|
||||
corners.into()
|
||||
})
|
||||
.collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
json!({"pose": lines.pose().to_cols_array(), "quads": quads})
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -877,6 +877,22 @@ pub fn item_entity(
|
||||
&& item(&v["item"], stack, w)
|
||||
}
|
||||
|
||||
/// `RecipeCraftedTrigger.TriggerInstance.matches`: each ingredient predicate
|
||||
/// takes a different one of the stacks used, in order.
|
||||
pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
|
||||
let Some(preds) = v.as_array() else {
|
||||
return true;
|
||||
};
|
||||
let mut remaining: Vec<&ItemView> = used.iter().collect();
|
||||
preds.iter().all(|p| {
|
||||
let Some(i) = remaining.iter().position(|it| item(p, it, w)) else {
|
||||
return false;
|
||||
};
|
||||
remaining.remove(i);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
|
||||
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
match v {
|
||||
|
||||
@@ -146,6 +146,7 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"shader-packs",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"graphics-hdr",
|
||||
"extras",
|
||||
"horizon",
|
||||
"agent",
|
||||
@@ -425,7 +426,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
|
||||
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
|
||||
|
||||
@@ -27,7 +27,7 @@ impl Ingredient {
|
||||
pub fn of(items: Vec<String>) -> Self {
|
||||
Self(items.iter().cloned().collect(), items)
|
||||
}
|
||||
fn parse(v: &Value, items: &Items) -> Option<Self> {
|
||||
pub(crate) fn parse(v: &Value, items: &Items) -> Option<Self> {
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
let mut add = |s: &str| {
|
||||
let more: Vec<String> = match s.strip_prefix('#') {
|
||||
@@ -43,14 +43,6 @@ impl Ingredient {
|
||||
match v {
|
||||
Value::String(s) => add(s),
|
||||
Value::Array(a) => a.iter().filter_map(Value::as_str).for_each(&mut add),
|
||||
Value::Object(o) => {
|
||||
if let Some(s) = o.get("item").and_then(Value::as_str) {
|
||||
add(s);
|
||||
}
|
||||
if let Some(t) = o.get("tag").and_then(Value::as_str) {
|
||||
add(&format!("#{t}"));
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
(!list.is_empty()).then(|| Self::of(list))
|
||||
|
||||
@@ -527,6 +527,53 @@ impl Creative {
|
||||
pub fn scroller_offset(&self) -> f32 {
|
||||
((Self::SCROLL_HEIGHT - 17.0) * self.scroll) as i32 as f32
|
||||
}
|
||||
/// The tabs whose `CreativeModeTab.contains(stack)` holds, in
|
||||
/// `CreativeModeTabs.tabs()` order, the search tab left out: the stack is
|
||||
/// among the tab's search-tab items, by item and components. The export
|
||||
/// lists each tab's own items; the search tab's stacks that no tab shows
|
||||
/// (the enchanted books below their top level, `SEARCH_TAB_ONLY`) belong
|
||||
/// to the tabs that show their item.
|
||||
pub fn tabs_containing(&self, stack: &ItemStack) -> Vec<usize> {
|
||||
let category = |t: &Tab| !t.search && !t.inventory;
|
||||
let tabs_where = |shows: &dyn Fn(&ItemStack) -> bool| -> Vec<usize> {
|
||||
(0..self.tabs.len())
|
||||
.filter(|&i| category(&self.tabs[i]) && self.tabs[i].items.iter().any(shows))
|
||||
.collect()
|
||||
};
|
||||
let found = tabs_where(&|s| same_components(s, stack));
|
||||
if found.is_empty() && self.search_items.iter().any(|s| same_components(s, stack)) {
|
||||
return tabs_where(&|s| s.item == stack.item);
|
||||
}
|
||||
found
|
||||
}
|
||||
/// The `#namespace:path` lines `getTooltipFromContainerItem` adds for a
|
||||
/// search tab stack while the search is a `#` query: the item tags
|
||||
/// `updateVisibleTags` keeps for the text after `#` (the path contains it;
|
||||
/// with a colon, the namespace and the path each contain their part) that
|
||||
/// the stack has.
|
||||
pub fn search_tag_lines(&self, stack: &ItemStack) -> Vec<String> {
|
||||
let Some(text) = self.search.value.strip_prefix('#') else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(entry) = self
|
||||
.search_items
|
||||
.iter()
|
||||
.position(|s| same_components(s, stack))
|
||||
.and_then(|i| self.search_index.get(i))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let visible = |(ns, path): &(String, String)| match text.split_once(':') {
|
||||
None => path.contains(text),
|
||||
Some((n, p)) => ns.contains(java_trim(n)) && path.contains(java_trim(p)),
|
||||
};
|
||||
entry
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| visible(t))
|
||||
.map(|(ns, path)| format!("#{ns}:{path}"))
|
||||
.collect()
|
||||
}
|
||||
/// Tab button position relative to the screen origin (CreativeModeInventoryScreen.getTabX/Y).
|
||||
pub fn tab_position(tab: &Tab) -> (f32, f32) {
|
||||
let mut x = tab.column as f32 * 27.0;
|
||||
@@ -537,12 +584,35 @@ impl Creative {
|
||||
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
|
||||
(x, y)
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.extractTabButton`'s cursor: an unselected
|
||||
/// tab asks for the pointing hand when the whole-pixel mouse, relative to
|
||||
/// the screen origin, is strictly inside its 26x32 button.
|
||||
pub fn tab_wants_hand(tab: &Tab, selected: bool, mouse: (f32, f32)) -> bool {
|
||||
let (x, y) = Self::tab_position(tab);
|
||||
let (mx, my) = (mouse.0.floor(), mouse.1.floor());
|
||||
!selected && mx > x && my > y && mx < x + 26.0 && my < y + 32.0
|
||||
}
|
||||
}
|
||||
/// Whether a suffix array of `strings` has a suffix starting with `text`:
|
||||
/// some string contains it (none for no strings, even when `text` is empty).
|
||||
fn any<'a>(strings: impl IntoIterator<Item = &'a str>, text: &str) -> bool {
|
||||
strings.into_iter().any(|s| s.contains(text))
|
||||
}
|
||||
/// `ItemStack.isSameItemSameComponents`, the count left out, as the
|
||||
/// tabs' `ItemStackLinkedSet` compares: no components and empty ones match.
|
||||
fn same_components(a: &ItemStack, b: &ItemStack) -> bool {
|
||||
let none = crate::item_data::StackData::default();
|
||||
a.item == b.item
|
||||
&& a.damage == b.damage
|
||||
&& a.data().unwrap_or(&none) == b.data().unwrap_or(&none)
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the plain
|
||||
/// tooltip only for a category tab's own grid slots (`CustomCreativeSlot`);
|
||||
/// everywhere else in the screen the tab names (and, on the search tab's
|
||||
/// grid, the tag lines) follow the item's name.
|
||||
pub fn tooltip_lists_tabs(category_tab: bool, grid_slot: bool) -> bool {
|
||||
!(category_tab && grid_slot)
|
||||
}
|
||||
/// String.toLowerCase(Locale.ROOT).
|
||||
fn java_lower(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
@@ -690,6 +760,109 @@ mod tests {
|
||||
(carried, effect)
|
||||
}
|
||||
|
||||
/// Tabs as the export lists them: two block tabs sharing the oak log,
|
||||
/// ingredients with the top-level book only, the search tab with every
|
||||
/// stack (a level 1 book only there) and its tags, the inventory.
|
||||
fn tooltip_tabs() -> Creative {
|
||||
let book = |level: u8| {
|
||||
format!(
|
||||
r#"{{components:{{"minecraft:stored_enchantments":{{"minecraft:sharpness":{level}}}}},count:1,id:"minecraft:enchanted_book"}}"#
|
||||
)
|
||||
};
|
||||
let log = r#"{count:1,id:"minecraft:oak_log"}"#;
|
||||
let dirt = r#"{count:1,id:"minecraft:dirt"}"#;
|
||||
let tab = |id: &str, kind: &str, items: Vec<String>| serde_json::json!({"id": id, "type": kind, "title": id, "icon": log, "items": items});
|
||||
let mut search = tab(
|
||||
"minecraft:search",
|
||||
"SEARCH",
|
||||
vec![log.into(), dirt.into(), book(1), book(5)],
|
||||
);
|
||||
search["lines"] = serde_json::json!([[], [], [], []]);
|
||||
search["tags"] = serde_json::json!([
|
||||
[
|
||||
"minecraft:logs",
|
||||
"minecraft:oak_logs",
|
||||
"minecraft:logs_that_burn"
|
||||
],
|
||||
["minecraft:dirt"],
|
||||
[],
|
||||
[]
|
||||
]);
|
||||
Creative::from_json(&serde_json::json!({"tabs": [
|
||||
tab("minecraft:building_blocks", "CATEGORY", vec![log.into()]),
|
||||
tab("minecraft:natural_blocks", "CATEGORY", vec![log.into(), dirt.into()]),
|
||||
tab("minecraft:hotbar", "HOTBAR", vec![]),
|
||||
search,
|
||||
tab("minecraft:ingredients", "CATEGORY", vec![book(5)]),
|
||||
tab("minecraft:inventory", "INVENTORY", vec![]),
|
||||
]}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_lists_the_tabs_that_contain_the_stack() {
|
||||
let c = tooltip_tabs();
|
||||
let ids = |s: &ItemStack| -> Vec<&str> {
|
||||
c.tabs_containing(s)
|
||||
.into_iter()
|
||||
.map(|i| c.tabs[i].id.as_str())
|
||||
.collect()
|
||||
};
|
||||
// In tab order, the search and inventory tabs left out; the count
|
||||
// doesn't matter.
|
||||
assert_eq!(
|
||||
ids(&stack("oak_log", 37)),
|
||||
["minecraft:building_blocks", "minecraft:natural_blocks"]
|
||||
);
|
||||
assert_eq!(ids(&stack("dirt", 1)), ["minecraft:natural_blocks"]);
|
||||
// A stack no tab holds: other components, or no creative item.
|
||||
let mut damaged = stack("oak_log", 1);
|
||||
damaged.damage = 3;
|
||||
assert!(ids(&damaged).is_empty());
|
||||
assert!(ids(&stack("barrier", 1)).is_empty());
|
||||
// A level 1 book is a search-tab-only stack of the ingredients tab.
|
||||
let books: Vec<&ItemStack> = c
|
||||
.search_items()
|
||||
.iter()
|
||||
.filter(|s| &*s.item == "minecraft:enchanted_book")
|
||||
.collect();
|
||||
assert_eq!(ids(books[0]), ["minecraft:ingredients"]);
|
||||
assert_eq!(ids(books[1]), ["minecraft:ingredients"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_tab_names_skip_only_a_category_tab_grid() {
|
||||
assert!(!tooltip_lists_tabs(true, true));
|
||||
// The hotbar row of a category tab, the search tab's grid and hotbar,
|
||||
// the inventory tab.
|
||||
assert!(tooltip_lists_tabs(true, false));
|
||||
assert!(tooltip_lists_tabs(false, true));
|
||||
assert!(tooltip_lists_tabs(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_search_lists_the_matching_tags_of_the_stack() {
|
||||
let mut c = tooltip_tabs();
|
||||
let log = stack("oak_log", 1);
|
||||
assert!(c.search_tag_lines(&log).is_empty());
|
||||
c.search.set_value("logs");
|
||||
assert!(c.search_tag_lines(&log).is_empty());
|
||||
c.search.set_value("#logs");
|
||||
assert_eq!(
|
||||
c.search_tag_lines(&log),
|
||||
[
|
||||
"#minecraft:logs",
|
||||
"#minecraft:oak_logs",
|
||||
"#minecraft:logs_that_burn"
|
||||
]
|
||||
);
|
||||
c.search.set_value("#oak");
|
||||
assert_eq!(c.search_tag_lines(&log), ["#minecraft:oak_logs"]);
|
||||
c.search.set_value("#mine : burn");
|
||||
assert_eq!(c.search_tag_lines(&log), ["#minecraft:logs_that_burn"]);
|
||||
assert!(c.search_tag_lines(&stack("dirt", 1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
|
||||
let filled = Hover::Display { filled: true };
|
||||
@@ -1006,4 +1179,38 @@ mod tests {
|
||||
c.search.key(KeyCode::Backspace, false, false);
|
||||
assert_eq!(c.search.value, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unselected_tabs_want_the_hand_strictly_inside() {
|
||||
let tab = |top, column| Tab {
|
||||
id: String::new(),
|
||||
title: String::new(),
|
||||
icon: String::new(),
|
||||
items: Vec::new(),
|
||||
top,
|
||||
column,
|
||||
search: false,
|
||||
inventory: false,
|
||||
};
|
||||
// Top row, second column: the button spans x 27..53, y -28..4.
|
||||
let t = tab(true, 1);
|
||||
assert!(Creative::tab_wants_hand(&t, false, (40.0, -10.0)));
|
||||
assert!(!Creative::tab_wants_hand(&t, true, (40.0, -10.0)));
|
||||
// Its edges ask for nothing; the mouse counts in whole pixels.
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (27.0, -10.0)));
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (27.9, -10.0)));
|
||||
assert!(Creative::tab_wants_hand(&t, false, (28.0, -10.0)));
|
||||
assert!(Creative::tab_wants_hand(&t, false, (52.9, -10.0)));
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (53.0, -10.0)));
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (40.0, -28.0)));
|
||||
assert!(Creative::tab_wants_hand(&t, false, (40.0, -27.0)));
|
||||
assert!(Creative::tab_wants_hand(&t, false, (40.0, 3.9)));
|
||||
assert!(!Creative::tab_wants_hand(&t, false, (40.0, 4.0)));
|
||||
// Bottom row, right-aligned inventory column: x 169..195, y 132..164.
|
||||
let b = tab(false, 6);
|
||||
assert!(Creative::tab_wants_hand(&b, false, (180.0, 140.0)));
|
||||
assert!(!Creative::tab_wants_hand(&b, false, (169.0, 140.0)));
|
||||
assert!(Creative::tab_wants_hand(&b, false, (170.0, 140.0)));
|
||||
assert!(!Creative::tab_wants_hand(&b, false, (180.0, 132.0)));
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,14 @@ impl DebugCharts {
|
||||
}
|
||||
self.packet_tick = (self.packet_tick + 1) % 20;
|
||||
}
|
||||
/// Whether `draw` (or the lightmap view) puts something in the bottom
|
||||
/// left and the bottom right corner, for a `remote` server or not.
|
||||
pub fn corners(&self, remote: bool) -> [bool; 2] {
|
||||
[
|
||||
self.fps || (self.network && remote),
|
||||
(self.fps && !self.tick_times.is_empty()) || self.network || self.lightmap,
|
||||
]
|
||||
}
|
||||
/// The charts along the bottom of the screen (`extractRenderState`):
|
||||
/// frame times left and tick times right, or bandwidth left (remote
|
||||
/// servers only) and ping right. `mspt` is the level's milliseconds
|
||||
@@ -525,6 +533,26 @@ mod tests {
|
||||
assert!(overlay && c.fps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_are_where_the_charts_are_drawn() {
|
||||
let mut c = DebugCharts::default();
|
||||
assert_eq!(c.corners(true), [false, false]);
|
||||
// Frame times left; tick times right once the server logged a tick.
|
||||
c.fps = true;
|
||||
assert_eq!(c.corners(false), [true, false]);
|
||||
c.tick_times.log(1);
|
||||
assert_eq!(c.corners(false), [true, true]);
|
||||
// Ping right; bandwidth left on a remote server only.
|
||||
c.fps = false;
|
||||
c.network = true;
|
||||
assert_eq!(c.corners(false), [false, true]);
|
||||
assert_eq!(c.corners(true), [true, true]);
|
||||
// The lightmap view, bottom right.
|
||||
c.network = false;
|
||||
c.lightmap = true;
|
||||
assert_eq!(c.corners(true), [false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lightmap_puts_full_sky_light_at_the_top_left() {
|
||||
let mut pixels = [0u8; 16 * 16 * 4];
|
||||
|
||||
@@ -37,9 +37,11 @@ pub enum Kind {
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs),
|
||||
/// and one button per dialog they resolve to (`set_list`).
|
||||
DialogList {
|
||||
dialogs: Tag,
|
||||
entries: Vec<Button>,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
@@ -325,29 +327,36 @@ impl Control {
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let label = || t.get("label").map(text);
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"text" => Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial: string(t.get("initial")).unwrap_or_default(),
|
||||
max_length: int(t, "max_length", 32),
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
},
|
||||
"text" => {
|
||||
let initial = string(t.get("initial")).unwrap_or_default();
|
||||
let max_length = int(t, "max_length", 32);
|
||||
// TextInput: the default text may not be longer than the length.
|
||||
if initial.encode_utf16().count() > max_length.max(0) as usize {
|
||||
return None;
|
||||
}
|
||||
Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial,
|
||||
max_length,
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
"boolean" => Self::Boolean {
|
||||
label: label()?,
|
||||
initial: boolean(t, "initial", false),
|
||||
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
|
||||
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
|
||||
},
|
||||
"single_option" => Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
"single_option" => {
|
||||
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
|
||||
options: list(t.get("options"))
|
||||
let options: Vec<_> = list(t.get("options"))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
.map(|o| match o {
|
||||
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
|
||||
o => Some((
|
||||
string(o.get("id"))?,
|
||||
@@ -355,10 +364,18 @@ impl Control {
|
||||
boolean(o, "initial", false),
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
},
|
||||
.collect::<Option<_>>()?;
|
||||
// A non-empty list with at most one initial entry.
|
||||
if options.is_empty() || options.iter().filter(|o| o.2).count() > 1 {
|
||||
return None;
|
||||
}
|
||||
Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
options,
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
}
|
||||
}
|
||||
"number_range" => {
|
||||
let float = |key| number(t.get(key)).map(|v| v as f32);
|
||||
Self::NumberRange {
|
||||
@@ -395,6 +412,44 @@ impl Control {
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// The text on a cycle button or slider holding `value`
|
||||
/// (`CycleButton.createFullName`, `NumberRangeInput.computeLabel`).
|
||||
pub fn message(&self, value: &InputValue) -> Option<Text> {
|
||||
match (self, value) {
|
||||
(
|
||||
Self::SingleOption {
|
||||
options,
|
||||
label,
|
||||
label_visible,
|
||||
..
|
||||
},
|
||||
InputValue::Option(i),
|
||||
) => {
|
||||
let (id, display, _) = options.get(*i)?;
|
||||
let shown = display.clone().unwrap_or_else(|| Text::literal(id));
|
||||
Some(if *label_visible {
|
||||
Text::translate("options.generic_value", &[label.clone(), shown])
|
||||
} else {
|
||||
shown
|
||||
})
|
||||
}
|
||||
(
|
||||
Self::NumberRange {
|
||||
label,
|
||||
label_format,
|
||||
..
|
||||
},
|
||||
InputValue::Slider(_),
|
||||
) => {
|
||||
let shown = number_string(self.scaled(value)?);
|
||||
Some(Text::translate(
|
||||
label_format,
|
||||
&[label.clone(), Text::literal(shown)],
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTemplateSubstitution`.
|
||||
pub fn substitution(&self, value: &InputValue) -> String {
|
||||
match (self, value) {
|
||||
@@ -536,6 +591,7 @@ impl Dialog {
|
||||
},
|
||||
"dialog_list" => Kind::DialogList {
|
||||
dialogs: t.get("dialogs")?.clone(),
|
||||
entries: Vec::new(),
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
@@ -543,20 +599,21 @@ impl Dialog {
|
||||
_ => return None,
|
||||
};
|
||||
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
|
||||
// A body or input it cannot read fails the whole dialog.
|
||||
let body = match t.get("body") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
|
||||
Some(one) => Body::parse(one).into_iter().collect(),
|
||||
Some(Tag::List(items)) => items.iter().map(Body::parse).collect::<Option<_>>()?,
|
||||
Some(one) => vec![Body::parse(one)?],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let inputs = list(t.get("inputs"))
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
.map(|i| {
|
||||
Some(Input {
|
||||
key: string(i.get("key"))?,
|
||||
control: Control::parse(i)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Option<_>>()?;
|
||||
Some(Self {
|
||||
kind,
|
||||
title: text(t.get("title")?),
|
||||
@@ -588,7 +645,51 @@ impl Dialog {
|
||||
Kind::Notice(b) => vec![b],
|
||||
Kind::Confirmation { yes, no } => vec![yes, no],
|
||||
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
|
||||
Kind::ServerLinks { exit, .. } => exit.iter().collect(),
|
||||
Kind::DialogList { entries, exit, .. } => entries.iter().chain(exit).collect(),
|
||||
}
|
||||
}
|
||||
/// `DialogListDialogScreen.createListActions`: a button for each of `dialogs`
|
||||
/// (what the list names), labelled by its external title, opening it.
|
||||
pub fn set_list(&mut self, dialogs: &[Tag]) {
|
||||
let Kind::DialogList {
|
||||
entries,
|
||||
button_width,
|
||||
..
|
||||
} = &mut self.kind
|
||||
else {
|
||||
return;
|
||||
};
|
||||
*entries = dialogs
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let sub = Dialog::parse(tag)?;
|
||||
Some(Button {
|
||||
label: sub.external_title.unwrap_or(sub.title),
|
||||
tooltip: None,
|
||||
width: *button_width,
|
||||
action: Some(Action::Static(Click::ShowDialog(tag.clone()))),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
/// The dialogs a `dialog_list`'s `dialogs` names: the entries of a `#tag`
|
||||
/// (by `tag`), registry ids (by `registry`) and dialogs written inline.
|
||||
pub fn list_entries(
|
||||
dialogs: &Tag,
|
||||
tag: &dyn Fn(&str) -> Vec<Tag>,
|
||||
registry: &dyn Fn(&str) -> Option<Tag>,
|
||||
) -> Vec<Tag> {
|
||||
let one = |t: &Tag| match t {
|
||||
Tag::String(id) => registry(&id.to_string_lossy()),
|
||||
inline => Some(inline.clone()),
|
||||
};
|
||||
match dialogs {
|
||||
Tag::String(id) if id.to_string_lossy().starts_with('#') => {
|
||||
tag(&id.to_string_lossy()[1..])
|
||||
}
|
||||
Tag::List(items) => items.iter().filter_map(one).collect(),
|
||||
single => one(single).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
/// What `status` reports of it.
|
||||
|
||||
@@ -31,6 +31,44 @@ pub enum Part {
|
||||
Check { input: usize, rect: [f32; 4] },
|
||||
/// A button of `Dialog::buttons`.
|
||||
Button { index: usize, rect: [f32; 4] },
|
||||
/// A single option input's cycle button.
|
||||
Cycle { input: usize, rect: [f32; 4] },
|
||||
/// A number range input's slider.
|
||||
Slider { input: usize, rect: [f32; 4] },
|
||||
/// An item body's widget; `body` indexes `Dialog::body`.
|
||||
Item { body: usize, rect: [f32; 4] },
|
||||
}
|
||||
impl Part {
|
||||
/// The part moved by `(dx, dy)`.
|
||||
fn moved(self, dx: f32, dy: f32) -> Self {
|
||||
match self {
|
||||
Self::Line(t, x, y) => Self::Line(t, x + dx, y + dy),
|
||||
Self::Field { input, rect } => Self::Field {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Check { input, rect } => Self::Check {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Button { index, rect } => Self::Button {
|
||||
index,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Cycle { input, rect } => Self::Cycle {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Slider { input, rect } => Self::Slider {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Item { body, rect } => Self::Item {
|
||||
body,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -55,21 +93,7 @@ struct Element {
|
||||
impl Element {
|
||||
/// Its parts moved to `(x, y)`.
|
||||
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
|
||||
self.parts.into_iter().map(move |p| match p {
|
||||
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
})
|
||||
self.parts.into_iter().map(move |p| p.moved(x, y))
|
||||
}
|
||||
}
|
||||
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
|
||||
@@ -100,9 +124,43 @@ fn message(font: &Font, contents: &Text, width: f32) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
Some(match control {
|
||||
/// `DialogBodyHandlers.ItemHandler`: the item widget, with its description 2
|
||||
/// to the right of it, both centred on each other vertically.
|
||||
fn item(
|
||||
font: &Font,
|
||||
body: usize,
|
||||
description: Option<&(Text, i32)>,
|
||||
(w, h): (f32, f32),
|
||||
) -> Element {
|
||||
let widget = |rect_h: f32, y: f32| Part::Item {
|
||||
body,
|
||||
rect: [0.0, y, w, rect_h],
|
||||
};
|
||||
let Some((contents, width)) = description else {
|
||||
return Element {
|
||||
w,
|
||||
h,
|
||||
parts: vec![widget(h, 0.0)],
|
||||
};
|
||||
};
|
||||
let text = message(font, contents, *width as f32);
|
||||
let height = h.max(text.h);
|
||||
let mut parts = vec![widget(h, centre(height, h))];
|
||||
parts.extend(
|
||||
text.parts
|
||||
.into_iter()
|
||||
.map(|p| p.moved(w + 2.0, centre(height, text.h))),
|
||||
);
|
||||
Element {
|
||||
w: w + 2.0 + text.w,
|
||||
h: height,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
match control {
|
||||
Control::Text {
|
||||
width,
|
||||
label,
|
||||
@@ -111,14 +169,14 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
if !label_visible {
|
||||
return Some(Element {
|
||||
return Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
}],
|
||||
});
|
||||
};
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
@@ -145,8 +203,25 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
}],
|
||||
}
|
||||
}
|
||||
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
|
||||
})
|
||||
// CycleButton and AbstractSliderButton: one 20 high button whose
|
||||
// message holds the label.
|
||||
Control::SingleOption { width, .. } => Element {
|
||||
w: *width as f32,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Cycle {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, *width as f32, 20.0],
|
||||
}],
|
||||
},
|
||||
Control::NumberRange { width, .. } => Element {
|
||||
w: *width as f32,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Slider {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, *width as f32, 20.0],
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
|
||||
@@ -210,9 +285,20 @@ impl Layout {
|
||||
let mut elements: Vec<Element> = dialog
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
|
||||
Body::Item { .. } => None,
|
||||
.enumerate()
|
||||
.map(|(i, b)| match b {
|
||||
Body::Message { contents, width } => message(font, contents, *width as f32),
|
||||
Body::Item {
|
||||
description,
|
||||
width,
|
||||
height,
|
||||
..
|
||||
} => item(
|
||||
font,
|
||||
i,
|
||||
description.as_ref(),
|
||||
(*width as f32, *height as f32),
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
elements.extend(
|
||||
@@ -220,26 +306,24 @@ impl Layout {
|
||||
.inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, input_)| input(font, i, &input_.control)),
|
||||
.map(|(i, input_)| input(font, i, &input_.control)),
|
||||
);
|
||||
let buttons = dialog.buttons();
|
||||
let (listed, footer_buttons, footer_h) = match &dialog.kind {
|
||||
Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns: n,
|
||||
exit, columns: n, ..
|
||||
}
|
||||
| Kind::DialogList {
|
||||
exit, columns: n, ..
|
||||
} => {
|
||||
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
|
||||
let listed = buttons.len() - usize::from(exit.is_some());
|
||||
let widths: Vec<f32> = buttons[..listed].iter().map(|b| b.width as f32).collect();
|
||||
elements.push(columns(&widths, *n as usize, 0));
|
||||
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
|
||||
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
|
||||
(
|
||||
actions.len(),
|
||||
footer,
|
||||
if exit.is_some() { BAR } else { 5.0 },
|
||||
)
|
||||
let footer = exit.as_ref().map(|_| listed).into_iter().collect();
|
||||
(listed, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
|
||||
Kind::ServerLinks { exit, .. } => {
|
||||
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
|
||||
(0, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
@@ -303,24 +387,10 @@ impl Layout {
|
||||
/// whether it is in the body (clipped to its area).
|
||||
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
|
||||
let dy = self.area[1] - scroll;
|
||||
let body = self.body.iter().map(move |p| {
|
||||
let p = match p.clone() {
|
||||
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
};
|
||||
(p, true)
|
||||
});
|
||||
let body = self
|
||||
.body
|
||||
.iter()
|
||||
.map(move |p| (p.clone().moved(0.0, dy), true));
|
||||
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,7 +953,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
// A mount's rolled health and speed stay attribute bases, which
|
||||
// the server syncs (its hearts over the rider's hotbar).
|
||||
let equine = crate::mobs::equine::is_equine(m.species());
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" if equine => crate::mobs::finalize::set_base(
|
||||
&mut m,
|
||||
crate::mobs::finalize::Attr::MaxHealth,
|
||||
v,
|
||||
),
|
||||
"minecraft:movement_speed" if equine => crate::mobs::finalize::set_base(
|
||||
&mut m,
|
||||
crate::mobs::finalize::Attr::MovementSpeed,
|
||||
v,
|
||||
),
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
|
||||
@@ -1692,6 +1705,33 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `FireworkRocketEntity.readAdditionalSaveData`: Life, LifeTime (0 when
|
||||
/// absent: a bare `/summon` explodes at once), FireworksItem and
|
||||
/// ShotAtAngle, with `Projectile`'s Owner and LeftOwner.
|
||||
pub struct RocketNbt {
|
||||
pub position: Vec3,
|
||||
pub motion: Vec3,
|
||||
pub life: i32,
|
||||
pub lifetime: i32,
|
||||
pub item: Option<crate::items::ItemStack>,
|
||||
pub angled: bool,
|
||||
pub owner: Option<Who>,
|
||||
pub left_owner: bool,
|
||||
}
|
||||
pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<RocketNbt> {
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
Some(RocketNbt {
|
||||
position: position(tag)?,
|
||||
motion: motion(tag),
|
||||
life: number(tag.get("Life")).unwrap_or(0) as i32,
|
||||
lifetime: number(tag.get("LifeTime")).unwrap_or(0) as i32,
|
||||
item: tag.get("FireworksItem").and_then(item_from_nbt),
|
||||
angled: flag(tag, "ShotAtAngle"),
|
||||
owner: (player.is_some() && owner == player).then_some(Who::Player),
|
||||
left_owner: flag(tag, "LeftOwner"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn falling_block_to_nbt(
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ItemPose {
|
||||
Swing,
|
||||
/// A STAB swing (`SwingAnimationType` 2): a spear's jab.
|
||||
Stab,
|
||||
Using,
|
||||
Eat {
|
||||
remaining: f32,
|
||||
@@ -86,6 +88,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
|
||||
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
|
||||
}
|
||||
if matches!(pose, ItemPose::Stab) {
|
||||
return m * crate::spear::first_person_attack(attack, !left);
|
||||
}
|
||||
if matches!(pose, ItemPose::SpinAttack) {
|
||||
return m
|
||||
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
|
||||
@@ -172,8 +177,94 @@ impl MapHold {
|
||||
}
|
||||
}
|
||||
|
||||
/// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left`
|
||||
/// or right arm.
|
||||
pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
let f = if left { -1.0 } else { 1.0 };
|
||||
let pi = std::f32::consts::PI;
|
||||
let root = attack.sqrt();
|
||||
let x = -0.3 * (root * pi).msin();
|
||||
let y = 0.4 * (root * std::f32::consts::TAU).msin();
|
||||
let z = -0.4 * (attack * pi).msin();
|
||||
let z_rot = (attack * attack * pi).msin();
|
||||
let y_rot = (root * pi).msin();
|
||||
let deg = |d: f32| d.to_radians();
|
||||
Mat4::from_translation(Vec3::new(
|
||||
f * (x + 0.640_000_05),
|
||||
y - 0.6 + inverse_height * -0.6,
|
||||
z + -0.719_999_97,
|
||||
)) * Mat4::from_rotation_y(deg(f * 45.0))
|
||||
* Mat4::from_rotation_y(deg(f * y_rot * 70.0))
|
||||
* Mat4::from_rotation_z(deg(f * z_rot * -20.0))
|
||||
* Mat4::from_translation(Vec3::new(-f, 3.6, 3.5))
|
||||
* Mat4::from_rotation_z(deg(f * 120.0))
|
||||
* Mat4::from_rotation_x(deg(200.0))
|
||||
* Mat4::from_rotation_y(deg(f * -135.0))
|
||||
* Mat4::from_translation(Vec3::new(f * 5.6, 0.0, 0.0))
|
||||
}
|
||||
|
||||
/// `renderTwoHandedMap` up to its arms: the swing, the tilt from the view
|
||||
/// pitch (`calculateMapTilt`) and the turn towards the player.
|
||||
pub fn two_handed_map_base(pitch: f32, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
let pi = std::f32::consts::PI;
|
||||
let y_swing = -0.2 * (attack * pi).msin();
|
||||
let z_swing = -0.4 * (attack.sqrt() * pi).msin();
|
||||
let tilt = (1.0 - pitch / 45.0 + 0.1).clamp(0.0, 1.0);
|
||||
let tilt = -(tilt * pi).mcos() * 0.5 + 0.5;
|
||||
Mat4::from_translation(Vec3::new(0.0, -y_swing / 2.0, z_swing))
|
||||
* Mat4::from_translation(Vec3::new(
|
||||
0.0,
|
||||
0.04 + inverse_height * -1.2 + tilt * -0.5,
|
||||
-0.72,
|
||||
))
|
||||
* Mat4::from_rotation_x((tilt * -85.0).to_radians())
|
||||
}
|
||||
|
||||
/// The arms under a held map, as (left arm, pose before `renderPlayerHand`):
|
||||
/// `renderOneHandedMap`'s arm turned 10 degrees outwards, or
|
||||
/// `renderTwoHandedMap`'s two `renderMapHand` arms under the map's
|
||||
/// `two_handed_map_base`. Vanilla skips them for an invisible player.
|
||||
pub fn map_arm_poses(
|
||||
hold: MapHold,
|
||||
left: bool,
|
||||
pitch: f32,
|
||||
inverse_height: f32,
|
||||
attack: f32,
|
||||
) -> Vec<(bool, glam::Mat4)> {
|
||||
use glam::{Mat4, Vec3};
|
||||
let deg = |d: f32| d.to_radians();
|
||||
match hold {
|
||||
MapHold::OneHanded => {
|
||||
let f = if left { -1.0 } else { 1.0 };
|
||||
let m = Mat4::from_translation(Vec3::new(f * 0.125, -0.125, 0.0))
|
||||
* Mat4::from_rotation_z(deg(f * 10.0))
|
||||
* player_arm_pose(left, inverse_height, attack);
|
||||
vec![(left, m)]
|
||||
}
|
||||
MapHold::TwoHanded => {
|
||||
let base = two_handed_map_base(pitch, inverse_height, attack)
|
||||
* Mat4::from_rotation_y(deg(90.0));
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.map(|left| {
|
||||
let f = if left { -1.0 } else { 1.0 };
|
||||
let hand = Mat4::from_rotation_y(deg(92.0))
|
||||
* Mat4::from_rotation_x(deg(45.0))
|
||||
* Mat4::from_rotation_z(deg(f * -41.0))
|
||||
* Mat4::from_translation(Vec3::new(f * 0.3, -1.1, 0.45));
|
||||
(left, base * hand)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `renderOneHandedMap` up to `renderMap`: the pose of a map in the hand on
|
||||
/// the `left` or right arm (the arm under it is not drawn).
|
||||
/// the `left` or right arm (its arm: [`map_arm_poses`]).
|
||||
pub fn one_handed_map_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
@@ -945,6 +1036,83 @@ mod item_pose_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn maps_draw_the_arms_under_them_as_vanilla() {
|
||||
// PoseStack.last().pose() (column-major) where renderOneHandedMap's
|
||||
// renderPlayerArm and renderTwoHandedMap's renderMapHand call
|
||||
// renderPlayerHand, replayed with vanilla's PoseStack, Axis and Mth.
|
||||
let near = |got: glam::Mat4, want: [f32; 16], what: &str| {
|
||||
let got = got.to_cols_array();
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert!((g - w).abs() < 1e-5, "{what}: {got:?}");
|
||||
}
|
||||
};
|
||||
// (left, inverse height, attack, arm)
|
||||
#[rustfmt::skip]
|
||||
let one: [(bool, f32, f32, [f32; 16]); 3] = [
|
||||
(false, 0.0, 0.0, [
|
||||
-0.2350201, -0.7860474, -0.5717472, 0.0,
|
||||
0.24694103, 0.52063686, -0.8172866, 0.0,
|
||||
0.9400987, -0.33326668, 0.07174723, 0.0,
|
||||
0.65913033, -1.1543381, -0.73980355, 1.0,
|
||||
]),
|
||||
(true, 0.25, 0.3, [
|
||||
-0.4967606, 0.84289914, -0.20676011, 0.0,
|
||||
0.7202682, 0.2674863, -0.6400506, 0.0,
|
||||
-0.4841926, -0.46687466, -0.7399901, 0.0,
|
||||
-0.59715486, -1.4160433, -0.7328731, 1.0,
|
||||
]),
|
||||
(false, 0.6, 0.85, [
|
||||
-0.47358897, -0.8403256, -0.2637542, 0.0,
|
||||
0.081554025, 0.25634, -0.96313995, 0.0,
|
||||
0.87696207, -0.47764266, -0.052867785, 0.0,
|
||||
0.61887383, -1.6482887, -0.7229744, 1.0,
|
||||
]),
|
||||
];
|
||||
for (left, inverse_height, attack, want) in one {
|
||||
let arms = map_arm_poses(MapHold::OneHanded, left, 0.0, inverse_height, attack);
|
||||
assert_eq!(arms.len(), 1, "one arm under a one-handed map");
|
||||
assert_eq!(arms[0].0, left, "the arm of the hand holding it");
|
||||
near(
|
||||
arms[0].1,
|
||||
want,
|
||||
&format!("one {left} {inverse_height} {attack}"),
|
||||
);
|
||||
}
|
||||
// (pitch, inverse height, attack, right arm, left arm)
|
||||
#[rustfmt::skip]
|
||||
let two: [(f32, f32, f32, [f32; 16], [f32; 16]); 2] = [
|
||||
(0.0, 0.0, 0.0, [
|
||||
-0.7380599, 0.44766384, 0.50484115, 0.0,
|
||||
-0.67428374, -0.46198538, -0.57611704, 0.0,
|
||||
-0.024677532, -0.76561517, 0.64282537, 0.0,
|
||||
0.5091893, -0.16204369, 0.3544525, 1.0,
|
||||
], [
|
||||
-0.77043974, -0.3951866, -0.50025004, 0.0,
|
||||
0.637035, -0.5076031, -0.58010805, 0.0,
|
||||
-0.024677532, -0.76561517, 0.64282537, 0.0,
|
||||
-0.48071152, -0.12760738, 0.35746527, 1.0,
|
||||
]),
|
||||
(30.0, 0.2, 0.5, [
|
||||
-0.7380599, -0.11453738, 0.6649426, 0.0,
|
||||
-0.67428374, 0.16124588, -0.72065324, 0.0,
|
||||
-0.024677532, -0.98024535, -0.1962398, 0.0,
|
||||
0.5091893, -0.9508533, -0.13439482, 1.0,
|
||||
], [
|
||||
-0.77043974, 0.14373614, -0.6210978, 0.0,
|
||||
0.637035, 0.13586378, -0.75876695, 0.0,
|
||||
-0.024677532, -0.98024535, -0.1962398, 0.0,
|
||||
-0.48071152, -0.9316926, -0.1056232, 1.0,
|
||||
]),
|
||||
];
|
||||
for (pitch, inverse_height, attack, right, left) in two {
|
||||
let arms = map_arm_poses(MapHold::TwoHanded, false, pitch, inverse_height, attack);
|
||||
assert_eq!(arms.len(), 2, "both arms under a two-handed map");
|
||||
assert_eq!((arms[0].0, arms[1].0), (false, true), "right, then left");
|
||||
near(arms[0].1, right, &format!("two right {pitch}"));
|
||||
near(arms[1].1, left, &format!("two left {pitch}"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ranged_draw_uses_native_charge_curves_and_caps() {
|
||||
for (ticks, stretch) in [
|
||||
(0.0, 1.0),
|
||||
|
||||
+739
-293
File diff suppressed because it is too large.
Load diff
@@ -588,10 +588,17 @@ impl Game<'_> {
|
||||
.announced
|
||||
.push(Text::translate(key, &[player, name]));
|
||||
}
|
||||
None => self.system_message(Text::translate(
|
||||
key,
|
||||
&[Text::literal(super::chat::PLAYER_NAME), name],
|
||||
)),
|
||||
None => {
|
||||
let text = Text::translate(key, &[self.player_display_text(), name]);
|
||||
// The guests hear of the host's advancement too.
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub
|
||||
.system_message(horizon_client::lan::data::component_nbt(
|
||||
&text.component(),
|
||||
));
|
||||
}
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1366,6 +1373,55 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// `RecipeCraftedTrigger`: the recipe and the stacks that went into it
|
||||
/// (`ServerPlayer.triggerRecipeCrafted`); an empty slot is air.
|
||||
pub(super) fn trigger_recipe_crafted(
|
||||
&mut self,
|
||||
recipe: &str,
|
||||
ingredients: &[Option<ItemStack>],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let used: Vec<ItemView> = ingredients
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.as_ref().map_or_else(
|
||||
|| ItemView {
|
||||
id: "minecraft:air".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
item_view,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let recipe = recipe.to_owned();
|
||||
self.trigger("minecraft:recipe_crafted", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::id_matches(&c["recipes"], "recipe", &recipe, w)
|
||||
&& adv::recipe_ingredients(&c["ingredients"], &used, w)
|
||||
});
|
||||
}
|
||||
/// `LootTableTrigger`: a container's loot table unpacked for this player
|
||||
/// (`RandomizableContainer.unpackLootTable`).
|
||||
pub(super) fn trigger_loot_table(&mut self, table: &str) {
|
||||
let me = self.player_entity();
|
||||
let table = table.to_owned();
|
||||
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::id_matches(&c["loot_tables"], "loot_table", &table, w)
|
||||
});
|
||||
}
|
||||
/// `BredAnimalsTrigger`.
|
||||
pub(super) fn trigger_bred(
|
||||
&mut self,
|
||||
@@ -1493,7 +1549,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter_map(|t| t.slot)
|
||||
.chain(packs)
|
||||
.fold(0, |used, slot| used | 1 << slot)
|
||||
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
|
||||
}
|
||||
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
|
||||
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
|
||||
|
||||
@@ -424,7 +424,6 @@ impl Game<'_> {
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -536,7 +535,9 @@ impl Game<'_> {
|
||||
title
|
||||
.recent_worlds()
|
||||
.iter()
|
||||
.map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.creative }))
|
||||
.map(
|
||||
|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 }),
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
match &title.mode {
|
||||
@@ -546,7 +547,7 @@ impl Game<'_> {
|
||||
selected,
|
||||
} => json!({
|
||||
"mode": "select-world",
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.creative })).collect::<Vec<_>>(),
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
|
||||
"selected": selected,
|
||||
}),
|
||||
Mode::Create {
|
||||
@@ -609,6 +610,9 @@ impl Game<'_> {
|
||||
json!({ "mode": "multiplayer", "disconnected": reason })
|
||||
}
|
||||
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
|
||||
Mode::MultiplayerWarning { check } => {
|
||||
json!({ "mode": "multiplayer-warning", "check": check })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,7 +1456,15 @@ impl Game<'_> {
|
||||
self.playing()?;
|
||||
let all = a.pos.first() == Some(&"all");
|
||||
let before = self.held().map(|s| self.stack_value(s));
|
||||
self.drop_held(all);
|
||||
if self.remote.is_some() {
|
||||
// As Q does on a server (and on the single-player server
|
||||
// thread): the server throws the stack, the client predicts it.
|
||||
if !self.spectator() {
|
||||
self.remote_drop(all);
|
||||
}
|
||||
} else {
|
||||
self.drop_held(all);
|
||||
}
|
||||
ok(
|
||||
json!({ "ok": true, "dropped_from": before, "held": self.held().map(|s| self.stack_value(s)) }),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! falling, `interactAt` equipment swaps by clicked height, the two-hit break
|
||||
//! of `hurtServer` (creative breaks at once), and `ArmorStandRenderer` with
|
||||
//! the default pose, the hit wobble and humanoid armor layers.
|
||||
use super::{Game, view_vector};
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
entities::ArmorStand,
|
||||
items::ItemStack,
|
||||
@@ -374,8 +374,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The armor stand under the crosshair, its distance and the hit point.
|
||||
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, a) in self.armor_stands.iter().enumerate() {
|
||||
@@ -617,9 +616,6 @@ impl Game<'_> {
|
||||
};
|
||||
for a in &self.armor_stands {
|
||||
let entity_start = out.len();
|
||||
if a.invisible {
|
||||
continue;
|
||||
}
|
||||
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
|
||||
// edge (4 for a marker's empty box), whatever the entity distance.
|
||||
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
|
||||
@@ -691,16 +687,20 @@ impl Game<'_> {
|
||||
a.position.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
wood,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
|
||||
// layers (held items, custom head, armour) are submitted all the same.
|
||||
if !a.invisible {
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
wood,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet.
|
||||
for (slot, layer) in [
|
||||
|
||||
@@ -105,10 +105,28 @@ impl Game<'_> {
|
||||
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
|
||||
return;
|
||||
};
|
||||
self.tick_beacon(key, &mut b);
|
||||
self.tick_beacon(key, &mut b, false);
|
||||
self.world.block_entities.put(key, BlockEntity::Beacon(b));
|
||||
}
|
||||
fn tick_beacon(&mut self, (x, y, z): (i32, i32, i32), b: &mut Beacon) {
|
||||
/// `ClientLevel.tickBlockEntities` for beacons: a server's chunk data
|
||||
/// carries no beam sections, so the client builds them too; effects,
|
||||
/// sounds and the advancement stay with the server.
|
||||
pub(super) fn client_tick_beacons(&mut self) {
|
||||
let keys: Vec<_> = self
|
||||
.world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter_map(|(k, e)| matches!(e, BlockEntity::Beacon(_)).then_some(*k))
|
||||
.collect();
|
||||
for key in keys {
|
||||
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
|
||||
continue;
|
||||
};
|
||||
self.tick_beacon(key, &mut b, true);
|
||||
self.world.block_entities.put(key, BlockEntity::Beacon(b));
|
||||
}
|
||||
}
|
||||
fn tick_beacon(&mut self, (x, y, z): (i32, i32, i32), b: &mut Beacon, client: bool) {
|
||||
let mut check = if b.last_check_y < y {
|
||||
b.checking.clear();
|
||||
b.last_check_y = y - 1;
|
||||
@@ -153,7 +171,7 @@ impl Game<'_> {
|
||||
if !b.sections.is_empty() {
|
||||
b.levels = self.beacon_base(x, y, z);
|
||||
}
|
||||
if b.levels > 0 && !b.sections.is_empty() {
|
||||
if !client && b.levels > 0 && !b.sections.is_empty() {
|
||||
self.beacon_effects([x, y, z], b);
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.ambient");
|
||||
}
|
||||
@@ -163,10 +181,12 @@ impl Game<'_> {
|
||||
b.sections = std::mem::take(&mut b.checking);
|
||||
let (was, is) = (previous > 0, b.levels > 0);
|
||||
if !was && is {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
|
||||
let levels = b.levels;
|
||||
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
|
||||
} else if was && !is {
|
||||
if !client {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
|
||||
let levels = b.levels;
|
||||
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
|
||||
}
|
||||
} else if was && !is && !client {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
|
||||
}
|
||||
}
|
||||
@@ -551,7 +571,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
|
||||
pub(super) fn beacon_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
pub(super) fn beacon_geometry(
|
||||
&mut self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(s) = self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -563,15 +588,28 @@ impl Game<'_> {
|
||||
};
|
||||
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
|
||||
let scoping = self.scoping();
|
||||
let view = f64::from(self.settings.render_distance * 16);
|
||||
for (k, e) in &self.world.block_entities {
|
||||
let BlockEntity::Beacon(b) = e else { continue };
|
||||
// `getBeamSections` is empty while Levels is 0.
|
||||
if b.levels == 0 || b.sections.is_empty() {
|
||||
continue;
|
||||
// `BeaconRenderer.shouldRender` culls at the render distance; far
|
||||
// terrain extends that to its own distance.
|
||||
let view = f64::from(self.terrain_distance() * 16);
|
||||
let world = &self.world;
|
||||
if self.lod.is_some() {
|
||||
// The beam stays after its chunk unloads; once the chunk is back,
|
||||
// its block entity decides whether the beam is still there.
|
||||
self.far_beacons.retain(|&pos, _| {
|
||||
let (x, _, z) = pos;
|
||||
!world.chunks.contains_key(&(x >> 4, z >> 4))
|
||||
|| world.block_entities.get(&pos).and_then(beam).is_some()
|
||||
});
|
||||
} else {
|
||||
self.far_beacons.clear();
|
||||
}
|
||||
for (k, e) in &world.block_entities {
|
||||
if let Some(sections) = beam(e) {
|
||||
self.far_beacons.insert(*k, sections.to_vec());
|
||||
}
|
||||
}
|
||||
for (k, sections) in &self.far_beacons {
|
||||
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
|
||||
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
|
||||
let offset = base - camera;
|
||||
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
|
||||
if horizontal >= view {
|
||||
@@ -586,14 +624,30 @@ impl Game<'_> {
|
||||
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
|
||||
- camera)
|
||||
.as_vec3();
|
||||
horizon_client::block_entity_render::beacon_beam(
|
||||
origin,
|
||||
&b.sections,
|
||||
time,
|
||||
scale,
|
||||
s,
|
||||
out,
|
||||
);
|
||||
horizon_client::block_entity_render::beacon_beam(origin, sections, time, scale, s, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The beam sections of an active beacon (`BeaconRenderer` draws no beam for
|
||||
/// a beacon without a pyramid or with all sections cleared).
|
||||
fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
|
||||
match e {
|
||||
BlockEntity::Beacon(b) if b.levels > 0 && !b.sections.is_empty() => Some(&b.sections),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_active_beacons_have_beams() {
|
||||
let mut b = Beacon::default();
|
||||
assert!(beam(&BlockEntity::Beacon(Box::new(b.clone()))).is_none());
|
||||
b.levels = 1;
|
||||
b.sections.push((0xFFFFFF, 10));
|
||||
assert_eq!(
|
||||
beam(&BlockEntity::Beacon(Box::new(b))),
|
||||
Some(&[(0xFFFFFF, 10)][..])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -153,5 +153,6 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.update_spawner_display(key);
|
||||
}
|
||||
}
|
||||
@@ -658,16 +658,30 @@ impl Game<'_> {
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
|
||||
let Some(boat) = self.boat_to_place(stack) else {
|
||||
return false;
|
||||
};
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return false;
|
||||
self.next_entity += 1;
|
||||
let key = (
|
||||
boat.position.x.floor() as i32 >> 4,
|
||||
boat.position.z.floor() as i32 >> 4,
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return false;
|
||||
};
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The boat `BoatItem.use` would place for `stack` (its SUCCESS), or
|
||||
/// None when it aims at nothing (PASS) or the spot is taken (FAIL).
|
||||
pub(super) fn boat_to_place(&self, stack: &ItemStack) -> Option<Boat> {
|
||||
let (wood, chest) = any_boat_wood(&stack.item)?;
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return None;
|
||||
}
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
@@ -707,7 +721,7 @@ impl Game<'_> {
|
||||
}
|
||||
(Some(b), None) => b.0,
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return false,
|
||||
(None, None) => return None,
|
||||
};
|
||||
let mut boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
@@ -731,21 +745,12 @@ impl Game<'_> {
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
let key = (hit[0].floor() as i32 >> 4, hit[2].floor() as i32 >> 4);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
Some(boat)
|
||||
}
|
||||
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, b) in self.boats.iter().enumerate() {
|
||||
|
||||
@@ -95,6 +95,11 @@ impl Game<'_> {
|
||||
/// `BookViewScreen.mouseClicked`: the click event of the text under the
|
||||
/// cursor on the page.
|
||||
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
|
||||
self.book_style_at(page, left)?.click
|
||||
}
|
||||
/// `ActiveTextCollector` over the page: the style of the character
|
||||
/// under the cursor, for its click and hover events.
|
||||
fn book_style_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::Style> {
|
||||
let (mx, my) = self.cursor;
|
||||
let row = ((my - 32.0) / 9.0).floor();
|
||||
if row < 0.0 || row >= LINES as f32 {
|
||||
@@ -109,7 +114,7 @@ impl Game<'_> {
|
||||
for (c, style) in line.chars() {
|
||||
x -= self.gui.atlas.font.advance(c, style);
|
||||
if x < 0.0 {
|
||||
return style.click.clone();
|
||||
return Some(style.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -139,22 +144,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
ClickEvent::RunCommand(command) => self.click_command(&command),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
// defaultHandleClickEvent: the chat screen takes it over.
|
||||
if self.player.is_some() {
|
||||
self.close_book();
|
||||
self.open_chat(&command);
|
||||
}
|
||||
// closeContainerOnServer, then clickCommandAction with no screen
|
||||
// after the command: the book closes and the command runs.
|
||||
ClickEvent::RunCommand(command) => {
|
||||
self.close_book();
|
||||
self.click_command(&command);
|
||||
}
|
||||
// Screen.defaultHandleClickEvent: copied, the book stays open.
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
// BookViewScreen.handleClickEvent: the rest is
|
||||
// Screen.defaultHandleGameClickEvent's.
|
||||
// defaultHandleGameClickEvent: the dialog opens over the book.
|
||||
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
|
||||
_ => {}
|
||||
// SuggestCommand's insertText does nothing on a book; a server's
|
||||
// text never opens a file.
|
||||
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) => {}
|
||||
}
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
@@ -1314,6 +1319,16 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
|
||||
// show_text hover event, as the chat shows it.
|
||||
if let Some(hover) = pages
|
||||
.get(*page)
|
||||
.and_then(|t| self.book_style_at(t, left))
|
||||
.and_then(|s| s.hover)
|
||||
{
|
||||
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
|
||||
self.draw_tooltip_lines(batch, &lines, mx, my);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ impl Game<'_> {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
|
||||
return;
|
||||
};
|
||||
if tag.get("LootTable").is_none() {
|
||||
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let table = table.to_string_lossy();
|
||||
self.trigger_loot_table(&table);
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
decorated_pot::unpack_loot(
|
||||
&mut tag,
|
||||
|
||||
Loaded 100 of 430 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user