Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 42ee9ad644 CI: run verify in the merge queue (merge_group)
The council decided to enable GitHub's merge queue on main (poll P43, decision D17). Main requires
`verify`, and no workflow listened to merge_group, so every queued pull request would have waited forever.

- ci.yml triggers on merge_group (checks_requested). verify stays unconditional and scopes the queue's
  commit against merge_group.base_sha/head_sha.
- Queue runs always use GitHub's runners: the commit can hold a fork's code, which job-started.sh
  refuses on ours, so SELF_HOSTED_CI=all must not send them there.
- Each entry gets its own concurrency group (merge_group.head_ref), never cancelled by the workflow.
- launcher and parity are skipped in the queue; main's push and parity-main still run them.
- docs/release-flow.md: what runs in the queue and the owner's steps to turn it on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 10:43:04 +01:00
1048 changed files with 11547 additions and 78387 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
# `cargo xtask …` runs the repository's task crate, so the commands in docs/perf-platform.md and
# web/perf/README.md work as written. `cargo run -p xtask -- …` stays equivalent.
[alias]
xtask = "run --package xtask --"
+2 -3
View File
@@ -2,9 +2,8 @@
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). Over about 500 lines of code
(docs and test files don't count): link the issue with its approved spec too ("Spec: #123"), CONTRIBUTING.md
"Large pull requests". -->
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). Over about 500 lines: link the issue with its
approved spec too ("Spec: #123"), CONTRIBUTING.md "Large pull requests". -->
## Kind
+32 -18
View File
@@ -1,5 +1,5 @@
name: checks
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
# verify: every pull request, every merge queue entry and every push to main (runners: see the job). It is the
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
@@ -7,11 +7,15 @@ name: checks
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
# never start this workflow. See docs/release-flow.md.
# merge_group: the merge queue's commit (main's tip, the pull requests ahead in the queue and this one) runs verify
# before it lands, on GitHub's runners; parity and launcher wait for main's push (docs/release-flow.md#merge-queue).
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
merge_group:
types: [checks_requested]
workflow_dispatch:
permissions:
contents: read
@@ -19,7 +23,8 @@ concurrency:
# A new push to a pull request replaces its running checks. On main, the running check finishes and only the
# newest push waits behind it: GitHub cancels a waiting run when a newer one joins the group. A burst of merges
# queued ~100 main runs on 2026-11-09; the release follows the newest finished main run (docs/release-flow.md).
group: checks-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.sha }}
# Each merge queue entry has a group of its own (its temporary branch), and nothing cancels it: the queue does.
group: checks-${{ github.event.pull_request.number || github.event.merge_group.head_ref || (github.event_name == 'push' && 'main') || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
@@ -34,11 +39,14 @@ jobs:
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
# could move it off a machine that is off: it always runs on GitHub's. So does a merge queue's run, whatever
# SELF_HOSTED_CI says: its commit can hold a fork's code, which job-started.sh refuses on ours. On ours the build
# folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are
# GitHub's only.
verify:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
(vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
@@ -52,22 +60,24 @@ jobs:
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
# A merge queue's run reads its commit against main's tip (merge_group's base and head).
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
# A pull request (or a merge queue entry) adds only its own commits to main, so it reads those; main's pushes
# read every ref's history.
- name: Keep game artifacts out of Git
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
run: |
python tools/check_public_tree.py
if [ "$EVENT" = pull_request ]; then
if [ "$EVENT" = pull_request ] || [ "$EVENT" = merge_group ]; then
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
else
python tools/check_public_tree.py --history
@@ -84,21 +94,22 @@ jobs:
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
# changes since then would count as the pull request's (#13874).
# changes since then would count as the pull request's (#13874). A merge queue's entry compares with main's tip
# (merge_group.base_sha), whatever the queue's merge method makes of HEAD^1.
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
@@ -139,10 +150,13 @@ jobs:
cargo +stable test --locked --workspace --doc
# 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).
# alone and break it together (#2059 and the registry ratings did, 2026-10-26). Not in the merge queue: no check of
# its is required, so the queue wouldn't wait for it, and main's push runs it anyway.
launcher:
if: github.event_name != 'merge_group'
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
(vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
-56
View File
@@ -1,56 +0,0 @@
name: perf-wasm
# The site consumes this reader bundle from horizon-rust/horizon-site.
on:
push:
branches: [main]
paths:
- 'crates/horizon-perf/**'
- 'crates/horizon-perf-format/**'
- 'crates/horizon-bench/**'
- 'xtask/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.github/workflows/perf-wasm.yml'
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'crates/horizon-perf/**'
- 'crates/horizon-perf-format/**'
- 'crates/horizon-bench/**'
- 'xtask/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.github/workflows/perf-wasm.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: perf-wasm-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
HORIZON_TELEMETRY_URL: "off"
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: perf-wasm
# The agreement test: the C ABI, called natively, must print the bytes the CLI prints.
- name: The wasm ABI agrees with the CLI
run: cargo test -p horizon-perf --features wasm --test wasm
- name: Build the wasm the page loads
run: cargo run -p xtask -- perf-wasm
- uses: actions/upload-artifact@v4
with:
name: perf-wasm
path: target/perf-wasm
if-no-files-found: error
+3 -5
View File
@@ -34,9 +34,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- 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
they would change the vanilla look.
- Small PRs (< ~500 lines of code; docs and test files don't count:
[CONTRIBUTING.md](CONTRIBUTING.md#pull-requests)). Labels: `feature` (players get something new), `skip-release`
(they see no change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
@@ -103,7 +102,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-perf-format` | The `.hzperf` capture contract ([docs/perf-platform.md](docs/perf-platform.md) §3): `proto/horizon/perf/v1/capture.proto`, its generated types, the gzip writer and its thread. No Horizon dependencies; nothing in the game reads a capture |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
@@ -215,7 +213,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
## How `Game` is organised
+2 -5
View File
@@ -144,9 +144,7 @@ it (edited on every push, never repeated): talk to its author in the thread befo
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
pieces that each make sense alone. Small pull requests get reviewed and merged the same day. Bigger than that:
[large pull requests](#large-pull-requests). What counts: lines added plus lines removed, outside Markdown (`.md`)
files and test files (any `tests/` or `test/` folder, and `test_*.py`). Tests written inside a source file do count.
pr_ready counts it the same way and warns when a pull request is over.
[large pull requests](#large-pull-requests).
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
@@ -171,8 +169,7 @@ How agents, checks, releases and signed updates keep this safe: [security](docs/
### Large pull requests
500 lines of code ([what counts](#pull-requests)) stays the default. A pull request that can't be split below it
starts from a spec:
500 lines stays the default. A pull request that can't be split below it starts from a spec:
1. **Open an issue with the spec** before the code: what changes, the files it touches, the behaviour players and
other code will see, and the tests or harnesses that prove it.
Generated
+5 -342
View File
@@ -18,15 +18,6 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "addr2line"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
@@ -138,12 +129,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.7.0"
@@ -279,12 +264,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "beef"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -580,7 +559,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
@@ -1032,12 +1011,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1136,18 +1109,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "framehop"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f54fe4785e899d4d6f43793b151c63c5647240fc630b005509d2614a939f693"
dependencies = [
"arrayvec",
"cfg-if",
"fallible-iterator",
"gimli",
]
[[package]]
name = "fs4"
version = "1.1.0"
@@ -1285,15 +1246,6 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "gimli"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -1474,7 +1426,7 @@ dependencies = [
name = "horizon-bench"
version = "0.1.0"
dependencies = [
"horizon-system",
"horizon-telemetry",
"serde",
"serde_json",
]
@@ -1505,6 +1457,7 @@ dependencies = [
"bytemuck",
"chrono",
"crc32fast",
"dunce",
"flate2",
"fs4",
"gilrs",
@@ -1529,8 +1482,6 @@ dependencies = [
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1546,6 +1497,7 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
@@ -1589,7 +1541,6 @@ dependencies = [
"bytemuck",
"chrono",
"crc32fast",
"dunce",
"flate2",
"fs4",
"getrandom 0.3.4",
@@ -1610,8 +1561,6 @@ dependencies = [
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1666,8 +1615,6 @@ dependencies = [
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
@@ -1885,7 +1832,6 @@ dependencies = [
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-perf-share",
"horizon-platform",
"log",
"winresource",
@@ -1910,54 +1856,12 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-perf"
version = "0.1.0"
dependencies = [
"flate2",
"horizon-bench",
"horizon-perf-format",
"horizon-perf-share",
"prost",
"regex",
"serde",
"serde_json",
]
[[package]]
name = "horizon-perf-format"
version = "0.1.0"
dependencies = [
"flate2",
"prost",
"prost-build",
"prost-types",
"protox",
]
[[package]]
name = "horizon-perf-share"
version = "0.1.0"
dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"log",
"open",
"serde",
"serde_json",
"ureq",
"url",
]
[[package]]
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-hooks",
"horizon-perf-format",
"horizon-prepare",
"horizon-protocol",
"horizon-server-files",
@@ -2042,6 +1946,7 @@ dependencies = [
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-mcp",
"horizon-platform",
@@ -2125,14 +2030,9 @@ version = "0.1.0"
name = "horizon-threads"
version = "0.1.0"
dependencies = [
"addr2line",
"framehop",
"libc",
"libmimalloc-sys",
"memmap2",
"mimalloc",
"object",
"rustc-demangle",
"windows-sys 0.61.2",
]
@@ -2142,7 +2042,6 @@ version = "0.1.0"
dependencies = [
"flate2",
"horizon-core",
"horizon-threads",
"libm",
"log",
"lz4_flex",
@@ -2384,15 +2283,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2619,72 +2509,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "logos"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154"
dependencies = [
"logos-derive 0.15.1",
]
[[package]]
name = "logos"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
dependencies = [
"logos-derive 0.16.1",
]
[[package]]
name = "logos-codegen"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c"
dependencies = [
"beef",
"fnv",
"lazy_static",
"proc-macro2",
"quote",
"regex-syntax",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "logos-codegen"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
dependencies = [
"fnv",
"proc-macro2",
"quote",
"regex-automata",
"regex-syntax",
"syn 2.0.119",
]
[[package]]
name = "logos-derive"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470"
dependencies = [
"logos-codegen 0.15.1",
]
[[package]]
name = "logos-derive"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
dependencies = [
"logos-codegen 0.16.1",
]
[[package]]
name = "lru"
version = "0.18.5"
@@ -2727,28 +2551,6 @@ dependencies = [
"libc",
]
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"miette-derive",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
@@ -2802,12 +2604,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -3388,15 +3184,6 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -3728,16 +3515,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -3762,96 +3539,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"regex",
"syn 2.0.119",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "prost-reflect"
version = "0.16.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1"
dependencies = [
"logos 0.16.1",
"miette",
"prost",
"prost-types",
]
[[package]]
name = "prost-types"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
dependencies = [
"prost",
]
[[package]]
name = "protox"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1"
dependencies = [
"bytes",
"miette",
"prost",
"prost-reflect",
"prost-types",
"protox-parse",
"thiserror 2.0.21",
]
[[package]]
name = "protox-parse"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366"
dependencies = [
"logos 0.15.1",
"miette",
"prost-types",
"thiserror 2.0.21",
]
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -4151,12 +3838,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustc-demangle"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4715,18 +4396,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -4996,12 +4665,6 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
-3
View File
@@ -37,9 +37,6 @@ members = [
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-perf-format",
"crates/horizon-perf",
"crates/horizon-perf-share",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mods",
-5
View File
@@ -62,8 +62,6 @@ The GLSL frontend also resets the common type for each comma-separated declarato
variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
The shared interpolation defaults also compile with `glsl-in` alone, without needing the
SPIR-V or WGSL frontend to enable them.
## DirectX Shader Compiler
@@ -358,9 +356,6 @@ Horizon (Copyright (c) 2026 Ludovic Marie and the Horizon contributors).
`crates/horizon-client-render/src/touch.rs` and `touch/tests/` are adapted from the touch input
state of Starling's `crates/horizon-client-render/src/touch.rs` at the same commit.
`crates/horizon-client-render/src/touch/draw.rs` is adapted from the touch control drawing in
Starling's `crates/horizon-client-render/src/touch.rs` at the same commit.
`crates/horizon-client-render/src/android_runtime.rs` and `android_runtime/tests/` are adapted from the
input part of Starling's `crates/horizon-client-render/src/android_runtime.rs` at the same commit.
-6
View File
@@ -35,12 +35,6 @@ pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
/// The performance viewer page a saved capture opens in (`HORIZON_PERF_VIEWER` overrides it).
pub const PERF_VIEWER: &str = "https://playhorizon.dev/perf";
/// The capture upload API behind Share (`HORIZON_PERF_CAPTURES` overrides it).
pub const PERF_CAPTURES: &str = "https://playhorizon.dev/api/perf/captures";
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
+1 -33
View File
@@ -206,7 +206,7 @@ impl Config {
Ok(file.anticheat)
}
/// Thresholds can't be negative, and neither can the timer's milliseconds.
/// Thresholds can't be negative.
pub fn check(&self) -> Result<(), String> {
for &c in Check::ALL {
let a = self.actions(c);
@@ -220,18 +220,6 @@ impl Config {
));
}
}
// A negative tolerance flags a client on time every tick, and so does a cap below minus the
// tolerance (the balance rests at the cap); NaN never flags, or never caps the balance. No
// negative number of milliseconds means anything, so all are refused.
if [self.timer.tolerance_ms, self.timer.max_lag_ms]
.iter()
.any(|v| !v.is_finite() || *v < 0.0)
{
return Err(
"anticheat.timer: tolerance_ms and max_lag_ms are numbers of milliseconds from 0 up"
.into(),
);
}
let tolerance = self.prediction.tolerance;
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
@@ -373,26 +361,6 @@ mod tests {
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
}
/// A negative `tolerance_ms` flags a client on time every tick, and so does a `max_lag_ms` below
/// `-tolerance_ms` (the balance rests at the cap); a NaN turns the check (or its cap) off without a
/// word. Every negative or non-finite value is refused, as a negative threshold is.
#[test]
fn refuses_timer_settings_that_are_not_milliseconds_from_0_up() {
for bad in [
"tolerance_ms = -1",
"tolerance_ms = nan",
"tolerance_ms = inf",
"max_lag_ms = -1",
"max_lag_ms = nan",
] {
let text = format!("[anticheat.timer]\n{bad}\n");
let e = Config::from_server_toml(&text).expect_err(bad);
assert!(e.starts_with("anticheat.timer:"), "{bad}: {e}");
}
let edge = "[anticheat.timer]\ntolerance_ms = 0\nmax_lag_ms = 0\n";
assert!(Config::from_server_toml(edge).is_ok());
}
#[test]
fn levels_rise_decay_and_act_in_order() {
let mut c = on();
+1 -25
View File
@@ -45,11 +45,6 @@ mod tests {
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
run_at(b, 20.0, server_ms, per_tick, seconds)
}
/// [`run`] at the tick rate `rate` (`/tick rate`), which the client's ticks follow.
fn run_at(b: &mut Balance, rate: f32, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
let c = TimerConfig::default();
let mut owed = 0.0;
let mut flags = 0;
@@ -58,31 +53,12 @@ mod tests {
owed += per_tick;
while owed >= 1.0 {
owed -= 1.0;
flags += usize::from(b.tick_end(rate, &c).is_some());
flags += usize::from(b.tick_end(20.0, &c).is_some());
}
}
flags
}
/// At another tick rate a vanilla client ticks at the rate the server sent it, and each of its
/// ticks is worth `1000 / rate` ms: never flagged on time, flagged again and again 1.5 times as fast.
#[test]
fn the_balance_follows_the_tick_rate() {
for rate in [10.0_f32, 60.0] {
let tick_ms = 1000.0 / f64::from(rate);
assert_eq!(
run_at(&mut Balance::default(), rate, tick_ms, 1.0, 600.0),
0,
"on time at {rate}"
);
// Half a tick ahead every server tick: 75 flags in a minute at 10 a second, 94 at 60.
let flags = run_at(&mut Balance::default(), rate, tick_ms, 1.5, 60.0);
assert!(flags >= 50, "1.5 times as fast at {rate}: {flags} flags");
}
// The rate counts: at 10 a second, a client sending 20 ticks a second is twice as fast.
assert!(run_at(&mut Balance::default(), 10.0, 100.0, 2.0, 60.0) >= 50);
}
#[test]
fn a_vanilla_client_is_never_flagged() {
let mut b = Balance::default();
+2 -4
View File
@@ -9,7 +9,5 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The hardware probes (cores, clocks, drive, VRAM, power, form); a reader of reports (the wasm
# build of horizon-perf) needs the types below, never the probes.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
horizon-system = { path = "../horizon-system" }
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
-434
View File
@@ -1,434 +0,0 @@
//! Paired A/B comparison of benchmark blocks (docs/benchmark-report.md, "A/B runs").
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{round, Frames};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
TooFewPairs,
WithinNoise,
Negligible,
Slower,
Faster,
}
/// B against A over paired blocks; lower milliseconds are better.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Paired {
pub pairs: usize,
pub a: f64,
pub b: f64,
/// Hodges-Lehmann estimate of b - a.
pub diff: f64,
/// Absent when `a` is zero.
pub diff_pct: Option<f64>,
/// Exact Wilcoxon signed-rank interval; absent below 6 pairs.
pub ci95: Option<[f64; 2]>,
pub confidence: Option<f64>,
/// Median distance between neighbouring blocks of the same arm.
pub noise: Option<f64>,
pub verdict: Verdict,
}
type Getter = fn(&Frames) -> Option<f64>;
fn median(values: &[f64]) -> f64 {
let mut v = values.to_vec();
v.sort_by(f64::total_cmp);
match v.len() {
0 => 0.0,
n if n % 2 == 1 => v[n / 2],
n => (v[n / 2 - 1] + v[n / 2]) / 2.0,
}
}
fn round4(v: f64) -> f64 {
(v * 10_000.0).round() / 10_000.0
}
/// `(a, b)` per pair, `noise` left for the caller.
pub fn paired(pairs: &[(f64, f64)]) -> Paired {
let n = pairs.len();
let a = median(&pairs.iter().map(|p| p.0).collect::<Vec<_>>());
let b = median(&pairs.iter().map(|p| p.1).collect::<Vec<_>>());
let d: Vec<f64> = pairs.iter().map(|p| p.1 - p.0).collect();
let mut walsh = Vec::with_capacity(n * (n + 1) / 2);
for i in 0..n {
for j in i..n {
walsh.push((d[i] + d[j]) / 2.0);
}
}
walsh.sort_by(f64::total_cmp);
let diff = median(&walsh);
let interval = interval(n, &walsh);
let verdict = match interval {
None => Verdict::TooFewPairs,
Some(([lo, hi], _)) if lo <= 0.0 && 0.0 <= hi => Verdict::WithinNoise,
Some(_) if diff.abs() < 0.05_f64.max(0.02 * a) => Verdict::Negligible,
Some(([lo, _], _)) if lo > 0.0 => Verdict::Slower,
Some(_) => Verdict::Faster,
};
Paired {
pairs: n,
a: round(a) + 0.0,
b: round(b) + 0.0,
diff: round(diff) + 0.0,
diff_pct: (a != 0.0).then(|| round(100.0 * diff / a) + 0.0),
ci95: interval.map(|([lo, hi], _)| [round(lo) + 0.0, round(hi) + 0.0]),
confidence: interval.map(|(_, c)| round4(c)),
noise: None,
verdict,
}
}
/// The widest symmetric interval of the sorted Walsh averages whose tails hold at most 2.5% each, and its confidence.
fn interval(n: usize, walsh: &[f64]) -> Option<([f64; 2], f64)> {
let m = walsh.len();
// Null probabilities of T+ (halving is exact in binary, so these are the counts over 2^n).
let mut p = vec![0.0f64; m + 1];
p[0] = 1.0;
for r in 1..=n {
for t in (0..=m).rev() {
p[t] = (p[t] + if t >= r { p[t - r] } else { 0.0 }) / 2.0;
}
}
let (mut k, mut tail, mut best) = (0, 0.0, 0.0);
while k < m {
tail += p[k];
if tail > 0.025 {
break;
}
k += 1;
best = tail;
}
(k >= 1).then(|| ([walsh[k - 1], walsh[m - k]], 1.0 - 2.0 * best))
}
/// The result of an A/B run: pairs of blocks in B A A B order, recomputable from the report's `frames`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ab {
pub switch: String,
pub a: String,
pub b: String,
pub pairs: u32,
pub block_s: f64,
pub warmup_s: f64,
/// Block 0 (measured before any switch) lies outside the later B blocks.
pub first_block_outside_range: bool,
/// By `gpu_p50_ms`, `cpu_p50_ms`, `frame_p50_ms`; a metric missing from any block is left out.
pub result: BTreeMap<String, Paired>,
}
impl Ab {
/// Pairs the `ab.a`/`ab.b` entries of `frames` by `repeat` (0, 1, 2...): pair k is blocks 2k and 2k+1.
pub fn from_frames(
switch: &str,
a: &str,
b: &str,
block_s: f64,
warmup_s: f64,
frames: &[Frames],
) -> Result<Ab, String> {
let mut blocks: Vec<&Frames> = frames
.iter()
.filter(|f| f.phase == "ab.a" || f.phase == "ab.b")
.collect();
blocks.sort_by_key(|f| f.repeat);
if blocks.is_empty()
|| !blocks.len().is_multiple_of(2)
|| blocks
.iter()
.enumerate()
.any(|(i, f)| f.repeat as usize != i)
{
return Err("A/B blocks must be numbered 0..N with N even".to_owned());
}
if blocks.chunks(2).any(|p| p[0].phase == p[1].phase) {
return Err("each pair needs one ab.a and one ab.b block".to_owned());
}
let pairs = blocks.len() / 2;
let metrics: [(&str, Getter); 3] = [
("gpu_p50_ms", |f| f.gpu_p50_ms),
("cpu_p50_ms", |f| f.cpu_p50_ms),
("frame_p50_ms", |f| Some(f.p50_ms)),
];
let mut result = BTreeMap::new();
let mut series: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
for (name, get) in metrics {
let Some(values) = blocks.iter().map(|f| get(f)).collect::<Option<Vec<f64>>>() else {
continue;
};
let by_arm: Vec<(f64, f64)> = blocks
.chunks(2)
.zip(values.chunks(2))
.map(|(p, v)| {
if p[0].phase == "ab.a" {
(v[0], v[1])
} else {
(v[1], v[0])
}
})
.collect();
let mut stat = paired(&by_arm);
let gaps: Vec<f64> = (1..values.len().saturating_sub(1))
.step_by(2)
.filter(|&i| blocks[i].phase == blocks[i + 1].phase)
.map(|i| (values[i] - values[i + 1]).abs())
.collect();
stat.noise = (!gaps.is_empty()).then(|| round(median(&gaps)) + 0.0);
result.insert(name.to_owned(), stat);
series.insert(name, values);
}
let first_block_outside_range = series
.get("gpu_p50_ms")
.or_else(|| series.get("frame_p50_ms"))
.is_some_and(|values| {
let later: Vec<f64> = (1..values.len())
.filter(|&i| blocks[i].phase == "ab.b")
.map(|i| values[i])
.collect();
blocks[0].phase == "ab.b"
&& !later.is_empty()
&& (values[0] < later.iter().copied().fold(f64::INFINITY, f64::min)
|| values[0] > later.iter().copied().fold(f64::NEG_INFINITY, f64::max))
});
Ok(Ab {
switch: switch.to_owned(),
a: a.to_owned(),
b: b.to_owned(),
pairs: pairs as u32,
block_s,
warmup_s,
first_block_outside_range,
result,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{App, Kind, Report};
// Expected values: exact-rational Walsh averages and a brute-force sign enumeration, run once outside this code.
fn check(
a: &[f64],
b: &[f64],
diff: f64,
ci: Option<[f64; 2]>,
conf: Option<f64>,
verdict: Verdict,
) {
let pairs: Vec<(f64, f64)> = a.iter().copied().zip(b.iter().copied()).collect();
let p = paired(&pairs);
assert_eq!(
(p.pairs, p.diff, p.ci95, p.confidence, p.verdict),
(a.len(), diff, ci, conf, verdict),
"{b:?}"
);
}
#[test]
fn the_decided_vectors() {
use Verdict::*;
let z = |n: usize| vec![0.0; n];
let up = |n: usize| (1..=n).map(|v| v as f64).collect::<Vec<_>>();
check(&z(8), &up(8), 4.5, Some([2.0, 7.0]), Some(0.9609), Slower);
let wobble = [-0.3, 0.2, -0.1, 0.4, -0.2, 0.1, 0.3, -0.4];
check(
&z(8),
&wobble,
0.0,
Some([-0.3, 0.3]),
Some(0.9609),
WithinNoise,
);
check(&z(6), &up(6), 3.5, Some([1.0, 6.0]), Some(0.9688), Slower);
check(&z(5), &up(5), 3.0, None, None, TooFewPairs);
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
check(&a, &b, 1.96, Some([1.905, 2.03]), Some(0.9609), Slower);
let tiny: Vec<f64> = (1..=8).map(|v| 10.0 + v as f64 / 100.0).collect();
check(
&[10.0; 8],
&tiny,
0.045,
Some([0.02, 0.07]),
Some(0.9609),
Negligible,
);
let down: Vec<f64> = up(8).iter().map(|_| 0.0).collect();
check(
&up(8),
&down,
-4.5,
Some([-7.0, -2.0]),
Some(0.9609),
Faster,
);
let mixed = [0.5, -0.2, 0.0, 0.3, 0.9, -0.1, 0.4];
check(
&z(7),
&mixed,
0.225,
Some([-0.1, 0.65]),
Some(0.9531),
WithinNoise,
);
let ten = [0.3, -0.1, 0.8, 0.2, -0.5, 0.6, 0.1, 0.4, 0.7, 0.05];
check(
&z(10),
&ten,
0.25,
Some([-0.025, 0.55]),
Some(0.9512),
WithinNoise,
);
}
#[test]
fn medians_percent_and_empty_input() {
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
let p = paired(&a.iter().copied().zip(b).collect::<Vec<_>>());
assert_eq!(
(p.a, p.b, p.diff_pct, p.noise),
(3.67, 5.625, Some(53.406), None)
);
assert_eq!(
paired(&[(0.0, 1.0); 8]).diff_pct,
None,
"no percent of zero"
);
assert_eq!(paired(&[]).verdict, Verdict::TooFewPairs);
}
fn block(phase: &str, repeat: u32, frame: f64, cpu: Option<f64>, gpu: Option<f64>) -> Frames {
Frames {
phase: phase.into(),
repeat,
frames: 100,
seconds: 4.0,
avg_fps: 250.0,
low_1pct_fps: 100.0,
low_01pct_fps: 90.0,
p50_ms: frame,
p95_ms: frame + 1.0,
p99_ms: frame + 2.0,
p999_ms: frame + 3.0,
max_ms: frame + 4.0,
cpu_p50_ms: cpu,
gpu_p50_ms: gpu,
histogram: vec![0; crate::HISTOGRAM_EDGES_MS.len()],
}
}
// B A A B B A A B B A A B, as gpu milliseconds; frame time is gpu + 1.
fn twelve(first_b: f64) -> Vec<Frames> {
let gpu = [
first_b, 3.0, 3.2, 5.0, 5.2, 3.1, 3.1, 5.1, 5.4, 3.4, 3.3, 5.3,
];
gpu.iter()
.enumerate()
.map(|(i, &g)| {
block(
if matches!(i % 4, 0 | 3) {
"ab.b"
} else {
"ab.a"
},
i as u32,
g + 1.0,
Some(g / 2.0),
Some(g),
)
})
.collect()
}
fn from(frames: &[Frames]) -> Result<Ab, String> {
Ab::from_frames("gpu-culling", "off", "on", 4.0, 2.0, frames)
}
#[test]
fn noise_and_the_first_block_flag() {
let ab = from(&twelve(6.4)).unwrap();
assert!(
ab.first_block_outside_range,
"6.4 is 1 ms above the other B blocks"
);
let gpu = &ab.result["gpu_p50_ms"];
// Neighbours (1,2) (3,4) (5,6) (7,8) (9,10): 0.2 0.2 0 0.3 0.1.
assert_eq!(
(gpu.pairs, gpu.a, gpu.b, gpu.noise),
(6, 3.15, 5.25, Some(0.2))
);
assert_eq!(
(gpu.ci95, gpu.verdict),
(Some([1.8, 3.4]), Verdict::Slower),
"every d is positive: min and max Walsh averages"
);
assert_eq!(ab.result["cpu_p50_ms"].noise, Some(0.1));
assert_eq!(
(ab.pairs, ab.switch.as_str(), ab.a.as_str(), ab.b.as_str()),
(6, "gpu-culling", "off", "on")
);
assert!(!from(&twelve(5.2)).unwrap().first_block_outside_range);
}
#[test]
fn a_metric_missing_from_one_block_is_left_out() {
let mut frames = twelve(5.2);
frames[7].gpu_p50_ms = None;
let ab = from(&frames).unwrap();
assert_eq!(
ab.result.keys().collect::<Vec<_>>(),
["cpu_p50_ms", "frame_p50_ms"]
);
let mut frames = twelve(6.4);
frames[0].gpu_p50_ms = None;
frames[0].p50_ms = 6.0;
assert!(
!from(&frames).unwrap().first_block_outside_range,
"frame time 6.0 is inside 6.0..6.4 of the later B blocks"
);
}
#[test]
fn blocks_that_do_not_pair_are_refused() {
assert!(from(&[]).is_err());
assert!(from(&twelve(5.2)[..11]).is_err(), "odd number of blocks");
let mut frames = twelve(5.2);
frames[1].phase = "ab.b".into();
assert!(from(&frames).is_err(), "a pair of two B blocks");
let mut frames = twelve(5.2);
frames[3].repeat = 9;
assert!(from(&frames).is_err(), "a gap in the numbering");
}
#[test]
fn a_report_with_ab_round_trips_and_old_ones_still_parse() {
let mut r = Report::new(Kind::Game, App::default());
let old = r.to_json();
assert!(!old.contains("\"ab\""));
assert_eq!(Report::parse(&old).unwrap().ab, None);
r.frames = twelve(5.2);
r.ab = Some(from(&r.frames).unwrap());
let json = r.to_json();
assert_eq!(Report::parse(&json).unwrap(), r);
assert!(
json.contains("\"first_block_outside_range\": false")
&& json.contains("\"verdict\": \"slower\"")
);
assert_eq!(r.for_upload().ab, r.ab);
let mut few = paired(&[(1.0, 2.0); 3]);
few.noise = None;
assert!(serde_json::to_string(&few)
.unwrap()
.contains("\"ci95\":null,\"confidence\":null"));
}
}
+10 -19
View File
@@ -2,7 +2,7 @@
//! come from the program that rendered (the game knows its adapter, the launcher its window).
#[cfg(any(target_os = "macos", target_os = "linux"))]
use horizon_system::command_output as output;
use horizon_telemetry::system::command_output as output;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -62,7 +62,6 @@ pub struct Display {
pub refresh_hz: Option<u32>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Hardware {
pub fn detect() -> Hardware {
let probes = probes();
@@ -70,12 +69,12 @@ impl Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: horizon_system::cpu(),
cpu: horizon_telemetry::system::cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: horizon_system::ram_bytes().map(|b| b >> 20),
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
@@ -86,14 +85,13 @@ impl Hardware {
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
Hardware {
storage: horizon_system::storage(game_dir).map(str::to_owned),
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
..Hardware::detect()
}
}
}
#[cfg(not(target_arch = "wasm32"))]
/// What `horizon_system::Machine` reads.
/// What the telemetry probes read (`horizon_telemetry::system`).
struct Probes {
cores: Option<u16>,
base: Option<u32>,
@@ -101,31 +99,27 @@ struct Probes {
form: Option<String>,
}
#[cfg(not(target_arch = "wasm32"))]
fn probes() -> Probes {
let s = horizon_system::Machine::detect();
let s = horizon_telemetry::payload::System::detect();
let some = |n: u32| (n > 0).then_some(n);
Probes {
cores: (s.cores > 0).then_some(s.cores),
base: some(s.cpu_base_mhz),
boost: some(s.cpu_boost_mhz),
form: (!s.form.is_empty()).then(|| s.form.to_owned()),
form: (!s.form.is_empty()).then_some(s.form),
}
}
#[cfg(not(target_arch = "wasm32"))]
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_system::power()
horizon_telemetry::system::power()
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
@@ -138,7 +132,6 @@ fn os_version() -> Option<String> {
})
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
@@ -147,19 +140,17 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
})
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
horizon_system::windows_version()
horizon_telemetry::system::windows_version()
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[cfg(test)]
mod tests {
use super::*;
+2 -11
View File
@@ -11,15 +11,11 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
pub mod compare;
mod hardware;
#[cfg(not(target_arch = "wasm32"))]
pub use hardware::power_source;
pub use hardware::{Display, Gpu, Hardware};
pub use hardware::{power_source, Display, Gpu, Hardware};
/// The process probes, from the platform (`horizon-system`).
#[cfg(not(target_arch = "wasm32"))]
pub use horizon_system::{cpu_seconds, load_average, rss_bytes};
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
@@ -240,9 +236,6 @@ pub struct Report {
pub metrics: BTreeMap<String, Summary>,
#[serde(default)]
pub frames: Vec<Frames>,
/// Present in A/B runs only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ab: Option<compare::Ab>,
/// Local only: a name the person running it chose.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
@@ -253,7 +246,6 @@ pub struct Report {
impl Report {
/// An empty report for this machine, created now.
#[cfg(not(target_arch = "wasm32"))]
pub fn new(kind: Kind, app: App) -> Report {
Self::with_hardware(kind, app, Hardware::detect())
}
@@ -271,7 +263,6 @@ impl Report {
conditions: Conditions::default(),
metrics: BTreeMap::new(),
frames: Vec::new(),
ab: None,
label: None,
local: Vec::new(),
}
+17 -66
View File
@@ -952,16 +952,8 @@ mod tests {
(en, seen)
}
/// The crates whose `horizon.` keys must be in en_us.json.
const CLIENT_CRATES: [&str; 4] = [
"horizon-client",
"horizon-client-render",
"horizon-client-core",
"horizon-client-input",
];
/// The Rust sources of `krates`, or of every crate under `crates/` when it is empty.
fn crate_sources(krates: &[&str]) -> Vec<std::path::PathBuf> {
#[test]
fn every_horizon_key_the_code_names_is_in_en_us() {
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
@@ -972,68 +964,27 @@ mod tests {
}
}
}
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
if krates.is_empty() {
for entry in std::fs::read_dir(&crates).unwrap().flatten() {
rust_files(&entry.path().join("src"), &mut files);
}
}
for krate in krates {
rust_files(&crates.join(krate).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
files
}
/// A sentence key (the English text itself, not a `horizon.` id) that no
/// source names as a string literal is text the game no longer shows: its
/// rewording left it behind in every language (#13802).
#[test]
fn every_sentence_key_in_en_us_is_one_the_code_names() {
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
// Every crate: a feature moved out of the client crates keeps its strings.
let code: String = crate_sources(&[])
.iter()
.map(|file| std::fs::read_to_string(file).unwrap())
.collect();
let sentences: Vec<&String> = en.keys().filter(|k| !k.starts_with("horizon.")).collect();
assert!(
sentences.len() > 100,
"found only {} sentence keys",
sentences.len()
);
// A JSON string literal escapes `"` and `\` as Rust's does.
let mut dead: Vec<&str> = sentences
.into_iter()
.filter(|key| !code.contains(&serde_json::to_string(key).unwrap()))
.map(String::as_str)
.collect();
dead.sort_unstable();
assert!(
dead.is_empty(),
"en_us.json keys no code asks for: {dead:#?}"
);
}
#[test]
fn every_horizon_key_the_code_names_is_in_en_us() {
let english = |dir: &std::path::Path| -> HashMap<String, String> {
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
};
let mut en = english(&bundled_dir());
let mut krates = CLIENT_CRATES.to_vec();
// A crate that ships lang files names its keys in its own sources.
let lang_dirs = crate_lang_dirs();
for (name, dir) in &lang_dirs {
en.extend(english(dir));
krates.push(name);
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
"horizon-client",
"horizon-client-render",
"horizon-client-core",
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
// A crate that ships lang files names its keys in its own sources.
for (name, dir) in crate_lang_dirs() {
en.extend(english(&dir));
rust_files(&crates.join(name).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in crate_sources(&krates) {
for file in files {
let text = std::fs::read_to_string(&file).unwrap();
for (at, _) in text.match_indices("\"horizon.") {
let rest = &text[at + 1..];
+1 -32
View File
@@ -61,8 +61,6 @@ pub enum ClickEvent {
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
/// A `/profiler` line the client's own buttons run; never decoded from a server's text, so only they reach the local profiler.
Profiler(String),
}
/// A non-character `minecraft:object` embedded in a text component.
@@ -204,12 +202,7 @@ impl Text {
}
}
pub fn styled(s: impl Into<String>, style: Style) -> Self {
let mut text = Self::literal(s).with_style(&style);
// `inherit` never passes an object on, but a styled span's own style carries it.
for span in &mut text.spans {
span.style.inline_object.clone_from(&style.inline_object);
}
text
Self::literal(s).with_style(&style)
}
pub fn colored(s: impl Into<String>, color: u32) -> Self {
Self::styled(s, Style::color(color))
@@ -626,9 +619,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
event
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
ClickEvent::Profiler(command) => {
json!({ "action": "run_command", "command": command })
}
};
out.push(("click_event".to_owned(), event));
}
@@ -782,27 +772,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
mod tests {
use super::*;
#[test]
fn a_styled_object_span_keeps_its_object_through_append_and_slice() {
let object = InlineObject::Atlas {
atlas: "minecraft:gui".into(),
sprite: "horizon:gui/mark".into(),
fallback: "H".into(),
};
let sprite = Text::styled(
"\u{fffc}",
Style {
inline_object: Some(object.clone()),
..Style::color(WHITE)
},
);
let line = Text::colored("[", DARK_GRAY)
.append(sprite)
.append(Text::colored("] ", DARK_GRAY));
let line = line.slice(0, 3);
assert_eq!(line.spans[1].style.inline_object, Some(object));
assert_eq!(line.plain(), "[H]");
}
#[test]
fn appending_builds_the_same_tree_in_linear_time() {
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
// takes the siblings itself; a copy taken before an append keeps its own tree.
-8
View File
@@ -27,8 +27,6 @@ horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
# The .hzperf capture format and writer (docs/perf-platform.md).
horizon-perf-format = { path = "../horizon-perf-format" }
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
# once, not for every phase of every tick.
rayon = "1.10"
@@ -93,8 +91,6 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# Resolve virtualized folders to shell-compatible physical paths (desktop.rs).
dunce = "1.0.5"
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
@@ -114,8 +110,6 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Dev-only: the ignored tracker-send benchmark uses the executable's allocator.
tracker-send-bench = []
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
@@ -136,8 +130,6 @@ workspace = true
horizon-theme = { path = "../horizon-theme" }
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Reads back the captures the server_perf tests write.
horizon-perf = { path = "../horizon-perf" }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
-5
View File
@@ -23,7 +23,6 @@ horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-perf-format = { path = "../horizon-perf-format" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
@@ -75,9 +74,6 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
# The `scene_replay` example's allocator counts allocations (horizon-threads' `alloc-count`).
alloc-count = ["horizon-threads/alloc-count"]
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
# the variable.
@@ -87,7 +83,6 @@ horizon-extras = []
workspace = true
[dev-dependencies]
horizon-perf = { path = "../horizon-perf" }
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
horizon-theme = { path = "../horizon-theme" }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
@@ -6,14 +6,13 @@
//! [--mipmaps N] [--lod-distance CHUNKS] [--render-distance CHUNKS] [--size WxH]
//! [--section-draws flat|indirect] [--section-cull on|off] [--section-sides on|off]
//! [--gpu-culling on|off] [--root RESOURCES] [--screenshot PNG]
//! [--world SAVE --leaf-culling MODE] [--memory on] [--gpu-detail on|off]`
//! [--world SAVE --leaf-culling MODE] [--memory on]`
//! `--screenshot` saves the first view's last frame, after the timed frames.
//! `--world` meshes the captured sections again from that save's region files
//! (blocks, biomes and saved light; `ao`, `biomeBlendRadius` and `cutoutLeaves`
//! from `./options.txt`) with `--leaf-culling` (default `off`), so a mesher
//! change can be measured on the captured view. A section whose 3x3 columns are
//! not all saved keeps its captured mesh.
//! `--gpu-detail on` adds GPU culling's dispatch times and, on Vulkan, the world passes' counters.
//! `--memory on` adds the renderer's largest GPU resources and, on macOS, the process's
//! physical footprint by category (`footprint`), taken after the timed frames.
use horizon_client_render::{
@@ -25,7 +24,6 @@ use horizon_client_render::{
gfx::{
PreparedTextures, Renderer, ScreenshotRequest,
capture::{self, AtlasCheck, Scene},
pack_timing,
panorama::Faces,
},
graphics::LeafCullingMode,
@@ -63,10 +61,7 @@ use winit::{
// The game's allocator, so that `--memory`'s footprint is the game's: the system heap kept
// gigabytes of the meshes `--world` frees dirty.
#[global_allocator]
static ALLOCATOR: horizon_threads::GameAllocator = horizon_threads::GAME_ALLOCATOR;
/// Frames drawn with detail on, enough for a few timed frames of each kind.
const DETAIL_FRAMES: usize = 60;
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
#[derive(Default)]
struct Options {
@@ -89,7 +84,6 @@ struct Options {
world: Option<PathBuf>,
leaf_culling: LeafCullingMode,
memory: bool,
gpu_detail: bool,
}
fn on_off(value: &str) -> Result<bool, String> {
@@ -142,7 +136,6 @@ fn options() -> Result<Options, String> {
"--screenshot" => o.screenshot = Some(value.into()),
"--world" => o.world = Some(value.into()),
"--memory" => o.memory = on_off(&value)?,
"--gpu-detail" => o.gpu_detail = on_off(&value)?,
"--leaf-culling" => {
o.leaf_culling = LeafCullingMode::ALL
.into_iter()
@@ -407,23 +400,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None => notes.push("screenshot: not saved".into()),
}
}
let mut detail = serde_json::Map::new();
if o.gpu_detail {
renderer.set_pass_detail(true);
for _ in 0..DETAIL_FRAMES {
draw(&mut renderer)?;
}
detail.insert(
"gpu_pass_parts_median_ms".into(),
pack_timing::parts_json(&renderer.own_pass_parts()),
);
let unit = renderer.pass_detail_support().and_then(|s| s.fragment_unit);
detail.insert(
"gpu_counters_median".into(),
pack_timing::counters_by_pass_json(&renderer.own_pass_counters(), unit),
);
renderer.set_pass_detail(false);
}
views.push(json!({
"yaw_offset": offset,
"gpu_ms": summary(&gpu),
@@ -441,9 +417,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
// indices hidden.
"culled": renderer.culled_sections(),
}));
if let Some(Value::Object(view)) = views.last_mut() {
view.extend(detail);
}
}
let section_draws = if renderer.flat_draws() {
"flat"
@@ -451,11 +424,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
"indirect"
};
let memory = o.memory.then(|| memory_report(&renderer));
let gpu_detail = o
.gpu_detail
.then(|| renderer.pass_detail_support())
.flatten()
.map(pack_timing::support_json);
let report = json!({
"capture": o.dir.display().to_string(),
"adapter": renderer.info.name,
@@ -486,7 +454,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
"notes": notes,
"views": views,
"memory": memory,
"gpu_detail": gpu_detail,
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
@@ -1442,8 +1442,6 @@ impl Audio {
} else {
Err("no output on a dedicated server".into())
};
// Without `audio` a stream can't exist (`Infallible`), so the `Ok` arm never runs.
#[cfg_attr(not(feature = "audio"), allow(unreachable_code))]
let (stream, live) = match opened {
Ok(v) => (Some(v), true),
Err(e) => {
+1 -13
View File
@@ -1279,9 +1279,6 @@ pub struct ChatScreen {
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
pub abilities: ChatAbilities,
pub layout: SuggestLayout,
/// `MouseHandler.lastClick`: a click within 250 ms of the last one on
/// this screen is a double click.
last_click: Option<std::time::Instant>,
}
/// Local completions waiting for the server's reply to request `id`.
struct Awaiting {
@@ -1404,7 +1401,6 @@ impl ChatScreen {
auto_suggestions: true,
abilities,
layout: SuggestLayout::CHAT,
last_click: None,
};
s.update_command_info(m, c);
s
@@ -1443,7 +1439,6 @@ impl ChatScreen {
auto_suggestions: true,
abilities: ChatAbilities::new(ChatVisibility::Full),
layout: SuggestLayout::COMMAND_BLOCK,
last_click: None,
};
s.update_command_info(m, c);
s
@@ -1971,15 +1966,8 @@ impl ChatScreen {
}
self.suggestions = Some(l);
}
// MouseHandler: the same screen's click within 250 ms is a double
// click, which selects the word under the pointer.
let now = std::time::Instant::now();
let double = self
.last_click
.is_some_and(|at| now.duration_since(at).as_millis() < 250);
self.last_click = Some(now);
if self.input.contains(x, y) {
self.input.click(m, x, false, double);
self.input.click(m, x, false);
return true;
}
false
@@ -305,31 +305,6 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
}
}
/// `EditBox.onClick`: a double click selects the word under the pointer; a
/// single click only places the caret there.
#[test]
fn a_double_click_in_the_chat_box_selects_the_word() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "hello world");
// The pointer on the last letter: the first click places the caret there,
// the second takes the word.
let x = f64::from(screen.input.x) + f64::from(font.width("hello world")) - 1.0;
let y = f64::from(screen.input.y) + 2.0;
assert!(screen.click(&m, &c, x, y));
assert!((10..=11).contains(&screen.input.cursor));
assert_eq!(screen.input.highlight, screen.input.cursor);
assert!(screen.click(&m, &c, x, y));
assert_eq!((screen.input.highlight, screen.input.cursor), (6, 11));
assert_eq!(screen.input.selected_text(), "world");
}
#[test]
fn history_arrows_recall_messages_and_restore_the_draft() {
use winit::keyboard::KeyCode::{ArrowDown, ArrowUp};
-25
View File
@@ -715,31 +715,6 @@ impl Chunk {
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
BlockStateId(self.get(x, y, z))
}
/// The blocks that `matches`, section by section from the bottom, each section's in y, z, x
/// order. A section whose palette holds none is skipped.
pub fn blocks_where(
&self,
matches: impl Fn(BlockStateId) -> bool,
) -> Vec<horizon_core::position::BlockPos> {
let mut out = Vec::new();
for (s, section) in self.sections.iter().enumerate() {
if !section.blocks.maybe_has(|v| matches(BlockStateId(v))) {
continue;
}
let base = self.min_y + 16 * s as i32;
for y in base..base + 16 {
for lz in 0..16 {
for lx in 0..16 {
let (wx, wz) = (self.x * 16 + lx, self.z * 16 + lz);
if matches(self.block(wx, y, wz)) {
out.push(horizon_core::position::BlockPos::new(wx, y, wz));
}
}
}
}
}
out
}
/// The highest y where `matches`, scanning down. Sections holding only
/// `skip` are passed over whole: `matches` must be false for it.
pub fn highest(&self, skip: u32, mut matches: impl FnMut(i32) -> bool) -> Option<i32> {
@@ -413,20 +413,12 @@ pub enum NameOrId {
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
/// `containerData` slot 9 as saved (`triggered`): any int, triggered
/// only at 1 ([`Crafter::is_triggered`]).
pub triggered: i32,
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
impl Crafter {
/// `CrafterBlockEntity.isTriggered`: `containerData.get(9) == 1`.
pub fn is_triggered(&self) -> bool {
self.triggered == 1
}
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<BannerLayer>,
@@ -473,9 +465,6 @@ pub struct Jukebox {
/// reference exists. Keep this separate from legacy playback adoption.
pub song_clock_present: bool,
pub playing: bool,
/// A clock a save brought back, not playing yet: `setSongWithoutPlaying`
/// keeps the song and its clock without the sound's level event 1010.
pub loaded_clock: bool,
}
impl Jukebox {
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
@@ -291,15 +291,10 @@ impl SceneCollisions<'_, '_> {
}
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
let y = bounds.min_position().y;
// An entity context is needed wherever PowderSnowBlock or a strider
// reads the entity: boots, the walkable tag, a long fall.
if !self.subject.is_some_and(|s| {
s.lava_walker
|| s.leather_boots
|| s.walkable_type
|| s.falling_block
|| s.fall_distance > 2.5
}) {
if !self
.subject
.is_some_and(|s| s.lava_walker || s.leather_boots)
{
return CollisionContext::position(y);
}
CollisionContext::for_entity(
@@ -3,8 +3,7 @@
//! - a function with `sampler2D` parameters is copied once per set of samplers its callers pass
//! (all of them a shader's global samplers), the copy reading those samplers by name;
//! - an unsized array (`const float[] w = float[](...)`, `float w[] = ...`) takes the size its
//! initializer gives;
//! - an array declared with a `const int`'s size takes the constant's expression.
//! initializer gives.
/// The longest shader rewritten (vanilla's, includes expanded, are a few KiB).
const MAX_LEN: usize = 64 * 1024;
@@ -18,115 +17,7 @@ pub(super) fn fixups(text: &str) -> String {
if text.len() > MAX_LEN {
return text.to_owned();
}
size_by_int_constants(&size_unsized_arrays(&specialize_sampler_params(text)))
}
/// The most `const int`s a shader's arrays are sized by.
const MAX_INT_CONSTANTS: usize = 256;
/// `type name[n]` becomes `type name[(expression)]` where `const int n = expression;` is in scope;
/// accesses (`name[n]`) are left as they are. Two constants of one name and different values in
/// one scope, which only exclusive `#if` branches allow, leave the text as it was; the same one
/// twice is a guarded include pasted twice.
fn size_by_int_constants(text: &str) -> String {
struct Constant<'a> {
name: &'a str,
expression: &'a str,
from: usize,
to: usize,
}
let bytes = text.as_bytes();
let mut constants: Vec<Constant> = Vec::new();
let mut scopes: Vec<Vec<usize>> = vec![Vec::new()];
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'{' => scopes.push(Vec::new()),
b'}' => {
for c in scopes.pop().unwrap_or_default() {
constants[c].to = i;
}
if scopes.is_empty() {
return text.to_owned();
}
}
b'c' if text[i..].starts_with("const int ")
&& (i == 0 || !is_ident(char::from(bytes[i - 1]))) =>
{
let start = i + "const int ".len();
let Some(end) = text[start..].find([';', '{', '}']).map(|e| start + e) else {
break;
};
if bytes[end] == b';'
&& let Some((name, expression)) = text[start..end].split_once('=')
{
let (name, expression) = (name.trim(), expression.trim());
if is_name(name) && !expression.is_empty() {
let scope = scopes.last_mut().expect("the global scope stays");
let same = scope.iter().find(|&&c| constants[c].name == name);
match same {
Some(&c) if constants[c].expression == expression => {}
Some(_) => return text.to_owned(),
None if constants.len() == MAX_INT_CONSTANTS => {
return text.to_owned();
}
None => {
scope.push(constants.len());
constants.push(Constant {
name,
expression,
from: end,
to: text.len(),
});
}
}
}
}
i = end;
continue;
}
_ => {}
}
i += 1;
}
if constants.is_empty() {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
let mut copied = 0;
let mut at = 0;
while let Some(found) = text[at..].find('[') {
let open = at + found;
at = open + 1;
let Some(close) = text[open..].find(']').map(|c| open + c) else {
break;
};
let size = text[open + 1..close].trim();
let Some(constant) = constants
.iter()
.filter(|c| c.name == size && c.from < open && open < c.to)
.max_by_key(|c| c.from)
else {
continue;
};
if !declares_an_array(text, open) {
continue;
}
out.push_str(&text[copied..=open]);
out.push('(');
out.push_str(constant.expression);
out.push(')');
copied = close;
}
out.push_str(&text[copied..]);
out
}
/// Whether the `[` at `open` follows `type name`, so it sizes a declaration.
fn declares_an_array(text: &str, open: usize) -> bool {
ident_before(text, open)
.and_then(|(start, _)| ident_before(text, start))
.is_some_and(|(_, ty)| !matches!(ty, "return" | "else" | "case"))
size_unsized_arrays(&specialize_sampler_params(text))
}
fn is_ident(c: char) -> bool {
@@ -245,72 +245,6 @@ fn thousands_of_calls_are_left_as_they_were() {
);
}
#[test]
fn an_array_sized_by_a_local_int_constant_takes_its_expression() {
let text =
"void main() {\n const int n = COUNT / 4;\n vec4 s[n];\n s[0] = vec4(n);\n}\n";
let out = size_by_int_constants(text);
assert!(out.contains("vec4 s[(COUNT / 4)];"), "{out}");
assert!(out.contains("s[0] = vec4(n);"), "{out}");
}
#[test]
fn each_function_sizes_by_its_own_constant_and_accesses_are_kept() {
let text = "float f() {\n const int n = 2;\n float a[n];\n a[1] = 1.0;\n return a[n - 1];\n}\n\
float g() {\n const int n = 4;\n float b[n];\n b[3] = 1.0;\n float c = b[n];\n return c;\n}\n";
let out = size_by_int_constants(text);
assert!(out.contains("float a[(2)];"), "{out}");
assert!(out.contains("float b[(4)];"), "{out}");
assert!(out.contains("b[3] = 1.0;"), "{out}");
assert!(out.contains("float c = b[n];"), "{out}");
assert!(out.contains("return a[n - 1];"), "{out}");
}
#[test]
fn an_inner_constant_shadows_the_outer_one_only_inside_its_block() {
let text = "void main() {\n const int n = 2;\n {\n const int n = 4;\n float inner[n];\n }\n\
float outer[n];\n}\n";
let out = size_by_int_constants(text);
assert!(out.contains("float inner[(4)];"), "{out}");
assert!(out.contains("float outer[(2)];"), "{out}");
}
#[test]
fn constants_of_exclusive_branches_leave_the_text_as_it_was() {
let text = "void main() {\n#ifdef A\n const int n = 2;\n#else\n const int n = 4;\n#endif\n float a[n];\n}\n";
assert_eq!(size_by_int_constants(text), text);
}
#[test]
fn a_guarded_include_pasted_twice_still_sizes_its_arrays() {
let include = "#ifndef I\n#define I\nconst int bins = 2;\n#endif\n";
let text = format!("{include}{include}void main() {{\n float a[bins];\n}}\n");
assert!(size_by_int_constants(&text).contains("float a[(2)];"));
}
#[test]
fn arrays_sized_by_two_functions_constants_translate_to_their_own_sizes() {
let fsh = "#version 330\nout vec4 fragColor;\n\
float f() {\n const int n = 2;\n float a[n];\n a[1] = 1.0;\n return a[1];\n}\n\
float g() {\n const int n = 4;\n float b[n];\n b[3] = 1.0;\n return b[3];\n}\n\
void main() {\n fragColor = vec4(f() + g());\n}\n";
let program = translate(fsh).unwrap();
let mut sizes: Vec<u32> = program
.fragment
.types
.iter()
.filter_map(|(_, ty)| match ty.inner {
naga::TypeInner::Array {
size: naga::ArraySize::Constant(n),
..
} => Some(n.get()),
_ => None,
})
.collect();
sizes.sort_unstable();
assert_eq!(sizes, [2, 4]);
}
#[test]
fn unsized_arrays_take_their_initializers_size() {
let text = "const float[] w = float[](0.25, 0.5, 0.25);\n\
+264 -82
View File
@@ -210,7 +210,6 @@ mod hdr_icc;
mod hdr_start;
mod hdr_stats;
pub(crate) mod hud_coverage;
mod in_flight;
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
@@ -248,11 +247,9 @@ mod pack_post;
mod pack_shadow;
mod pack_sky;
mod pack_textures;
pub mod pack_timing;
pub(crate) mod pack_timing;
pub mod pack_uniforms;
mod pack_weather;
mod pass_timing_api;
use pass_timing_api::{after_culling_pass, pixel_count, world_pass_after_culling};
mod page_runs;
pub mod panorama;
mod readback;
@@ -341,6 +338,42 @@ fn surface_pass<'e>(
..Default::default()
})
}
fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
timing: Option<&mut pack_timing::PassTiming>,
name: &str,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, after culling"),
timestamp_writes: timing.and_then(|t| t.writes(name)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
// A shader pack reads it as depthtex0.
store: if keep_depth {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// The point a `shaders/` pack's terrain is drawn relative to: the camera with the fraction of
/// each coordinate rounded to the nearest 1/65536 block. Read back with a probe pack of ours,
/// Iris 1.11.7's terrain `gl_Vertex` is the block's position minus this, for cameras from 13
@@ -1441,14 +1474,10 @@ pub struct Renderer {
pack_loading: Option<PackLoading>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
native: Option<native_pack::NativePack>,
/// Time the shader pack's passes on the GPU (F3, or a capture made with `--gpu`).
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
/// Times the parts of passes too (`set_pass_detail`).
pass_detail: bool,
/// Least time between timed frames (`set_pass_timing_gap`).
pass_timing_gap: std::time::Duration,
/// Whether the timers keep every timed frame for `take_timed_frames`.
keep_timed_frames: bool,
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
own_timing: Option<pack_timing::PassTiming>,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
@@ -5057,10 +5086,8 @@ impl Renderer {
pack_post: None,
pack_loading: None,
native: None,
pass_timing: false,
pass_detail: false,
pass_timing: pack_timing::PassTiming::logging(),
pass_timing_gap: std::time::Duration::ZERO,
keep_timed_frames: false,
own_timing: None,
pack_entity_rest: Vec::new(),
cloud_bindings,
@@ -7422,6 +7449,51 @@ impl Renderer {
.terrain_inputs(self.pack_terrain_flat),
)
}
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open), or always
/// with `HORIZON_SHADER_PACK_TIMING`.
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on || pack_timing::PassTiming::logging();
}
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
/// samples than a measurement). `HORIZON_SHADER_PACK_TIMING` keeps every sample.
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
self.pass_timing_gap = if pack_timing::PassTiming::logging() {
std::time::Duration::ZERO
} else {
gap
};
}
/// The running shader pack's timer, of either kind.
fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
native: &'a mut Option<native_pack::NativePack>,
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
match (pack, native) {
(Some((_, Some(post))), _) => Some(&mut post.timing),
(_, Some(native)) => Some(&mut native.timing),
_ => None,
}
}
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
/// they are timed (`set_pass_timing`).
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
let timer = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
};
timer
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
/// timed (`set_pass_timing`).
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// The macros this renderer gives shader packs: the GL extensions this device has
/// (subgroups, when it has them), and the vendor, renderer and mipmap levels
/// (`Macros::define_renderer`).
@@ -8030,8 +8102,6 @@ impl Renderer {
}
if let Some(t) = &mut self.own_timing {
t.gap = self.pass_timing_gap;
t.keep_frames(self.keep_timed_frames);
t.set_detail(self.pass_detail);
t.begin(&self.device, &self.queue);
}
if self.animated_textures {
@@ -8451,7 +8521,6 @@ impl Renderer {
&mut self.uploads,
&mut encoder,
&input,
self.own_timing.as_mut(),
)
});
// A frame whose planes leave the test's integers (the camera millions of blocks
@@ -9800,7 +9869,6 @@ impl Renderer {
}
if let Some(Some(t)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
t.gap = self.pass_timing_gap;
t.keep_frames(self.keep_timed_frames);
}
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
@@ -10325,8 +10393,7 @@ impl Renderer {
store: wgpu::StoreOp::Store,
},
})];
let world_pixels = pixel_count(world_depth);
let first_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world"),
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("World")),
color_attachments: &attachments,
@@ -10346,16 +10413,9 @@ impl Renderer {
}),
..Default::default()
});
let mut pass = pack_timing::counted(
self.own_timing.as_mut(),
first_pass,
world_pixels,
"before translucent",
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) {
pass.stage(self.own_timing.as_mut(), "sky");
if !frame.title && frame.skybox && !pack_draws_sky && !native_sky_pass {
match self
.native
@@ -10442,15 +10502,16 @@ impl Renderer {
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pack_timing::CountedPass::plain(pass)
pass
};
// The occlusion test's pipeline draws into the world's colour alone.
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
let mut pass = after_culling_pass(encoder, world_view, world_depth, true, None)
.forget_lifetime();
let mut pass =
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pack_timing::CountedPass::plain(pass)
pass
};
if let Some(surface) = &surface {
drop(pass);
@@ -10504,7 +10565,6 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after visibility buffer",
world_pixels,
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
@@ -10537,7 +10597,6 @@ impl Renderer {
flat.draw(pass, &flat.calls[calls.clone()], vertices);
}
};
pass.stage(self.own_timing.as_mut(), "solid terrain");
match cull {
// As below: phase 1, the test, then phase 2's draws
// that the test saw, copied after it.
@@ -10547,8 +10606,6 @@ impl Renderer {
drop(pass);
pass = plain_pass(&mut encoder);
}
pass.split(self.own_timing.as_mut(), "occlusion boxes");
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
@@ -10562,12 +10619,10 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after culling",
world_pixels,
),
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
for (pipeline, _, calls) in passes {
pass.set_pipeline(pipeline);
flat.draw_phase2(&mut pass, calls.clone(), vertices);
@@ -10605,7 +10660,6 @@ impl Renderer {
let pulled = material.is_none() && self.flat.is_some();
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
pass.stage(self.own_timing.as_mut(), "solid terrain");
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
}
@@ -10630,8 +10684,6 @@ impl Renderer {
drop(pass);
pass = plain_pass(&mut encoder);
}
pass.split(self.own_timing.as_mut(), "occlusion boxes");
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
@@ -10644,7 +10696,6 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after culling",
world_pixels,
),
};
pass.set_bind_group(0, &self.group, &[]);
@@ -10654,7 +10705,6 @@ impl Renderer {
wgpu::IndexFormat::Uint32,
);
pass.set_vertex_buffer(1, self.instances.slice(..));
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
for (pipeline, _, draws) in passes {
pass.set_pipeline(pipeline);
self.draw_sections_from(
@@ -10702,8 +10752,6 @@ impl Renderer {
drop(pass);
pass = plain_pass(&mut encoder);
}
pass.split(self.own_timing.as_mut(), "occlusion boxes");
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
gpu.test(&mut pass);
drop(pass);
gpu.compact(&mut encoder, self.own_timing.as_mut());
@@ -10716,7 +10764,6 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after culling",
world_pixels,
),
};
pass.set_bind_group(0, &self.group, &[]);
@@ -10725,7 +10772,6 @@ impl Renderer {
self.indices.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
for (pipeline, draws, _) in passes {
pass.set_pipeline(pipeline);
gpu.draw(&mut pass, &self.arena, page_group, 1);
@@ -10752,7 +10798,6 @@ impl Renderer {
&& let Some(d) = &n.displacement
&& let Some(pipeline) = n.material.as_ref().and_then(|m| m.displaced.as_ref())
{
pass.stage(self.own_timing.as_mut(), "displacement");
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
d.draw(&mut pass, &d.view, pipeline);
@@ -10769,7 +10814,6 @@ impl Renderer {
let hdr_entity_mark =
warped.map_or(&self.hdr_entity_mark_pipeline, |w| &w.hdr_entity_mark);
if static_entity_quads > 0 {
pass.stage(self.own_timing.as_mut(), "static block entities");
pass.set_pipeline(
warped.map_or(&self.static_entity_pipeline, |w| &w.static_entities),
);
@@ -10795,7 +10839,6 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if entity_quads > 0 {
pass.stage(self.own_timing.as_mut(), "entities");
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10807,7 +10850,6 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if text_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "entity text");
pass.set_pipeline(warped.map_or(&self.text_polygon_offset_pipeline, |w| &w.text));
self.bind_world_text(&mut pass, warped.is_none(), false);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10817,7 +10859,6 @@ impl Renderer {
// FeatureRenderDispatcher.PreparedFrame.executeTranslucent: the shadows
// come before the translucent items, which then cover them (#7026).
if shadow_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "shadows");
pass.set_pipeline(warped.map_or(&self.shadow_blob_pipeline, |w| &w.entity_shadows));
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10833,7 +10874,6 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "translucent items");
pass.set_pipeline(
warped.map_or(&self.translucent_item_pipeline, |w| &w.translucent_items),
);
@@ -10869,7 +10909,6 @@ impl Renderer {
.enumerate()
{
if count > 0 {
pass.stage(self.own_timing.as_mut(), "terrain models");
pass.set_pipeline(&self.terrain_model_pipelines[i]);
pass.set_bind_group(
0,
@@ -10890,7 +10929,6 @@ impl Renderer {
// translucent material, before terrain transparency. This material
// always writes depth and never joins the improved-transparency queues.
if fluid_quads > 0 {
pass.stage(self.own_timing.as_mut(), "fluid block entities");
pass.set_pipeline(&self.fluid_pipeline);
pass.set_bind_group(0, &self.fluid_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10903,7 +10941,6 @@ impl Renderer {
}
// Cracks multiply the existing light; scalar OIT coverage cannot carry that factor.
if crumbling_quads > 0 && (!oit_on || hdr_gain) {
pass.stage(self.own_timing.as_mut(), "block breaking");
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10917,7 +10954,6 @@ impl Renderer {
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
// before translucent terrain, so no water shows inside the boat.
if water_mask_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "boat water mask");
pass.set_pipeline(&self.water_mask_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10930,7 +10966,6 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.lightning.bolts.is_empty() && !oit_on {
pass.stage(self.own_timing.as_mut(), "lightning");
pass.set_pipeline(&self.lightning_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
self.lightning.draw(&mut pass, &frame.lightning);
@@ -10941,7 +10976,6 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if swirl_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "energy swirl");
pass.set_pipeline(&self.energy_swirl_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10958,7 +10992,6 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if additive_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "additive entities");
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
let mut first = 0;
@@ -10984,24 +11017,20 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if outline_edges > 0 && !frame.outline_after_terrain && !pack_draws_outline {
pass.stage(self.own_timing.as_mut(), "block outline");
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
// DrawableGizmoPrimitives' opaque group writes depth, the translucent one does not.
if debug_opaque > 0 {
pass.stage(self.own_timing.as_mut(), "debug lines");
pass.set_pipeline(&self.debug_lines_opaque);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
pass.draw(0..4, 0..debug_opaque);
}
if debug_depth > debug_opaque {
pass.stage(self.own_timing.as_mut(), "debug lines");
pass.set_pipeline(&self.debug_lines);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
pass.draw(0..4, debug_opaque..debug_depth);
}
if debug_top > 0 {
pass.stage(self.own_timing.as_mut(), "debug lines");
pass.set_pipeline(&self.debug_lines_on_top);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
pass.draw(0..4, debug_depth..debug_depth + debug_top);
@@ -11029,7 +11058,6 @@ impl Renderer {
keep_depth,
self.own_timing.as_mut(),
"World after copy",
world_pixels,
);
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
@@ -11203,11 +11231,9 @@ impl Renderer {
true,
self.own_timing.as_mut(),
"World after OIT",
world_pixels,
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.stage(self.own_timing.as_mut(), "OIT marks");
if cloud_quads > 0 && !pack_draws_clouds {
self.mark_clouds(&mut pass, cloud_quads);
}
@@ -11240,8 +11266,6 @@ impl Renderer {
self.mark_translucent_terrain(&mut pass, flat, solid_draws, translucent_draws);
}
Some(flat) => {
pass.split(self.own_timing.as_mut(), "translucent terrain");
pass.stage(self.own_timing.as_mut(), "translucent terrain");
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
flat.draw(
&mut pass,
@@ -11256,11 +11280,8 @@ impl Renderer {
solid_draws,
translucent_draws,
);
pass.split(self.own_timing.as_mut(), "after translucent");
}
None => {
pass.split(self.own_timing.as_mut(), "translucent terrain");
pass.stage(self.own_timing.as_mut(), "translucent terrain");
pass.set_pipeline(translucent_pipeline);
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
@@ -11283,7 +11304,6 @@ impl Renderer {
translucent_draws,
);
}
pass.split(self.own_timing.as_mut(), "after translucent");
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
@@ -11291,7 +11311,6 @@ impl Renderer {
// the quads write depth, so before it they would hide the glass
// or water behind them.
if translucent_particle_quads > 0 && !oit_on {
pass.stage(self.own_timing.as_mut(), "translucent particles");
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
if let Some(group) = warp_group {
pass.set_bind_group(2, group, &[]);
@@ -11320,7 +11339,6 @@ impl Renderer {
// particles (also after the OIT composite) and before the hand.
// Its own geometry is independent of block-entity lifetime.
if world_text_quads + world_text_offset_quads > 0 {
pass.stage(self.own_timing.as_mut(), "world text");
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11343,11 +11361,9 @@ impl Renderer {
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
pass.stage(self.own_timing.as_mut(), "block outline");
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if translucent_crumbling_quads > 0 && (!oit_on || hdr_gain) {
pass.stage(self.own_timing.as_mut(), "block breaking");
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11358,7 +11374,6 @@ impl Renderer {
);
}
if cloud_quads > 0 && !pack_draws_clouds && !oit_on {
pass.stage(self.own_timing.as_mut(), "clouds");
pass.set_pipeline(&self.cloud_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.cloud_group, &[]);
@@ -11370,7 +11385,6 @@ impl Renderer {
// terrain and clouds, so the water behind the rain is drawn first.
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if !pack_draws_weather && !oit_on {
pass.stage(self.own_timing.as_mut(), "weather");
let native_weather = self
.native
.as_ref()
@@ -11382,7 +11396,6 @@ impl Renderer {
}
// LevelRenderer: the world border after translucent terrain and weather.
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
pass.stage(self.own_timing.as_mut(), "world border");
pass.set_pipeline(&self.border_pipeline);
pass.set_bind_group(0, &self.border_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -11395,7 +11408,6 @@ impl Renderer {
}
// LevelRenderer.executeSeeThrough, after the rest of the world.
if see_through_quads > 0 {
pass.stage(self.own_timing.as_mut(), "see-through");
pass.set_pipeline(&self.see_through_pipeline);
self.bind_world_text(&mut pass, true, true);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11408,11 +11420,9 @@ impl Renderer {
// Native mods' primitives: after the entities and the rest of the world, into its
// targets (its resolution, reversed depth and TAA), before the hand.
if world_primitives {
pass.stage(self.own_timing.as_mut(), "mod primitives");
self.world_primitives.draw(&mut pass);
}
if hand_in_world {
pass.stage(self.own_timing.as_mut(), "hand");
pass.set_pipeline(&self.depth_reset);
pass.set_bind_group(0, &self.group, &[]);
pass.draw(0..3, 0..1);
@@ -12352,6 +12362,130 @@ pub(crate) fn copy_frame(
);
buffer
}
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// 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,
) -> 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];
let format = self.config.format.remove_srgb_suffix();
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
// Other 16-bit frames keep their clipped 8-bit copy.
let copy = self
.hdr
.as_mut()
.and_then(|hdr| {
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
})
.or_else(|| {
(texture.format() != format && texture.format() == self.output.frame.format)
.then(|| {
self.readback
.unorm_copy(&self.device, encoder, texture, format)
})
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
@@ -12457,8 +12591,56 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
mod view_frustum;
pub use view_frustum::Frustum;
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
planes: [Vec4; 6],
}
impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether a camera-relative sphere is at least partly inside. Frustum
/// planes are normalized, so each test needs only one dot product and a
/// radius comparison.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
return true;
}
!self
.planes
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
}
/// Vanilla's `Projection`: reversed depth (near maps to 1, far to 0, tests
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
@@ -71,7 +71,7 @@ use super::{
Frustum, SectionKey,
arena::{Arena, try_create},
occlusion::Reach,
pack_timing::{PassTiming, compute_parts},
pack_timing::PassTiming,
resident,
submit::Queue,
upload::Uploads,
@@ -374,21 +374,6 @@ pub(super) enum Prepared {
OutOfMemory,
}
/// A part of a compute pass: `pipeline` over `[x, y]` workgroups, nothing when `x` is 0.
fn step<'a>(
group: &'a wgpu::BindGroup,
pipeline: &'a wgpu::ComputePipeline,
[x, y]: [u32; 2],
) -> impl FnMut(&mut wgpu::ComputePass<'_>) + 'a {
move |pass| {
if x > 0 {
pass.set_bind_group(0, group, &[]);
pass.set_pipeline(pipeline);
pass.dispatch_workgroups(x, y, 1);
}
}
}
/// The frame's `tables` before the regions, into `words`, and the parameters
/// the frustum test and facing read with them; `None` when the CPU draws the
/// frame (`Prepared::Cpu`): a sum of a plane's terms could leave i32
@@ -1205,13 +1190,12 @@ impl GpuCulling {
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
input: &FrameInputs<'_>,
timing: Option<&mut PassTiming>,
) -> Prepared {
self.pages.clear();
self.ids = 0;
self.n_updates = 0;
self.occluding = false;
self.prepare_buffers(device, queue, uploads, encoder, input, timing)
self.prepare_buffers(device, queue, uploads, encoder, input)
.unwrap_or(Prepared::OutOfMemory)
}
@@ -1222,7 +1206,6 @@ impl GpuCulling {
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
input: &FrameInputs<'_>,
mut timing: Option<&mut PassTiming>,
) -> Option<Prepared> {
// Every buffer grows before anything is written. One made anew drops
// the bind group at once, and a new section table needs every record:
@@ -1304,9 +1287,6 @@ impl GpuCulling {
occl = OCCLUDE | if probe { PROBE } else { 0 };
}
}
let uploading = timing
.as_mut()
.and_then(|t| t.encoder_begin(encoder, "GPU culling uploads"));
if self.records.all_dirty {
// The whole table, after it grew or more records changed than
// updates take (GPU culling switched on in a loaded world, a
@@ -1372,9 +1352,6 @@ impl GpuCulling {
// are written before they are read.
let cleared = if self.occluding { tests_at } else { phase1_end };
encoder.clear_buffer(&self.counts.buffer, 0, Some(cleared * 4));
if let Some(t) = timing {
t.encoder_end(encoder, uploading);
}
if self.group.is_none() {
self.group = Some(self.bind(device));
if let Some(test) = &mut self.test {
@@ -1426,24 +1403,23 @@ impl GpuCulling {
let Some(group) = self.group.as_ref().filter(|_| self.ids > 0) else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("GPU culling"),
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling")),
});
pass.set_bind_group(0, group, &[]);
let [apply, cull, scan, scatter] = &self.pipelines;
if self.n_updates > 0 {
pass.set_pipeline(apply);
pass.dispatch_workgroups(self.n_updates.div_ceil(WORKGROUP), 1, 1);
}
let blocks = self.ids.div_ceil(WORKGROUP);
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
let updates = [self.n_updates.div_ceil(WORKGROUP), 1];
let (mut apply, mut cull) = (step(group, apply, updates), step(group, cull, rows));
let pages = [self.pass_pages, 1];
let (mut scan, mut scatter) = (step(group, scan, pages), step(group, scatter, rows));
compute_parts(
encoder,
timing,
"GPU culling",
&mut [
("apply", &mut apply),
("cull", &mut cull),
("scan", &mut scan),
("scatter", &mut scatter),
],
);
pass.set_pipeline(cull);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
pass.set_pipeline(scan);
pass.dispatch_workgroups(self.pass_pages, 1, 1);
pass.set_pipeline(scatter);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
}
/// The kept sections' solid draws, a count draw per page, with the quad
@@ -1518,22 +1494,19 @@ impl GpuCulling {
if !self.occluding {
return;
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("GPU culling phase 2"),
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling phase 2")),
});
pass.set_bind_group(0, group, &[]);
let [count2, scan2, scatter2] = &test.phase2;
let blocks = self.ids.div_ceil(WORKGROUP);
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
let pages = [self.pass_pages, 1];
let (mut count, mut scatter) = (step(group, count2, rows), step(group, scatter2, rows));
let mut scan = step(group, scan2, pages);
compute_parts(
encoder,
timing,
"GPU culling phase 2",
&mut [
("count2", &mut count),
("scan2", &mut scan),
("scatter2", &mut scatter),
],
);
pass.set_pipeline(count2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
pass.set_pipeline(scan2);
pass.dispatch_workgroups(self.pass_pages, 1, 1);
pass.set_pipeline(scatter2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
}
/// After the frame's world passes (phase 2's counts too), in the same
@@ -2747,11 +2720,6 @@ mod tests {
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let needed =
wgpu::Features::MULTI_DRAW_INDIRECT_COUNT | wgpu::Features::INDIRECT_FIRST_INSTANCE;
let timing = adapter.features()
& (wgpu::Features::TIMESTAMP_QUERY
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS
| wgpu::Features::SHADER_EARLY_DEPTH_TEST);
if !adapter.features().contains(needed)
|| super::super::flat::is_broken_intel_vulkan(&adapter.get_info())
{
@@ -2759,7 +2727,7 @@ mod tests {
}
eprintln!("GPU culling on {} ({name})", adapter.get_info().name);
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: needed | timing,
required_features: needed,
..Default::default()
}))
.ok()
@@ -2866,11 +2834,6 @@ mod tests {
// was drawn.
let (mut eyed, mut hidden, mut several) = (0, 0, 0);
let (mut edge, mut edge_drawn) = (None, false);
// Timed in detail as the renderer does: the commands, and so the draws, stay the same.
let mut timing = PassTiming::new(device, queue);
if let Some(t) = &mut timing {
t.set_detail(true);
}
for case in 0..25 {
let radius = [2, 5, 9][case % 3];
let at_edge = case == 24;
@@ -3018,9 +2981,6 @@ mod tests {
edge = Some(id);
}
let whole = gpu.records.all_dirty;
if let Some(t) = &mut timing {
t.begin(device, queue);
}
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
camera,
@@ -3040,18 +3000,11 @@ mod tests {
camera: camera + DVec3::Y * 2.0e7,
..input
};
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up, None);
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up);
assert_eq!(prepared, Prepared::Cpu, "case 7");
assert!(gpu.records.all_dirty || !gpu.records.dirty.is_empty());
}
let prepared = gpu.prepare(
device,
queue,
&mut uploads,
&mut encoder,
&input,
timing.as_mut(),
);
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
assert_eq!(prepared, Prepared::Gpu, "case {case}");
match case {
4 => assert!(
@@ -3107,15 +3060,12 @@ mod tests {
culled += 1;
}
}
gpu.dispatch(&mut encoder, timing.as_mut());
gpu.dispatch(&mut encoder, None);
// Its statistics come back with a CPU part of the case's own.
let cpu = [case * 7, case * 3, case];
gpu.copy_stats(&mut encoder, case as u64, cpu);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
if let Some(t) = &mut timing {
t.submitted(queue);
}
gpu.submitted().expect("a readback free")();
let pages = u64::from(gpu.pass_pages);
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
@@ -3174,18 +3124,6 @@ mod tests {
"of {eyed} from an eye, {hidden} with a bucket left out, {several} in three runs"
);
assert!(edge_drawn, "the bucket at the section's edge");
if let Some(t) = &timing {
let parts = t.part_medians();
let names = |pass: &str| {
let parts = parts.iter().find(|p| p.0 == pass)?;
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
};
assert_eq!(
names("GPU culling"),
Some(vec!["apply", "cull", "scan", "scatter"]),
"{parts:?}"
);
}
assert!(culled > 2_000, "{culled} within the distance left out");
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
assert!(
@@ -3198,113 +3136,6 @@ mod tests {
);
}
/// Detail times both phases and the uploads and leaves counts and draws as an untimed frame's.
#[test]
fn detail_times_both_phases_and_leaves_the_draws_alone() {
on_each_device(|device, queue| {
let mut rng = Rng(0x1F83_D9AB_FB41_BD6B);
let (frustum, _) = view(&mut rng);
let records: Vec<Record> = (0..400)
.map(|i| {
let key = (rng.range(-6, 6), rng.range(0, 23), rng.range(-6, 6));
Record::new(
key,
i % 3,
u64::from(i) * 1024,
1 + rng.below(500),
UNBOUNDED,
)
})
.collect();
let mut timing = PassTiming::new(device, queue);
let early = device
.features()
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST);
let Some(t) = timing.as_mut().filter(|_| early) else {
eprintln!("no timestamps or early depth test: skipped");
return;
};
t.set_detail(true);
let run = |timing: &mut Option<PassTiming>| {
let mut gpu = GpuCulling::new(device, Some(wgpu::TextureFormat::Rgba8Unorm))
.expect("the pass");
let mut uploads = Uploads::new(device);
for r in &records {
gpu.records.insert(*r);
}
let mut out = Vec::new();
for frame in 0..12 {
if let Some(t) = timing {
t.begin(device, queue);
}
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
camera: DVec3::new(0.5, 70.0, 0.5),
min_y: -64,
sections: 24,
radius: 5,
frustum: &frustum,
reach: None,
eye: None,
view_proj: Mat4::IDENTITY,
occlude: true,
};
let prepared = gpu.prepare(
device,
queue,
&mut uploads,
&mut encoder,
&input,
timing.as_mut(),
);
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
assert!(gpu.occluding(), "frame {frame}: phase 2 runs");
gpu.dispatch(&mut encoder, timing.as_mut());
gpu.compact(&mut encoder, timing.as_mut());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
if let Some(t) = timing {
t.submitted(queue);
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pages = u64::from(gpu.pass_pages);
out.push((
read(device, queue, &gpu.counts.buffer, (STATS + 2 * pages) * 4),
read(device, queue, &gpu.draws.buffer, gpu.draws.bytes),
));
}
out
};
let timed = run(&mut timing);
let plain = run(&mut None);
assert_eq!(timed, plain, "detail changes what is drawn");
let t = timing.as_ref().expect("the timer");
let parts = t.part_medians();
let names = |pass: &str| {
let parts = parts.iter().find(|p| p.0 == pass)?;
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
};
assert_eq!(
names("GPU culling"),
Some(vec!["apply", "cull", "scan", "scatter"]),
"{parts:?}"
);
assert_eq!(
names("GPU culling phase 2"),
Some(vec!["count2", "scan2", "scatter2"]),
"{parts:?}"
);
// The uploads are a pass of their own, never a part of one.
assert_eq!(names("GPU culling uploads"), None, "{parts:?}");
let passes = t.medians();
assert_eq!(
passes.iter().any(|p| p.0 == "GPU culling uploads"),
t.detail_support().encoder_stamps,
"{passes:?}"
);
});
}
/// The statistics come back with their frame, in place and from the submit
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
/// with its `after` handed back): three frames on their way fill the ring, a
@@ -3546,7 +3377,7 @@ struct Quad {
view_proj: Mat4::IDENTITY,
occlude: false,
};
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input, None);
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
let mut params = tables(&input, &mut words).expect("the planes fit");
params.pages = gpu.pass_pages;
@@ -1,132 +0,0 @@
//! Frames in flight: the readbacks a submission starts, landing frames the submit thread
//! submitted, keeping offscreen frames at most two ahead of the GPU, and a screenshot's copy.
use super::{Renderer, ScreenshotRequest, copy_frame, screenshot_save, submit};
impl Renderer {
/// The frame's readbacks to start right after its submission (on the submit thread when
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
pub(super) fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
let cull = self.cull.as_mut().and_then(|c| c.submitted());
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
match (cull, gpu) {
(Some(c), Some(g)) => Some(Box::new(move || {
c();
g();
})),
(Some(c), None) => Some(Box::new(c)),
(None, Some(g)) => Some(Box::new(g)),
(None, None) => None,
}
}
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
pub(super) fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
if frame > self.stats_frame {
self.stats_frame = frame;
(
self.drawn_sections,
self.section_draws,
self.section_draw_calls,
) = (sections, draws, calls);
}
}
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
pub fn stats_lag(&self) -> u64 {
self.frame_serial - self.stats_frame
}
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
pub(super) fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
pub(super) fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
pub(super) fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> 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];
let format = self.config.format.remove_srgb_suffix();
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
// Other 16-bit frames keep their clipped 8-bit copy.
let copy = self
.hdr
.as_mut()
.and_then(|hdr| {
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
})
.or_else(|| {
(texture.format() != format && texture.format() == self.output.frame.format)
.then(|| {
self.readback
.unorm_copy(&self.device, encoder, texture, format)
})
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
}
+1 -4
View File
@@ -696,16 +696,13 @@ impl Oit {
),
] {
{
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
});
let pixels = super::pixel_count(terrain_depth);
let mut pass =
super::pack_timing::counted(timing.as_deref_mut(), pass, pixels, label);
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, stage);
}
File diff suppressed because it is too large. Load diff
@@ -1,169 +0,0 @@
//! The world pass after culling, and the renderer's GPU pass timing switches and readings.
use super::{Renderer, native_pack, pack_post, pack_timing};
pub(super) fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
mut timing: Option<&mut pack_timing::PassTiming>,
name: &str,
pixels: u64,
) -> pack_timing::CountedPass<'e> {
let pass = after_culling_pass(
encoder,
view,
depth,
keep_depth,
timing.as_deref_mut().and_then(|t| t.writes(name)),
);
pack_timing::counted(timing, pass, pixels, "before translucent")
}
/// The pixels of a depth attachment, for `fragments_per_pixel`.
pub(super) fn pixel_count(depth: &wgpu::TextureView) -> u64 {
let texture = depth.texture();
u64::from(texture.width()) * u64::from(texture.height())
}
pub(super) fn after_culling_pass<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
depth: &wgpu::TextureView,
keep_depth: bool,
timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, after culling"),
timestamp_writes,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
// A shader pack reads it as depthtex0.
store: if keep_depth {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
}),
stencil_ops: None,
}),
..Default::default()
})
}
impl Renderer {
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open or a capture records them).
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on;
}
/// Times the parts of Horizon's own passes while `on`.
pub fn set_pass_detail(&mut self, on: bool) {
self.pass_detail = on;
}
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
/// samples than a measurement).
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
self.pass_timing_gap = gap;
}
/// Keeps each timed frame's pass times for `take_timed_frames` while `on` (a capture records them).
pub fn set_keep_timed_frames(&mut self, on: bool) {
self.keep_timed_frames = on;
}
/// The frames both timers timed since the last call, without waiting on the GPU.
pub fn take_timed_frames(&mut self) -> pack_timing::TimedFrames {
let own = self
.own_timing
.as_mut()
.map(pack_timing::PassTiming::take_frames)
.unwrap_or_default();
let pack = match Self::pass_timer(&mut self.pack_post, &mut self.native) {
Some(Some(t)) => t.take_frames(),
_ => Vec::new(),
};
pack_timing::TimedFrames { own, pack }
}
/// The running shader pack's timer, of either kind.
pub(super) fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
native: &'a mut Option<native_pack::NativePack>,
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
match (pack, native) {
(Some((_, Some(post))), _) => Some(&mut post.timing),
(_, Some(native)) => Some(&mut native.timing),
_ => None,
}
}
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
/// they are timed (`set_pass_timing`).
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
let timer = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
};
timer
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
/// timed (`set_pass_timing`).
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// Each of Horizon's own passes with its parts' median GPU times in milliseconds.
pub fn own_pass_parts(&self) -> Vec<(String, Vec<(String, f32)>)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::part_medians)
.unwrap_or_default()
}
/// Each counted pass's counters (medians), while detail is on and the device has them.
pub fn own_pass_counters(&self) -> Vec<(String, pack_timing::PassCounters)> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::counter_medians)
.unwrap_or_default()
}
/// Both timers' readings and this adapter, for a capture; `None` when neither has timed a pass.
pub fn gpu_readout(&self) -> Option<pack_timing::GpuReadout> {
let own = self
.own_timing
.as_ref()
.map(pack_timing::PassTiming::readings)
.filter(|r| !r.is_empty());
let pack = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
}
.map(pack_timing::PassTiming::readings)
.filter(|r| !r.is_empty());
(own.is_some() || pack.is_some()).then(|| pack_timing::GpuReadout {
adapter: self.info.clone(),
own,
pack,
})
}
/// Whether dispatch parts and counters are being asked for (`set_pass_detail`).
pub fn pass_detail(&self) -> bool {
self.pass_detail
}
/// What detail timing can show on this device; `None` without pass timing.
pub fn pass_detail_support(&self) -> Option<pack_timing::DetailSupport> {
self.own_timing
.as_ref()
.map(pack_timing::PassTiming::detail_support)
}
}
@@ -2,8 +2,8 @@
//! an adapter that can't present to the window gives way to another of its backend
//! (#13493).
use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, pack_timing, startup_step,
surface_format, timing,
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
@@ -135,7 +135,6 @@ pub(super) async fn open_adapter(
// atomics on images: their buffers and images. Filtering 32-bit float textures: shader
// packs' RGBA32F buffers, which GL filters and mipmaps. Count draws: GPU culling's (`gpu_culling`).
let features = timing::GpuTimer::features(adapter)
| pack_timing::PassTiming::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
@@ -1,252 +0,0 @@
//! The camera's view frustum, as camera-relative clip planes.
use glam::{Vec3, Vec4};
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
pub(super) planes: [Vec4; 6],
}
impl Frustum {
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether a camera-relative sphere is at least partly inside. Frustum
/// planes are normalized, so each test needs only one dot product and a
/// radius comparison.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
return true;
}
!self
.planes
.iter()
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
}
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
return true;
}
!self.planes[4..].iter().any(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, max, min);
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
/// rounding keeps that order.
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
self.planes.iter().all(|p| {
let sign = p.truncate().cmpge(Vec3::ZERO);
let v = Vec3::select(sign, min, max);
p.truncate().dot(v) + p.w >= 0.0
})
}
/// Whether the camera-relative `point` is on or inside every plane (vanilla's
/// `Frustum.pointInFrustum`). A point that is not finite is kept: a test that cannot
/// tell must not hide the particle.
pub fn contains_point(&self, point: Vec3) -> bool {
if !point.is_finite() {
return true;
}
self.planes
.iter()
.all(|plane| plane.truncate().dot(point) + plane.w >= 0.0)
}
/// The same frustum seen from a camera moved `blocks` back along the view direction
/// (vanilla's `Frustum.offset`): the sides open up near the camera and the near plane
/// moves behind it, so what sits just behind or beside the camera stays inside.
///
/// The view direction is the normal of the depth planes (the two are opposite), signed
/// towards where the side planes point. A frustum that has no direction to pull along
/// comes back unchanged: a box with no depth planes (the shadow pass's), and a box whose
/// sides are parallel to the view direction (an orthographic projection), where the side
/// normals cancel and say nothing about which way is forward.
pub fn pulled_back(&self, blocks: f32) -> Frustum {
let sides: Vec3 = self.planes[..4].iter().map(|p| p.truncate()).sum();
let depth = self.planes[4].truncate();
let along = depth.dot(sides);
// A perspective frustum's four side normals each lean forward, so this is well above 1.
if along.abs() < 1.0e-3 {
return *self;
}
let toward_sides = if along > 0.0 { depth } else { -depth };
let axis = toward_sides.normalize_or_zero();
let mut planes = self.planes;
for plane in &mut planes {
plane.w += blocks * plane.truncate().dot(axis);
}
Frustum { planes }
}
/// The frustum of a camera-relative `view_proj`, for tests outside this module.
#[cfg(test)]
pub(crate) fn from_view_projection(view_proj: glam::Mat4) -> Frustum {
Frustum {
planes: super::frustum(view_proj),
}
}
}
#[cfg(test)]
mod tests {
use super::{Frustum, Vec3};
use crate::gfx::world_projection;
use glam::Mat4;
const FOV: f32 = 70.0;
const ASPECT: f32 = 16.0 / 9.0;
const FAR: f32 = 512.0;
const EPS: f32 = 0.01;
fn looking_down_minus_z() -> Frustum {
Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR))
}
fn tangents() -> (f32, f32) {
let up = (FOV / 2.0).to_radians().tan();
(up * ASPECT, up)
}
/// Each of the six planes of the unmoved frustum, a point just inside and one just outside,
/// from the field of view worked out by hand.
#[test]
fn a_point_is_inside_only_within_all_six_planes() {
let view = looking_down_minus_z();
let (tan_x, tan_y) = tangents();
let depth = 10.0;
let inside = [
Vec3::new(0.0, 0.0, -depth),
Vec3::new(depth * tan_x - EPS, 0.0, -depth),
Vec3::new(-(depth * tan_x - EPS), 0.0, -depth),
Vec3::new(0.0, depth * tan_y - EPS, -depth),
Vec3::new(0.0, -(depth * tan_y - EPS), -depth),
Vec3::new(0.0, 0.0, -0.06),
Vec3::new(0.0, 0.0, -(FAR - 0.1)),
];
let outside = [
Vec3::new(depth * tan_x + EPS, 0.0, -depth),
Vec3::new(-(depth * tan_x + EPS), 0.0, -depth),
Vec3::new(0.0, depth * tan_y + EPS, -depth),
Vec3::new(0.0, -(depth * tan_y + EPS), -depth),
Vec3::new(0.0, 0.0, -0.04),
Vec3::new(0.0, 0.0, -(FAR + 0.1)),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(0.0, 0.0, 0.0),
];
for p in inside {
assert!(view.contains_point(p), "{p} should be inside");
}
for p in outside {
assert!(!view.contains_point(p), "{p} should be outside");
}
assert!(view.contains_point(Vec3::NAN), "an unknown point is kept");
}
/// Camera 3 blocks back: its apex is at z = 3, so the sides open by 3 * tan, the near
/// plane is at z = 3 - 0.05 and the far plane at z = 3 - FAR.
#[test]
fn pulling_the_camera_back_moves_every_plane() {
let (tan_x, tan_y) = tangents();
let back = looking_down_minus_z().pulled_back(3.0);
let inside = [
Vec3::new(0.0, 0.0, 2.9),
Vec3::new(3.0 * tan_x - EPS, 0.0, 0.0),
Vec3::new(-(3.0 * tan_x - EPS), 0.0, 0.0),
Vec3::new(0.0, 3.0 * tan_y - EPS, 0.0),
Vec3::new(0.0, -(3.0 * tan_y - EPS), 0.0),
Vec3::new(0.0, 0.0, 3.0 - FAR + 0.1),
];
let outside = [
Vec3::new(0.0, 0.0, 3.0),
Vec3::new(3.0 * tan_x + EPS, 0.0, 0.0),
Vec3::new(-(3.0 * tan_x + EPS), 0.0, 0.0),
Vec3::new(0.0, 3.0 * tan_y + EPS, 0.0),
Vec3::new(0.0, -(3.0 * tan_y + EPS), 0.0),
Vec3::new(0.0, 0.0, 3.0 - FAR - 0.1),
];
for p in inside {
assert!(back.contains_point(p), "{p} should be inside");
}
for p in outside {
assert!(!back.contains_point(p), "{p} should be outside");
}
// The unmoved frustum does not have the point that only the offset one has.
assert!(!looking_down_minus_z().contains_point(Vec3::new(0.0, 0.0, 2.9)));
// An offset of nothing changes no verdict.
let same = looking_down_minus_z();
let unmoved = same.pulled_back(0.0);
for p in [
Vec3::new(1.0, 2.0, -3.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(40.0, 0.0, -5.0),
] {
assert_eq!(same.contains_point(p), unmoved.contains_point(p));
}
}
/// The offset follows the camera's own view direction, whatever way it looks.
#[test]
fn the_offset_is_along_the_view_direction() {
for (yaw, pitch) in [(0.3_f32, 0.0_f32), (2.0, -0.5), (-1.2, 0.9), (3.0, 1.2)] {
let pose = Mat4::from_rotation_x(pitch) * Mat4::from_rotation_y(yaw);
let view = Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR) * pose);
let forward = pose.inverse().transform_vector3(-Vec3::Z);
let back = view.pulled_back(3.0);
assert!(
back.contains_point(forward * -2.9),
"{yaw} {pitch}: 2.9 behind"
);
assert!(
!back.contains_point(forward * -3.0),
"{yaw} {pitch}: 3 behind"
);
assert!(
!view.contains_point(forward * -2.9),
"{yaw} {pitch}: unmoved"
);
assert!(back.contains_point(forward * 20.0) && view.contains_point(forward * 20.0));
}
}
/// An orthographic box has no apex to pull back from: its side normals cancel, so there is
/// no direction, and the frustum must come back as it was.
#[test]
fn an_orthographic_frustum_stays_where_it_is() {
let ortho =
Frustum::from_view_projection(Mat4::orthographic_rh(-8.0, 8.0, -4.5, 4.5, 100.0, 0.05));
let moved = ortho.pulled_back(3.0);
for p in [
Vec3::new(0.0, 0.0, 2.0),
Vec3::new(0.0, 0.0, -9.0),
Vec3::new(7.9, 4.4, -9.0),
Vec3::new(8.1, 0.0, -9.0),
Vec3::new(0.0, 0.0, -101.0),
] {
assert_eq!(ortho.contains_point(p), moved.contains_point(p), "{p}");
}
}
/// The shadow pass's box has no depth planes, so no direction: it must not move.
#[test]
fn a_frustum_without_depth_planes_stays_where_it_is() {
let mut flat = looking_down_minus_z();
flat.planes[4] = glam::Vec4::W;
flat.planes[5] = glam::Vec4::W;
let moved = flat.pulled_back(3.0);
for p in [
Vec3::new(0.0, 0.0, 2.0),
Vec3::new(0.0, 0.0, -9.0),
Vec3::new(80.0, 0.0, -9.0),
] {
assert_eq!(flat.contains_point(p), moved.contains_point(p));
}
}
}
@@ -239,17 +239,17 @@ impl Visbuffer {
encoder: &'e mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
clear: bool,
mut timing: Option<&mut PassTiming>,
) -> super::pack_timing::CountedPass<'e> {
timing: Option<&mut PassTiming>,
) -> wgpu::RenderPass<'e> {
let (ids, _) = self.ids.as_ref().expect("fitted before the world pass");
let name = if clear {
"Visibility IDs"
} else {
"Visibility IDs after culling"
};
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon visibility buffer"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(name)),
timestamp_writes: timing.and_then(|t| t.writes(name)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: ids,
resolve_target: None,
@@ -272,8 +272,7 @@ impl Visbuffer {
stencil_ops: None,
}),
..Default::default()
});
super::pack_timing::counted(timing, pass, super::pixel_count(depth), name)
})
}
/// Phase 1's solid calls into the IDs, as the normal passes draw them: the opaque
+36 -59
View File
@@ -90,8 +90,6 @@ fn nits_setting(nits: u32) -> u32 {
/// Simple-level presets. `Custom` leaves every option as the player set it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Preset {
/// Every saving on, over the lowest settings (`preset::table`).
FullPerformance,
Performance,
Balanced,
Quality,
@@ -100,8 +98,7 @@ pub enum Preset {
Custom,
}
impl Preset {
pub const ALL: [Preset; 6] = [
Preset::FullPerformance,
pub const ALL: [Preset; 5] = [
Preset::Performance,
Preset::Balanced,
Preset::Quality,
@@ -110,7 +107,6 @@ impl Preset {
];
pub fn name(self) -> &'static str {
match self {
Preset::FullPerformance => "full_performance",
Preset::Performance => "performance",
Preset::Balanced => "balanced",
Preset::Quality => "quality",
@@ -120,7 +116,6 @@ impl Preset {
}
pub fn label(self) -> &'static str {
match self {
Preset::FullPerformance => "Full Performance",
Preset::Performance => "Performance",
Preset::Balanced => "Balanced",
Preset::Quality => "Quality",
@@ -591,45 +586,6 @@ pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// Parallel Tick's setting, for the worlds this game runs itself (single player, open to LAN):
/// whether the server tick's phases that give the same result in any order share their work
/// among helper threads (`parallel`). Auto shares it only while the tick is heavy and sharing
/// makes it faster; a quiet world stays on one thread and starts no helper.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ParallelTickMode {
#[default]
Off = 0,
Auto = 1,
On = 2,
}
impl ParallelTickMode {
pub const ALL: [ParallelTickMode; 3] = [
ParallelTickMode::Off,
ParallelTickMode::Auto,
ParallelTickMode::On,
];
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "auto",
Self::On => "on",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Auto => "Auto",
Self::On => "On",
}
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|m| m.name() == s)
.unwrap_or_default()
}
}
/// GPU Culling's setting: off, on where it is offered, or Auto, which has it on only where the
/// CPU limits the frame and it makes the frame faster (`autotune::GpuCullingAuto`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -817,9 +773,9 @@ pub struct Graphics {
pub cave_culling: bool,
/// Near solid terrain culled, and its draws listed, on the GPU (`gfx::gpu_culling`), where it
/// is offered (`gpu_culling_offer`). Off by default (GPU Culling, Performance graphics); it
/// applies at once, and every preset sets it to Auto. Every save writes it, so a default
/// turned on later needs a new key, as `batchTranslucent` did. Auto turns it on only where
/// the CPU limits the frame and it makes the frame faster (`autotune`).
/// applies at once, and no preset sets it. Every save writes it, so a default turned on later
/// needs a new key, as `batchTranslucent` did. Auto turns it on only where the CPU limits the
/// frame and it makes the frame faster (`autotune`).
pub gpu_culling: GpuCullingMode,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
@@ -865,9 +821,6 @@ pub struct Graphics {
pub pacing: Pacing,
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
pub workers: u32,
/// Parallel Tick (Performance graphics, beside Worker Threads): applies from the next tick,
/// and no preset sets it.
pub parallel_tick: ParallelTickMode,
pub overlay: Overlay,
/// World resolution as a percentage of the window's (50..=200).
pub resolution_scale: u32,
@@ -970,7 +923,6 @@ impl Default for Graphics {
present_mode: PresentMode::Auto,
pacing: Pacing::Efficient,
workers: 0,
parallel_tick: ParallelTickMode::Off,
overlay: Overlay::Off,
resolution_scale: 100,
dynamic_resolution: false,
@@ -1093,7 +1045,6 @@ impl Graphics {
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"opaqueIce" => g.opaque_ice = flag(g.opaque_ice),
"parallelTick" => g.parallel_tick = ParallelTickMode::parse(value),
"blockEntityCache" => {
let enabled = flag(false);
g.cache_shulker_boxes = enabled;
@@ -1193,7 +1144,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\ndynamicResolution:{}\ntargetFps:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\nparallelTick:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\ndynamicResolution:{}\ntargetFps:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -1251,7 +1202,6 @@ impl Graphics {
bool_text(self.translucent_batching),
bool_text(self.hdr_smooth_gradients),
self.gpu_culling.key(),
self.parallel_tick.name(),
);
if let Some(driver) = &self.hdr_failed_start {
text += &format!("hdrFailedStart:{driver}\n");
@@ -1330,9 +1280,37 @@ impl Graphics {
}
}
#[path = "graphics_preset.rs"]
mod preset;
pub use preset::apply_preset;
/// A preset's values for vanilla's options and Horizon's own.
pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
let (rd, sim, clouds, particles, shadows, blend, mipmaps, entity, leaves, weather_r) =
match preset {
Preset::Performance => (8, 6, "false", 2, false, 0, 0, 0.5, false, 3),
Preset::Balanced => (12, 8, "fast", 1, true, 1, 2, 0.75, true, 5),
Preset::Quality => (16, 12, "true", 0, true, 2, 4, 1.0, true, 10),
Preset::Ultra => (24, 12, "true", 0, true, 3, 4, 1.5, true, 10),
Preset::Custom => return,
};
s.render_distance = rd;
s.simulation_distance = sim;
s.clouds = clouds.into();
s.particles = particles;
s.entity_shadows = shadows;
s.biome_blend = blend;
s.mipmap_levels = mipmaps;
s.entity_distance = entity;
s.cutout_leaves = leaves;
s.weather_radius = weather_r;
s.graphics_preset = "custom".into();
let g = &mut s.horizon;
g.preset = preset;
g.cave_culling = true;
g.animated_textures = true;
g.chunk_updates = 0;
g.upload_budget_kib = 0;
g.sky = true;
g.fog = true;
g.weather = true;
}
#[cfg(test)]
mod tests {
@@ -1568,7 +1546,6 @@ mod tests {
present_mode: PresentMode::Mailbox,
pacing: Pacing::Precise,
workers: 6,
parallel_tick: ParallelTickMode::Auto,
overlay: Overlay::Graph,
resolution_scale: 75,
dynamic_resolution: false,
@@ -1,169 +0,0 @@
//! The graphics presets: each one a fixed table of the options it sets, vanilla's and
//! Horizon's own, so that picking one gives the same settings whatever was set before.
//! Every preset turns on the optimizations that leave the picture as it is; Full
//! Performance adds the ones that change it. What no table names keeps the player's
//! choice: the display preferences (`graphics_ui`'s `touched`), Far Detail and Leaf
//! Culling, which follow the machine and the look the player picked, and Translucent
//! Batching, on unless the player turned it off.
use super::{EntityCullingMode, GpuCullingMode, ParticleCullingMode, Preset, Upscaling};
use crate::settings::Settings;
/// What differs between the presets. Every one of them also turns on entity and particle
/// culling on a worker, the block-entity caches and GPU Culling's Auto (which tries it only
/// where it makes frames faster), keeps cave culling, the sky, the fog, the weather and the
/// animated textures on, uploads meshes unthrottled and draws at a fixed resolution without
/// TAA.
struct Table {
render_distance: i32,
simulation_distance: i32,
/// `options.txt`'s `renderClouds`: `false`, `fast` or `true`.
clouds: &'static str,
/// 0 all, 1 decreased, 2 minimal.
particles: u8,
entity_shadows: bool,
biome_blend: i32,
mipmap_levels: i32,
entity_distance: f32,
cutout_leaves: bool,
weather_radius: i32,
/// Far terrain's range in chunks; 0 is off.
lod_distance: u32,
opaque_ice: bool,
resolution_scale: u32,
fxaa: bool,
dither_cutout_mips: bool,
}
fn table(preset: Preset) -> Option<Table> {
Some(match preset {
Preset::FullPerformance => Table {
render_distance: 6,
simulation_distance: 5,
clouds: "false",
particles: 2,
entity_shadows: false,
biome_blend: 0,
mipmap_levels: 0,
entity_distance: 0.5,
cutout_leaves: false,
weather_radius: 3,
lod_distance: 0,
opaque_ice: true,
resolution_scale: 75,
fxaa: false,
dither_cutout_mips: false,
},
Preset::Performance => Table {
render_distance: 8,
simulation_distance: 6,
clouds: "false",
particles: 2,
entity_shadows: false,
biome_blend: 0,
mipmap_levels: 0,
entity_distance: 0.5,
cutout_leaves: false,
weather_radius: 3,
lod_distance: 0,
opaque_ice: false,
resolution_scale: 100,
fxaa: false,
dither_cutout_mips: false,
},
Preset::Balanced => Table {
render_distance: 12,
simulation_distance: 8,
clouds: "fast",
particles: 1,
entity_shadows: true,
biome_blend: 1,
mipmap_levels: 2,
entity_distance: 0.75,
cutout_leaves: true,
weather_radius: 5,
lod_distance: 0,
opaque_ice: false,
resolution_scale: 100,
fxaa: false,
dither_cutout_mips: false,
},
Preset::Quality => Table {
render_distance: 16,
simulation_distance: 12,
clouds: "true",
particles: 0,
entity_shadows: true,
biome_blend: 2,
mipmap_levels: 4,
entity_distance: 1.0,
cutout_leaves: true,
weather_radius: 10,
lod_distance: 64,
opaque_ice: false,
resolution_scale: 100,
fxaa: true,
dither_cutout_mips: false,
},
Preset::Ultra => Table {
render_distance: 24,
simulation_distance: 12,
clouds: "true",
particles: 0,
entity_shadows: true,
biome_blend: 3,
mipmap_levels: 4,
entity_distance: 1.5,
cutout_leaves: true,
weather_radius: 10,
lod_distance: 128,
opaque_ice: false,
resolution_scale: 100,
fxaa: true,
dither_cutout_mips: true,
},
Preset::Custom => return None,
})
}
/// Sets what `preset`'s table names, in vanilla's options and Horizon's own.
pub fn apply_preset(preset: Preset, s: &mut Settings) {
let Some(t) = table(preset) else { return };
s.render_distance = t.render_distance;
s.simulation_distance = t.simulation_distance;
s.clouds = t.clouds.into();
s.particles = t.particles;
s.entity_shadows = t.entity_shadows;
s.biome_blend = t.biome_blend;
s.mipmap_levels = t.mipmap_levels;
s.entity_distance = t.entity_distance;
s.cutout_leaves = t.cutout_leaves;
s.weather_radius = t.weather_radius;
s.graphics_preset = "custom".into();
let g = &mut s.horizon;
g.preset = preset;
g.lod_distance = t.lod_distance;
g.cave_culling = true;
g.gpu_culling = GpuCullingMode::Auto;
g.entity_culling = EntityCullingMode::Asynchronous;
g.particle_culling = ParticleCullingMode::Asynchronous;
g.opaque_ice = t.opaque_ice;
g.cache_shulker_boxes = true;
g.cache_signs = true;
g.cache_decorated_pots = true;
g.cache_bells = true;
g.cache_chests = true;
g.cache_banners = true;
g.cache_copper_golem_statues = true;
g.chunk_updates = 0;
g.upload_budget_kib = 0;
g.sky = true;
g.fog = true;
g.weather = true;
g.animated_textures = true;
g.dither_cutout_mips = t.dither_cutout_mips;
g.resolution_scale = t.resolution_scale;
g.dynamic_resolution = false;
g.fxaa = t.fxaa;
g.taa = false;
g.upscaling = Upscaling::Native;
}
+30 -45
View File
@@ -8,8 +8,8 @@ use crate::{
gfx::EffectiveOutput,
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParallelTickMode, ParticleCullingMode,
PresentMode, Preset, Upscaling,
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
Preset, Upscaling,
},
graphics_style::Cost,
lang,
@@ -98,7 +98,6 @@ pub enum H {
PresentMode,
Pacing,
Workers,
ParallelTick,
Overlay,
ResolutionScale,
DynamicResolution,
@@ -434,11 +433,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
None,
"Threads that generate, light and mesh chunks. Next start.",
),
h(
H::ParallelTick,
Saves,
"Shares your world's tick among threads. Auto: only while that is faster.",
),
],
),
(
@@ -859,7 +853,7 @@ impl H {
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
let g = &s.horizon;
match self {
H::Preset => labelled("Horizon Preset", g.preset.label()),
H::Preset => labelled("Preset", g.preset.label()),
H::LodDistance => match g.lod_distance {
0 => labelled("LOD Distance", "Off"),
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
@@ -893,7 +887,6 @@ impl H {
H::ParticleCulling => labelled("Particle Culling", g.particle_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::OpaqueIce => labelled("Opaque Ice", on_off(g.opaque_ice)),
H::ParallelTick => labelled("Parallel Tick", g.parallel_tick.label()),
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
H::CacheDecoratedPots => labelled("Decorated Pots", on_off(g.cache_decorated_pots)),
@@ -1048,9 +1041,6 @@ impl H {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::OpaqueIce => g.opaque_ice = !g.opaque_ice,
H::ParallelTick => {
g.parallel_tick = step(&ParallelTickMode::ALL, g.parallel_tick, back)
}
H::CacheShulkerBoxes => g.cache_shulker_boxes = !g.cache_shulker_boxes,
H::CacheSigns => g.cache_signs = !g.cache_signs,
H::CacheDecoratedPots => g.cache_decorated_pots = !g.cache_decorated_pots,
@@ -1300,13 +1290,13 @@ impl Item {
| H::Pacing
| H::PresentMode
| H::Workers
| H::ParallelTick
| H::FrameGeneration
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
| H::TranslucentBatching
| H::HdrSmoothGradients
| H::GpuCulling
)
| Item::Vanilla(
Opt::GuiScale
@@ -1763,14 +1753,6 @@ mod tests {
ui.set_search("tearing");
let found = ui.listed(&s, SIZE);
assert!(found.iter().any(|(_, i)| *i == Item::Vanilla(Opt::Vsync)));
// Both presets answer "preset"; Horizon's own, named apart from vanilla's, answers its name.
ui.set_search("preset");
let found = ui.listed(&s, SIZE);
let general = |item| (Page::General, item);
assert!(found.contains(&general(Item::Horizon(H::Preset))));
assert!(found.contains(&general(Item::Vanilla(Opt::Preset))));
ui.set_search("horizon preset");
assert_eq!(ui.listed(&s, SIZE), [general(Item::Horizon(H::Preset))]);
ui.set_search("hdr");
let [x, y, w, h] = cols.page(Page::ALL.len() - 1);
press(&mut ui, &mut s, (x + w / 2.0, y + h / 2.0));
@@ -2243,18 +2225,9 @@ mod tests {
fn presets_set_vanilla_and_horizon_options() {
let mut s = Settings::default();
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480], &NONE);
assert_eq!(s.horizon.preset, Preset::FullPerformance);
assert_eq!(s.render_distance, 6);
assert_eq!(s.clouds, "false");
assert_eq!(s.horizon.resolution_scale, 75);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
assert!(s.horizon.opaque_ice && s.horizon.cache_chests);
// The next one puts back what only Full Performance lowers.
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480], &NONE);
assert_eq!(s.horizon.preset, Preset::Performance);
assert_eq!(s.render_distance, 8);
assert_eq!(s.horizon.resolution_scale, 100);
assert!(!s.horizon.opaque_ice && s.horizon.cache_chests);
assert_eq!(s.clouds, "false");
// Any quality change leaves the preset.
Item::Vanilla(Opt::Clouds).cycle(&mut s, false, [854, 480], &NONE);
assert_eq!(s.horizon.preset, Preset::Custom);
@@ -2262,14 +2235,6 @@ mod tests {
Item::Horizon(H::Preset).cycle(&mut s, true, [854, 480], &NONE);
Item::Vanilla(Opt::Vsync).cycle(&mut s, false, [854, 480], &NONE);
assert_eq!(s.horizon.preset, Preset::Ultra);
// Ultra keeps the optimizations that leave the picture as it is, and no others.
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
assert_eq!(s.horizon.gpu_culling, GpuCullingMode::Auto);
assert_eq!((s.horizon.lod_distance, s.horizon.fxaa), (128, true));
assert_eq!(
(s.horizon.resolution_scale, s.horizon.opaque_ice),
(100, false)
);
}
#[test]
@@ -2284,7 +2249,7 @@ mod tests {
assert_eq!(seen, [2, 4, 8, 1]);
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480], &NONE);
assert_eq!(s.horizon.screenshot_quality, 8);
assert_eq!(s.horizon.preset, Preset::FullPerformance);
assert_eq!(s.horizon.preset, Preset::Performance);
}
#[test]
@@ -2332,6 +2297,14 @@ mod tests {
let item = Item::Horizon(H::EntityCulling);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(item.label(&s, &NONE).plain(), "Entity Culling: Off");
for mode in EntityCullingMode::ALL {
s.horizon.entity_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.entity_culling, mode);
}
}
s.horizon.entity_culling = EntityCullingMode::Off;
let mut ui = ui();
let r = row(&mut ui, &s, item);
for mode in [
@@ -2360,6 +2333,10 @@ mod tests {
assert_eq!(item.label(&s, &NONE).plain(), "Particle Culling: Off");
for mode in ParticleCullingMode::ALL {
s.horizon.particle_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.particle_culling, mode);
}
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).particle_culling,
mode
@@ -2553,8 +2530,8 @@ mod tests {
}
}
/// GPU Culling: Off, On or Auto right after Cave Culling, off by default, and greyed out
/// with its reason, reading Off, wherever it isn't offered.
/// GPU Culling: Off, On or Auto right after Cave Culling, off by default, no part of a
/// preset, and greyed out with its reason, reading Off, wherever it isn't offered.
#[test]
fn gpu_culling_steps_off_on_auto_where_it_is_offered() {
use crate::graphics::GpuCullingOffer;
@@ -2593,7 +2570,7 @@ mod tests {
assert!(key(&mut ui, &mut s, K::Enter).is_empty());
assert_eq!(s.horizon.gpu_culling, GpuCullingMode::On, "{offer:?}");
}
// Where it is, it steps through its three values and leaves the preset.
// Where it is, it steps through its three values and keeps the preset.
crate::graphics::offer_gpu_culling(GpuCullingOffer::Offered);
assert_eq!(item.blocked(&s, &NONE), None);
for label in ["Auto", "Off", "On"] {
@@ -2609,10 +2586,18 @@ mod tests {
}
item.cycle(&mut s, true, FB, &NONE);
assert_eq!(s.horizon.gpu_culling, GpuCullingMode::Off);
assert_eq!(s.horizon.preset, Preset::Custom);
assert_eq!(s.horizon.preset, Preset::Balanced);
// Auto says what it does.
s.horizon.gpu_culling = GpuCullingMode::Auto;
assert!(description(item, &s, &NONE, "").starts_with("Auto: "));
// No preset sets it.
for mode in GpuCullingMode::ALL {
s.horizon.gpu_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.gpu_culling, mode, "{preset:?}");
}
}
}
#[test]
+8 -34
View File
@@ -16,7 +16,6 @@ use model_reducing::reducible_model_sprites;
mod animate;
pub mod cit_glint;
mod gui_sprite;
mod model_reducing;
mod pack_text;
pub mod pictures;
@@ -56,7 +55,7 @@ pub struct TextureFlags {
impl TextureFlags {
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
pub fn from_mcmeta(json: &str) -> Option<Self> {
let meta: serde_json::Value = crate::packs::first_json(json.as_bytes()).ok()?;
let meta = crate::packs::first_json(json.as_bytes())?;
let section = meta.get("texture")?;
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
let flags = Self {
@@ -389,7 +388,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
}
/// The `animation` section of a texture's `.mcmeta`.
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
let meta: serde_json::Value = crate::packs::first_json(mcmeta?.as_bytes()).ok()?;
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
}
/// An animated strip's first frame: the region the mcmeta's frame size picks
@@ -1402,9 +1401,9 @@ impl HudAtlas {
edition.3 = pixels;
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
// The controller's art and Horizon's mark, unless a pack brought its own (walked above, as a
// mod's GUI textures are).
for image in crate::pad_glyph::images().chain(crate::mark_sprite::image()) {
// The controller's art, unless a pack brought its own (walked above, as a mod's GUI
// textures are).
for image in crate::pad_glyph::images() {
if !images.iter().any(|i| i.0 == image.0) {
images.push(image);
}
@@ -2467,8 +2466,9 @@ impl HudBatch {
);
}
}
InlineObject::Atlas { .. } => {
if let Some(rect) = atlas.inline_object_sprite(object) {
InlineObject::Atlas { sprite, .. } => {
let key = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
if let Some(rect) = atlas.sprites.get(key).copied() {
self.rect_uv(x, y, 8.0, 8.0, rect, tint);
}
}
@@ -3773,32 +3773,6 @@ mod tests {
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
}
#[test]
fn an_inline_atlas_sprite_resolves_in_its_named_atlas() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-inline-{}", std::process::id()));
let gui = root.join("assets/minecraft/textures/gui/sprites/boss_bar");
fs::create_dir_all(&gui).unwrap();
write_strip(
&gui.join("pink_background.png"),
16,
16,
&[[1, 2, 3, 255]],
None,
);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let packed = atlas.sprites["gui/sprites/boss_bar/pink_background"];
let missing = atlas.sprites[MISSING_SPRITE];
let found = atlas
.inline_sprite("minecraft:gui", "minecraft:boss_bar/pink_background")
.unwrap();
assert_eq!((found.u0, found.v0), (packed.u0, packed.v0));
let none = atlas
.inline_sprite("minecraft:gui", "minecraft:boss_bar/nope")
.unwrap();
assert_eq!((none.u0, none.v0), (missing.u0, missing.v0));
}
#[test]
fn a_root_single_sprite_strip_taller_than_the_atlas_packs_one_frame() {
let root = animated_root();
let atlases = root.join("assets/minecraft/atlases");
@@ -1,41 +0,0 @@
use super::{HudAtlas, MISSING_SPRITE, Rect};
/// The packed key of a GUI sprite id: `minecraft:boss_bar/pink_background` is
/// `gui/sprites/boss_bar/pink_background`, another namespace's `ns:gui/sprites/...`.
fn atlas_key_of_gui_sprite(id: &str) -> String {
match id.split_once(':') {
Some(("minecraft", path)) => format!("gui/sprites/{path}"),
Some((namespace, path)) => format!("{namespace}:gui/sprites/{path}"),
None => format!("gui/sprites/{id}"),
}
}
impl HudAtlas {
/// `AtlasGlyphProvider`: the sprite `sprite` of the atlas named `atlas`
/// (`minecraft:gui` keeps its sprites under `gui/sprites/`), or the missing
/// sprite when that atlas has none by that name.
pub fn inline_sprite(&self, atlas: &str, sprite: &str) -> Option<Rect> {
let plain = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
let gui = atlas_key_of_gui_sprite(sprite);
let gui_atlas = atlas.strip_prefix("minecraft:").unwrap_or(atlas) == "gui";
let (first, second) = if gui_atlas {
(gui.as_str(), plain)
} else {
(plain, gui.as_str())
};
self.sprites
.get(first)
.or_else(|| self.sprites.get(second))
.or_else(|| self.sprites.get(MISSING_SPRITE))
.copied()
}
/// `inline_sprite` for an inline atlas object; `None` for any other object.
pub(super) fn inline_object_sprite(&self, object: &crate::text::InlineObject) -> Option<Rect> {
match object {
crate::text::InlineObject::Atlas { atlas, sprite, .. } => {
self.inline_sprite(atlas, sprite)
}
_ => None,
}
}
}
@@ -39,13 +39,14 @@ pub fn text_with_backdrop(
background: u32,
) {
if background != 0 {
// GuiGraphicsExtractor.textWithBackdrop adds two pixels around the
// text's advance and the font's nine-pixel line height.
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
// the glyphs, the glyph advance to the right and nine high: not a
// fixed two-pixel frame.
out.push(fill(
x - 2,
y - 2,
x + w + 2,
y + 11,
x - 1,
y - 1,
x + w,
y + 9,
multiply(background, color),
));
}
@@ -1490,10 +1491,10 @@ mod boss_bar_room_tests {
mod backdrop_tests {
use super::*;
/// `GuiGraphicsExtractor.textWithBackdrop`: two pixels around the advance
/// and the nine-pixel font line, independently of glyph-background drawing.
/// `markBackground`: the backdrop reaches one pixel above and left of the
/// glyphs, their advance to the right and nine down, not 2 on every side.
#[test]
fn the_backdrop_frames_the_text_by_two_pixels() {
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
let mut out = Vec::new();
text_with_backdrop(
&mut out,
@@ -1507,6 +1508,6 @@ mod backdrop_tests {
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
panic!("a backdrop is a fill first");
};
assert_eq!((*x0, *y0, *x1, *y1), (8, 18, 18, 31));
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
}
}
-4
View File
@@ -74,7 +74,6 @@ pub mod level_loading;
pub mod light;
pub mod lod;
pub mod logging;
pub mod mark_sprite;
pub mod menu_blur;
pub mod menu_style;
pub mod mesher;
@@ -103,9 +102,6 @@ pub mod pad_glyph;
pub mod paged_maps;
pub mod par;
pub mod particles;
#[cfg(target_os = "linux")]
pub mod perf_capture;
pub mod perf_gpu;
pub mod poi_sections;
pub mod polytone_post;
pub mod post_chain;
+7 -31
View File
@@ -9,7 +9,6 @@ use crate::{
};
pub mod bake;
pub mod cache;
mod detail;
mod kept;
mod mesh;
pub mod packed;
@@ -2388,6 +2387,13 @@ impl Lod {
self.shared.mesh_wake.notify_all();
}
}
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
/// 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
}
@@ -6109,34 +6115,4 @@ mod tests {
assert!(lod.stale.contains(&key));
assert_eq!(queued(&mut lod), [key]);
}
#[test]
fn changing_far_detail_samples_tiles_again() {
for level in [0, 1, 2, 3, 5] {
let mut lod = Lod::scheduler_for_test();
let key = TileKey { x: 0, z: 0, level };
lod.plan.nodes.insert(key);
lod.active.insert(key);
lod.ready.insert(key, Arc::new(samples(70)));
assert!(
queued(&mut lod).is_empty(),
"level {level}: sampled tile is not asked again"
);
lod.set_reduced_detail(true);
assert!(
lod.ready.contains_key(&key),
"level {level}: old samples stay"
);
assert_eq!(queued(&mut lod), [key], "level {level}: not sampled again");
lod.finish_sample(key, samples(70));
lod.set_reduced_detail(true);
assert!(queued(&mut lod).is_empty(), "level {level}: same value");
lod.set_reduced_detail(false);
assert_eq!(
queued(&mut lod),
[key],
"level {level}: not sampled again on the way back"
);
}
}
}
@@ -1,23 +0,0 @@
//! Reduced far detail.
use super::*;
impl Lod {
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
/// 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) {
if self.reduced_detail != reduced {
let keys: Vec<_> = self
.ready
.keys()
.chain(self.pending.iter())
.copied()
.collect();
for key in keys {
self.mark_stale(key);
}
}
self.reduced_detail = reduced;
self.shared.approximate.store(reduced, Ordering::Relaxed);
}
}
@@ -1,11 +0,0 @@
//! Horizon's mark (`docs/media/brand/mark-dark.svg`) on the text grid, for an inline text sprite.
/// The mark's name in the HUD atlas.
pub const SPRITE: &str = "horizon:gui/mark";
const PNG: &[u8] = include_bytes!("../../horizon-client/assets/horizon/textures/gui/mark.png");
/// The mark as the HUD atlas takes an image.
pub fn image() -> std::iter::Once<(String, u32, u32, Vec<u8>)> {
let (width, height, rgba) = crate::hud::decode_bytes(PNG).expect("Horizon's mark is PNG");
std::iter::once((SPRITE.to_owned(), width, height, rgba))
}
+46 -34
View File
@@ -1594,9 +1594,9 @@ pub struct OptionsUi {
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// Pack Options, on the settings shell, and the packs' Polytone config entries by pack id
/// (the game fills them beside `pack_infos`).
pack_options: shell::Shell<crate::pack_options_ui::PackOptionsScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
/// fills them beside `pack_infos`).
pub pack_options: crate::pack_options_ui::PackOptionsUi,
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
@@ -3401,6 +3401,10 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if self.page() == Page::PackOptions {
self.pack_options.draw(batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.waiting();
let in_world = self.in_world;
if self.draw_radial_name(batch, atlas, cursor, in_world) {
@@ -3782,6 +3786,20 @@ impl OptionsUi {
});
return self.shell_events(s, outputs);
}
if self.page() == Page::PackOptions {
let mut events = Vec::new();
for action in self
.pack_options
.click((x, y), size, drag, right || self.shift)
{
match action {
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
crate::pack_options_ui::Action::SliderDrag => {}
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
}
}
return events;
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag {
@@ -3811,7 +3829,14 @@ impl OptionsUi {
crate::pack_screen::Action::BrowsePick(i) => {
events.push(Event::DownloadPack(i));
}
crate::pack_screen::Action::Options(id) => self.open_pack_options(&id),
crate::pack_screen::Action::Options(id) => {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(&id) {
configs.merge(own);
}
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
self.push(Page::PackOptions);
}
crate::pack_screen::Action::None => {}
}
return events;
@@ -4049,6 +4074,7 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
self.pack_options.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || shell_slider.is_some() || calibration_slider {
@@ -4142,6 +4168,10 @@ impl OptionsUi {
self.studio.wheel(amount);
return Vec::new();
}
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return Vec::new();
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
@@ -4225,6 +4255,10 @@ impl OptionsUi {
self.studio.wheel(amount);
return;
}
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
@@ -4359,8 +4393,14 @@ impl OptionsUi {
self.can_change_value = false;
match page {
Page::PackOptions => {
let screen = std::mem::take(&mut self.pack_options.screen);
events.extend(screen.finished());
let ui = std::mem::take(&mut self.pack_options);
if ui.changed {
let entries = ui.configs.entries().keys().cloned().collect();
events.push(Event::PackOptions {
entries,
chosen: ui.configs.chosen(),
});
}
}
Page::ShaderOptions | Page::ShaderPacks => {
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
@@ -4920,34 +4960,6 @@ mod tests {
/// A renderer whose destination offers nothing and whose display reports nothing.
const NONE: EffectiveOutput = EffectiveOutput::NONE;
#[test]
fn pack_options_leave_with_a_change_and_with_nothing_after_its_undo() {
use crate::settings_shell::Screen as _;
let mut s = Settings::default();
let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"));
let atlas = atlas.unwrap();
let mut ui = OptionsUi::default();
let mut configs = horizon_polytone::pack::Configs::default();
let birds = serde_json::json!({"default_value": true});
configs.add_entry("p:birds".into(), &birds);
ui.pack_configs.insert("pack".into(), configs);
for undone in [false, true] {
ui.open_pack_options("pack");
assert!(ui.shell().is_some(), "Pack Options is on the shell");
let birds = crate::pack_options_ui::Item::Entry(0);
ui.pack_options.screen.press(&mut s, birds, false);
if undone {
let before = s.clone();
ui.pack_options.screen.undo(&mut s, &before);
}
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None);
let sent = events
.iter()
.any(|e| matches!(e, Event::PackOptions { .. }));
assert_eq!(sent, !undone, "undone: {undone}");
}
}
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
/// must not claim the keys: the movement and menu keys keep working there.
@@ -23,7 +23,6 @@ impl OptionsUi {
Page::ControllerRadialPick => Some(&self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
Page::PackOptions => Some(&self.pack_options),
_ => None,
}
}
@@ -36,20 +35,9 @@ impl OptionsUi {
Page::ControllerRadialPick => Some(&mut self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
Page::PackOptions => Some(&mut self.pack_options),
_ => None,
}
}
/// Pack Options for the pack `id`, from the pack screen: its Polytone config entries.
pub(super) fn open_pack_options(&mut self, id: &str) {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(id) {
configs.merge(own);
}
// Opening a shell starts it afresh: the entries go in after.
self.push(Page::PackOptions);
self.pack_options.screen = crate::pack_options_ui::PackOptionsScreen::open(configs);
}
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
/// action), for a controller's focus to land there.
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
@@ -65,14 +53,13 @@ impl OptionsUi {
return;
}
// Every shell: the click may have changed the page.
let shells: [&mut dyn shell::Hosted; 7] = [
let shells: [&mut dyn shell::Hosted; 6] = [
&mut self.extras,
&mut self.graphics,
&mut self.controller,
&mut self.radial,
&mut self.radial_pick,
&mut self.shader,
&mut self.pack_options,
];
for shell in shells {
shell.clicks_step(false);
@@ -1,36 +1,45 @@
//! The Pack Options screen (#11486), on the settings shell (`settings_shell`): the config entries
//! of the selected resource packs' Polytone `config_entries` (wiki "Polytone Configs"). Each
//! `section` is a page, named by the packs' own translations; numbers are sliders that stop at
//! the entry's `step`, value lists are choices, booleans switch and free text reads. The first
//! page starts with the pack-wide presets and Reset all. The shell gives it the keyboard, a
//! controller's focus, the search, the strip's descriptions and Undo; leaving keeps what differs
//! from how the screen opened, and the packs load again with it.
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
//! and the packs load again with it.
use crate::widgets::Action as Declared;
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
settings::Settings,
settings_shell::{self as shell, Group, Output, Row, Sheet, Value},
settings_shell::{self as shell, Columns, Line, Lines},
text::Text,
ui,
};
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
/// A row: the pack-wide presets, Reset all, or the entry at an index of `ids`.
/// What a click asks of the options screen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Item {
Presets,
ResetAll,
Entry(u16),
pub enum Action {
ButtonClick,
SliderDrag,
/// Done: leave, keeping what changed.
Done,
}
const TITLE: &str = "horizon.pack.options.title";
/// The first page's group of rows for the whole pack, and every page's group of entries.
const WHOLE_PACK: &str = "Whole pack";
const PACK_OPTIONS: &str = "Pack settings";
const RESET_ALL: &str = "Reset all";
const RESET_ABOUT: &str = "Puts every option of this pack back to its default.";
const AT_DEFAULTS: &str = "Every option is at its default already.";
const PRESETS_ABOUT: &str = "Sets many options at once, as the pack groups them.";
const PACK_SETTINGS: &str = "Settings made by the pack's author: they change only this pack.";
/// A pack's tooltips, with the performance line after them, run longer than Horizon's own.
const NOTE_LINES: usize = 3;
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
#[derive(Default)]
pub struct PackOptionsUi {
/// The selected packs' entries and the values being picked.
pub configs: Configs,
/// Entry ids, by the row index the list uses.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its rows.
sections: Vec<(Option<String>, Vec<u16>)>,
scroll: ui::Scroll,
dragging: Option<u16>,
/// A value changed since the page opened.
pub changed: bool,
}
/// The presets' row (and its section): past every entry's index.
const PRESETS: u16 = u16::MAX;
/// A translation, or `fallback` where no language has the key.
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
@@ -56,34 +65,9 @@ fn readable(path: &str) -> String {
.unwrap_or_default()
}
/// A number as its entry shows it: whole with a whole step, else up to two decimals.
fn number_text(n: f64, step: f64) -> String {
if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
let text = format!("{n:.2}");
text.trim_end_matches('0').trim_end_matches('.').to_owned()
}
}
/// The screen: the entries being edited, as they were when it opened (for Undo and for whether
/// leaving changes anything), and how they are paged.
#[derive(Default)]
pub(crate) struct PackOptionsScreen {
/// The selected packs' entries and the values being picked.
pub(crate) configs: Configs,
opened: Configs,
/// Entry ids, by `Item::Entry`'s index.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its entries.
sections: Vec<(Option<String>, Vec<u16>)>,
/// Bumped on every change, for `stamp`: the values are floats, which don't hash.
revision: u64,
}
impl PackOptionsScreen {
/// The screen opens on `configs`, the selected packs' entries with the player's values.
pub(crate) fn open(configs: Configs) -> Self {
impl PackOptionsUi {
/// The page opens on `configs`, the selected packs' entries with the player's values.
pub fn open(configs: Configs) -> Self {
let mut ids: Vec<(String, Option<String>, i64)> = configs
.entries()
.iter()
@@ -112,21 +96,25 @@ impl PackOptionsScreen {
(None, None) => a.0.cmp(&b.0),
});
Self {
opened: configs.clone(),
configs,
ids: ids.into_iter().map(|(id, ..)| id).collect(),
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
revision: 0,
..Self::default()
}
}
/// What leaving sends: the entries and the player's choices, if they differ from how the
/// screen opened (an Undo, or a value stepped back, leaves nothing to send).
pub(crate) fn finished(&self) -> Option<crate::options_ui::Event> {
(self.configs != self.opened).then(|| crate::options_ui::Event::PackOptions {
entries: self.configs.entries().keys().cloned().collect(),
chosen: self.configs.chosen(),
})
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
let cols = Columns::of(w, h);
let mut lines = Lines::new();
// The pack-wide presets first, when the entries have some.
if !self.configs.presets().is_empty() {
lines.titled(PRESETS, vec![PRESETS]);
}
for (i, (_, rows)) in self.sections.iter().enumerate() {
lines.titled(i as u16, rows.clone());
}
let (lines, content) = lines.finish();
(cols, lines, content)
}
/// The namespace the presets' translations are under: of the first entry with presets.
@@ -178,14 +166,21 @@ impl PackOptionsScreen {
None if back => n - 1,
None => 0,
};
let before = self.configs.clone();
self.configs.apply_preset(&presets[next]);
self.revision += 1;
if self.configs != before {
self.changed = true;
}
}
/// A section's page name: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: usize) -> String {
let Some((id, rows)) = self.sections.get(section) else {
return lang::get("horizon.pack.options.general");
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: u16) -> String {
if section == PRESETS {
let key = format!("{}.presets", self.presets_namespace());
return translated(&key, || lang::get("horizon.pack.options.presets"));
}
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
return String::new();
};
let Some(id) = id else {
return lang::get("horizon.pack.options.general");
@@ -197,218 +192,367 @@ impl PackOptionsScreen {
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance line after it.
fn description(&self, id: &str) -> String {
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
fn parts(&self, row: u16) -> (String, Option<String>) {
if row == PRESETS {
let label = self.preset_label(self.current_preset().as_deref());
return (self.section_name(PRESETS), Some(label));
}
let Some(id) = self.ids.get(usize::from(row)) else {
return (String::new(), None);
};
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let value = match self.configs.get(id) {
Some(ConfigValue::Number(n)) => {
let step = match entry.kind {
ConfigKind::Number { step, .. } => step,
_ => 1.0,
};
let text = if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
format!("{n:.2}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
};
Some(text)
}
Some(ConfigValue::Text(t)) => Some(t.clone()),
Some(ConfigValue::Bool(_)) | None => None,
};
let value = value.map(|v| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
_ => v,
});
(name, value)
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
fn description(&self, row: u16) -> String {
let Some(id) = self.ids.get(usize::from(row)) else {
return String::new();
};
let (namespace, path) = split(id);
let key = format!("config.{namespace}.{path}.tooltip");
let tooltip = if lang::has(&key) {
let mut text = if lang::has(&key) {
lang::get(&key)
} else {
String::new()
};
let impact = self.configs.entries()[id]
.performance_impact
.as_ref()
.map(|impact| {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
lang::tr("horizon.pack.options.impact", &[&level])
});
match impact {
Some(line) if tooltip.is_empty() => line,
Some(line) => format!("{tooltip} {line}"),
None => tooltip,
if let Some(impact) = &self.configs.entries()[id].performance_impact {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
let line = lang::tr("horizon.pack.options.impact", &[&level]);
text = if text.is_empty() {
line
} else {
format!("{text} {line}")
};
}
text
}
/// A slider's place (0 to 1) for a number entry.
fn slider(&self, row: u16) -> Option<f32> {
let id = self.ids.get(usize::from(row))?;
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
return None;
};
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
return None;
};
Some(if max > min {
((n - min) / (max - min)) as f32
} else {
0.0
})
}
fn switch(&self, row: u16) -> Option<bool> {
match self.configs.get(self.ids.get(usize::from(row))?)? {
ConfigValue::Bool(b) => Some(*b),
_ => None,
}
}
/// Entry `i`'s row: a switch, a slider in its steps, a choice, or text to read.
fn entry_row(&self, i: u16) -> Option<Row<Item>> {
let id = self.ids.get(usize::from(i))?;
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let shown = |text: String| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&text]),
_ => text,
};
let value = match (&entry.kind, self.configs.get(id)?) {
(_, ConfigValue::Bool(on)) => Value::Switch(*on),
(&ConfigKind::Number { min, max, step }, &ConfigValue::Number(n)) => {
let span = max - min;
let stops = if step > 0.0 {
(span / step).round()
} else {
0.0
};
Value::Slider {
position: if span > 0.0 {
((n - min) / span) as f32
} else {
0.0
},
steps: (stops >= 1.0).then_some(stops as u32),
text: shown(number_text(n, step)),
pub fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
let (cols, lines, content) = self.layout(w, h);
self.scroll.clamp(cols.top, cols.bottom, content);
shell::backdrop(batch, atlas, in_world);
shell::mark(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
shell::plate(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let mut described = None;
shell::list(
batch,
atlas,
&cols,
&lines,
self.scroll.amount,
|section| self.section_name(section),
|batch, row: u16, y| {
let [rx, ry, rw, rh] = cols.row(y);
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
if hover || self.dragging == Some(row) {
described = Some(row);
}
let (name, value) = self.parts(row);
let label = match &value {
Some(v) => format!("{name}: {v}"),
None => name.clone(),
};
let view = shell::RowView {
label: &label,
name: &name,
value: value.as_deref(),
switch: self.switch(row),
slider: self.slider(row),
live: true,
hover,
focused: false,
held: self.dragging == Some(row),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
);
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
// The strip: the pointed-at entry's tooltip.
let [x, y, ..] = cols.dock;
style::panel(batch, atlas, cols.dock);
if let Some(row) = described {
let name = Text::literal(self.parts(row).0);
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
}
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
if self
.configs
.chosen()
.iter()
.any(|(id, _)| self.ids.contains(id))
{
foot.push((cols.button(1), "controls.reset", None));
}
for (rect, key, declared) in foot {
if let Some(declared) = declared {
crate::widgets::declare_action(declared, rect, true);
}
(ConfigKind::Text { allowed }, ConfigValue::Text(t)) if !allowed.is_empty() => {
Value::Choice(shown(t.clone()))
}
(_, value) => Value::Read(shown(match value {
ConfigValue::Number(n) => number_text(*n, 1.0),
ConfigValue::Text(t) => t.clone(),
ConfigValue::Bool(b) => b.to_string(),
})),
};
Some(Row::new(Item::Entry(i), name, value).about(self.description(id)))
let label = Text::literal(lang::get(key));
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
}
}
/// The rows for the whole pack: its presets, when it has some, and Reset all.
fn whole_pack(&self) -> Group<Item> {
let mut rows = Vec::new();
if !self.configs.presets().is_empty() {
let key = format!("{}.presets", self.presets_namespace());
let name = translated(&key, || lang::get("horizon.pack.options.presets"));
let label = self.preset_label(self.current_preset().as_deref());
rows.push(
Row::new(Item::Presets, name, Value::Choice(label)).about(lang::get(PRESETS_ABOUT)),
);
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
pub fn click(
&mut self,
(x, y): (f32, f32),
(w, h): (f32, f32),
drag: bool,
back: bool,
) -> Vec<Action> {
let (cols, lines, content) = self.layout(w, h);
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
if drag {
if self.scroll.held() {
self.scroll.drag(y, cols.top, cols.bottom, content);
} else if let Some(row) = self.dragging {
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
return Vec::new();
}
let at_defaults = self.configs.chosen().is_empty();
let reset = Row::new(Item::ResetAll, lang::get(RESET_ALL), Value::Press)
.about(lang::get(RESET_ABOUT))
.blocked(at_defaults.then(|| lang::get(AT_DEFAULTS)));
rows.push(reset);
Group {
name: WHOLE_PACK,
rows,
if cols.scrolls()
&& self
.scroll
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
{
return Vec::new();
}
if at(cols.button(0)) {
return vec![Action::ButtonClick, Action::Done];
}
if at(cols.button(1)) {
self.reset();
return vec![Action::ButtonClick];
}
if y < cols.top || y >= cols.bottom {
return Vec::new();
}
let Some(row) = lines.iter().find_map(|line| {
let row = line.setting?;
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
}) else {
return Vec::new();
};
if row == PRESETS {
self.cycle_preset(back);
return vec![Action::ButtonClick];
}
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return Vec::new();
};
let entry = self.configs.entries()[&id].clone();
match (&entry.kind, self.configs.get(&id).cloned()) {
(ConfigKind::Number { .. }, _) => {
self.dragging = Some(row);
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
self.set(&id, ConfigValue::Bool(!b));
}
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => return Vec::new(),
}
vec![Action::ButtonClick]
}
fn set(&mut self, id: &str, value: ConfigValue) {
let before = self.configs.get(id).cloned();
self.configs.set(id, value);
self.revision += 1;
if self.configs.get(id).cloned() != before {
self.changed = true;
}
}
/// Number entry `row` at slider place `at` (0 to 1).
fn set_slider(&mut self, row: u16, at: f32) {
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
}
}
/// Every entry back to its default.
fn reset(&mut self) {
let defaults: Vec<_> = (self.configs.entries().iter())
let defaults: Vec<_> = self
.configs
.entries()
.iter()
.map(|(id, e)| (id.clone(), e.default.clone()))
.collect();
for (id, value) in defaults {
self.set(&id, value);
}
}
}
impl shell::Screen for PackOptionsScreen {
type Page = u16;
type Row = Item;
fn title(&self) -> &'static str {
TITLE
/// The pointer lets go.
pub fn release(&mut self) {
self.scroll.release();
self.dragging = None;
}
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read only the screen's own entries, which change through it alone.
fn stamp(&self, _s: &Settings) -> u64 {
self.revision
}
/// A page per section; the first starts with the rows for the whole pack.
fn sheets(&self, _s: &Settings) -> Vec<Sheet<u16, Item>> {
let about = lang::get(PACK_SETTINGS);
let mut sheets = Vec::new();
for (i, (_, entries)) in self.sections.iter().enumerate() {
let rows = entries.iter().filter_map(|&e| self.entry_row(e)).collect();
let mut groups = vec![Group {
name: PACK_OPTIONS,
rows,
}];
if i == 0 {
groups.insert(0, self.whole_pack());
}
sheets.push(Sheet::new(
i as u16,
self.section_name(i),
about.clone(),
groups,
));
}
if sheets.is_empty() {
let groups = vec![self.whole_pack()];
sheets.push(Sheet::new(0, self.section_name(0), about, groups));
}
sheets
}
/// A switch flips, a choice and the presets step (back with Shift, or Left), Reset all
/// resets.
fn press(&mut self, _s: &mut Settings, item: Item, back: bool) -> Vec<Output> {
let i = match item {
Item::Presets => {
self.cycle_preset(back);
return Vec::new();
}
Item::ResetAll => {
self.reset();
return Vec::new();
}
Item::Entry(i) => i,
};
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
return Vec::new();
};
let kind = self.configs.entries()[&id].kind.clone();
match (kind, self.configs.get(&id).cloned()) {
(_, Some(ConfigValue::Bool(on))) => self.set(&id, ConfigValue::Bool(!on)),
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let at = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (at + n - 1) % n } else { (at + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => {}
}
Vec::new()
}
/// A slider moves its entry to the step nearest it (`Configs::set` snaps it).
fn slide(&mut self, _s: &mut Settings, item: Item, position: f32) {
let Item::Entry(i) = item else { return };
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
let value = min + f64::from(position.clamp(0.0, 1.0)) * (max - min);
self.set(&id, ConfigValue::Number(value));
}
}
/// With the "Scroll wheel changes settings" extra, the wheel steps a choice and the presets.
fn wheel_step(&mut self, s: &mut Settings, item: Item, back: bool) -> Option<Vec<Output>> {
let steps = match item {
Item::Presets => true,
Item::Entry(i) => self
.entry_row(i)
.is_some_and(|row| matches!(row.value, Value::Choice(_))),
Item::ResetAll => false,
};
if !steps {
return None;
}
let mut outputs = vec![Output::Event(crate::options_ui::Event::ButtonClick)];
outputs.extend(self.press(s, item, back));
Some(outputs)
}
fn undoes(&self) -> bool {
true
}
/// The entries as the screen opened with them.
fn undo(&mut self, _s: &mut Settings, _before: &Settings) -> Vec<Output> {
self.configs = self.opened.clone();
self.revision += 1;
Vec::new()
}
/// Undo shows exactly while an entry differs from how the screen opened.
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
Some(self.configs != self.opened)
/// The mouse wheel scrolls the list.
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
let (cols, _, content) = self.layout(w, h);
self.scroll
.wheel(amount, 18.0, cols.top, cols.bottom, content);
}
}
#[cfg(test)]
mod tests;
mod tests {
use super::*;
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let ui = PackOptionsUi::open(configs());
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut ui = PackOptionsUi::open(configs());
let size = (480.0, 270.0);
let (cols, lines, _) = ui.layout(size.0, size.1);
let ids = ui.ids.clone();
let point = |id: &str| {
let row = ids.iter().position(|i| i == id).unwrap() as u16;
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
let [x, y, w, h] = cols.row(cols.top + line.y);
(x + w - 4.0, y + h / 2.0)
};
let (birds, rain) = (point("p:birds"), point("p:rain"));
ui.click(birds, size, false, false);
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
ui.click(rain, size, false, false);
assert_eq!(
ui.configs.get("p:rain"),
Some(&ConfigValue::Text("streaks".into()))
);
// The slider's far right end is its maximum.
let fog = point("p:fog");
let (tx, tw) = cols.track();
ui.click((tx + tw, fog.1), size, false, false);
ui.release();
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
assert!(ui.changed);
assert_eq!(ui.configs.chosen().len(), 3);
ui.reset();
assert!(ui.configs.chosen().is_empty());
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
let mut ui = PackOptionsUi::open(c);
// The defaults are no preset.
assert_eq!(ui.current_preset(), None);
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
ui.cycle_preset(true);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
ui.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(ui.current_preset(), None);
assert!(ui.changed);
}
}
@@ -1,281 +0,0 @@
use super::*;
use crate::options_ui::Event;
use crate::settings_shell::{Columns, Hosted, Screen as _, Shell, Target};
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
/// Fog (a number) under Sky, rain (a list of values) under Weather, birds (a boolean) in no
/// section.
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
/// Two entries with the pack-wide presets `fast` and `pretty`.
fn with_presets() -> Configs {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
c
}
fn shell_of(configs: Configs) -> Shell<PackOptionsScreen> {
let mut shell = Shell::default();
shell.screen = PackOptionsScreen::open(configs);
shell
}
/// The row of entry `id`, and the page it is on.
fn entry(shell: &Shell<PackOptionsScreen>, id: &str) -> (u16, Item) {
let i = shell.screen.ids.iter().position(|e| e == id).unwrap() as u16;
let page = shell.screen.sections.iter().position(|s| s.1.contains(&i));
(page.unwrap() as u16, Item::Entry(i))
}
fn press(shell: &mut Shell<PackOptionsScreen>, s: &mut Settings, item: Item) -> Vec<Output> {
let at = shell.point_at(s, SIZE, item);
shell.click(s, at, SIZE, false, false, false)
}
fn value<'a>(shell: &'a Shell<PackOptionsScreen>, id: &str) -> Option<&'a ConfigValue> {
shell.screen.configs.get(id)
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let screen = PackOptionsScreen::open(configs());
let order: Vec<_> = screen.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
// Each section is a page, the first one holding Reset all before its entries.
let sheets = screen.sheets(&Settings::default());
let names: Vec<_> = sheets.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Weather", "Sky", "horizon.pack.options.general"]);
let first: Vec<Vec<Item>> = (sheets[0].groups.iter())
.map(|g| g.rows.iter().map(|r| r.id).collect())
.collect();
let rain = screen.ids.iter().position(|e| e == "p:rain").unwrap() as u16;
assert_eq!(first, [vec![Item::ResetAll], vec![Item::Entry(rain)]]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut s = Settings::default();
let mut shell = shell_of(configs());
let (page, birds) = entry(&shell, "p:birds");
shell.show(page);
assert_eq!(
press(&mut shell, &mut s, birds)[0],
Output::Event(Event::ButtonClick)
);
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(false)));
let (page, rain) = entry(&shell, "p:rain");
shell.show(page);
press(&mut shell, &mut s, rain);
let streaks = ConfigValue::Text("streaks".into());
assert_eq!(value(&shell, "p:rain"), Some(&streaks));
// The slider's far right end is its maximum.
let (page, fog) = entry(&shell, "p:fog");
shell.show(page);
let at = shell.point_at(&s, SIZE, fog);
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
shell.click(&mut s, (track + width, at.1), SIZE, false, false, false);
shell.release();
assert_eq!(value(&shell, "p:fog"), Some(&ConfigValue::Number(1.0)));
assert_eq!(shell.screen.configs.chosen().len(), 3);
// Reset all, on the first page, puts them all back, and is greyed once they are.
shell.show(0);
press(&mut shell, &mut s, Item::ResetAll);
assert!(shell.screen.configs.chosen().is_empty());
let sheets = shell.screen.sheets(&s);
assert!(!sheets[0].groups[0].rows[0].live);
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut screen = PackOptionsScreen::open(with_presets());
// The defaults are no preset.
assert_eq!(screen.current_preset(), None);
screen.cycle_preset(false);
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
assert_eq!(screen.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
screen.cycle_preset(false);
assert_eq!(screen.current_preset().as_deref(), Some("pretty"));
screen.cycle_preset(true);
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
screen.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(screen.current_preset(), None);
let presets = &screen.sheets(&Settings::default())[0].groups[0].rows[0];
assert_eq!(presets.id, Item::Presets);
let custom = lang::get("horizon.pack.options.presets.custom");
assert_eq!(presets.value, Value::Choice(custom));
}
#[test]
fn left_and_right_step_the_presets() {
let mut s = Settings::default();
let mut shell = shell_of(with_presets());
shell.set_focus(Some(Target::Row(Item::Presets)));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("pretty"));
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
assert_eq!(shell.focused_row(), Some(Item::Presets));
}
#[test]
fn sliders_stop_at_the_entrys_step() {
let mut c = Configs::default();
let json = serde_json::json!({"default_value": 4.0, "min": 0.0, "max": 10.0, "step": 2.0});
c.add_entry("p:clouds".into(), &json);
let mut s = Settings::default();
let mut shell = shell_of(c);
let row = &shell.screen.sheets(&s)[0].groups[1].rows[0];
let stops = Value::Slider {
position: 0.4,
steps: Some(5),
text: "4".into(),
};
assert_eq!(row.value, stops);
// A press a little past the middle of the track lands on the step nearest it.
let at = shell.point_at(&s, SIZE, Item::Entry(0));
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
shell.click(
&mut s,
(track + width * 0.55, at.1),
SIZE,
false,
false,
false,
);
shell.release();
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(6.0)));
// Held by the press, Right goes one step on and Left one back.
shell.set_focus(Some(Target::Row(Item::Entry(0))));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(8.0)));
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(4.0)));
}
/// A language with the pack's own strings: a name, a section and a tooltip.
struct PackLang;
impl lang::ResourceSource for PackLang {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
let strings = serde_json::json!({
"config.minecraft.haze": "Haze",
"config.minecraft.haze.tooltip": "Thickens the distant air.",
"config.minecraft.section.sky": "Sky and fog",
});
serde_json::to_vec(&strings).unwrap()
})
}
}
fn haze() -> Configs {
let mut c = configs();
let json =
serde_json::json!({"default_value": false, "section": "sky", "performance_impact": "high"});
c.add_entry("minecraft:haze".into(), &json);
c
}
#[test]
fn the_search_finds_an_entry_by_its_tooltip_and_the_strip_has_its_impact() {
let language = lang::Language::load(&[&PackLang], "en_us");
lang::scoped(language, || {
let s = Settings::default();
let mut shell = shell_of(haze());
let (page, haze) = entry(&shell, "minecraft:haze");
shell.set_search("distant air");
assert_eq!(shell.listed(&s, SIZE), [(page, haze)]);
let sheets = shell.screen.sheets(&s);
assert_eq!(sheets[usize::from(page)].name, "Sky and fog");
let row = sheets.iter().flat_map(|p| &p.groups).flat_map(|g| &g.rows);
let row = row.into_iter().find(|r| r.id == haze).unwrap();
assert_eq!(row.name, "Haze");
assert_eq!(
row.about,
"Thickens the distant air. Performance impact: high"
);
});
}
#[test]
fn tab_reaches_every_row_of_every_page() {
let mut s = Settings::default();
let mut shell = shell_of(haze());
let pages = shell.screen.sheets(&s).len() as u16;
let mut seen = Vec::new();
for page in 0..pages {
shell.show(page);
shell.set_focus(None);
for _ in 0..8 {
shell.key(&mut s, K::Tab, None, false, SIZE);
if let Some(row) = shell.focused_row()
&& !seen.contains(&row)
{
seen.push(row);
}
}
}
// Reset all is greyed at the defaults; every entry is reached.
let mut entries: Vec<Item> = (0..shell.screen.ids.len() as u16)
.map(Item::Entry)
.collect();
seen.sort_by_key(|item| format!("{item:?}"));
entries.sort_by_key(|item| format!("{item:?}"));
assert_eq!(seen, entries);
}
#[test]
fn undo_puts_the_entries_back_and_then_leaving_sends_nothing() {
let mut s = Settings::default();
let mut shell = shell_of(configs());
let (page, birds) = entry(&shell, "p:birds");
shell.show(page);
press(&mut shell, &mut s, birds);
assert!(shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
assert!(shell.screen.finished().is_some());
let undo = shell
.foot_at(&s, SIZE)
.into_iter()
.find(|f| f.0 == Target::Undo);
let [x, y, w, h] = undo.unwrap().1;
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
SIZE,
false,
false,
false,
);
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(true)));
assert!(!shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
assert_eq!(shell.screen.finished(), None);
// Stepped there and back by hand, it sends nothing either.
press(&mut shell, &mut s, birds);
press(&mut shell, &mut s, birds);
assert_eq!(shell.screen.finished(), None);
press(&mut shell, &mut s, birds);
let Some(Event::PackOptions { entries, chosen }) = shell.screen.finished() else {
panic!("a change is sent");
};
assert_eq!(entries.len(), 3);
assert_eq!(chosen.get("p:birds"), Some(&ConfigValue::Bool(false)));
}
@@ -677,7 +677,7 @@ impl PackScreen {
return action;
}
if self.search.contains(x, y) {
self.search.click(m, x, false, false);
self.search.click(m, x, false);
self.set_focus(Focus::Search);
return Action::None;
}
@@ -1137,7 +1137,7 @@ impl PackScreen {
if d::inside(point, qx, qy, BROWSER_SEARCH_W, 18) {
self.browser_search.x = qx;
self.browser_search.y = qy;
self.browser_search.click(m, x, false, false);
self.browser_search.click(m, x, false);
self.browser_focus = true;
return Some(Action::None);
}
+16 -232
View File
@@ -468,20 +468,17 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
first_json(bytes).ok()
first_json(bytes)
}
/// The first JSON value of a `.mcmeta` file, as `ResourceMetadata.fromJsonStream` reads it:
/// Gson's `TypeAdapter.read` stops after the value, so text after it (packs leave `//` notes
/// there) is never read, and a UTF-8 byte order mark is skipped.
pub(crate) fn first_json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> serde_json::Result<T> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
match serde_json::Deserializer::from_slice(bytes)
/// Reads the first JSON value from resource metadata, matching Gson's
/// `JsonReader` (which ignores a trailing signature or comment). Resource
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()
{
Some(value) => value,
None => Err(serde::de::Error::custom("empty metadata")),
}
.next()?
.ok()
}
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
/// in the pack's base, not its overlays) at their default values.
@@ -830,6 +827,13 @@ fn build_overlay_uncached(
}
// 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
.keys()
.flat_map(|f| {
let parts: Vec<&str> = f.split('/').collect();
(1..parts.len()).map(move |n| parts[..n].join("/"))
})
.collect();
let zips: Vec<Option<Zip>> = packs
.iter()
.map(|p| {
@@ -878,21 +882,6 @@ fn build_overlay_uncached(
merged.insert(name.clone(), bytes);
}
}
add_sprite_renames(
vanilla,
&merged,
&filters,
&mut files,
&mut hidden,
dependencies,
);
let touched_dirs: BTreeSet<String> = files
.keys()
.flat_map(|f| {
let parts: Vec<&str> = f.split('/').collect();
(1..parts.len()).map(move |n| parts[..n].join("/"))
})
.collect();
/// A zip entry to write: its pack, its name in the zip and where it goes.
type Extract<'a> = (usize, &'a str, PathBuf);
#[allow(clippy::too_many_arguments)]
@@ -1043,123 +1032,6 @@ fn build_overlay_uncached(
}
extracted
}
/// `SingleFile` with a `sprite`: the atlas holds the resource's pixels, and the resource's
/// `.mcmeta`, under the sprite's id. Every other sprite of the blocks and items atlases is its
/// texture's own id, and the loaders read a model's sprite from `textures/<sprite>.png`, so the
/// overlay gives a renamed sprite that file. ItemsAdder and Nexo packs name their models'
/// textures only through these renames (#14485).
///
/// A sprite gets its resource's file only when the whole source list, in order, ends on the
/// rename (`SpriteSourceList.list`: a later source of the same id replaces it), when the other
/// atlas, which reads the same file, lists it from that resource too or not at all, and when it
/// has no texture of its own. A texture that is there may be read as itself (another source's
/// resource, an unstitched or paletted sprite's input), so no alias ever replaces one; such a
/// sprite draws its own texture, as before aliases.
fn add_sprite_renames(
vanilla: &Path,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
files: &mut BTreeMap<String, (usize, Provided)>,
hidden: &mut BTreeSet<String>,
dependencies: &mut cache::Dependencies,
) {
use crate::atlas_sources::{
SpriteOrigin, SpriteSource, decode, list, list_textures, texture_path,
};
let png = |id: &str| {
texture_path(Path::new(""), id, "textures")
.to_string_lossy()
.replace('\\', "/")
};
let atlases: Vec<_> = ["blocks", "items"]
.into_iter()
.filter_map(|atlas| merged.get(&format!("assets/minecraft/atlases/{atlas}.json")))
.filter_map(|bytes| decode(std::str::from_utf8(bytes).ok()?))
.collect();
let renamed = |source: &SpriteSource| matches!(source, SpriteSource::Single { resource, sprite: Some(sprite) } if sprite != resource);
if !atlases.iter().flatten().any(renamed) {
return;
}
// The textures a source can list: the packs' and vanilla's the packs leave visible.
let mut textures: Vec<String> = list_textures(vanilla)
.into_iter()
.filter(|id| {
let name = png(id);
!hidden.contains(&name) && !filters.iter().any(|f| f.blocks(&name))
})
.chain(files.keys().filter_map(|name| {
let (namespace, rest) = name.strip_prefix("assets/")?.split_once('/')?;
let path = rest.strip_prefix("textures/")?.strip_suffix(".png")?;
Some(format!("{namespace}:{path}"))
}))
.collect();
textures.sort();
textures.dedup();
// Each sprite's resource, `None` where an atlas lists it any other way.
let mut renames: BTreeMap<String, Option<String>> = BTreeMap::new();
for sources in &atlases {
for (sprite, origin) in list(sources, &textures) {
let from = match origin {
SpriteOrigin::File(resource) if resource != sprite => Some(resource),
_ => None,
};
renames
.entry(sprite)
.and_modify(|seen| {
if *seen != from {
*seen = None;
}
})
.or_insert(from);
}
}
let renames = renames
.into_iter()
.filter(|(sprite, _)| textures.binary_search(sprite).is_err())
.filter_map(|(sprite, resource)| Some((png(&resource?), png(&sprite))));
// Each resource is the file the packs give, never another rename's sprite.
let mut found = Vec::new();
for (from, to) in renames {
// `texture_path` names an id that would leave the root as a file that is never there.
if !stays_inside(&from) || !stays_inside(&to) {
continue;
}
let from_meta = format!("{from}.mcmeta");
// A pack's texture with its own `.mcmeta`, or vanilla's below every pack.
let (file, meta) = match files.get(&from) {
Some(file) => (file.clone(), files.get(&from_meta).cloned()),
None => {
let (path, meta_path) = (vanilla.join(&from), vanilla.join(&from_meta));
dependencies.file(&path);
dependencies.file(&meta_path);
if !path.is_file() || filters.iter().any(|f| f.blocks(&from)) {
continue;
}
// `createStackMetadataFinder`: a pack's filter hides vanilla's `.mcmeta` too.
let meta = files.get(&from_meta).cloned().or_else(|| {
(meta_path.is_file() && !filters.iter().any(|f| f.blocks(&from_meta)))
.then(|| (0, (Some(meta_path), from_meta.clone())))
});
((0, (Some(path), from.clone())), meta)
}
};
found.push((to, file, meta));
}
for (to, file, meta) in found {
let to_meta = format!("{to}.mcmeta");
files.insert(to, file);
match meta {
Some(meta) => {
hidden.remove(&to_meta);
files.insert(to_meta, meta);
}
None => {
files.remove(&to_meta);
hidden.insert(to_meta);
}
}
}
}
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
/// for a small pack of text, and never past 8 GiB.
@@ -2034,94 +1906,6 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn a_renamed_atlas_sprite_reads_its_resource() {
let base =
std::env::temp_dir().join(format!("horizon-sprite-rename-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let write = |path: PathBuf, bytes: &[u8]| {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
};
let vanilla = base.join("vanilla");
let textures = vanilla.join("assets/minecraft/textures");
let animation = b"{\"animation\":{}}";
write(textures.join("block/sea_lantern.png"), b"lantern");
write(textures.join("block/sea_lantern.png.mcmeta"), animation);
write(textures.join("item/stick.png"), b"vanilla stick");
write(textures.join("item/stick.png.mcmeta"), animation);
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
let pack = base.join("packs/IA");
write(pack.join("pack.mcmeta"), meta);
write(pack.join("assets/iageneric/textures/item/box.png"), b"box");
// An ItemsAdder pack's items atlas: its models name only the `ia:` sprites.
write(
pack.join("assets/minecraft/atlases/items.json"),
br#"{"sources":[
{"type":"single","resource":"iageneric:item/box","sprite":"ia:0"},
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:1199"},
{"type":"single","resource":"iageneric:item/box","sprite":"minecraft:item/stick"},
{"type":"single","resource":"minecraft:item/stick","sprite":"ia:3"},
{"type":"single","resource":"iageneric:item/gone","sprite":"ia:2"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:../../../escape"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:7"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:8"},
{"type":"single","resource":"iageneric:item/box","sprite":"ia:9"}
]}"#,
);
// A pack above it: later sources of three of those sprites, a blocks atlas that
// disagrees about another, and a filter hiding vanilla's animation of the stick.
let higher = base.join("packs/Top");
write(
higher.join("pack.mcmeta"),
br#"{"pack":{"description":"d","pack_format":97},
"filter":{"block":[{"path":"textures/item/stick\\.png\\.mcmeta"}]}}"#,
);
write(
higher.join("assets/minecraft/atlases/items.json"),
br#"{"sources":[
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:8"},
{"type":"filter","pattern":{"namespace":"ia","path":"7"}}
]}"#,
);
write(
higher.join("assets/minecraft/atlases/blocks.json"),
br#"{"sources":[
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:9"}
]}"#,
);
let packs = discover(&vanilla, &base.join("packs"));
let out = base.join("overlay");
build_overlay(&vanilla, &packs, &out).unwrap();
let read = |rel: &str| fs::read(out.join(rel)).ok();
let texture = |id: &str| read(&format!("assets/ia/textures/{id}.png"));
assert_eq!(texture("0").as_deref(), Some(&b"box"[..]));
assert_eq!(read("assets/ia/textures/0.png.mcmeta"), None);
// A vanilla resource comes with its animation.
assert_eq!(texture("1199").as_deref(), Some(&b"lantern"[..]));
assert_eq!(
read("assets/ia/textures/1199.png.mcmeta").as_deref(),
Some(&animation[..])
);
// A texture that is there stays itself: other sources may read it (here as `ia:3`'s
// resource; an unstitched or paletted sprite's input alike).
assert_eq!(
read("assets/minecraft/textures/item/stick.png").as_deref(),
Some(&b"vanilla stick"[..])
);
// ...with its `.mcmeta` only where no filter hides it.
assert_eq!(texture("3").as_deref(), Some(&b"vanilla stick"[..]));
assert_eq!(read("assets/ia/textures/3.png.mcmeta"), None);
// A missing resource adds nothing, and no rename leaves the overlay.
assert_eq!(texture("2"), None);
assert!(!out.join("escape.png").exists());
// The last source of a sprite wins: a filter drops it, a later rename replaces it.
assert_eq!(texture("7"), None);
assert_eq!(texture("8").as_deref(), Some(&b"lantern"[..]));
// The two atlases read one file, and disagree about `ia:9`: it gets neither.
assert_eq!(texture("9"), None);
let _ = fs::remove_dir_all(base);
}
#[test]
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
@@ -13,8 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
// Version 7 stacks blockstate files (every pack's definition, bottom first).
// Version 8 left an atlas `single` source's renamed sprite without its texture file (#14485).
const VERSION: u32 = 9;
const VERSION: u32 = 8;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
+105 -119
View File
@@ -1402,8 +1402,100 @@ impl ParticleLightCache {
}
}
mod view_cull;
use view_cull::{PARTICLE_FRUSTUM_OFFSET, ParticleSectionFrustumCache, SectionFrustumResult};
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
@@ -4611,7 +4703,16 @@ impl ParticleEngine {
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_in_view(camera, base, partial, None, light_at, out);
self.render_inner::<false>(
camera,
base,
partial,
0.0,
None,
&mut |_, _| false,
light_at,
out,
);
}
/// Render particles after rejecting view-frustum misses and any particles
@@ -4629,7 +4730,7 @@ impl ParticleEngine {
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render_in_view(camera, base, partial, frustum, light_at, out);
self.render(camera, base, partial, light_at, out);
return;
}
self.render_inner::<true>(
@@ -4660,16 +4761,10 @@ impl ParticleEngine {
additive.clear();
let mut section_cache =
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
let view = frustum.map(|f| f.pulled_back(PARTICLE_FRUSTUM_OFFSET));
for p in &self.particles {
if p.no_render {
continue;
}
if let Some(view) = &view
&& !view_cull::in_view(view, p, camera)
{
continue;
}
// SparkParticle.extract: twinkling sparks blink after a third of their life.
if let Behaviour::Spark { twinkle: true, .. } = p.behaviour
&& p.age >= p.lifetime / 3
@@ -5104,115 +5199,6 @@ mod tests {
assert_eq!(attributes(&disabled), attributes(&regular));
}
/// An engine whose smoke particles stand at `positions`, camera-relative (the camera is at
/// the origin looking down -z, like `Mat4::IDENTITY` as a view).
fn smoke_at(positions: &[[f64; 3]]) -> ParticleEngine {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
engine.atlas.insert(
sprite.clone(),
(
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
false,
),
);
let mut random = LegacyRandom::new(7);
for &at in positions {
let mut particle = Particle::moving(&mut random, ParticleType::Smoke, at, [0.0; 3]);
particle.sprite = Sprite::Named(sprite.clone());
engine.particles.push(particle);
}
engine
}
fn quad_vertices(engine: &ParticleEngine, view: Option<&crate::gfx::Frustum>) -> usize {
let mut out = Vec::new();
engine.render_in_view(DVec3::ZERO, Quat::IDENTITY, 0.5, view, &|_| 0xf0, &mut out);
out.len()
}
#[test]
fn particles_outside_the_pulled_back_frustum_make_no_quads() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
let kept = [
[0.0, 0.0, -5.0], // straight ahead
[0.0, 0.0, 2.5], // behind the camera, inside the 3 blocks of offset
[0.0, 0.0, -300.0], // far, inside the far plane
];
let dropped = [
[0.0, 0.0, 10.0], // well behind the camera
[0.0, 0.0, 3.5], // just past the offset
[100.0, 0.0, -5.0], // far to the side
[0.0, 60.0, -5.0], // far above
[0.0, 0.0, -600.0], // beyond the far plane
];
let all: Vec<_> = kept.iter().chain(&dropped).copied().collect();
let engine = smoke_at(&all);
let only_kept = quad_vertices(&smoke_at(&kept), None);
assert!(only_kept > 0);
assert_eq!(quad_vertices(&engine, Some(&view)), only_kept);
// Without a view every particle is drawn, as before.
assert_eq!(
quad_vertices(&engine, None),
only_kept * all.len() / kept.len()
);
// `render` is the same as no view.
let mut regular = Vec::new();
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &|_| 0xf0, &mut regular);
assert_eq!(regular.len(), quad_vertices(&engine, None));
}
#[test]
fn the_culled_path_applies_the_same_frustum_test() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
let engine = smoke_at(&[[0.0, 0.0, -5.0], [0.0, 0.0, 10.0]]);
let mut out = Vec::new();
// The disabled setting still keeps vanilla's always-on test.
engine.render_culled(
DVec3::ZERO,
Quat::IDENTITY,
0.5,
900.0,
false,
Some(&view),
|_, _| panic!("disabled culling must not query particle visibility"),
&|_| 0xf0,
&mut out,
);
assert_eq!(
out.len(),
quad_vertices(&smoke_at(&[[0.0, 0.0, -5.0]]), None)
);
}
#[test]
fn a_particle_is_tested_at_its_position_this_tick_not_an_interpolated_one() {
use crate::gfx::{Frustum, world_projection};
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
// The frame is drawn half way (0.5) between `previous` and `position`.
// Ahead now, far behind a tick ago (half way: 12.5 behind, outside): kept.
let mut coming = smoke_at(&[[0.0, 0.0, -5.0]]);
coming.particles[0].previous = [0.0, 0.0, 30.0];
assert!(quad_vertices(&coming, None) > 0);
assert_eq!(
quad_vertices(&coming, Some(&view)),
quad_vertices(&coming, None)
);
// Behind now, ahead a tick ago (half way: 5 ahead, inside): dropped.
let mut going = smoke_at(&[[0.0, 0.0, 10.0]]);
going.particles[0].previous = [0.0, 0.0, -20.0];
assert_eq!(quad_vertices(&going, Some(&view)), 0);
}
#[test]
fn quads_face_the_rotation_they_are_given() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -1,138 +0,0 @@
//! The camera's view frustum for particles: vanilla's always-on point test, and the section
//! cache behind the opt-in Particle Culling setting.
use super::{Particle, ParticleEngine, Vertex};
use glam::{DVec3, Quat, Vec3 as FVec3};
use std::array;
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
/// How far back, in blocks, the camera's frustum is pulled for the particle test (vanilla's
/// `Frustum.offset` in `ParticleEngine.extract`).
pub(super) const PARTICLE_FRUSTUM_OFFSET: f32 = 3.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
pub(super) struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
pub(super) fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
/// Whether `QuadParticleGroup`'s test keeps `particle`: its position this tick (camera-relative, not
/// interpolated) is on or inside `view`, the camera's frustum pulled back [`PARTICLE_FRUSTUM_OFFSET`] blocks.
pub(super) fn in_view(view: &crate::gfx::Frustum, particle: &Particle, camera: DVec3) -> bool {
let at = DVec3::from_array(particle.position) - camera;
view.contains_point(at.as_vec3())
}
impl ParticleEngine {
/// [`Self::render`], keeping only the particles inside `view`, the camera's frustum pulled
/// back [`PARTICLE_FRUSTUM_OFFSET`] blocks (vanilla's `ParticleEngine.extract` and
/// `QuadParticleGroup`): the test runs whatever the Particle Culling setting says. With
/// `None`, every particle is drawn.
pub fn render_in_view(
&self,
camera: DVec3,
base: Quat,
partial: f32,
view: Option<&crate::gfx::Frustum>,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
base,
partial,
0.0,
view,
&mut |_, _| false,
light_at,
out,
);
}
}
@@ -1,406 +0,0 @@
//! A stopped sampler session as the capture's `samples` message (docs/perf-platform.md §3.5).
use std::collections::HashMap;
use horizon_perf_format::Interner;
use horizon_perf_format::v1::{
BlockUnits, Contexts, Functions, Nodes, Overhead, SampleKind, SampleProfile, SampleSource,
SampleUnit, SampledThread, Side, Unit, Weights,
};
use horizon_threads::heap_sample::HeapProfile;
use horizon_threads::profile::{Aggregate, Frame, Profile, State, Symbols, symbolize};
use horizon_threads::work::{self, Space};
#[cfg(test)]
mod tests;
const UNKNOWN: &str = "[unknown]";
/// The name `work::set_dim` ids stand for: 1 overworld, 2 the nether, 3 the end.
fn dimension_name(id: u8) -> Option<&'static str> {
match id {
1 => Some("overworld"),
2 => Some("the_nether"),
3 => Some("the_end"),
_ => None,
}
}
/// The crate a demangled function name belongs to, or "" when it names none.
fn crate_of(name: &str) -> &str {
if let Some(inner) = name.strip_prefix('[') {
let module = inner.split([' ', ']']).next().unwrap_or("");
return if module == "unmapped" { "" } else { module };
}
let name = name.trim_start_matches(['<', '&', '*']);
let name = name.strip_prefix("dyn ").unwrap_or(name);
match name.split_once("::") {
Some((krate, _)) if krate.chars().all(|c| c.is_alphanumeric() || c == '_') => krate,
_ => "",
}
}
#[derive(Default)]
struct FunctionTable {
rows: Functions,
index: HashMap<(u32, u32), u32>,
allocator: Vec<bool>,
}
impl FunctionTable {
fn id(
&mut self,
strings: &mut Interner,
name: &str,
file: Option<&str>,
line: u32,
addr: u64,
) -> u32 {
let (name_id, file_id) = (
strings.intern(name),
file.map_or(0, |file| strings.intern(file)),
);
if let Some(&row) = self.index.get(&(name_id, file_id)) {
return row;
}
let row = self.rows.name.len() as u32;
self.rows.name.push(name_id);
self.rows.file.push(file_id);
self.rows.line.push(line);
self.rows.crate_.push(match crate_of(name) {
"" => 0,
krate => strings.intern(krate),
});
self.rows.addr.push(addr);
self.allocator.push(allocator_frame(name));
self.index.insert((name_id, file_id), row);
row
}
}
/// The thread each trie node belongs to; node 0 is the root.
fn thread_of(agg: &Aggregate) -> Vec<u16> {
let mut of = vec![0u16; agg.nodes()];
for n in 1..agg.nodes() {
of[n] = match agg.frame(n as u32) {
Frame::Thread(i) => i,
_ => of[agg.parent(n as u32) as usize],
};
}
of
}
/// The trie nodes some counted sample passes through.
fn live_nodes(agg: &Aggregate) -> Vec<bool> {
let mut live = vec![false; agg.nodes()];
for key in agg.counts.keys() {
let mut n = key.node as usize;
while n != 0 && !live[n] {
live[n] = true;
n = agg.parent(n as u32) as usize;
}
}
live
}
#[derive(Default)]
struct NodeTable {
rows: Nodes,
index: HashMap<(Option<u32>, u32, u16), u32>,
}
impl NodeTable {
fn id(&mut self, parent: Option<u32>, function: u32, thread: u16) -> u32 {
let key = (parent, function, if parent.is_none() { thread } else { 0 });
if let Some(&row) = self.index.get(&key) {
return row;
}
let nodes = &mut self.rows;
nodes.parent.push(parent.unwrap_or(0));
nodes.function.push(function);
nodes.thread_root.push(match parent {
None => u32::from(thread) + 1,
Some(_) => 0,
});
let row = nodes.parent.len() as u32 - 1;
self.index.insert(key, row);
row
}
}
fn named(strings: &mut Interner, space: Space, id: u16) -> u32 {
match id {
0 => 0,
id => match work::name(space, id) {
Some(name) => strings.intern(name),
None => strings.intern(&format!("#{id}")),
},
}
}
/// Whether `name` is the allocator's own frame, which a heap sample's stack drops from its leaf end.
fn allocator_frame(name: &str) -> bool {
let name = name.trim_start_matches('<');
name.starts_with("horizon_threads::heap_sample::")
|| name.starts_with("__rust")
|| name.starts_with("__rg_")
|| name.starts_with("__rdl_")
|| name.starts_with("alloc::alloc::")
|| name.starts_with("alloc::raw_vec::")
|| name.starts_with("mimalloc::")
}
/// One aggregate's trie nodes as rows of the shared tables: `last[n]` is the output node of trie node `n`.
fn add_tree(
agg: &Aggregate,
threads: &[u16],
symbols: &Symbols,
functions: &mut FunctionTable,
nodes: &mut NodeTable,
strings: &mut Interner,
) -> (Vec<u16>, Vec<Option<u32>>) {
let (local, live) = (thread_of(agg), live_nodes(agg));
let thread_of: Vec<u16> = local
.iter()
.map(|&t| threads.get(usize::from(t)).copied().unwrap_or(0))
.collect();
let mut last: Vec<Option<u32>> = vec![None; agg.nodes()];
for n in (1..agg.nodes()).filter(|&n| live[n]) {
let inherited = last[agg.parent(n as u32) as usize];
last[n] = match agg.frame(n as u32) {
Frame::Thread(_) => None,
Frame::Wall(_) => inherited,
Frame::Addr(addr) => {
let offset = symbols.module_offset(addr);
let mut at = inherited;
for sym in symbols.frames(addr).iter().rev() {
let f = functions.id(strings, &sym.name, sym.file.as_deref(), sym.line, offset);
at = Some(nodes.id(at, f, thread_of[n]));
}
if at == inherited {
let f = functions.id(strings, UNKNOWN, None, 0, offset);
at = Some(nodes.id(at, f, thread_of[n]));
}
at
}
};
}
(thread_of, last)
}
fn trie_addrs(agg: &Aggregate) -> impl Iterator<Item = u64> + '_ {
let live = live_nodes(agg);
(1..agg.nodes())
.filter(move |&n| live[n])
.filter_map(|n| match agg.frame(n as u32) {
Frame::Addr(a) => Some(a),
_ => None,
})
}
/// `profile` (and the heap session's `heap`) as a `SampleProfile` of `side`; strings go through `strings`, whose new entries the caller appends first.
pub fn sample_profile(
profile: &Profile,
heap: Option<&HeapProfile>,
side: Side,
unit: Unit,
block_name: &dyn Fn(u16) -> Option<String>,
strings: &mut Interner,
) -> SampleProfile {
let agg = &profile.agg;
let addrs: Vec<u64> = trie_addrs(agg)
.chain(heap.into_iter().flat_map(|h| trie_addrs(&h.agg)))
.collect();
let symbols = symbolize(addrs);
let mut names: Vec<String> = agg.threads.clone();
let cpu_threads: Vec<u16> = (0..agg.threads.len() as u16).collect();
let heap_threads: Vec<u16> = heap.map_or_else(Vec::new, |h| {
h.agg
.threads
.iter()
.map(|name| match names.iter().position(|n| n == name) {
Some(at) => at as u16,
None => {
names.push(name.clone());
names.len() as u16 - 1
}
})
.collect()
});
let mut functions = FunctionTable::default();
let mut nodes = NodeTable::default();
let mut trees = vec![(
agg,
add_tree(
agg,
&cpu_threads,
&symbols,
&mut functions,
&mut nodes,
strings,
),
)];
if let Some(h) = heap {
let tree = add_tree(
&h.agg,
&heap_threads,
&symbols,
&mut functions,
&mut nodes,
strings,
);
trees.push((&h.agg, tree));
}
let wall = agg.counts.keys().any(|k| k.state != State::Cpu);
let period_ms = 1000.0 / f64::from(profile.config.sampler.hz.max(1));
let kind = |source: SampleSource, unit: SampleUnit, period: f64| SampleKind {
source: source as i32,
unit: unit as i32,
period,
};
let mut kinds = vec![kind(SampleSource::Cpu, SampleUnit::Ms, period_ms)];
if wall {
kinds.push(kind(SampleSource::Wall, SampleUnit::Ms, period_ms));
}
if heap.is_some() {
kinds.push(kind(SampleSource::Alloc, SampleUnit::Bytes, 1.0));
kinds.push(kind(SampleSource::Live, SampleUnit::Bytes, 1.0));
}
let kind_of = |state: State| -> u32 {
let source = match state {
State::Cpu => SampleSource::Cpu,
State::Running | State::Waiting => SampleSource::Wall,
State::Alloc => SampleSource::Alloc,
State::Live => SampleSource::Live,
};
kinds
.iter()
.position(|k| k.source == source as i32)
.unwrap_or(0) as u32
};
let mut contexts = Contexts::default();
let mut context_ids: HashMap<[u32; 6], u32> = HashMap::new();
let mut weights = Weights::default();
let mut unknown_roots: HashMap<u16, u32> = HashMap::new();
for (agg, (thread_of, last)) in &trees {
let mut counted: Vec<_> = agg.counts.iter().collect();
counted.sort_by_key(|(k, _)| {
(
thread_of[k.node as usize],
k.node,
k.state as u8,
k.slow,
k.time_block,
(k.span, k.entity, k.block, k.dim, k.chunk),
)
});
for (key, &count) in counted {
let thread = thread_of[key.node as usize];
let mut node = match last[key.node as usize] {
Some(node) => node,
None => *unknown_roots.entry(thread).or_insert_with(|| {
let f = functions.id(strings, UNKNOWN, None, 0, 0);
nodes.id(None, f, thread)
}),
};
if matches!(key.state, State::Alloc | State::Live) {
while nodes.rows.thread_root[node as usize] == 0
&& functions.allocator[nodes.rows.function[node as usize] as usize]
{
node = nodes.rows.parent[node as usize];
}
}
let row = [
named(strings, Space::Span, key.span),
named(strings, Space::Entity, key.entity),
match key.block {
0 => 0,
id => match block_name(id) {
Some(name) => strings.intern(&name),
None => strings.intern(&format!("#{id}")),
},
},
dimension_name(key.dim).map_or(0, |name| strings.intern(name)),
key.chunk.0 as u32,
key.chunk.1 as u32,
];
let context = *context_ids.entry(row).or_insert_with(|| {
contexts.span.push(row[0]);
contexts.entity.push(row[1]);
contexts.block.push(row[2]);
contexts.dim.push(row[3]);
contexts.chunk_x.push(key.chunk.0);
contexts.chunk_z.push(key.chunk.1);
contexts.span.len() as u32 - 1
});
weights.node.push(node);
weights.thread.push(u32::from(thread));
weights.kind.push(kind_of(key.state));
weights.state.push(match key.state {
State::Running => 1,
State::Waiting => 2,
State::Cpu | State::Alloc | State::Live => 0,
});
weights.slow.push(key.slow);
weights.context.push(context);
weights.block.push(u32::from(key.time_block));
weights.count.push(count);
}
}
let blocks = agg.time_blocks();
let mut threads = Vec::new();
let mut block_units = Vec::new();
for (i, name) in names.iter().enumerate() {
let ticks = agg.ticks.get(&(i as u16)).filter(|_| i < agg.threads.len());
threads.push(SampledThread {
name: strings.intern(name),
units: ticks.map_or(0, |t| t.count),
slow_units: ticks.map_or(0, |t| t.slow),
unit: unit as i32,
});
if i >= agg.threads.len() {
continue;
}
let mut units = agg
.block_ticks
.get(&(i as u16))
.cloned()
.unwrap_or_default();
units.resize(blocks, 0);
block_units.push(BlockUnits {
thread: i as u32,
units,
});
}
SampleProfile {
side: side as i32,
kinds,
threads,
block_s: profile.config.block_s,
block_units,
functions: Some(functions.rows),
nodes: Some(nodes.rows),
contexts: Some(contexts),
weights: Some(weights),
slow_over_ms: profile.config.ticks_over_ms as f32,
}
}
/// What the sampler and its writer thread cost during the session.
pub fn overhead(profile: &Profile, heap: Option<&HeapProfile>) -> Overhead {
let wall_ns = profile.elapsed.as_nanos().max(1) as f64;
let handler_ns = profile.stats.handler_ns.unwrap_or(0.0) * profile.stats.samples as f64;
Overhead {
sampler_share_of_thread: (handler_ns / wall_ns) as f32,
writer_core_share: (profile.writer_cpu_ns as f64 / wall_ns) as f32,
dropped_samples: profile.stats.dropped + profile.agg.lost + heap.map_or(0, |h| h.agg.lost),
heap_samples: heap.map_or(0, |h| h.samples),
heap_sample_ns: heap.map_or(0, |h| h.sample_ns),
..Overhead::default()
}
}
@@ -1,649 +0,0 @@
use std::collections::HashMap;
use std::hint::black_box;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, SystemTime};
use horizon_perf_format::v1::{
Kind as CaptureKind, SampleProfile, Side, Start, Stop, StringEntry, Unit,
};
use horizon_perf_format::{CaptureWriter, Field};
use horizon_threads::profile::{self, Aggregate, Config, Profile};
use horizon_threads::sampler::{self, Context, Kind, Record, Stats};
use horizon_threads::unwind::MAX_FRAMES;
use horizon_threads::work;
use super::*;
#[inline(never)]
fn alpha() {
black_box(1);
}
#[inline(never)]
fn beta() {
black_box(2);
}
#[inline(never)]
fn gamma() {
black_box(3);
}
fn at(f: fn(), offset: usize) -> usize {
f as *const () as usize + offset
}
fn record(kind: Kind, ctx: Context, value: u64, stack: &[usize]) -> Record {
let mut frames = [0; MAX_FRAMES];
frames[..stack.len()].copy_from_slice(stack);
Record {
kind,
depth: stack.len() as u8,
ctx,
value,
frames,
}
}
fn profile_of(agg: Aggregate, config: Config) -> Profile {
Profile {
agg,
config,
stats: Stats::default(),
elapsed: Duration::from_secs(10),
started: SystemTime::now(),
writer_cpu_ns: 0,
}
}
struct Converted {
samples: SampleProfile,
strings: Vec<StringEntry>,
}
impl Converted {
fn text(&self, id: u32) -> &str {
match id {
0 => "",
id => &self.strings.iter().find(|s| s.id == id).unwrap().text,
}
}
fn function(&self, row: u32) -> &str {
self.text(self.samples.functions.as_ref().unwrap().name[row as usize])
}
/// Function names from the root down to `node`.
fn path(&self, node: u32) -> Vec<&str> {
let nodes = self.samples.nodes.as_ref().unwrap();
let mut path = vec![self.function(nodes.function[node as usize])];
let mut at = node as usize;
while nodes.thread_root[at] == 0 {
at = nodes.parent[at] as usize;
path.push(self.function(nodes.function[at]));
}
path.reverse();
path
}
/// Samples per entity name, optionally only the slow ones.
fn by_entity(&self, slow_only: bool) -> HashMap<String, u64> {
let (w, c) = (
self.samples.weights.as_ref().unwrap(),
self.samples.contexts.as_ref().unwrap(),
);
let mut by = HashMap::new();
for i in 0..w.node.len() {
if slow_only && !w.slow[i] {
continue;
}
let entity = self.text(c.entity[w.context[i] as usize]).to_owned();
*by.entry(entity).or_default() += w.count[i];
}
by
}
}
fn convert(profile: &Profile) -> Converted {
let mut interner = Interner::default();
let samples = sample_profile(
profile,
None,
Side::Server,
Unit::Tick,
&|id| (id == 7).then(|| "minecraft:hopper".to_owned()),
&mut interner,
);
Converted {
samples,
strings: interner.take_new(Side::Server),
}
}
fn short(name: &str) -> &str {
name.rsplit("::").next().unwrap()
}
#[test]
fn a_stack_becomes_a_root_to_leaf_chain_with_its_context() {
let entity = work::entity_id("test:zombie");
let ctx = Context {
tick: 1,
entity_type: entity,
block: 7,
dim: 2,
chunk: (-3, 9),
..Context::default()
};
let mut agg = Aggregate::new(50_000_000);
for _ in 0..5 {
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(alpha, 4), at(gamma, 3)]),
);
}
agg.add("tick", &record(Kind::Tick, ctx, 9_000_000, &[]));
agg.finish();
let c = convert(&profile_of(agg, Config::default()));
let w = c.samples.weights.as_ref().unwrap();
assert_eq!(
(w.node.len(), w.count[0], w.state[0], w.slow[0]),
(1, 5, 0, false)
);
let path: Vec<_> = c.path(w.node[0]).into_iter().map(short).collect();
assert_eq!(path, ["gamma", "alpha"]);
let nodes = c.samples.nodes.as_ref().unwrap();
assert_eq!(nodes.thread_root, [1, 0]);
let ctxs = c.samples.contexts.as_ref().unwrap();
let row = w.context[0] as usize;
assert_eq!(c.text(ctxs.entity[row]), "test:zombie");
assert_eq!(c.text(ctxs.block[row]), "minecraft:hopper");
assert_eq!(c.text(ctxs.dim[row]), "the_nether");
assert_eq!((ctxs.chunk_x[row], ctxs.chunk_z[row]), (-3, 9));
assert_eq!(c.text(c.samples.threads[0].name), "tick");
assert_eq!(
(c.samples.threads[0].units, c.samples.threads[0].slow_units),
(1, 0)
);
}
#[test]
fn addresses_of_one_function_share_one_row() {
let ctx = Context::default();
let mut agg = Aggregate::new(50_000_000);
for (inner, count) in [(4, 2), (8, 3)] {
for _ in 0..count {
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(alpha, inner), at(gamma, 3)]),
);
}
}
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(beta, 4), at(gamma, 3)]),
);
agg.finish();
let c = convert(&profile_of(agg, Config::default()));
let f = c.samples.functions.as_ref().unwrap();
let names: Vec<_> = (0..f.name.len() as u32)
.map(|row| short(c.function(row)))
.collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(sorted, ["alpha", "beta", "gamma"]);
let w = c.samples.weights.as_ref().unwrap();
let on_alpha: u64 = (0..w.node.len())
.filter(|&i| short(c.path(w.node[i]).last().unwrap()) == "alpha")
.map(|i| w.count[i])
.sum();
assert_eq!(on_alpha, 5);
let n = c.samples.nodes.as_ref().unwrap();
let alphas: Vec<usize> = (0..n.function.len())
.filter(|&i| short(c.function(n.function[i])) == "alpha")
.collect();
assert_eq!(alphas.len(), 1, "one node per (parent, function)");
assert_eq!(
short(c.function(n.function[n.parent[alphas[0]] as usize])),
"gamma"
);
let weight_on_alpha: u64 = (0..w.node.len())
.filter(|&i| w.node[i] as usize == alphas[0])
.map(|i| w.count[i])
.sum();
assert_eq!(weight_on_alpha, 5);
let alpha_row = names.iter().position(|n| *n == "alpha").unwrap();
assert!(f.addr[alpha_row] > 0, "a module-relative address is kept");
}
#[test]
fn time_blocks_and_ticks_per_block_follow_when_they_were_added() {
let ctx = |tick| Context {
tick,
..Context::default()
};
let mut agg = Aggregate::new(50_000_000);
for (block, tick, samples) in [(0, 1, 2u64), (1, 2, 3), (1, 3, 1), (3, 4, 4)] {
agg.set_time_block(block);
for _ in 0..samples {
agg.add("tick", &record(Kind::Cpu, ctx(tick), 0, &[at(alpha, 1)]));
}
agg.add("tick", &record(Kind::Tick, ctx(tick), 1_000_000, &[]));
}
agg.finish();
let config = Config {
block_s: 2.5,
..Config::default()
};
let c = convert(&profile_of(agg, config));
let w = c.samples.weights.as_ref().unwrap();
let mut per_block = [0u64; 4];
for i in 0..w.node.len() {
per_block[w.block[i] as usize] += w.count[i];
}
assert_eq!(per_block, [2, 4, 0, 4]);
assert_eq!(c.samples.block_s, 2.5);
assert_eq!(c.samples.block_units[0].units, [1, 2, 0, 1]);
assert_eq!(c.samples.threads[0].units, 4);
}
#[test]
fn a_sample_with_no_stack_hangs_off_an_unknown_root() {
let mut agg = Aggregate::new(50_000_000);
agg.add("tick", &record(Kind::Cpu, Context::default(), 0, &[]));
agg.finish();
let c = convert(&profile_of(agg, Config::default()));
let w = c.samples.weights.as_ref().unwrap();
assert_eq!(c.path(w.node[0]), ["[unknown]"]);
}
#[test]
fn wall_samples_carry_their_state_and_a_second_kind() {
let mut agg = Aggregate::new(50_000_000);
let ctx = Context::default();
agg.add("tick", &record(Kind::Cpu, ctx, 0, &[at(alpha, 1)]));
agg.add("tick", &record(Kind::Wall, ctx, 1, &[at(alpha, 1)]));
agg.add("tick", &record(Kind::Wall, ctx, 0, &[at(beta, 1)]));
agg.finish();
let config = Config {
sampler: sampler::Config {
hz: 200,
..sampler::Config::default()
},
..Config::default()
};
let c = convert(&profile_of(agg, config));
assert_eq!(c.samples.kinds.len(), 2);
assert_eq!(c.samples.kinds[0].period, 5.0);
let w = c.samples.weights.as_ref().unwrap();
let mut seen: Vec<_> = (0..w.node.len())
.map(|i| {
(
short(c.path(w.node[i]).last().unwrap()).to_owned(),
w.kind[i],
w.state[i],
)
})
.collect();
seen.sort();
assert_eq!(
seen,
[
("alpha".into(), 0, 0),
("alpha".into(), 1, 1),
("beta".into(), 1, 2)
]
);
}
#[inline(never)]
fn spin_fast() {
spin(8);
black_box(0);
}
#[inline(never)]
fn spin_slow() {
spin(120);
black_box(1);
}
fn spin(cpu_ms: u64) {
let until = sampler::thread_cpu_ns() + cpu_ms * 1_000_000;
let mut x = 1u64;
while sampler::thread_cpu_ns() < until {
for _ in 0..1 << 14 {
x = black_box(x.wrapping_mul(6364136223846793005).wrapping_add(1));
}
}
black_box(x);
}
fn live_config() -> Config {
Config {
sampler: sampler::Config {
hz: 1000,
ring_samples: 512,
unwind: sampler::Unwind::StackCopy,
stack_copy_kib: 16,
wall: false,
},
ticks_over_ms: 50.0,
..Config::default()
}
}
/// Runs `body` on a thread opted in as `name`, profiled for exactly its duration.
fn profiled(name: &'static str, body: fn()) -> Profile {
let (ready, wait) = mpsc::channel();
let (go, start) = mpsc::channel::<()>();
let worker = thread::spawn(move || {
assert!(sampler::sampled_thread(name));
ready.send(()).unwrap();
start.recv().unwrap();
body();
});
wait.recv().unwrap();
let session = profile::start(Config {
threads: vec![name.into()],
..live_config()
})
.expect("start profiling");
go.send(()).unwrap();
worker.join().unwrap();
session.stop().expect("stop profiling")
}
fn entity_units() {
let (a, b) = (work::entity_id("test:a"), work::entity_id("test:b"));
for unit in 0..4 {
work::tick_start();
work::set_entity(if unit < 3 { a } else { b });
let started = sampler::thread_cpu_ns();
spin(40);
work::clear_work();
work::tick_end(sampler::thread_cpu_ns() - started);
}
}
#[test]
fn three_units_of_a_and_one_of_b_split_the_samples_three_to_one() {
let c = convert(&profiled("perf-capture-entities", entity_units));
let by = c.by_entity(false);
let total = (by["test:a"] + by["test:b"]) as f64;
let (a, b) = (by["test:a"] as f64 / total, by["test:b"] as f64 / total);
assert!((a - 0.75).abs() < 0.1 && (b - 0.25).abs() < 0.1, "{by:?}");
assert!(total > 100.0, "{total} samples");
assert_eq!(c.samples.threads[0].units, 4);
}
fn ticks_with_one_slow() {
for tick in 0..6 {
work::tick_start();
let started = sampler::thread_cpu_ns();
if tick == 3 {
spin_slow();
} else {
spin_fast();
}
work::tick_end(sampler::thread_cpu_ns() - started);
}
}
#[test]
fn only_the_slow_ticks_function_is_in_the_slow_weights() {
let c = convert(&profiled("perf-capture-slow", ticks_with_one_slow));
let w = c.samples.weights.as_ref().unwrap();
let (mut slow, mut fast) = (0, 0);
for i in 0..w.node.len() {
let names: Vec<_> = c.path(w.node[i]).into_iter().map(short).collect();
if w.slow[i] {
slow += w.count[i];
assert!(
names.contains(&"spin_slow") && !names.contains(&"spin_fast"),
"{names:?}"
);
} else {
fast += w.count[i];
assert!(!names.contains(&"spin_slow"), "{names:?}");
}
}
assert!(slow > 60 && fast > 20, "{slow} slow, {fast} other");
assert_eq!(
(c.samples.threads[0].units, c.samples.threads[0].slow_units),
(6, 1)
);
}
#[test]
fn overhead_counts_lost_samples_and_the_writers_share_of_a_core() {
let mut agg = Aggregate::new(1);
agg.lost = 4;
let mut profile = profile_of(agg, Config::default());
profile.stats.dropped = 3;
profile.stats.samples = 1000;
profile.stats.handler_ns = Some(2_000.0);
profile.writer_cpu_ns = 50_000_000;
let o = overhead(&profile, None);
assert_eq!(o.dropped_samples, 7);
assert!((o.sampler_share_of_thread - 0.0002).abs() < 1e-7);
assert!((o.writer_core_share - 0.005).abs() < 1e-6);
}
#[test]
fn crates_come_from_the_first_path_segment() {
for (name, krate) in [
(
"horizon_world::level::tick::random_tick_at",
"horizon_world",
),
(
"<horizon_world::X as core::ops::Drop>::drop",
"horizon_world",
),
("core::ptr::drop_in_place<alloc::vec::Vec<u8>>", "core"),
("clone3", ""),
("[horizon-server 0x4f2a]", "horizon-server"),
("[unmapped 0x7ffd]", ""),
] {
assert_eq!(crate_of(name), krate, "{name}");
}
}
fn ask(
file: &std::path::Path,
parts: &[&str],
) -> horizon_perf::wire::Envelope<horizon_perf::query::Row> {
let env = horizon_perf::cli::Env {
horizon_home: None,
poll: Duration::from_millis(2),
};
let mut args = vec!["query".to_owned()];
args.extend(parts.iter().map(|p| (*p).to_owned()));
args.push(file.to_string_lossy().into_owned());
let mut out = Vec::new();
if let Err(e) = horizon_perf::cli::run(&args, &env, &mut out) {
panic!("{e:?}");
}
serde_json::from_slice(&out)
.unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&out)))
}
#[test]
fn a_converted_profile_written_to_a_file_reads_back_through_the_queries() {
let ctx = Context::default();
let mut agg = Aggregate::new(50_000_000);
for _ in 0..5 {
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(alpha, 4), at(gamma, 3)]),
);
}
for _ in 0..2 {
agg.add(
"tick",
&record(Kind::Cpu, ctx, 0, &[at(beta, 4), at(gamma, 3)]),
);
}
for _ in 0..2 {
agg.add("tick", &record(Kind::Tick, ctx, 9_000_000, &[]));
}
for _ in 0..3 {
agg.add("worker", &record(Kind::Cpu, ctx, 0, &[at(beta, 4)]));
}
agg.add("worker", &record(Kind::Tick, ctx, 9_000_000, &[]));
agg.finish();
let config = Config {
sampler: sampler::Config {
hz: 100,
..sampler::Config::default()
},
..Config::default()
};
let c = convert(&profile_of(agg, config));
let mut fields = vec![Field::Start(Start {
side: Side::Server as i32,
kind: CaptureKind::Dedicated as i32,
..Default::default()
})];
fields.extend(c.strings.into_iter().map(Field::String));
fields.push(Field::Samples(c.samples));
fields.push(Field::Stop(Stop {
side: Side::Server as i32,
t: 10.0,
..Default::default()
}));
let dir = std::env::temp_dir().join(format!("horizon-render-perf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("real.hzperf");
let mut writer = CaptureWriter::new(std::fs::File::create(&file).unwrap()).unwrap();
for field in fields {
writer.write(field).unwrap();
}
writer.finish().unwrap();
// 10 ms samples: alpha has 5 over the tick thread's 2 ticks.
let hot = ask(&file, &["hot"]);
let alpha = hot.rows().iter().find_map(|r| match r {
horizon_perf::query::Row::Hot(h) if h.full.ends_with("alpha") => Some(h),
_ => None,
});
assert_eq!(alpha.map(|h| (h.self_ms, h.samples)), Some((25.0, 5)));
let worker = ask(&file, &["split", "t:worker", "--by", "thread"]);
let sel = worker.selection.as_ref().unwrap();
assert_eq!(
(sel.thread.as_deref(), sel.ticks, sel.samples),
(Some("worker"), Some(1), Some(3))
);
let values: Vec<_> = worker
.rows()
.iter()
.map(|r| match r {
horizon_perf::query::Row::Split(s) => (s.key.clone(), s.value),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(values, [(Some("t:worker".to_owned()), 30.0)]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn heap_samples_share_the_tables_without_the_allocators_own_frames() {
let mut cpu = Aggregate::new(50_000_000);
cpu.add(
"tick",
&record(
Kind::Cpu,
Context::default(),
0,
&[at(alpha, 4), at(gamma, 3)],
),
);
cpu.finish();
let mut heap = Aggregate::new(0);
let inside = HeapProfile::bytes as *const () as usize + 4;
let on_tick = heap.thread_index("tick");
let key = heap.stack_key(
on_tick,
profile::State::Alloc,
&Context::default(),
&[inside, at(beta, 4), at(gamma, 3)],
);
heap.add_weight(key, 3000);
heap.add_weight(
profile::Key {
state: profile::State::Live,
..key
},
1000,
);
let on_worker = heap.thread_index("worker");
let key = heap.stack_key(
on_worker,
profile::State::Live,
&Context::default(),
&[inside, at(alpha, 4)],
);
heap.add_weight(key, 500);
let heap = HeapProfile {
agg: heap,
interval: 4096,
samples: 3,
live_samples: 2,
sample_ns: 0,
freed: 2000,
elapsed: Duration::from_secs(10),
};
let mut interner = Interner::default();
let samples = sample_profile(
&profile_of(cpu, Config::default()),
Some(&heap),
Side::Server,
Unit::Tick,
&|_| None,
&mut interner,
);
let c = Converted {
samples,
strings: interner.take_new(Side::Server),
};
let sources: Vec<_> = c
.samples
.kinds
.iter()
.map(|k| (k.source(), k.period))
.collect();
assert_eq!(
sources,
[
(SampleSource::Cpu, 4.0),
(SampleSource::Alloc, 1.0),
(SampleSource::Live, 1.0)
]
);
let names: Vec<_> = c.samples.threads.iter().map(|t| c.text(t.name)).collect();
assert_eq!(names, ["tick", "worker"]);
let w = c.samples.weights.as_ref().unwrap();
let mut rows: Vec<_> = (0..w.node.len())
.map(|i| {
let path: Vec<_> = c.path(w.node[i]).into_iter().map(short).collect();
(w.kind[i], w.thread[i], path.join(">"), w.count[i])
})
.collect();
rows.sort();
assert_eq!(
rows,
[
(0, 0, "gamma>alpha".to_owned(), 1),
(1, 0, "gamma>beta".to_owned(), 3000),
(2, 0, "gamma>beta".to_owned(), 1000),
(2, 1, "alpha".to_owned(), 500),
]
);
let o = overhead(
&profile_of(Aggregate::new(0), Config::default()),
Some(&heap),
);
assert_eq!(o.heap_samples, 3);
}
@@ -1,484 +0,0 @@
//! The GPU pass timers' readings as the capture's `gpu` messages (docs/perf-platform.md §3.5); apart from `perf_capture`, which only Linux has.
use horizon_perf_format::Interner;
use horizon_perf_format::v1::{
GpuBackend, GpuCounter, GpuCounts, GpuDevice, GpuFragmentUnit, GpuFrame, GpuFrames, GpuPart,
GpuPartCounts, GpuPartsMode, GpuPass, GpuPassSplit, GpuPasses, GpuSource, Side,
};
use std::time::Instant;
use crate::gfx::pack_timing::{
Counters, FragmentUnit, GpuReadout, PartsMode, PassCounters, Readings, TimedFrame, TimedFrames,
};
/// Every time one timer's frames gave a pass or a part during a capture.
#[derive(Default)]
struct Times {
passes: Vec<(String, Vec<f32>)>,
parts: Vec<(String, Vec<(String, Vec<f32>)>)>,
}
fn times_of<'a>(list: &'a mut Vec<(String, Vec<f32>)>, name: &str) -> &'a mut Vec<f32> {
let i = match list.iter().position(|e| e.0 == name) {
Some(i) => i,
None => {
list.push((name.to_owned(), Vec::new()));
list.len() - 1
}
};
&mut list[i].1
}
impl Times {
fn add(&mut self, frame: &TimedFrame) {
for (pass, ms) in &frame.passes {
times_of(&mut self.passes, pass).push(*ms);
}
for (pass, parts) in &frame.parts {
let i = match self.parts.iter().position(|e| e.0 == *pass) {
Some(i) => i,
None => {
self.parts.push((pass.clone(), Vec::new()));
self.parts.len() - 1
}
};
for (part, ms) in parts {
times_of(&mut self.parts[i].1, part).push(*ms);
}
}
}
fn is_empty(&self) -> bool {
self.passes.is_empty() && self.parts.is_empty()
}
/// `readings` with its times the medians over the whole capture instead of its last frames.
fn medians_into(&self, readings: &Readings) -> Readings {
let median = |v: &[f32]| {
let mut v = v.to_vec();
v.sort_by(f32::total_cmp);
v[v.len() / 2]
};
Readings {
passes: self
.passes
.iter()
.map(|(name, v)| (name.clone(), median(v)))
.collect(),
parts: self
.parts
.iter()
.map(|(pass, parts)| {
let parts = parts.iter().map(|(n, v)| (n.clone(), median(v))).collect();
(pass.clone(), parts)
})
.collect(),
..readings.clone()
}
}
}
/// A capture's timed frames on their way into `GpuFrames` blocks, every time kept for the whole capture's medians.
#[derive(Default)]
pub struct FrameLog {
pending: Vec<GpuFrame>,
cpu_frame_ms: Vec<f32>,
own: Times,
pack: Times,
}
impl FrameLog {
/// The CPU's own ms of one frame the window drew.
pub fn cpu_frame(&mut self, ms: f32) {
self.cpu_frame_ms.push(ms);
}
/// The frames timed since the last call; `t_of` puts a frame's start on the capture clock.
pub fn add(
&mut self,
frames: &TimedFrames,
t_of: impl Fn(Instant) -> f64,
strings: &mut Interner,
) {
for (source, list, times) in [
(GpuSource::Horizon, &frames.own, &mut self.own),
(GpuSource::ShaderPack, &frames.pack, &mut self.pack),
] {
for frame in list {
times.add(frame);
self.pending
.push(frame_message(frame, source, t_of(frame.at), strings));
}
}
}
/// The frames and CPU frame times since the last block; none when there are neither.
pub fn take_block(&mut self, side: Side) -> Option<GpuFrames> {
if self.pending.is_empty() && self.cpu_frame_ms.is_empty() {
return None;
}
Some(GpuFrames {
side: side as i32,
frames: std::mem::take(&mut self.pending),
cpu_frame_ms: std::mem::take(&mut self.cpu_frame_ms),
})
}
}
fn frame_message(
frame: &TimedFrame,
source: GpuSource,
t: f64,
strings: &mut Interner,
) -> GpuFrame {
let mut passes: Vec<GpuPass> = frame
.passes
.iter()
.map(|(name, ms)| GpuPass {
name: strings.intern(name),
ms: Some(*ms),
parts: Vec::new(),
})
.collect();
for (pass, parts) in &frame.parts {
let parts = parts
.iter()
.map(|(part, ms)| GpuPart {
name: strings.intern(part),
ms: *ms,
})
.collect();
let name = strings.intern(pass);
match passes.iter_mut().find(|p| p.name == name) {
Some(p) => p.parts = parts,
None => passes.push(GpuPass {
name,
ms: None,
parts,
}),
}
}
GpuFrame {
source: source as i32,
t,
total_ms: frame.total_ms,
passes,
}
}
fn backend(backend: wgpu::Backend) -> GpuBackend {
match backend {
wgpu::Backend::Vulkan => GpuBackend::Vulkan,
wgpu::Backend::Metal => GpuBackend::Metal,
wgpu::Backend::Dx12 => GpuBackend::Dx12,
wgpu::Backend::Gl => GpuBackend::Gl,
_ => GpuBackend::Other,
}
}
fn counts(c: Counters) -> GpuCounts {
GpuCounts {
vertex_shader_invocations: c.vertex_shader_invocations,
clipper_invocations: c.clipper_invocations,
clipper_primitives_out: c.clipper_primitives_out,
fragment_shader_invocations: c.fragment_shader_invocations,
}
}
fn counter(strings: &mut Interner, pass: &str, c: &PassCounters) -> GpuCounter {
GpuCounter {
pass: strings.intern(pass),
total: Some(counts(c.total)),
pixels: c.pixels,
parts: c
.parts
.iter()
.map(|(name, part)| GpuPartCounts {
name: strings.intern(name),
counts: Some(counts(*part)),
})
.collect(),
}
}
fn device(adapter: &wgpu::AdapterInfo, readings: &Readings) -> GpuDevice {
let driver = format!("{} {}", adapter.driver, adapter.driver_info);
GpuDevice {
adapter: adapter.name.clone(),
backend: backend(adapter.backend) as i32,
driver: driver.trim().to_owned(),
parts_mode: match readings.support.parts {
PartsMode::InsidePasses => GpuPartsMode::InsidePasses,
PartsMode::SeparatePasses => GpuPartsMode::SeparatePasses,
} as i32,
pass_split: match readings.support.parts {
PartsMode::InsidePasses => GpuPassSplit::DrawGroups,
PartsMode::SeparatePasses => GpuPassSplit::PassesOnly,
} as i32,
counters: readings.support.counters,
fragment_unit: match readings.support.fragment_unit {
None => GpuFragmentUnit::Unspecified,
Some(FragmentUnit::Pixel) => GpuFragmentUnit::Pixel,
Some(FragmentUnit::Quad) => GpuFragmentUnit::Quad,
} as i32,
}
}
fn section(
adapter: &wgpu::AdapterInfo,
side: Side,
source: GpuSource,
readings: &Readings,
strings: &mut Interner,
) -> GpuPasses {
let mut passes: Vec<GpuPass> = readings
.passes
.iter()
.map(|(name, ms)| GpuPass {
name: strings.intern(name),
ms: Some(*ms),
parts: Vec::new(),
})
.collect();
for (pass, parts) in &readings.parts {
let name = strings.intern(pass);
let at = passes
.iter()
.position(|p| p.name == name)
.unwrap_or_else(|| {
passes.push(GpuPass {
name,
ms: None,
parts: Vec::new(),
});
passes.len() - 1
});
passes[at].parts = parts
.iter()
.map(|(part, ms)| GpuPart {
name: strings.intern(part),
ms: *ms,
})
.collect();
}
GpuPasses {
side: side as i32,
device: Some(device(adapter, readings)),
passes,
counters: readings
.counters
.iter()
.map(|(pass, c)| counter(strings, pass, c))
.collect(),
source: source as i32,
}
}
/// One `GpuPasses` per timer that has timed something, its times the medians of `log` where it has them; the caller appends `strings`' new entries first.
pub fn gpu_passes(
readout: &GpuReadout,
log: &FrameLog,
side: Side,
strings: &mut Interner,
) -> Vec<GpuPasses> {
[
(GpuSource::Horizon, &readout.own, &log.own),
(GpuSource::ShaderPack, &readout.pack, &log.pack),
]
.into_iter()
.filter_map(|(source, readings, times)| {
let readings = readings.as_ref().filter(|r| !r.is_empty())?;
let whole = (!times.is_empty()).then(|| times.medians_into(readings));
let readings = whole.as_ref().unwrap_or(readings);
Some(section(&readout.adapter, side, source, readings, strings))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::pack_timing::DetailSupport;
fn adapter() -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "Test GPU".into(),
vendor: 0x10de,
device: 1,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: String::new(),
driver: "NVIDIA".into(),
driver_info: "570.1".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
}
}
fn support(unit: Option<FragmentUnit>) -> DetailSupport {
DetailSupport {
parts: PartsMode::InsidePasses,
encoder_stamps: true,
counters: true,
fragment_unit: unit,
}
}
#[test]
fn known_medians_parts_and_counters_come_through_with_their_names() {
let c = |v, f| Counters {
vertex_shader_invocations: v,
clipper_invocations: 2_050_000,
clipper_primitives_out: 1_200_000,
fragment_shader_invocations: f,
};
let own = Readings {
passes: vec![("World".into(), 1.5), ("HUD".into(), 0.25)],
parts: vec![("GPU culling".into(), vec![("cull".into(), 0.125)])],
counters: vec![(
"World".into(),
PassCounters {
total: c(4_100_000, 6_300_000),
pixels: 2_073_600,
parts: vec![
("before translucent".into(), c(3_000_000, 5_100_000)),
("translucent terrain".into(), c(1_100_000, 1_200_000)),
],
},
)],
support: support(Some(FragmentUnit::Quad)),
};
let readout = GpuReadout {
adapter: adapter(),
own: Some(own),
pack: None,
};
let mut strings = Interner::default();
let got = gpu_passes(&readout, &FrameLog::default(), Side::Client, &mut strings);
assert_eq!(got.len(), 1);
let g = &got[0];
assert_eq!((g.side(), g.source()), (Side::Client, GpuSource::Horizon));
let d = g.device.as_ref().unwrap();
assert_eq!(d.adapter, "Test GPU");
assert_eq!(d.backend(), GpuBackend::Vulkan);
assert_eq!(d.driver, "NVIDIA 570.1");
assert_eq!(d.parts_mode(), GpuPartsMode::InsidePasses);
assert!(d.counters);
assert_eq!(d.fragment_unit(), GpuFragmentUnit::Quad);
// World, HUD, then the pass known only through its parts.
let entries = strings.take_new(Side::Client);
let name = |id: u32| entries.iter().find(|e| e.id == id).unwrap().text.as_str();
let named: Vec<_> = g.passes.iter().map(|p| name(p.name)).collect();
assert_eq!(named, ["World", "HUD", "GPU culling"]);
let shown: Vec<_> = g.passes.iter().map(|p| p.ms).collect();
assert_eq!(shown, [Some(1.5), Some(0.25), None]);
assert_eq!(name(g.passes[2].parts[0].name), "cull");
assert_eq!(g.passes[2].parts[0].ms, 0.125);
let counted = &g.counters[0];
assert_eq!(counted.pixels, 2_073_600);
assert_eq!(
counted.total.as_ref().unwrap().fragment_shader_invocations,
6_300_000
);
assert_eq!(counted.parts.len(), 2);
assert_eq!(
counted.parts[1]
.counts
.as_ref()
.unwrap()
.vertex_shader_invocations,
1_100_000
);
// The pass's counters and its time name the same string.
assert_eq!(counted.pass, g.passes[0].name);
}
#[test]
fn a_timer_that_timed_nothing_adds_no_section() {
let idle = Readings {
passes: Vec::new(),
parts: Vec::new(),
counters: Vec::new(),
support: support(None),
};
let pack = Readings {
passes: vec![("composite".into(), 0.75)],
..idle.clone()
};
let readout = GpuReadout {
adapter: adapter(),
own: Some(idle),
pack: Some(pack),
};
let got = gpu_passes(
&readout,
&FrameLog::default(),
Side::Client,
&mut Interner::default(),
);
assert_eq!(got.len(), 1);
assert_eq!(got[0].source(), GpuSource::ShaderPack);
assert_eq!(
got[0].device.as_ref().unwrap().fragment_unit(),
GpuFragmentUnit::Unspecified
);
}
#[test]
fn the_end_section_takes_the_whole_capture_median_and_each_block_its_frames() {
let start = Instant::now();
let frame = |i: u32, world: f32| TimedFrame {
at: start + std::time::Duration::from_millis(u64::from(i) * 10),
total_ms: Some(world + 1.0),
passes: vec![("World".into(), world), ("HUD".into(), 0.5)],
parts: vec![("World".into(), vec![("sky".into(), world / 4.0)])],
};
let mut log = FrameLog::default();
let mut strings = Interner::default();
let worlds = [1.0, 2.0, 3.0, 4.0, 100.0];
let frames = TimedFrames {
own: worlds
.iter()
.enumerate()
.map(|(i, &w)| frame(i as u32, w))
.collect(),
pack: Vec::new(),
};
log.add(
&frames,
|at| at.duration_since(start).as_secs_f64(),
&mut strings,
);
log.cpu_frame(6.0);
let block = log.take_block(Side::Client).expect("a block");
assert_eq!(block.frames.len(), 5);
assert_eq!(block.cpu_frame_ms, [6.0]);
assert!((block.frames[4].t - 0.04).abs() < 1e-9);
assert_eq!(block.frames[4].total_ms, Some(101.0));
assert_eq!(block.frames[4].passes[0].parts[0].ms, 25.0);
assert!(log.take_block(Side::Client).is_none());
// The timer's own median (9.0) is of its last frames; the capture's is the third of five sorted.
let own = Readings {
passes: vec![("World".into(), 9.0)],
parts: Vec::new(),
counters: Vec::new(),
support: support(None),
};
let readout = GpuReadout {
adapter: adapter(),
own: Some(own),
pack: None,
};
let got = gpu_passes(&readout, &log, Side::Client, &mut strings);
let world = &got[0].passes[0];
assert_eq!(world.ms, Some(3.0));
assert_eq!(world.parts[0].ms, 0.75);
assert_eq!(
got[0].device.as_ref().unwrap().pass_split(),
GpuPassSplit::DrawGroups
);
}
}
@@ -864,21 +864,11 @@ impl TextField {
self.set_highlight(m, pos);
}
/// `onClick`: the cursor where the mouse is (`findClickedPositionInText`).
/// `EditBox.onClick` / `MultiLineEditBox.onClick`: a double click selects
/// the word the pointer is in (`EditBox.selectWord`), any other click
/// places the caret there.
pub fn click(&mut self, m: &Measure, x: f64, extend: bool, double: bool) {
pub fn click(&mut self, m: &Measure, x: f64, extend: bool) {
let pos = (x.floor() as i32 - self.text_x()).min(self.inner_width());
let rest = sub(&self.value, self.display, chars(&self.value));
let at = self.display + m.plain(&rest, pos, false);
if double {
let start = crate::ui::word_position(&self.value, -1, at);
let end = crate::ui::word_position(&self.value, 1, at);
self.move_cursor_to(m, start, false);
self.move_cursor_to(m, end, true);
} else {
self.move_cursor_to(m, at, extend);
}
self.move_cursor_to(m, at, extend);
}
pub fn contains(&self, x: f64, y: f64) -> bool {
x >= self.x as f64
@@ -222,6 +222,11 @@ impl<P: Copy, S: Copy> Lines<P, S> {
}
self.y += style::GROUP_GAP;
}
/// A group of rows under its page's own name (`list`'s `page_name`), for pages named at run
/// time, such as a resource pack's config entries (`pack_options_ui`).
pub(crate) fn titled(&mut self, page: P, rows: Vec<S>) {
self.group(page, "", rows);
}
/// The lines and the content's height.
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
@@ -347,7 +352,8 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
}
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
/// draws a setting at its `y`; `page_name` names a page, in the player's language.
/// draws a setting at its `y`; `page_name` names a page, and a group without a name of its own
/// (`Lines::titled`), in the player's language.
pub(crate) fn list<P: Copy, S: Copy>(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -380,6 +386,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
None => (page_name(line.page), style::accent(), 4.0),
};
@@ -945,10 +952,22 @@ pub(crate) fn dock_note(
}
}
/// Done, Undo or Apply at the foot of the page list, registered for a controller. Its label
/// leaves `room` at its left for a controller's glyph: a button that declared its job gets one,
/// as vanilla's do (`ui::action_button`), which a half-width Undo or Apply needs.
/// Done or Undo at the foot of the page list.
pub(crate) fn foot_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
cursor: (f32, f32),
flags: (bool, bool),
) {
foot_button_with_room(batch, atlas, rect, label, cursor, flags, 0.0);
}
/// A foot button whose label leaves `room` at its left for a controller's glyph: a button that
/// declared its job gets one, as vanilla's do (`ui::action_button`), which a half-width Undo or
/// Apply needs.
fn foot_button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
@@ -2395,7 +2414,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
}
None => 0.0,
};
foot_button(batch, atlas, rect, &label, cursor, flags, room);
foot_button_with_room(batch, atlas, rect, &label, cursor, flags, room);
}
}
fn click(
+17 -11
View File
@@ -93,17 +93,18 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>])
signed: signature == Signature::Signed,
..empty.clone()
};
// Authlib's MinecraftServicesSessionService validates every URL in
// unpackTextures before returning any textures. A bad cape rejects the skin too.
// Each texture is loaded independently: an invalid or disallowed URL for one
// texture (cape, elytra) must not drop another (the skin). Vanilla's
// SkinManager works the same way.
for (kind, texture) in textures {
let Some(url) = texture.get("url").and_then(|u| u.as_str()) else {
return empty;
continue;
};
let allowed = valid_uris
.iter()
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
if !allowed {
return empty;
continue;
}
if kind == "SKIN" {
out.slim = texture
@@ -1387,9 +1388,10 @@ mod tests {
&super::TEXTURE_URIS,
);
assert_eq!(textures.slim, slim && !url.contains("evil"));
// Authlib rejects the whole payload if any URL is invalid,
// including the allowed cape when the skin is disallowed.
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
// Textures are judged per URL (vanilla's SkinManager): the
// cape's URL is always allowed here, so it is kept even when
// the skin's URL is not.
assert!(textures.cape.is_some());
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
}
@@ -1542,14 +1544,18 @@ mod tests {
assert!(t.slim);
assert!(!unpack(wide, Signature::Signed, &TEXTURE_URIS).slim);
}
/// Authlib's `MinecraftServicesSessionService` rejects the whole profile in
/// `unpackTextures` when any texture URL is outside the allowed origins.
/// `unpack` keeps each texture independently: a cape from another origin
/// must not drop the skin.
#[test]
fn unpack_rejects_the_skin_when_another_texture_is_rejected() {
fn unpack_keeps_the_skin_when_another_texture_is_rejected() {
use super::{Signature, TEXTURE_URIS, unpack};
let mixed = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL3NraW4xIn0sICJDQVBFIjogeyJ1cmwiOiAiaHR0cHM6Ly9ldmlsLmV4YW1wbGUvdGV4dHVyZS9jYXBlMSJ9fX0=";
let t = unpack(mixed, Signature::Unsigned, &TEXTURE_URIS);
assert_eq!(t, super::Textures::default());
assert_eq!(
t.skin.as_deref(),
Some("https://textures.minecraft.net/texture/skin1")
);
assert_eq!(t.cape, None);
}
#[test]
fn profiles_parse_like_resolvable_profiles() {
+182 -315
View File
@@ -1,4 +1,10 @@
//! Named timings (wall and thread CPU) of a frame or tick, on with `HORIZON_SPIKES` or while a capture records; the slow ones go to the slow ring, not as span lists to the log.
//! Named timings within a frame, for finding what makes a frame slow:
//! `HORIZON_SPIKES=MS` prints the spans of every frame whose CPU time
//! exceeds MS milliseconds, and of every server tick (dedicated or
//! integrated) longer than MS. Each span keeps its wall time and the
//! thread's CPU time inside it, so a span that waited (for a lock, the GPU
//! or a core on a busy machine) shows apart from one that worked. Costs two
//! clock reads per span when enabled and a relaxed load otherwise.
use std::{
cell::{Cell, RefCell},
sync::atomic::{AtomicBool, Ordering},
@@ -52,10 +58,10 @@ pub fn record_here(on: bool) {
/// Whether this thread records spans: all of them on, or [`record_here`].
#[inline]
pub fn recording_here() -> bool {
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || timeline::on()
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || trace::on()
}
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): spans recorded
/// by `record_here` alone keep wall time only.
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
/// print it.
#[inline]
fn cpu_if_enabled() -> Option<Duration> {
if ENABLED.load(Ordering::Relaxed) {
@@ -76,41 +82,31 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
pub struct Guard {
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
_heap: Option<horizon_threads::heap_census::Tag>,
/// The sampler's span before this one.
outer: u16,
}
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
#[inline]
pub fn guard(name: &'static str) -> Guard {
let _heap = horizon_threads::heap_tag(name);
let outer = horizon_threads::work::enter_span(name);
if !recording_here() {
return Guard {
open: None,
_heap,
outer,
};
return Guard { open: None, _heap };
}
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
Guard {
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
_heap,
outer,
}
}
impl Drop for Guard {
fn drop(&mut self) {
horizon_threads::work::leave_span(self.outer);
let Some((name, started, cpu, depth)) = self.open.take() else {
return;
};
let elapsed = started.elapsed();
let ms = elapsed.as_secs_f32() * 1000.0;
let ms = started.elapsed().as_secs_f32() * 1000.0;
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
timeline::span(name, started, elapsed, depth);
trace::span(name, started, ms);
push(Span {
name,
ms,
@@ -119,22 +115,6 @@ impl Drop for Guard {
});
}
}
/// How many spans this thread holds.
fn recorded() -> usize {
SPANS.with(|s| s.borrow().len())
}
/// Moves the spans recorded since `mark` one level in: they ran inside the lap now ending.
fn nest_since(mark: usize, depth: u8) {
SPANS.with(|s| {
let mut s = s.borrow_mut();
let start = mark.min(s.len());
for span in &mut s[start..] {
if span.depth >= depth {
span.depth = span.depth.saturating_add(1);
}
}
});
}
/// Keeps a finished span for the thread's next [`take_raw`].
fn push(span: Span) {
SPANS.with(|s| {
@@ -145,6 +125,22 @@ fn push(span: Span) {
s.push(span);
});
}
/// Attach per-tick counts without interpreting them as durations.
pub fn counters(counts: &[(&str, u64)]) {
if !enabled() {
return;
}
NOTES.with(|n| {
n.borrow_mut().push((
0.0,
counts
.iter()
.map(|(name, count)| format!("{name}={count}"))
.collect::<Vec<_>>()
.join(", "),
))
});
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
@@ -177,36 +173,34 @@ pub fn take_notes() -> Vec<(f32, String)> {
}
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
pub struct Laps(Option<(Instant, Option<Duration>, usize)>);
pub struct Laps(Option<(Instant, Option<Duration>)>);
/// Start timing the steps of a function (see [`Laps`]).
#[inline]
pub fn laps() -> Laps {
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled(), recorded())))
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled())))
}
impl Laps {
/// Record the step that just ended under `name`, and start the next.
#[inline]
pub fn lap(&mut self, name: &'static str) {
let Some((started, cpu, mark)) = &mut self.0 else {
let Some((started, cpu)) = &mut self.0 else {
return;
};
let now = Instant::now();
let now_cpu = cpu.and_then(|_| thread_cpu());
let elapsed = now - *started;
let ms = elapsed.as_secs_f32() * 1000.0;
let ms = (now - *started).as_secs_f32() * 1000.0;
let cpu_ms = now_cpu
.zip(*cpu)
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
let depth = DEPTH.with(Cell::get);
timeline::span(name, *started, elapsed, depth);
nest_since(*mark, depth);
trace::span(name, *started, ms);
push(Span {
name,
ms,
cpu_ms,
depth,
});
(*started, *cpu, *mark) = (now, now_cpu, recorded());
(*started, *cpu) = (now, now_cpu);
}
}
/// The CPU time this thread has used, where the platform tells it: a tick
@@ -289,176 +283,173 @@ pub fn merge(spans: &[Span]) -> Vec<Span> {
sums.sort_by(|a, b| b.ms.total_cmp(&a.ms));
sums
}
/// The run's timeline: every span on every thread, kept in per-thread buffers until a capture drains them as `timeline` chunks.
pub mod timeline {
/// `name wall` for spans of at least 0.5 ms, with `/cpu` when the thread
/// worked for less than nine tenths of it: the slow-frame and slow-tick lines.
pub fn describe(spans: &[Span]) -> String {
let list: Vec<String> = spans
.iter()
.filter(|s| s.ms >= 0.5)
.map(|s| {
if s.cpu_ms < s.ms * 0.9 {
format!("{} {:.1}/{:.1}", s.name, s.ms, s.cpu_ms)
} else {
format!("{} {:.1}", s.name, s.ms)
}
})
.collect();
list.join(", ")
}
/// The run's timeline: every span ([`time`], [`guard`], [`Laps::lap`]) on every thread, as
/// Chrome trace events that Perfetto (ui.perfetto.dev) opens as they are. `HORIZON_TRACE=FILE`
/// writes it from the start; [`start`](trace::start) and [`stop`](trace::stop) write one for a
/// while (`horizon-trace` on a dedicated server's console, `trace` on the control socket). Spans
/// show on their threads where they ran, nested by time, so what ran beside what, and what a
/// thread waited for, can be read off. Off, a span costs one relaxed load more.
pub mod trace {
use std::{
cell::RefCell,
cell::Cell,
fs::File,
io::{BufWriter, Write as _},
path::Path,
sync::{
Arc, Mutex, PoisonError,
Mutex, PoisonError,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::{Duration, Instant},
time::Instant,
};
/// The most spans a thread holds between two drains; a thread that records more drops them.
const THREAD_EVENTS_MAX: usize = 1 << 18;
static ON: AtomicBool = AtomicBool::new(false);
static GENERATION: AtomicU32 = AtomicU32::new(0);
static RUN: Mutex<Option<Run>> = Mutex::new(None);
struct Run {
origin: Instant,
threads: Vec<Arc<Mutex<Thread>>>,
}
struct Thread {
name: String,
label: String,
events: Vec<Event>,
}
/// This thread's buffer in the timeline it last joined.
struct Mine {
generation: u32,
origin: Instant,
thread: Arc<Mutex<Thread>>,
}
static SINK: Mutex<Option<Sink>> = Mutex::new(None);
/// Counts the traces started: a thread names itself again in each one.
static TRACES: AtomicU32 = AtomicU32::new(0);
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
thread_local! {
static MINE: RefCell<Option<Mine>> = const { RefCell::new(None) };
/// This thread's id in traces (0: none yet).
static THREAD: Cell<u32> = const { Cell::new(0) };
/// The trace this thread last named itself in.
static NAMED_IN: Cell<u32> = const { Cell::new(0) };
}
/// How often a trace's buffer goes to the file, at most: a run that ends without
/// [`stop`] loses no more than this.
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
struct Sink {
file: BufWriter<File>,
origin: Instant,
/// Until when it records ([`start`]'s `seconds`).
until: Option<Instant>,
flushed: Instant,
/// Events written: the next one is preceded by a comma.
events: u64,
}
impl Sink {
fn event(&mut self, event: std::fmt::Arguments) {
let comma = if self.events == 0 { "" } else { ",\n" };
let _ = write!(self.file, "{comma}{{\"pid\":1,{event}}}");
self.events += 1;
}
}
/// One finished span.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Event {
pub name: &'static str,
/// Microseconds from the timeline's start.
pub start_us: u64,
pub dur_us: u32,
/// How many spans it ran inside on its thread.
pub depth: u8,
}
/// The spans one thread finished since the last drain, in the order they finished.
#[derive(Debug, PartialEq, Eq)]
pub struct ThreadEvents {
/// The thread's name; threads alive together under one name get `#2`, `#3`... after it.
pub thread: String,
pub events: Vec<Event>,
}
/// Whether a timeline is being recorded.
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
ON.load(Ordering::Relaxed)
}
/// Starts recording, timed from `origin`; false when a timeline already runs (the process has one).
pub fn start(origin: Instant) -> bool {
let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
if run.is_some() {
return false;
}
*run = Some(Run {
origin,
threads: Vec::new(),
});
GENERATION.fetch_add(1, Ordering::Relaxed);
ON.store(true, Ordering::Relaxed);
true
}
/// Takes what the threads finished since the last call.
pub fn drain() -> Vec<ThreadEvents> {
let run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
run.as_ref().map_or_else(Vec::new, take_all)
}
/// Stops recording and returns what the threads finished since the last drain.
pub fn finish() -> Vec<ThreadEvents> {
ON.store(false, Ordering::Relaxed);
let run = RUN.lock().unwrap_or_else(PoisonError::into_inner).take();
run.as_ref().map_or_else(Vec::new, take_all)
}
fn take_all(run: &Run) -> Vec<ThreadEvents> {
run.threads
.iter()
.filter_map(|thread| {
let mut thread = thread.lock().unwrap_or_else(PoisonError::into_inner);
(!thread.events.is_empty()).then(|| ThreadEvents {
thread: thread.label.clone(),
events: std::mem::take(&mut thread.events),
})
})
.collect()
}
/// Takes a buffer for this thread: the one of an exited thread of the same name, else a new one.
fn join() -> Option<Mine> {
let mut slot = RUN.lock().unwrap_or_else(PoisonError::into_inner);
let run = slot.as_mut()?;
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.to_owned();
let of_name =
|t: &Arc<Mutex<Thread>>| t.lock().unwrap_or_else(PoisonError::into_inner).name == name;
let thread = match run
.threads
.iter()
.find(|t| Arc::strong_count(t) == 1 && of_name(t))
{
Some(exited) => Arc::clone(exited),
None => {
let same = run.threads.iter().filter(|t| of_name(t)).count();
let label = if same == 0 {
name.clone()
} else {
format!("{name} #{}", same + 1)
};
let thread = Arc::new(Mutex::new(Thread {
name,
label,
events: Vec::new(),
}));
run.threads.push(Arc::clone(&thread));
thread
}
/// Starts a trace written to `path` (replacing a trace being written), for `seconds` or
/// until [`stop`].
pub fn start(path: &Path, seconds: Option<u32>) -> std::io::Result<()> {
let mut file = BufWriter::with_capacity(1 << 18, File::create(path)?);
file.write_all(b"[\n")?;
let now = Instant::now();
let sink = Sink {
file,
origin: now,
until: seconds.map(|s| now + std::time::Duration::from_secs(u64::from(s))),
flushed: now,
events: 0,
};
Some(Mine {
generation: GENERATION.load(Ordering::Relaxed),
origin: run.origin,
thread,
})
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(old) = slot.take() {
close(old);
}
*slot = Some(sink);
TRACES.fetch_add(1, Ordering::Relaxed);
ON.store(true, Ordering::Relaxed);
Ok(())
}
/// A span of this thread from `started`, `elapsed` long, inside `depth` others.
pub(super) fn span(name: &'static str, started: Instant, elapsed: Duration, depth: u8) {
/// Starts the trace `HORIZON_TRACE` names, if it names one.
pub fn start_from_env() {
if let Some(path) = std::env::var_os("HORIZON_TRACE")
&& let Err(e) = start(Path::new(&path), None)
{
log::warn!("Horizon: HORIZON_TRACE: {e}");
}
}
/// Ends the trace being written: the file is complete JSON. The events written so far
/// and whether one was being written.
pub fn stop() -> Option<u64> {
ON.store(false, Ordering::Relaxed);
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).take()?;
let events = sink.events;
close(sink);
Some(events)
}
/// Whether the trace being written has run its time ([`start`]'s `seconds`): the caller
/// then [`stop`]s it.
pub fn due() -> bool {
on() && SINK
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.and_then(|s| s.until)
.is_some_and(|until| Instant::now() >= until)
}
fn close(mut sink: Sink) {
let _ = sink.file.write_all(b"\n]\n");
let _ = sink.file.flush();
}
/// A span of this thread from `started`, `ms` long.
pub(super) fn span(name: &'static str, started: Instant, ms: f32) {
if !on() {
return;
}
let generation = GENERATION.load(Ordering::Relaxed);
let _ = MINE.try_with(|mine| {
let mut mine = mine.borrow_mut();
if mine.as_ref().is_none_or(|m| m.generation != generation) {
*mine = join();
}
let Some(mine) = mine.as_ref() else {
return;
};
let event = Event {
name,
start_us: started.saturating_duration_since(mine.origin).as_micros() as u64,
dur_us: elapsed.as_micros().min(u128::from(u32::MAX)) as u32,
depth,
};
let mut thread = mine.thread.lock().unwrap_or_else(PoisonError::into_inner);
if thread.events.len() < THREAD_EVENTS_MAX {
thread.events.push(event);
let tid = THREAD.with(|t| {
if t.get() == 0 {
t.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
}
t.get()
});
let trace = TRACES.load(Ordering::Relaxed);
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
let Some(sink) = slot.as_mut() else {
return;
};
if NAMED_IN.with(|n| n.replace(trace)) != trace {
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.replace(['"', '\\'], "_");
sink.event(format_args!(
r#""ph":"M","name":"thread_name","tid":{tid},"args":{{"name":"{name}"}}"#
));
}
let ts = started.saturating_duration_since(sink.origin).as_secs_f64() * 1e6;
sink.event(format_args!(
r#""ph":"X","name":"{name}","tid":{tid},"ts":{ts:.3},"dur":{:.3}"#,
f64::from(ms) * 1e3
));
let now = Instant::now();
if now.duration_since(sink.flushed) >= FLUSH_EVERY {
let _ = sink.file.flush();
sink.flushed = now;
}
}
}
@@ -663,46 +654,6 @@ mod tests {
SWITCH.lock().unwrap_or_else(|e| e.into_inner())
}
/// Samples taken inside a named span carry its id, with spans off, and the span is left after it.
#[cfg(target_os = "linux")]
#[test]
fn samples_inside_a_span_carry_its_id() {
use horizon_threads::{sampler, work};
use std::time::{Duration, Instant};
let worker = std::thread::spawn(|| {
assert!(sampler::sampled_thread("spans-id-test"));
let config = sampler::Config {
hz: 1000,
..Default::default()
};
let session = sampler::start(config, |n| n == "spans-id-test").unwrap();
let busy = |ms| {
let until = Instant::now() + Duration::from_millis(ms);
while Instant::now() < until {
std::hint::black_box(0);
}
};
super::time("spans_id_test_named", || busy(60));
assert_eq!(sampler::span(), 0);
busy(60);
let mut spans = Vec::new();
session.stop(|_, r| spans.push(r.ctx.span));
spans
});
let spans = worker.join().unwrap();
let inside = |s: &u16| work::name(work::Space::Span, *s) == Some("spans_id_test_named");
let (named, plain) = (
spans.iter().filter(|s| inside(s)).count(),
spans.iter().filter(|s| **s == 0).count(),
);
assert!(
named > 30 && plain > 30,
"{named} named, {plain} outside of {} samples",
spans.len()
);
assert_eq!(named + plain, spans.len(), "no other span id");
}
/// A slow tick's notes are its slowest steps, slowest first; short steps and steps on a
/// thread that records no spans leave none.
#[test]
@@ -729,7 +680,8 @@ mod tests {
assert!(super::take_notes().is_empty(), "taking them clears them");
}
/// Spans of one name add up, the slowest first; a span that waited keeps its small CPU time.
/// Spans of one name add up; a span the thread spent mostly waiting
/// shows its CPU time after the wall time, short ones are left out.
#[test]
fn spans_sum_by_name_and_show_waits() {
super::SPANS.with(|s| {
@@ -771,6 +723,7 @@ mod tests {
}
);
assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0));
assert_eq!(super::describe(&spans), "submit 30.0/0.2, tick 5.0");
assert!(super::take().is_empty());
}
@@ -890,22 +843,6 @@ mod tests {
assert!(spans[0].ms >= 2.0);
}
/// A span that ran during a lap belongs inside that lap, and not inside the next one.
#[test]
fn a_span_during_a_lap_nests_under_that_lap() {
let _switch = spans_switch();
let _ = super::take_raw();
super::enable(true);
let mut laps = super::laps();
super::time("inner", || {});
laps.lap("first");
laps.lap("second");
super::enable(false);
let spans = super::take_raw();
let depths: Vec<_> = spans.iter().map(|s| (s.name, s.depth)).collect();
assert_eq!(depths, [("inner", 1), ("first", 0), ("second", 0)]);
}
/// The thread's CPU clock moves with work done on it, not with time
/// spent asleep.
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
@@ -928,74 +865,4 @@ mod tests {
assert!(slept.as_millis() < 25, "{slept:?} while asleep");
assert!(worked.as_millis() >= 10, "{worked:?} while busy");
}
/// Each thread's finished spans reach the timeline with their depth and nesting in time; threads alive together under one name get labels of their own, ones after another share a lane.
#[test]
fn the_timeline_keeps_each_threads_spans_with_their_depth_and_place_in_time() {
use super::timeline;
use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};
let _switch = spans_switch();
assert!(timeline::start(Instant::now()));
assert!(
!timeline::start(Instant::now()),
"the process has one timeline"
);
let spawn = |name: &str, alive_with: Option<Arc<Barrier>>| {
std::thread::Builder::new()
.name(name.into())
.spawn(move || {
super::time("outer", || {
std::thread::sleep(Duration::from_millis(4));
super::time("inner", || std::thread::sleep(Duration::from_millis(3)));
});
if let Some(barrier) = alive_with {
barrier.wait();
}
})
.unwrap()
};
spawn("tl-solo", None).join().unwrap();
let together = Arc::new(Barrier::new(2));
let twins = [
spawn("tl-twin", Some(together.clone())),
spawn("tl-twin", Some(together)),
];
for twin in twins {
twin.join().unwrap();
}
spawn("tl-seq", None).join().unwrap();
spawn("tl-seq", None).join().unwrap();
let mut threads = timeline::finish();
threads.retain(|t| t.thread.starts_with("tl-"));
threads.sort_by(|a, b| a.thread.cmp(&b.thread));
let labels: Vec<_> = threads.iter().map(|t| t.thread.as_str()).collect();
assert_eq!(labels, ["tl-seq", "tl-solo", "tl-twin", "tl-twin #2"]);
for thread in &threads {
let runs = if thread.thread == "tl-seq" { 2 } else { 1 };
assert_eq!(thread.events.len(), 2 * runs, "{thread:?}");
for pair in thread.events.chunks(2) {
let [inner, outer] = pair else { unreachable!() };
assert_eq!((inner.name, inner.depth), ("inner", 1));
assert_eq!((outer.name, outer.depth), ("outer", 0));
assert!(
inner.dur_us >= 3_000 && outer.dur_us >= 7_000,
"{inner:?} {outer:?}"
);
assert!(inner.start_us >= outer.start_us + 4_000);
assert!(
inner.start_us + u64::from(inner.dur_us)
<= outer.start_us + u64::from(outer.dur_us)
);
}
}
let seq = threads.iter().find(|t| t.thread == "tl-seq").unwrap();
assert!(seq.events[2].start_us >= seq.events[1].start_us + u64::from(seq.events[1].dur_us));
spawn("tl-after", None).join().unwrap();
assert!(
timeline::drain().is_empty(),
"a stopped timeline records nothing"
);
}
}
+4 -4
View File
@@ -478,9 +478,9 @@ impl Sprites {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(crate::packs::first_json::<
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(text.as_bytes())?),
>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
@@ -589,9 +589,9 @@ impl Sprites {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(crate::packs::first_json::<
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(text.as_bytes())?),
>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
+36 -15
View File
@@ -28,7 +28,37 @@ pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
/// Worker CPU time and job counts by kind, for benchmarks
/// (`examples/gen_light_bench.rs`). Off unless enabled: a relaxed load per
/// job then.
pub mod job_stats;
pub mod job_stats {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
pub const KINDS: [&str; 7] = [
"load", "generate", "decorate", "finish", "light", "mesh", "other",
];
static ENABLED: AtomicBool = AtomicBool::new(false);
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
pub(super) fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
pub(super) fn add(kind: usize, nanos: u64) {
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
COUNT[kind].fetch_add(1, Ordering::Relaxed);
}
/// (kind, nanoseconds, count) since the last call; resets them.
pub fn take() -> Vec<(&'static str, u64, u64)> {
(0..KINDS.len())
.map(|i| {
(
KINDS[i],
NANOS[i].swap(0, Ordering::Relaxed),
COUNT[i].swap(0, Ordering::Relaxed),
)
})
.collect()
}
}
/// A chunk under generation, opaque to the streamer.
pub type Proto = Box<dyn std::any::Any + Send>;
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
@@ -1040,8 +1070,6 @@ pub struct WorkerContext {
pub light: Arc<LightTables>,
}
mod bounded;
mod queue_census;
pub use queue_census::QueueCensus;
mod forced_cover;
mod forced_ring;
mod relight_now;
@@ -1438,7 +1466,7 @@ impl Streaming {
let entities = std::mem::take(&mut chunk.entities);
world.entities_pending.insert((chunk.x, chunk.z));
if !entities.is_empty() {
world.queue_entity_nbt((chunk.x, chunk.z), entities);
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generated_total += 1;
// A chunk saved with its light needs no light job; its unlit
@@ -1655,7 +1683,6 @@ impl Streaming {
&mut self,
world: &mut World,
) -> (Vec<((i32, i32, i32), SectionMesh)>, Vec<Arc<Chunk>>) {
let mut hz_laps = crate::spans::laps();
let mut uploads = Vec::new();
if self.paused {
return (uploads, Vec::new());
@@ -1681,7 +1708,6 @@ impl Streaming {
self.scan_age = if sweep { 0 } else { self.scan_age + 1 };
self.requeue();
self.relight_edits(world);
hz_laps.lap("st_relight_edits");
let deferred = std::mem::take(&mut self.deferred);
let received = self.shared.done.take();
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
@@ -1731,7 +1757,6 @@ impl Streaming {
}
}
}
hz_laps.lap("st_results");
self.free(stale);
let mut removed = Vec::new();
if sweep {
@@ -1779,7 +1804,10 @@ impl Streaming {
> r4 * r4)
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges.forced_within(x, z, FORCED_TERRAIN_RING)
&& !ranges.forced_chunks().any(|f| {
(x - f.0).abs() <= FORCED_TERRAIN_RING
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
})
})
.map(|(k, _)| *k)
.collect();
@@ -1799,7 +1827,6 @@ impl Streaming {
}
self.save_checked_out(written);
}
hz_laps.lap("st_sweep");
let (protos, finishing) = (&self.protos, &self.finishing);
self.decorated.retain(|k| {
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
@@ -1831,7 +1858,6 @@ impl Streaming {
self.submit(Some((x, z)), 0, Job::Finish(proto));
}
}
hz_laps.lap("st_finalize");
// Decorate chunks whose 3x3 proto neighbourhood is available, in
// vanilla's order: decoration is order dependent where write regions
// overlap (see `DecorationOrder`).
@@ -1957,9 +1983,7 @@ impl Streaming {
self.submit(Some((x, z)), 1, Job::Decorate(x, z, region, finals));
}
}
hz_laps.lap("st_decorate");
self.schedule_generation(world, workers);
hz_laps.lap("st_generate");
// Lighting once all neighbours exist, nearest first and only as
// much of it as the workers can chew. Vanilla's light engine also
// hands a bounded queue to its workers and reschedules what did
@@ -1995,7 +2019,6 @@ impl Streaming {
Job::Light(x, z, region, self.sequence, self.sky_light),
);
}
hz_laps.lap("st_light");
// A server draws nothing: its sections are never meshed.
if !self.mesh {
world.dirty_sections.clear();
@@ -2036,7 +2059,6 @@ impl Streaming {
!back
});
}
hz_laps.lap("st_waiting");
// Column by column: the checks below hold for all of a column's
// sections, and columns waiting for light cost one look per frame.
columns.extend(world.dirty_sections.columns());
@@ -2069,7 +2091,6 @@ impl Streaming {
}
candidates.push((self.distance(x, z), x, z, sections));
}
hz_laps.lap("st_candidates");
// Nearest first, and only as many sections as the workers can chew.
// Every ready column used to be submitted at once, so at a wide
// render distance the queue — and `meshing`, and each job's 3x3
@@ -6,20 +6,16 @@ use std::collections::HashSet;
/// `Streaming::forced` and `mod_forced` as the Chebyshev distance from each column within
/// `REACH` of one to the nearest: `Ranges::in_main_range` answers by one lookup instead of going
/// through every forced chunk (it is asked for every column, several times a tick). Past `MOST`
/// forced chunks, and for a reach past `REACH`, their tiles answer.
/// through every forced chunk (it is asked for every column, several times a tick).
pub(super) struct ForcedCover {
forced: HashSet<(i32, i32)>,
mod_forced: HashSet<(i32, i32)>,
/// None past `MOST`.
near: Option<FxHashMap<(i32, i32), u8>>,
tiles: Tiles,
near: FxHashMap<(i32, i32), u8>,
}
impl ForcedCover {
/// The widest ring `in_main_range` asks of a forced chunk (margins 1 to 4).
const REACH: i32 = FORCED_TERRAIN_RING + 1;
/// Past this many forced chunks the distance map is not made (it holds up to
/// `(2 * REACH + 1)^2` entries a forced chunk): the tiles answer.
/// Past this many forced chunks the cover is not made: the scan answers.
const MOST: usize = 4096;
pub(super) fn refresh(
cover: &mut Option<Self>,
@@ -32,85 +28,33 @@ impl ForcedCover {
{
return;
}
let near = (forced.len() + mod_forced.len() <= Self::MOST).then(|| {
let mut near = FxHashMap::default();
for &(fx, fz) in forced.iter().chain(mod_forced) {
for dx in -Self::REACH..=Self::REACH {
for dz in -Self::REACH..=Self::REACH {
let d = dx.abs().max(dz.abs()) as u8;
near.entry((fx + dx, fz + dz))
.and_modify(|e: &mut u8| *e = (*e).min(d))
.or_insert(d);
}
if forced.len() + mod_forced.len() > Self::MOST {
*cover = None;
return;
}
let mut near = FxHashMap::default();
for &(fx, fz) in forced.iter().chain(mod_forced) {
for dx in -Self::REACH..=Self::REACH {
for dz in -Self::REACH..=Self::REACH {
let d = dx.abs().max(dz.abs()) as u8;
near.entry((fx + dx, fz + dz))
.and_modify(|e: &mut u8| *e = (*e).min(d))
.or_insert(d);
}
}
near
});
}
*cover = Some(Self {
forced: forced.clone(),
mod_forced: mod_forced.clone(),
near,
tiles: Tiles::new(forced.iter().chain(mod_forced)),
});
}
/// Whether a forced chunk lies within `reach` of `(x, z)` (Chebyshev).
pub(super) fn within(&self, x: i32, z: i32, reach: i32) -> bool {
match &self.near {
Some(near) if reach <= Self::REACH => {
near.get(&(x, z)).is_some_and(|&d| i32::from(d) <= reach)
}
_ => self.tiles.within(x, z, reach),
}
}
}
/// The forced chunks as bits, 8 by 8 columns a word: `(x, z)`'s bit is `(z & 7) * 8 + (x & 7)`
/// of the word at `(x >> 3, z >> 3)`. A question reads only the words its square overlaps (four
/// at most for a reach up to 4, nine up to 8), however many chunks are forced. It holds one word
/// and its key for each tile with a forced chunk in it.
struct Tiles(FxHashMap<(i32, i32), u64>);
impl Tiles {
fn new<'a>(chunks: impl Iterator<Item = &'a (i32, i32)>) -> Self {
let mut tiles = FxHashMap::default();
for &(x, z) in chunks {
*tiles.entry((x >> 3, z >> 3)).or_insert(0) |= 1u64 << ((z & 7) * 8 + (x & 7));
}
Self(tiles)
}
/// Whether a chunk's bit is set within `reach` of `(x, z)` (Chebyshev).
fn within(&self, x: i32, z: i32, reach: i32) -> bool {
if reach < 0 {
return false;
}
let (x0, x1) = (x.saturating_sub(reach), x.saturating_add(reach));
let (z0, z1) = (z.saturating_sub(reach), z.saturating_add(reach));
// The square's bits in the tile at (tx, tz).
let hit = |(tx, tz): (i32, i32), bits: u64| {
let (bx, bz) = (i64::from(tx) * 8, i64::from(tz) * 8);
let lx0 = (i64::from(x0) - bx).max(0);
let lx1 = (i64::from(x1) - bx).min(7);
let lz0 = (i64::from(z0) - bz).max(0);
let lz1 = (i64::from(z1) - bz).min(7);
if lx0 > lx1 || lz0 > lz1 {
return false;
}
// Columns lx0..=lx1 of one row, copied to every row, then rows lz0..=lz1.
let row = (0xFFu64 >> (7 - (lx1 - lx0))) << lx0;
let rows = (u64::MAX >> (8 * (7 - (lz1 - lz0)))) << (8 * lz0);
bits & row.wrapping_mul(0x0101_0101_0101_0101) & rows != 0
};
let (tx0, tx1, tz0, tz1) = (x0 >> 3, x1 >> 3, z0 >> 3, z1 >> 3);
let overlapped = (i64::from(tx1 - tx0) + 1) * (i64::from(tz1 - tz0) + 1);
// A square wider than the tiles held (a far reach): go through the tiles instead.
if overlapped > self.0.len() as i64 {
return self.0.iter().any(|(&t, &bits)| hit(t, bits));
}
(tz0..=tz1).any(|tz| {
(tx0..=tx1).any(|tx| {
self.0
.get(&(tx, tz))
.is_some_and(|&bits| hit((tx, tz), bits))
})
/// Whether a forced chunk lies within `reach` of `(x, z)`, or `None` past `REACH`.
pub(super) fn within(&self, x: i32, z: i32, reach: i32) -> Option<bool> {
(reach <= Self::REACH).then(|| {
self.near
.get(&(x, z))
.is_some_and(|&d| i32::from(d) <= reach)
})
}
}
@@ -139,13 +83,8 @@ impl Ranges<'_> {
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced_within(x, z, reach)
}
/// Whether a forced chunk lies within `reach` of `(x, z)` (Chebyshev): from the cover while
/// `update` runs, else by a scan of every forced chunk.
pub(super) fn forced_within(&self, x: i32, z: i32, reach: i32) -> bool {
if let Some(cover) = self.forced_cover {
return cover.within(x, z, reach);
if let Some(hit) = self.forced_cover.and_then(|c| c.within(x, z, reach)) {
return hit;
}
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
@@ -213,194 +152,14 @@ mod tests {
}
}
/// Too many forced chunks for a map: none is made, and the tiles answer.
/// Too many forced chunks for a map: none is made, and the scan answers.
#[test]
fn past_the_most_no_map_is_made() {
let forced: HashSet<(i32, i32)> = (0..=ForcedCover::MOST as i32).map(|x| (x, 0)).collect();
let mut cover = None;
ForcedCover::refresh(&mut cover, &HashSet::new(), &HashSet::from([(0, 0)]));
assert!(cover.as_ref().is_some_and(|c| c.near.is_some()));
assert!(cover.is_some());
ForcedCover::refresh(&mut cover, &forced, &HashSet::new());
let made = cover
.as_ref()
.expect("a cover is made however many are forced");
assert!(made.near.is_none());
assert!(made.within(-3, 3, 3) && !made.within(-3, 4, 3));
}
/// Forced chunks in a disc, in clusters and scattered: `n` of them (the clusters a few more).
fn sets(seed: &mut u64, n: usize) -> [HashSet<(i32, i32)>; 3] {
let r = (n as f64 / std::f64::consts::PI).sqrt().ceil() as i32;
let disc: HashSet<_> = (-r..=r)
.flat_map(|z| (-r..=r).map(move |x| (x, z)))
.filter(|&(x, z)| x * x + z * z <= r * r)
.take(n)
.collect();
let mut clusters = HashSet::new();
while clusters.len() < n {
let (cx, cz) = chunks(seed, 1, 400).into_iter().next().unwrap();
clusters.extend(
chunks(seed, 64, 6)
.into_iter()
.map(|(x, z)| (cx + x, cz + z)),
);
}
let spread = (n as f64).sqrt() as i32 * 6;
let mut scattered = HashSet::new();
while scattered.len() < n {
scattered.extend(chunks(seed, n - scattered.len(), spread));
}
[disc, clusters, scattered]
}
/// Well past `MOST`, and either side of it, the cover answers every reach as the scan of
/// every forced chunk does: around forced chunks, between them, on tile edges and far off,
/// with some held by both a `/forceload` and a mod's ticket.
#[test]
fn past_the_most_the_tiles_answer_as_the_scan() {
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let m = ForcedCover::MOST;
// Some held both ways past the boundary, none at it (a chunk held both ways counts twice
// towards `MOST`, as `refresh` counts them).
for (n, both) in [(m, false), (m + 1, false), (6000, true), (20_000, true)] {
for (kind, all) in sets(&mut seed, n).into_iter().enumerate() {
// Every third chunk a mod's ticket; with `both`, every fifth held both ways.
let (mut forced, mut mod_forced) = (HashSet::new(), HashSet::new());
for (i, &c) in all.iter().enumerate() {
if i % 3 == 0 || (both && i % 5 == 0) {
mod_forced.insert(c);
}
if i % 3 != 0 {
forced.insert(c);
}
}
let mut cover = None;
ForcedCover::refresh(&mut cover, &forced, &mod_forced);
let made = cover.as_ref().unwrap();
let held = forced.len() + mod_forced.len();
assert_eq!(made.near.is_some(), held <= m);
let ranges = |forced_cover| Ranges {
center: (10_000_000, 10_000_000),
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &mod_forced,
cover: None,
forced_cover,
band: None,
};
let (scan, map) = (ranges(None), ranges(Some(made)));
// The scan goes through every forced chunk: fewer columns for more of them.
let near: Vec<_> = all.iter().step_by(all.len() / 40).copied().collect();
let offsets = chunks(&mut seed, 6, 12);
let far = chunks(&mut seed, 12, 3000);
let columns = near
.iter()
.flat_map(|&(x, z)| offsets.iter().map(move |&(dx, dz)| (x + dx, z + dz)))
.chain(far);
for (x, z) in columns {
for reach in [-1, 0, 1, 2, 3, 4, 5, 6, 8, 11, 40] {
assert_eq!(
map.forced_within(x, z, reach),
scan.forced_within(x, z, reach),
"{n} forced, set {kind}, ({x}, {z}) reach {reach}"
);
}
for margin in 1..=4 {
assert_eq!(
map.in_main_range(x, z, margin),
scan.in_main_range(x, z, margin)
);
}
}
}
}
}
/// Each tile's bits answer for every square that overlaps it in part: corners, edges and
/// across negative coordinates.
#[test]
fn a_tiles_edges_answer_as_the_scan() {
let mut seed = 0xD1B5_4A32_D192_ED03u64;
for _ in 0..200 {
let set = chunks(&mut seed, 3, 9);
let tiles = Tiles::new(set.iter());
for z in -14..=14 {
for x in -14..=14 {
for reach in 0..=9 {
let scan = set
.iter()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach);
assert_eq!(
tiles.within(x, z, reach),
scan,
"{set:?} ({x}, {z}) {reach}"
);
}
}
}
}
}
/// What a question and the cover cost by how many chunks are forced: run with
/// `cargo test --release -p horizon-client-render forced_cover_cost -- --ignored --nocapture`.
#[test]
#[ignore = "timings; run with --release --ignored --nocapture"]
fn forced_cover_cost() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
for n in [1000, 4000, 4097, 6000, 20_000] {
for (kind, forced) in sets(&mut seed, n).into_iter().enumerate() {
let none = HashSet::new();
let started = std::time::Instant::now();
let mut cover = None;
ForcedCover::refresh(&mut cover, &forced, &none);
let made_in = started.elapsed();
let made = cover.as_ref().unwrap();
let ranges = |forced_cover| Ranges {
center: (10_000_000, 10_000_000),
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &none,
cover: None,
forced_cover,
band: None,
};
let spread = forced
.iter()
.map(|f| f.0.abs().max(f.1.abs()))
.max()
.unwrap()
+ 20;
let columns = chunks(&mut seed, 200_000, spread);
let time = |r: &Ranges, take: usize| {
let started = std::time::Instant::now();
let mut hits = 0usize;
for &(x, z) in columns.iter().take(take) {
for margin in 1..=4 {
hits += usize::from(r.in_main_range(x, z, margin));
}
}
(
started.elapsed().as_nanos() as f64 / (take * 4) as f64,
hits,
)
};
let (cover_ns, _) = time(&ranges(Some(made)), columns.len());
let (scan_ns, _) = time(&ranges(None), 2000);
let bytes = made.tiles.0.capacity() * (16 + 1)
+ made.near.as_ref().map_or(0, |m| m.capacity() * (12 + 1));
println!(
"{n:>6} forced ({}): cover {cover_ns:6.1} ns a question, scan {scan_ns:8.0} ns; \
made in {made_in:?}, {} tiles, distance map {}, ~{} KiB",
["disc", "clusters", "scattered"][kind],
made.tiles.0.len(),
made.near
.as_ref()
.map_or("none".to_string(), |m| m.len().to_string()),
bytes / 1024
);
}
}
assert!(cover.is_none());
}
}
@@ -1,29 +0,0 @@
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
pub const KINDS: [&str; 7] = [
"load", "generate", "decorate", "finish", "light", "mesh", "other",
];
static ENABLED: AtomicBool = AtomicBool::new(false);
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
pub(super) fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
pub(super) fn add(kind: usize, nanos: u64) {
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
COUNT[kind].fetch_add(1, Ordering::Relaxed);
}
/// (kind, nanoseconds, count) since the last call; resets them.
pub fn take() -> Vec<(&'static str, u64, u64)> {
(0..KINDS.len())
.map(|i| {
(
KINDS[i],
NANOS[i].swap(0, Ordering::Relaxed),
COUNT[i].swap(0, Ordering::Relaxed),
)
})
.collect()
}
@@ -1,39 +0,0 @@
//! What the streamer's queues hold, for `horizon-ctl memory` and captures.
use super::{Queued, Streaming};
/// What the streamer's work queues hold, each as (entries, estimated bytes).
#[derive(Debug, Default)]
pub struct QueueCensus {
/// Jobs queued and chunks generating.
pub generation: (usize, usize),
/// Chunks being lit and the edit log their jobs replay.
pub light: (usize, usize),
}
impl Streaming {
/// The queues' lengths and the bytes of their tables, from capacities. For `horizon-ctl memory`.
pub fn queue_census(&self) -> QueueCensus {
use std::mem::size_of;
let table = |capacity: usize, entry: usize| capacity * (entry + 1);
let queued = {
let queue = self.shared.queue.lock().unwrap();
(queue.len(), queue.capacity() * size_of::<Queued>())
};
QueueCensus {
generation: (
queued.0 + self.generating.len(),
queued.1 + table(self.generating.capacity(), size_of::<(i32, i32)>()),
),
light: (
self.lighting.len(),
table(self.lighting.capacity(), size_of::<((i32, i32), u64)>())
+ table(
self.light_snapshots.capacity(),
size_of::<((i32, i32), u64)>(),
)
+ self.light_log.capacity() * size_of::<(u64, (i32, i32, i32))>()
+ self.light_scratch.capacity() * size_of::<(i64, i32, i32)>(),
),
}
}
}
@@ -11,7 +11,6 @@
//! D-pad, a button, or a look gesture on the world) and keeps it until it
//! lifts, so moving, looking and pressing never take each other's fingers.
pub mod aim;
mod draw;
pub mod layout;
pub use aim::{
attack_indicator, camera_delta, hotbar_slot, item_places_entity_on_tap, item_uses_on_tap,
@@ -82,11 +81,6 @@ pub struct TouchControls {
pub vertical_movement: bool,
pub swimming: bool,
pub mounted: bool,
/// The player is sneaking: the sneak button shows held.
pub sneaking: bool,
/// How far the block under a world hold is broken (0..1), drawn in the
/// touch marker.
pub break_progress: f32,
/// A long hold on the world uses the held item instead of mining. Read when
/// a finger lands, so a change mid-hold (the last apple eaten) can't turn
/// eating into mining.
@@ -1,465 +0,0 @@
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
// 150a414b09a1, `crates/horizon-client-render/src/touch.rs`), written by Vasysik and the
// Starling contributors. Copyright (c) 2026 Ludovic Marie and the Horizon contributors, under the
// same MIT license as Horizon (LICENSE at the repository root).
//! Drawing the touch controls on the HUD, in GUI pixels. Buttons use the
//! resource pack's own button sprite (`widget/button`, highlighted while held)
//! with a short label; the joystick, the D-pad and the tap marker are plain
//! shapes. No art of its own ships with the game.
use super::{Control, Mode, Placement, Role, TouchControls, layout};
use crate::gamepad::{Button, PadState};
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::lang;
const BUTTON: &str = "gui/sprites/widget/button";
const BUTTON_HELD: &str = "gui/sprites/widget/button_highlighted";
impl TouchControls {
/// Draws every visible control, then the tap marker in the tap schemes.
pub fn draw(&self, b: &mut HudBatch, a: &HudAtlas) {
let state = self.state();
for control in Control::ALL {
if !self.visible(control) {
continue;
}
let p = self.placement(control);
let color = rgba(255, 255, 255, alpha(p.opacity));
if control == Control::Move {
self.draw_move(b, a, p, &state, color);
continue;
}
let bounds = square(b, p.center, p.size);
let rect = p.rect(self.aspect());
let pressed = self.held(control, &state)
|| self
.fingers
.values()
.any(|(_, point)| layout::contains(rect, *point));
button(b, a, bounds, &self.label(control), pressed, color);
}
if let Some(point) = self.pointer() {
let center = [point[0] * b.width, point[1] * b.height];
let radius = self.settings.circle_size / 2.0;
ring(b, a, center, radius + 0.7, rgba(0, 0, 0, 110));
ring(b, a, center, radius, rgba(255, 255, 255, 210));
// Mining fills the circle outwards from its centre.
let inner = mining_radius(radius, self.break_progress);
if inner > 0.0 {
disc(b, a, center, inner, rgba(255, 255, 255, 90));
ring(b, a, center, inner, rgba(255, 255, 255, 220));
}
}
}
/// Whether a control shows as held: its button is down, or it latched.
fn held(&self, control: Control, state: &PadState) -> bool {
if control == Control::Sneak && self.sneaking {
return true;
}
Self::button(control).is_some_and(|button| match button {
Button::LeftTrigger => state.triggers[0] > 0.0,
Button::RightTrigger => state.triggers[1] > 0.0,
_ => state.held(button),
})
}
/// The label a control's button shows, in the player's language. Key
/// names and symbols ("F3", "II") read the same in every language.
fn label(&self, control: Control) -> String {
let vertical = self.swimming || self.vertical_movement;
let key = match control {
Control::Move => return String::new(),
Control::Pause => return "II".to_owned(),
Control::Inventory => return "...".to_owned(),
Control::Debug => return "F3".to_owned(),
Control::Perspective => return "F5".to_owned(),
Control::Sprint => "horizon.touch.sprint",
Control::Jump if vertical => "horizon.touch.up",
Control::Jump => "horizon.touch.jump",
Control::Sneak if vertical => "horizon.touch.down",
Control::Sneak if self.mounted => "horizon.touch.dismount",
Control::Sneak => "horizon.touch.sneak",
Control::Attack => "horizon.touch.attack",
Control::Use => "horizon.touch.use",
Control::Chat => "horizon.touch.chat",
Control::Settings => "horizon.touch.settings",
Control::Offhand => "horizon.touch.swap",
Control::Shield => "horizon.touch.shield",
};
lang::get(key)
}
/// The joystick (a rim and a knob) or the D-pad (four arrow buttons).
fn draw_move(
&self,
b: &mut HudBatch,
a: &HudAtlas,
p: Placement,
state: &PadState,
color: u32,
) {
let anchor = self.fingers.values().find_map(|(role, _)| match role {
Role::Move(anchor) => Some(*anchor),
_ => None,
});
let center = match anchor {
Some(anchor) if self.dynamic_joystick() => anchor,
// A dynamic joystick only shows while a finger holds it.
None if self.dynamic_joystick() => return,
_ => p.center,
};
let [x, y, size, _] = square(b, center, p.size);
if self.settings.mode == Mode::Dpad {
let cell = size / 3.0;
for ([dx, dy], label, pressed) in dpad(state.left) {
let bounds = [x + cell * dx, y + cell * dy, cell, cell];
button(b, a, bounds, label, pressed, color);
}
return;
}
let middle = [x + size / 2.0, y + size / 2.0];
let [r, g, blue, opacity] = color.to_le_bytes();
ring(b, a, middle, size / 2.0, color);
disc(b, a, middle, size / 2.0, rgba(0, 0, 0, opacity / 3));
let knob = knob(middle, state.left, size);
disc(b, a, knob, size * 0.17, rgba(r, g, blue, opacity / 2));
ring(b, a, knob, size * 0.17, color);
}
}
/// The D-pad's arrows: each one's cell in a 3x3 grid, its label and whether
/// the stick (`left`, up positive) presses it.
fn dpad(left: [f32; 2]) -> [([f32; 2], &'static str, bool); 4] {
[
([1.0, 0.0], "^", left[1] > 0.2),
([0.0, 1.0], "<", left[0] < -0.2),
([2.0, 1.0], ">", left[0] > 0.2),
([1.0, 2.0], "v", left[1] < -0.2),
]
}
/// Where the joystick's knob sits: the stick is up positive, the screen down.
fn knob(middle: [f32; 2], left: [f32; 2], size: f32) -> [f32; 2] {
[
middle[0] + left[0] * size / 4.0,
middle[1] - left[1] * size / 4.0,
]
}
/// A placement's square in GUI pixels, `[x, y, size, size]`.
fn square(b: &HudBatch, center: [f32; 2], size: f32) -> [f32; 4] {
let size = size * b.height;
[
center[0] * b.width - size / 2.0,
center[1] * b.height - size / 2.0,
size,
size,
]
}
fn alpha(opacity: f32) -> u8 {
(opacity.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// A labelled button: the pack's button sprite, or a plain frame when the
/// pack has none. A held button darkens and its label drops a pixel.
fn button(
b: &mut HudBatch,
a: &HudAtlas,
bounds: [f32; 4],
label: &str,
pressed: bool,
color: u32,
) {
let [x, y, w, h] = bounds;
let [r, g, blue, opacity] = color.to_le_bytes();
if a.sprites.contains_key(BUTTON) {
let sprite = if pressed && a.sprites.contains_key(BUTTON_HELD) {
BUTTON_HELD
} else {
BUTTON
};
crate::ui::nine_slice(b, a, sprite, x, y, w, h, color);
} else {
let fill = if pressed { 150 } else { 90 };
b.fill(
a,
x,
y,
w,
h,
rgba(0, 0, 0, (u16::from(opacity) * fill / 255) as u8),
);
for edge in [
[x, y, w, 1.0],
[x, y + h - 1.0, w, 1.0],
[x, y, 1.0, h],
[x + w - 1.0, y, 1.0, h],
] {
b.fill(a, edge[0], edge[1], edge[2], edge[3], color);
}
}
if label.is_empty() {
return;
}
let text = if pressed {
rgba(r / 2 + 127, g / 2 + 127, blue / 2, opacity)
} else {
color
};
let drop = if pressed { (h / 18.0).min(2.0) } else { 0.0 };
let width = HudBatch::text_width(a, label);
let start = b.vertices.len();
b.text(
a,
label,
x + (w - width) / 2.0,
y + (h - 8.0) / 2.0 + drop,
text,
true,
);
// Shrink a long label to fit, never grow a short one: the width leaves 3
// pixels each side, and the height cap keeps the 8-pixel line inside the
// same margin, never above full size.
let fit_width = (w - 6.0).max(1.0) / width.max(1.0);
let height_cap = ((h - 6.0).max(1.0) / 8.0).min(1.0);
let scale = fit_width.min(height_cap);
b.scale_since(start, x + w / 2.0, y + h / 2.0 + drop, scale, scale);
}
/// How far the mining disc reaches inside a marker of `radius`, by progress.
fn mining_radius(radius: f32, progress: f32) -> f32 {
if progress.is_finite() {
(radius - 1.5).max(0.0) * progress.clamp(0.0, 1.0)
} else {
0.0
}
}
/// 65 points round the unit circle, the last one equal to the first.
fn circle_points() -> &'static [[f32; 2]; 65] {
static POINTS: std::sync::OnceLock<[[f32; 2]; 65]> = std::sync::OnceLock::new();
POINTS.get_or_init(|| {
std::array::from_fn(|i| {
let angle = i as f32 / 64.0 * std::f32::consts::TAU;
[angle.cos(), angle.sin()]
})
})
}
/// A 1.4-pixel circle line, one quad per segment so nothing blends twice.
fn ring(b: &mut HudBatch, a: &HudAtlas, center: [f32; 2], radius: f32, color: u32) {
let at = |p: [f32; 2], r: f32| [center[0] + p[0] * r, center[1] + p[1] * r];
let (outer, inner) = (radius + 0.7, (radius - 0.7).max(0.0));
for s in circle_points().windows(2) {
b.fill_quad(
a,
[
at(s[0], outer),
at(s[1], outer),
at(s[1], inner),
at(s[0], inner),
],
color,
);
}
}
/// A filled circle.
fn disc(b: &mut HudBatch, a: &HudAtlas, center: [f32; 2], radius: f32, color: u32) {
let at = |p: [f32; 2]| [center[0] + p[0] * radius, center[1] + p[1] * radius];
for s in circle_points().windows(2) {
b.fill_quad(a, [center, at(s[0]), at(s[1]), center], color);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-touch-draw")).unwrap()
}
fn vertices(t: &TouchControls, a: &HudAtlas) -> usize {
let mut b = HudBatch::new([1920, 1080]);
t.draw(&mut b, a);
b.vertices.len()
}
#[test]
fn mining_disc_grows_outwards_within_the_marker() {
assert_eq!(mining_radius(16.0, 0.0), 0.0);
assert_eq!(mining_radius(16.0, 0.5), 7.25);
assert_eq!(mining_radius(16.0, 1.0), 14.5);
assert_eq!(mining_radius(16.0, 2.0), 14.5);
assert_eq!(mining_radius(16.0, -1.0), 0.0);
assert_eq!(mining_radius(16.0, f32::NAN), 0.0);
}
#[test]
fn hidden_controls_draw_nothing() {
let a = atlas();
let mut t = TouchControls::default();
assert!(vertices(&t, &a) > 0);
t.settings.button_visibility = [layout::HIDDEN; layout::CONTROLS];
assert_eq!(vertices(&t, &a), 0);
}
#[test]
fn a_dynamic_joystick_shows_only_while_held() {
let a = atlas();
let mut t = TouchControls::default();
t.settings.dynamic_joystick = true;
let idle = vertices(&t, &a);
t.update(1, [0.1, 0.6], true, false);
assert!(vertices(&t, &a) > idle);
t.update(1, [0.1, 0.6], false, true);
assert_eq!(vertices(&t, &a), idle);
}
#[test]
fn the_marker_shows_in_tap_schemes_and_fills_while_mining() {
let a = atlas();
let mut t = TouchControls::default();
let idle = vertices(&t, &a);
t.update(1, [0.6, 0.4], true, false);
assert_eq!(vertices(&t, &a), idle, "no marker with a crosshair");
t.clear();
t.settings.mode = Mode::Tap;
let idle = vertices(&t, &a);
t.update(1, [0.6, 0.4], true, false);
let marker = vertices(&t, &a);
assert!(marker > idle);
t.break_progress = 0.5;
assert!(vertices(&t, &a) > marker);
}
fn english<R>(f: impl FnOnce() -> R) -> R {
let (result, missed) = lang::scoped(lang::Language::load(&[], "en_us"), f);
assert!(missed.is_empty(), "no English text for {missed:?}");
result
}
#[test]
fn labels_and_held_buttons_follow_the_player() {
let mut t = TouchControls::default();
let all = |t: &TouchControls| english(|| Control::ALL.map(|c| t.label(c)));
assert_eq!(all(&t)[..3], ["", "Sprint", "Jump"]);
assert!(all(&t).iter().all(|l| !l.starts_with("horizon.")));
assert_eq!(t.label(Control::Debug), "F3");
t.swimming = true;
assert_eq!(english(|| t.label(Control::Jump)), "Up");
assert_eq!(english(|| t.label(Control::Sneak)), "Down");
t.swimming = false;
t.mounted = true;
assert_eq!(english(|| t.label(Control::Sneak)), "Dismount");
let state = t.state();
assert!(!t.held(Control::Sneak, &state));
t.sneaking = true;
assert!(t.held(Control::Sneak, &state));
t.update(1, t.placement(Control::Attack).center, true, false);
assert!(t.held(Control::Attack, &t.state()));
assert!(!t.held(Control::Use, &t.state()));
}
#[test]
fn colours_split_into_rgba_in_order() {
let half = rgba(255, 255, 255, alpha(0.5));
assert_eq!(half.to_le_bytes(), [255, 255, 255, 128]);
assert_eq!(rgba(10, 20, 30, 40).to_le_bytes(), [10, 20, 30, 40]);
}
/// One button drawn alone, as `button` draws it in `draw`.
fn one(a: &HudAtlas, bounds: [f32; 4], label: &str, pressed: bool) -> HudBatch {
let mut b = HudBatch::new([1920, 1080]);
button(&mut b, a, bounds, label, pressed, rgba(255, 255, 255, 128));
b
}
#[test]
fn a_held_button_uses_the_packs_highlighted_sprite() {
let mut a = atlas();
let rect = |u0: f32| crate::hud::Rect {
u0,
v0: 0.0,
u1: u0 + 0.1,
v1: 0.1,
w: 200,
h: 20,
};
a.sprites.insert(BUTTON.to_owned(), rect(0.2));
a.sprites.insert(BUTTON_HELD.to_owned(), rect(0.5));
let uses = |b: &HudBatch, u0: f32| {
b.vertices
.iter()
.any(|v| (u0..=u0 + 0.1).contains(&v.uv[0]) && v.uv[1] <= 0.1)
};
let idle = one(&a, [0.0, 0.0, 60.0, 20.0], "", false);
assert!(uses(&idle, 0.2) && !uses(&idle, 0.5));
let held = one(&a, [0.0, 0.0, 60.0, 20.0], "", true);
assert!(uses(&held, 0.5) && !uses(&held, 0.2));
assert!(
held.vertices
.iter()
.all(|v| v.color == rgba(255, 255, 255, 128))
);
}
#[test]
fn without_the_sprite_a_plain_frame_darkens_while_held() {
let a = atlas();
assert!(!a.sprites.contains_key(BUTTON));
let colours = |pressed| {
let b = one(&a, [10.0, 10.0, 60.0, 20.0], "", pressed);
b.vertices.iter().map(|v| v.color).collect::<Vec<_>>()
};
let (idle, held) = (colours(false), colours(true));
let mut fill = HudBatch::new([1920, 1080]);
fill.fill(&a, 0.0, 0.0, 1.0, 1.0, 0);
let quad = fill.vertices.len();
// The backdrop, then the four one-pixel edges in the button's colour.
assert_eq!(idle.len(), 5 * quad);
assert!(idle[..quad].iter().all(|&c| c == rgba(0, 0, 0, 45)));
assert!(held[..quad].iter().all(|&c| c == rgba(0, 0, 0, 75)));
assert!(idle[quad..].iter().all(|&c| c == rgba(255, 255, 255, 128)));
}
#[test]
fn a_long_label_shrinks_to_fit_and_a_short_one_keeps_its_size() {
let a = atlas();
let frame = one(&a, [0.0, 0.0, 40.0, 20.0], "", false).vertices.len();
let span = |label: &str| {
let b = one(&a, [0.0, 0.0, 40.0, 20.0], label, false);
assert!(b.vertices.len() > frame, "{label} drew no text");
let xs = b.vertices[frame..].iter().map(|v| v.pos[0]);
let (lo, hi) = xs.fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(x), hi.max(x)));
(lo, hi)
};
let long = "a label much wider than its button";
assert!(HudBatch::text_width(&a, long) > 40.0);
let (lo, hi) = span(long);
assert!(lo >= 3.0 - 1e-3 && hi <= 37.0 + 1e-3, "{lo}..{hi}");
let width = HudBatch::text_width(&a, "Up");
let (lo, hi) = span("Up");
assert!(hi - lo >= width - 1e-3, "{} < {width}", hi - lo);
}
#[test]
fn the_dpad_and_knob_follow_the_stick_up_positive() {
let only = |left: [f32; 2]| {
let on: Vec<_> = dpad(left).into_iter().filter(|p| p.2).collect();
assert_eq!(on.len(), 1, "{left:?}");
(on[0].0, on[0].1)
};
assert_eq!(only([0.0, 0.5]), ([1.0, 0.0], "^"));
assert_eq!(only([0.0, -0.5]), ([1.0, 2.0], "v"));
assert_eq!(only([-0.5, 0.0]), ([0.0, 1.0], "<"));
assert_eq!(only([0.5, 0.0]), ([2.0, 1.0], ">"));
assert!(dpad([0.1, -0.1]).iter().all(|p| !p.2));
// Stick up draws the knob above the middle, on screen.
assert_eq!(knob([50.0, 50.0], [0.0, 1.0], 40.0), [50.0, 40.0]);
assert_eq!(knob([50.0, 50.0], [1.0, 0.0], 40.0), [60.0, 50.0]);
}
}
@@ -193,8 +193,6 @@ pub struct Settings {
pub dynamic_area_y: f32,
/// Scales every control without changing the saved sizes (0.5-2).
pub button_scale: f32,
/// The touch marker's diameter in GUI pixels (16-64).
pub circle_size: f32,
}
impl Default for Settings {
@@ -241,7 +239,6 @@ impl Default for Settings {
dynamic_area_x: 0.25,
dynamic_area_y: 1.0,
button_scale: 1.0,
circle_size: 32.0,
}
}
}
@@ -285,7 +282,6 @@ impl Settings {
*v = finite_or(*v, 1.0).clamp(0.1, 3.0);
}
self.button_scale = finite_or(self.button_scale, 1.0).clamp(0.5, 2.0);
self.circle_size = finite_or(self.circle_size, 32.0).clamp(16.0, 64.0);
self.dynamic_area_x = finite_or(self.dynamic_area_x, 0.25).clamp(0.0, 1.0);
self.dynamic_area_y = finite_or(self.dynamic_area_y, 1.0).clamp(0.0, 1.0);
}
@@ -478,17 +474,6 @@ mod tests {
assert_eq!(s.button_scale, 2.0);
}
#[test]
fn the_marker_size_defaults_and_stays_in_range() {
let mut s: Settings = serde_json::from_str(r#"{"mode":"tap"}"#).unwrap();
assert_eq!(s.circle_size, 32.0);
for (bad, fixed) in [(f32::NAN, 32.0), (1.0, 16.0), (500.0, 64.0)] {
s.circle_size = bad;
s.sanitize();
assert_eq!(s.circle_size, fixed);
}
}
#[test]
fn left_hands_mirror_and_dpad_can_swap_jump_and_sneak() {
let mut s = Settings::default();
+1 -23
View File
@@ -104,7 +104,7 @@ pub fn read_metadata(root: &Path) -> UiMetadata {
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
continue;
};
let Some(nine_slice) = crate::packs::first_json(text.as_bytes())
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
@@ -2025,28 +2025,6 @@ impl EditBox {
mod tests {
use super::*;
#[test]
fn notes_after_a_sprites_metadata_keep_its_nine_slice() {
// Gson stops reading after the first value, so vanilla keeps this nine slice
// (CozyUI+'s highlighted button, #3046).
let root = std::env::temp_dir().join(format!("horizon-ui-meta-{}", std::process::id()));
let widget = root.join("assets/minecraft/textures/gui/sprites/widget");
std::fs::create_dir_all(&widget).unwrap();
std::fs::write(
widget.join("button_highlighted.png.mcmeta"),
"{\"gui\": {\"scaling\": {\"type\": \"nine_slice\", \"width\": 80, \"height\": 20,\n\
\"border\": {\"left\": 34, \"top\": 8, \"right\": 34, \"bottom\": 8}}}}\n\n// 0 10\n// 1 8\n",
)
.unwrap();
let metadata = read_metadata(&root);
std::fs::remove_dir_all(&root).unwrap();
let slice = metadata.0["gui/sprites/widget/button_highlighted"];
assert_eq!(
(slice.width, slice.height, slice.border),
(80, 20, [34, 8, 34, 8])
);
}
/// The quads' destination rectangles and their UVs.
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
batch
+1 -10
View File
@@ -9,9 +9,6 @@ use horizon_core::random::{LegacyRandom, LevelRandom};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
mod pending_entity;
pub use pending_entity::{EntityLoadReason, PendingEntity};
mod modified;
pub use modified::ModifiedChunks;
@@ -84,7 +81,7 @@ pub struct World {
/// incrementally by streaming.
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<PendingEntity>)>,
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks loaded since the level's last pass, their entity sections not
/// ready yet (`PersistentEntitySectionManager`'s PENDING), empty ones too:
/// the pass ends by readying them (`processPendingLoads`).
@@ -218,12 +215,6 @@ impl Weather {
}
}
impl World {
/// Queue ordinary NBT; transient transfer state is never decoded from it.
pub fn queue_entity_nbt(&mut self, key: (i32, i32), tags: Vec<horizon_world::nbt::Tag>) {
self.loaded_entities
.push((key, tags.into_iter().map(PendingEntity::from).collect()));
}
pub fn new(air: BlockStateId, seed: i64) -> Self {
Self {
shape: LevelShape::OVERWORLD,
@@ -1,89 +0,0 @@
//! Pending entity NBT and the runtime context that must not be stored inside it.
use horizon_world::nbt::Tag;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntityLoadReason {
Ordinary,
TeleportedTnt,
}
#[derive(Clone, Debug)]
pub struct PendingEntity {
tag: Tag,
reason: EntityLoadReason,
}
impl From<Tag> for PendingEntity {
fn from(tag: Tag) -> Self {
Self {
tag,
reason: EntityLoadReason::Ordinary,
}
}
}
impl PendingEntity {
/// Only an in-memory TNT transfer supplies this context, never saved NBT.
pub fn after_tnt_portal(tag: Tag) -> Self {
Self {
tag,
reason: EntityLoadReason::TeleportedTnt,
}
}
pub fn into_parts(self) -> (Tag, EntityLoadReason) {
(self.tag, self.reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_nbt_cannot_request_transient_portal_state() {
for marker in [0, 1] {
let tag = Tag::compound([("horizon:tnt_used_portal", Tag::Byte(marker))]);
let (loaded, reason) = PendingEntity::from(tag.clone()).into_parts();
assert_eq!(loaded, tag);
assert_eq!(reason, EntityLoadReason::Ordinary);
}
}
#[test]
fn queue_swaps_and_appends_keep_each_entitys_context_and_order() {
let ordinary = Tag::compound([("id", Tag::String("ordinary".into()))]);
let transfer = Tag::compound([("id", Tag::String("transfer".into()))]);
let mut batches = vec![(
(1, 2),
vec![
ordinary.clone().into(),
PendingEntity::after_tnt_portal(transfer.clone()),
],
)];
let mut waiting = std::mem::take(&mut batches);
batches.push(((3, 4), vec![ordinary.clone().into()]));
waiting.append(&mut batches);
assert!(batches.is_empty());
let mut batches = waiting.into_iter();
let (key, entities) = batches.next().unwrap();
assert_eq!(key, (1, 2));
let mut entities = entities.into_iter();
assert_eq!(
entities.next().unwrap().into_parts(),
(ordinary.clone(), EntityLoadReason::Ordinary)
);
assert_eq!(
entities.next().unwrap().into_parts(),
(transfer, EntityLoadReason::TeleportedTnt)
);
assert!(entities.next().is_none());
let (key, entities) = batches.next().unwrap();
assert_eq!(key, (3, 4));
assert_eq!(
entities.into_iter().next().unwrap().into_parts(),
(ordinary, EntityLoadReason::Ordinary)
);
assert!(batches.next().is_none());
}
}
+5 -5
View File
@@ -26,7 +26,6 @@ horizon-hooks = { path = "../horizon-hooks" }
horizon-optifine = { path = "../horizon-optifine" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-perf-format = { path = "../horizon-perf-format" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
@@ -93,6 +92,10 @@ fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Resolve virtualized folders to shell-compatible physical paths.
dunce = "1.0.5"
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
@@ -131,9 +134,7 @@ friends = ["dep:horizon-friends"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
system-allocator = ["horizon-threads/system-allocator"]
# mimalloc that counts each thread's allocations and frees (`stats` replies with `alloc`).
alloc-count = ["horizon-threads/alloc-count"]
system-allocator = []
# mimalloc with a tag on each block: `horizon-ctl memory` lists the live heap by startup step and
# thread (`heap_by_tag_mb`). For measuring only: each block costs 16 bytes more.
alloc-census = ["horizon-threads/alloc-census"]
@@ -177,6 +178,5 @@ workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
horizon-perf = { path = "../horizon-perf" }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Growwe verre terrein in plaaslike wêrelde. Geïntegreerde GPU's stop by 128 stukke.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Verminder trek veel terrein growwer: vinniger op hierdie geïntegreerde GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Verminder trek veel terrein growwer, vir GPU's word dit stadiger.",
"Field of view. Wider shows more terrain at once.": "Gesigsveld. Wyer wys meer terrein op een slag.",
"Caps frames per second; a cap saves power and heat.": "Caps rame per sekonde; 'n pet spaar krag en hitte.",
"Waits for the display's refresh: no tearing, a little latency.": "Wag vir die verversing van die skerm: geen skeur nie, 'n bietjie vertraging.",
"Size of menus and the HUD.": "Grootte van spyskaarte en die HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Maak gekartelde rande glad. FXAA: sagter. TAA: gladste, bewegende dinge kan loop.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Maak die wêreld kleiner en herbou dit met TAA. Vinniger, sagter.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Verskerp die opgeskaalde beeld. Hoër: skerper rande, meer flikkering.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-uitset op 'n HDR-skerm: lawa, vuur en sonlig skyn helderder.",
"How frames reach the screen while VSync is off.": "Hoe rame die skerm bereik terwyl VSync af is.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Doeltreffende slaap tot die volgende raam; Presiese draaie, soos vanielje.",
"Fills the screen (borderless).": "Vul die skerm (randloos).",
"Brightens dark places.": "Verhelder donker plekke.",
"Lowers the frame rate when minimised, or idle too.": "Verlaag die raamtempo wanneer dit geminimaliseer word, of ook ledig.",
"Sways the view while walking.": "Swaai die uitsig terwyl jy stap.",
"Fades newly loaded chunks in.": "Vervaag nuutgelaaide stukke in.",
"Darkens the screen's edges.": "Maak die skerm se rande donkerder.",
"Shows when the world saves.": "Wys wanneer die wêreld red.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Hierdie bediener is in aanlynmodus: vanlyn profiele kan nie daarby aansluit nie; elke speler het sy eie Minecraft-rekening nodig.",
"horizon.splitscreen.own_account": "Hierdie bediener vereis elke speler se eie Minecraft-rekening",
"horizon.splitscreen.local_only": "Ander spelers kan slegs met hul eie Minecraft-rekening by hierdie bediener aansluit: vanlyn profiele speel in jou wêreld, op LAN of op 'n bediener op jou plaaslike netwerk.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteek modelle agter ondeursigtige blokke. Async kontroleer af-draad; entiteite merk.",
"Block Entities": "Blok Entiteite",
"Shulker Boxes": "Shulkerbokse",
"Reuses settled boxes; lid movement stays animated.": "Hergebruik afgewikkelde bokse; dekselbeweging bly geanimeerd.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Geopen vir hierdie sessie deur die program wat die speletjie begin het.",
"horizon.agent.socket": "Sok: %s (kopieer die besonderhede vir sy volledige pad)",
"horizon.agent.local_only": "Slegs plaaslik: agente op hierdie rekenaar, nooit die netwerk nie.",
"%s nits": "%s nete",
"Auto (%s nits)": "Outo (%s nete)",
"%.2f nits": "%.2f nete",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteek modelle agter ondeursigtige blokke. Entiteite-opdatering. Af met pasgemaakte pakke.",
"Entity Culling Precheck": "Entiteit-uitdunning-voorafkontrole",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Voer die skare-AABB-voorafkontrole uit ná hierdie aantal gelaaide skare. Nul skakel dit af.",
"%s mobs": "%s skares",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "التضاريس البعيدة الخشنة في العوالم المحلية. تتوقف وحدات معالجة الرسومات المدمجة عند 128 قطعة.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "تم تقليل الرسم على التضاريس البعيدة: بشكل أسرع على وحدة معالجة الرسومات المدمجة هذه.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "يتم تقليل الرسوم على التضاريس البعيدة بشكل أكثر خشونة، أما بالنسبة لوحدات معالجة الرسومات فإنها تبطئ.",
"Field of view. Wider shows more terrain at once.": "مجال الرؤية. يُظهر العرض الأوسع المزيد من التضاريس في وقت واحد.",
"Caps frames per second; a cap saves power and heat.": "قبعات الإطارات في الثانية الواحدة؛ غطاء يوفر الطاقة والحرارة.",
"Waits for the display's refresh: no tearing, a little latency.": "في انتظار تحديث الشاشة: بدون تمزق، وقليل من زمن الوصول.",
"Size of menus and the HUD.": "حجم القوائم وHUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "ينعم الحواف الخشنة. FXAA: أكثر ليونة. TAA: قد تتبع الأشياء الأكثر سلاسة وتحريكًا.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "يجعل العالم أصغر حجمًا ويعيد بنائه باستخدام TAA. أسرع وأكثر ليونة.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "يزيد حدة الصورة المرتقاة. أعلى: حواف أوضح ووميض أكثر.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "إخراج HDR على شاشة HDR: تتألق الحمم البركانية والنار وأشعة الشمس بشكل أكثر سطوعًا.",
"How frames reach the screen while VSync is off.": "كيفية وصول الإطارات إلى الشاشة أثناء إيقاف تشغيل VSync.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "ينام بكفاءة حتى الإطار التالي؛ يدور بدقة، مثل الفانيليا.",
"Fills the screen (borderless).": "يملأ الشاشة (بلا حدود).",
"Brightens dark places.": "يضيء الأماكن الداكنة.",
"Lowers the frame rate when minimised, or idle too.": "يخفض معدل الإطارات عند تصغيره أو في وضع الخمول أيضًا.",
"Sways the view while walking.": "يتأرجح المنظر أثناء المشي.",
"Fades newly loaded chunks in.": "يتلاشى القطع المحملة حديثًا.",
"Darkens the screen's edges.": "لتغميق حواف الشاشة.",
"Shows when the world saves.": "يظهر عندما ينقذ العالم.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "هذا الخادم في وضع الاتصال: لا يمكن للملفات الشخصية غير المتصلة الانضمام إليه، وكل لاعب يحتاج حساب Minecraft خاصًا به.",
"horizon.splitscreen.own_account": "يحتاج هذا الخادم حساب Minecraft خاصًا بكل لاعب",
"horizon.splitscreen.local_only": "يمكن للاعبين الآخرين الانضمام إلى هذا الخادم فقط بحساب Minecraft خاص بهم: أما الملفات الشخصية غير المتصلة فتلعب في عالمك، على LAN أو على خادم في شبكتك المحلية.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "يخفي النماذج خلف الكتل المعتمة. غير متزامن يتحقق من خارج الموضوع؛ علامة الكيانات.",
"Block Entities": "حظر الكيانات",
"Shulker Boxes": "صناديق شوكر",
"Reuses settled boxes; lid movement stays animated.": "إعادة استخدام الصناديق المستقرة؛ تبقى حركة الغطاء متحركة.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "تم فتحه لهذه الجلسة بواسطة البرنامج الذي بدأ اللعبة.",
"horizon.agent.socket": "المقبس: %s (انسخ التفاصيل لمساره الكامل)",
"horizon.agent.local_only": "محلي فقط: الوكلاء على هذا الكمبيوتر، وليس الشبكة أبدًا.",
"%s nits": "%s القمل",
"Auto (%s nits)": "تلقائي (%s شمعة في المتر المربع)",
"%.2f nits": "%.2f القمل",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "يخفي النماذج خلف الكتل المعتمة. تحديث الكيانات مع حزم مخصصة.",
"Entity Culling Precheck": "فحص استبعاد الكيانات المسبق",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "يشغّل فحص AABB للغوغاء بعد هذا العدد من الغوغاء المحمّلين. صفر يعطّله.",
"%s mobs": "%s غوغاء",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Yerli dünyalarda kobud uzaq ərazi. İnteqrasiya edilmiş GPU-lar 128 hissədə dayanır.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Azaldılmış ərazini daha qaba çəkir: bu inteqrasiya olunmuş GPU-da daha sürətli.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Azaldılmış ərazini daha qaba çəkir, GPU-lar üçün isə yavaşlayır.",
"Field of view. Wider shows more terrain at once.": "Baxış sahəsi. Daha geniş bir anda daha çox ərazini göstərir.",
"Caps frames per second; a cap saves power and heat.": "Saniyədə kadrların sayı; qapaq enerjiyə və istiliyə qənaət edir.",
"Waits for the display's refresh: no tearing, a little latency.": "Ekranın təzələnməsini gözləyir: cırılma, bir az gecikmə.",
"Size of menus and the HUD.": "Menyuların ölçüsü və HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Kəsik kənarları hamarlayır. FXAA: daha yumşaq. TAA: ən hamar, hərəkətli şeylər cığır çəkə bilər.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Dünyanı daha kiçik edir və TAA ilə yenidən qurur. Daha sürətli, daha yumşaq.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Böyüdülmüş təsviri kəskinləşdirir. Daha yüksək: daha aydın kənarlar, daha çox parıltı.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR displeydə HDR çıxışı: lava, yanğın və günəş işığı daha parlaq parlayır.",
"How frames reach the screen while VSync is off.": "VSync söndürüldükdə çərçivələr ekrana necə çatır.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Növbəti kadra qədər səmərəli yuxular; Vanil kimi dəqiq spinlər.",
"Fills the screen (borderless).": "Ekranı doldurur (sərhədsiz).",
"Brightens dark places.": "Qaranlıq yerləri işıqlandırır.",
"Lowers the frame rate when minimised, or idle too.": "Minimumlaşdırıldıqda və ya boş olduqda kadr sürətini azaldır.",
"Sways the view while walking.": "Gəzərkən mənzərəni yelləyir.",
"Fades newly loaded chunks in.": "Yeni yüklənmiş parçaları sönür.",
"Darkens the screen's edges.": "Ekranın kənarlarını qaraldır.",
"Shows when the world saves.": "Dünyanın nə vaxt xilas etdiyini göstərir.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Bu server onlayn rejimdədir: oflayn profillər ona qoşula bilməz, hər oyunçunun öz Minecraft hesabı lazımdır.",
"horizon.splitscreen.own_account": "Bu server hər oyunçunun öz Minecraft hesabını tələb edir",
"horizon.splitscreen.local_only": "Digər oyunçular bu serverə yalnız öz Minecraft hesabları ilə qoşula bilər: oflayn profillər sizin dünyanızda, LAN-da və ya yerli şəbəkənizdəki serverdə oynayır.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Modelləri qeyri-şəffaf blokların arxasında gizlədir. Async-dən kənar yoxlayır; qurumlar işarələyir.",
"Block Entities": "Blok obyektləri",
"Shulker Boxes": "Shulker qutuları",
"Reuses settled boxes; lid movement stays animated.": "Yerləşmiş qutuları təkrar istifadə edir; qapaq hərəkəti canlı qalır.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Oyuna başlayan proqram tərəfindən bu sessiya üçün açıldı.",
"horizon.agent.socket": "Soket: %s (tam yolu üçün təfərrüatları kopyalayın)",
"horizon.agent.local_only": "Yalnız yerli: bu kompüterdəki agentlər, heç vaxt şəbəkə.",
"%s nits": "%s nit",
"Auto (%s nits)": "Avtomatik (%s nits)",
"%.2f nits": "%.2f nit",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Modelləri qeyri-şəffaf blokların arxasında gizlədir. Müəssisələrin yenilənməsi. Fərdi paketlərlə endirim.",
"Entity Culling Precheck": "Obyekt kəsmə ön yoxlaması",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Yüklənmiş mob sayı bu həddə çatanda mob AABB ön yoxlamasını işlədir. Sıfır onu söndürür.",
"%s mobs": "%s mob",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Урындағы донъяларҙа ҡырыҫ алыҫ ерҙәр. Интеграцияланған график процессорҙар 128 өлөштә туҡтай.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ҡыҫҡартылған тарта алыҫ ер ҡырыҫ: тиҙерәк был интеграцияланған GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Кәметелгән тарта алыҫ ер ҡырыҫыраҡ, GPUs өсөн ул яйлатып.",
"Field of view. Wider shows more terrain at once.": "Күренеш өлкәһе. Киңерәк бер юлы күберәк ерҙе күрһәтә.",
"Caps frames per second; a cap saves power and heat.": "Ҡапҡастар кадрҙар секундына; ҡапҡас энергияны һәм йылылыҡты һаҡлай.",
"Waits for the display's refresh: no tearing, a little latency.": "Дисплейҙы яңыртыуҙы көтә: йыртыу юҡ, бер аҙ тотҡарлыҡ.",
"Size of menus and the HUD.": "Размер меню һәм HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Тешле ситтәрен тигеҙләй. ФХАА: йомшағыраҡ. ТАА: иң шыма, хәрәкәт итеүсе әйберҙәр эҙҙәре булыуы мөмкин.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Донъяны бәләкәйерәк итә һәм уны ТАА менән яңынан төҙөй. Тиҙерәк, йомшағыраҡ.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Масштабланған һүрәтте үткерләндерә. Юғарыраҡ: ситтәр асығыраҡ, ялтырау күберәк.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR дисплейында HDR сығышы: лава, ут һәм ҡояш нурҙары сағыуыраҡ балҡый.",
"How frames reach the screen while VSync is off.": "Нисек кадрҙар экранға етә, ә VSync өҙөлгән.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Һөҙөмтәле йоҡлай сираттағы кадрға тиклем; Теүәл әйләнештәр, ваниль кеүек.",
"Fills the screen (borderless).": "Экранды тултыра (сикһеҙ).",
"Brightens dark places.": "Ҡараңғы урындарҙы яҡтырта.",
"Lowers the frame rate when minimised, or idle too.": "Кадрҙар йышлығын түбәнәйтә, ҡасан минималь, йәки буш ла.",
"Sways the view while walking.": "Йөрөгәндә ҡарашты һелкетә.",
"Fades newly loaded chunks in.": "Яңы тейәлгән өлөштәрҙе һүнә.",
"Darkens the screen's edges.": "Экрандың ситтәрен ҡарайта.",
"Shows when the world saves.": "Донъя ҡотҡарғанда күрһәтә.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Был сервер онлайн режимда: офлайн профилдәр уға ҡушыла алмай, һәр уйынсыға үҙ Minecraft иҫәбе кәрәк.",
"horizon.splitscreen.own_account": "Был серверға һәр уйынсының үҙ Minecraft иҫәбе кәрәк",
"horizon.splitscreen.local_only": "Башҡа уйынсылар был серверға үҙҙәренең Minecraft иҫәбенә генә ҡушыла ала: офлайн профилдәр һеҙҙең донъяла, LAN йәки һеҙҙең урындағы селтәрҙәге серверҙа уйнай.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Моделдәрҙе асыҡ булмаған блоктар артында йәшерә. Ассинхрон тикшерә тышҡы еп; субъекттар галочкой.",
"Block Entities": "Блок субъекттары",
"Shulker Boxes": "Шулкер йәшниктәре",
"Reuses settled boxes; lid movement stays animated.": "Ҡабаттан ҡуллана урынлаштырылған йәшниктәр; ҡапҡас хәрәкәте йәнле ҡала.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Был сессия өсөн уйынды башлаған программа асты.",
"horizon.agent.socket": "Розетка: %s (уның тулы юлы өсөн реквизиттарҙы күсерергә)",
"horizon.agent.local_only": "Урындағы ғына: был компьютерҙа агенттар, бер ҡасан да селтәр.",
"%s nits": "%s нит",
"Auto (%s nits)": "Авто (%s нит)",
"%.2f nits": "%.2f нит",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Моделдәрҙе асыҡ булмаған блоктар артында йәшерә. Яңыртыу субъекттары. Ҡулланыусылар пакеттары менән өҙөлгән.",
"Entity Culling Precheck": "Субъекттарҙы ҡырҡыуҙы алдан тикшереү",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Моб AABB алдан тикшереүен был һандағы тейәлгән мобтан һуң башҡара. Ноль уны һүндерә.",
"%s mobs": "%s моб",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Грубая аддаленая мясцовасць у лакальных мірах. Убудаваныя графічныя працэсары спыняюцца на 128 кавалках.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Паменшанае малюнак больш грубай мясцовасці: хутчэй на гэтым убудаваным графічным працэсары.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Зніжэнне малюе больш грубую мясцовасць, для графічных працэсараў гэта запавольваецца.",
"Field of view. Wider shows more terrain at once.": "Поле зроку. Шырэй паказвае адразу больш мясцовасці.",
"Caps frames per second; a cap saves power and heat.": "Колькасць кадраў у секунду; шапка эканоміць энергію і цяпло.",
"Waits for the display's refresh: no tearing, a little latency.": "Чакае абнаўлення дысплея: без разрыву, з невялікай затрымкай.",
"Size of menus and the HUD.": "Памер меню і HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Згладжвае няроўныя краю. FXAA: мякчэй. TAA: самыя гладкія рэчы, якія рухаюцца, могуць цягнуцца.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Памяншае свет і перабудоўвае яго з TAA. Хутчэй, мякчэй.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Павялічвае рэзкасць павялічанай выявы. Вышэй: больш рэзкія краі, больш мігацення.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Вывад HDR на дысплеі HDR: лава, агонь і сонечнае святло ззяюць ярчэй.",
"How frames reach the screen while VSync is off.": "Як кадры трапляюць на экран, калі VSync выключаны.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Эфектыўнае спіць да наступнага кадра; Дакладныя спіны, як ваніль.",
"Fills the screen (borderless).": "Запаўняе экран (без палёў).",
"Brightens dark places.": "Асвятляе цёмныя месцы.",
"Lowers the frame rate when minimised, or idle too.": "Зніжае частату кадраў пры згортванні або ў рэжыме чакання.",
"Sways the view while walking.": "Качае погляд падчас хады.",
"Fades newly loaded chunks in.": "Змяншае толькі што загружаныя кавалкі.",
"Darkens the screen's edges.": "Зацямняе краю экрана.",
"Shows when the world saves.": "Паказвае, калі свет ратуе.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Гэты сервер у анлайн-рэжыме: афлайн-профілі не могуць далучыцца, кожнаму гульцу патрэбны ўласны ўліковы запіс Minecraft.",
"horizon.splitscreen.own_account": "Гэтаму серверу патрэбны ўласны ўліковы запіс Minecraft для кожнага гульца",
"horizon.splitscreen.local_only": "Іншыя гульцы могуць далучыцца да гэтага сервера толькі са сваім уліковым запісам Minecraft: афлайн-профілі гуляюць у вашым свеце, па LAN або на серверы ў вашай лакальнай сетцы.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Хавае мадэлі за непразрыстымі блокамі. Async правярае па-за патокам; суб'ектаў цік.",
"Block Entities": "Блокавыя аб'екты",
"Shulker Boxes": "Скрынкі Шулкера",
"Reuses settled boxes; lid movement stays animated.": "Паўторна выкарыстоўвае адстойнікі; рух вечка застаецца ажыўленым.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Адкрыты для гэтай сесіі праграмай, якая запусціла гульню.",
"horizon.agent.socket": "Сокет: %s (скапіруйце дэталі для поўнага шляху)",
"horizon.agent.local_only": "Толькі лакальна: агенты на гэтым кампутары, ніколі ў сетцы.",
"%s nits": "%s ніт",
"Auto (%s nits)": "Аўта (%s ніт)",
"%.2f nits": "%.2f ніт",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Хавае мадэлі за непразрыстымі блокамі. Абнаўленне суб'ектаў. Выключыце спецыяльныя пакеты.",
"Entity Culling Precheck": "Папярэдняя праверка адсячэння сутнасцей",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Выконвае папярэднюю праверку AABB мабаў пасля гэтай колькасці загружаных мабаў. Нуль адключае яе.",
"%s mobs": "%s мабаў",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Груб далечен терен в местните светове. Интегрираните графични процесори спират на 128 парчета.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Намалено чертане на по-груб терен: по-бързо с този интегриран GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Намалено рисува далеч по-груб терен, за GPU се забавя.",
"Field of view. Wider shows more terrain at once.": "Зрително поле. По-широко показва повече терен наведнъж.",
"Caps frames per second; a cap saves power and heat.": "Максимални кадри в секунда; капачката спестява енергия и топлина.",
"Waits for the display's refresh: no tearing, a little latency.": "Изчаква опресняване на дисплея: без разкъсване, малко забавяне.",
"Size of menus and the HUD.": "Размер на менютата и HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Изглажда назъбените ръбове. FXAA: по-мек. TAA: най-плавните, движещи се неща могат да се проточат.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Прави света по-малък и го възстановява с TAA. По-бързо, по-меко.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Изостря премащабираната картина. По-високо: по-ясни ръбове, повече трептене.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR изход на HDR дисплей: лава, огън и слънчева светлина блестят по-ярко.",
"How frames reach the screen while VSync is off.": "Как кадрите достигат до екрана, докато VSync е изключен.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Ефективно заспиване до следващия кадър; Прецизни завъртания, като ванилия.",
"Fills the screen (borderless).": "Запълва екрана (без граници).",
"Brightens dark places.": "Осветява тъмните места.",
"Lowers the frame rate when minimised, or idle too.": "Намалява честотата на кадрите, когато е минимизирана или неактивна.",
"Sways the view while walking.": "Люлее гледката по време на ходене.",
"Fades newly loaded chunks in.": "Избледнява новозаредените парчета.",
"Darkens the screen's edges.": "Затъмнява ръбовете на екрана.",
"Shows when the world saves.": "Показва кога светът спасява.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Този сървър е в онлайн режим: офлайн профилите не могат да се присъединят, всеки играч се нуждае от собствен акаунт в Minecraft.",
"horizon.splitscreen.own_account": "Този сървър изисква собствен акаунт в Minecraft за всеки играч",
"horizon.splitscreen.local_only": "Другите играчи могат да се присъединят към този сървър само със свой акаунт в Minecraft: офлайн профилите играят във вашия свят, в LAN или на сървър в локалната ви мрежа.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Скрива модели зад непрозрачни блокове. Async проверява извън нишката; субекти отметка.",
"Block Entities": "Блокиране на обекти",
"Shulker Boxes": "Кутии Шулкер",
"Reuses settled boxes; lid movement stays animated.": "Повторно използване на утаени кутии; движението на капака остава анимирано.",
@@ -616,6 +620,10 @@
"horizon.agent.session_opened": "Отворено за тази сесия от програмата, която стартира играта.",
"horizon.agent.socket": "Сокет: %s (копирайте подробностите за пълния му път)",
"horizon.agent.local_only": "Само локално: агенти на този компютър, никога в мрежата.",
"%s nits": "%s нита",
"Auto (%s nits)": "Автоматично (%s нита)",
"%.2f nits": "%.2f нита",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Скрива модели зад непрозрачни блокове. Актуализация на обекти. Изключване с персонализирани пакети.",
"Entity Culling Precheck": "Предварителна проверка на отстраняването",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Изпълнява предварителната проверка на AABB за тълпите след толкова заредени тълпи. Нула я изключва.",
"%s mobs": "%s тълпи",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Douaroù pell gros e bedoù lec'hel. Paouez a ra ar GPUoù kevredet da 128 tamm.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Digreskiñ a ra an douaroù kalz grosoc'h : buanoc'h war ar GPU kevredet-mañ.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ar reduset a denn an douaroù kalz grosoc'h, evit ar GPUoù e vez gorrekaet.",
"Field of view. Wider shows more terrain at once.": "Tachenn gwel. Ledanoc'h a ziskouez muioc'h a zouaroù war un dro.",
"Caps frames per second; a cap saves power and heat.": "Bep frammoù dre eilenn; ur c'hasketenn a espern energiezh ha tommder.",
"Waits for the display's refresh: no tearing, a little latency.": "Gortoz a ra freskaat ar skramm : tamm torr ebet, un tamm dale.",
"Size of menus and the HUD.": "Ment ar mennozhioù hag an HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Eeunaat a ra ar c'hornioù ruz. FXAA: dousoc'h. TAA: an traoù eeunañ, o fiñval a c'hell heuliañ.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Bihanaat a ra ar bed hag adsevel anezhañ gant TAA. Buanoc'h, dousoc'h.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Lemmaat a ra ar skeudenn gresket. Uheloc'h : bordoù lemmoc'h, muioc'h a sked.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Disoc'h HDR war ur skramm HDR : lava, tan ha gouloù an heol a lugern muioc'h.",
"How frames reach the screen while VSync is off.": "Penaos e tistro ar frammoù war ar skramm pa vez diweredekaet VSync.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Kousket a ra efedus betek ar framm da heul; Troioù resis, evel ar vanilha.",
"Fills the screen (borderless).": "Leuniañ ar skramm (hep bevennoù).",
"Brightens dark places.": "Sklaeraat a ra al lec'hioù teñval.",
"Lowers the frame rate when minimised, or idle too.": "Digreskiñ a ra ar feur framm pa vez bihanaet, pe pa vez didalvoud ivez.",
"Sways the view while walking.": "Heñchañ a ra ar gwel e-pad ma kerzher.",
"Fades newly loaded chunks in.": "Diverkañ a ra an tammoù nevez karget.",
"Darkens the screen's edges.": "Teñvalaat a ra ribloù ar skramm.",
"Shows when the world saves.": "Diskouez a ra pa savete ar bed.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "E mod enlinenn emañ ar servijer-mañ : n'hall ket ar profiloù ezlinenn mont e-barzh, pep c'hoarier en deus ezhomm eus e gont Minecraft.",
"horizon.splitscreen.own_account": "Ezhomm en deus ar servijer-mañ eus ur gont Minecraft evit pep c'hoarier",
"horizon.splitscreen.local_only": "Ar c'hoarierien all ne c'hallont mont e-barzh ar servijer-mañ nemet gant o c'hont Minecraft o-unan : ar profiloù ezlinenn a c'hoari en ho bed, war LAN pe war ur servijer war ho rouedad lec'hel.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Kuzhat a ra ar patromoù a-dreñv bloc'hoù dister. Gwiriañ a ra an async e-maez an neudennad; tik an entiteoù.",
"Block Entities": "Entiteoù stankañ",
"Shulker Boxes": "Boestoù Shulker",
"Reuses settled boxes; lid movement stays animated.": "Adimplij a ra ar voestoù staliet; fiñv ar golo a chom bev.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Digoret evit ar prantad-mañ gant ar programm en deus kroget ar c'hoari.",
"horizon.agent.socket": "Socket: %s (eilañ ar munudoù evit e hent klok)",
"horizon.agent.local_only": "Lec'hel hepken : an ajañsoù war an urzhiataer-mañ, biskoazh ar rouedad.",
"%s nits": "%s nits",
"Auto (%s nits)": "Emgefre (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Kuzhat a ra ar patromoù a-dreñv bloc'hoù dister. Hizivaat an ensavadurioù. Dilezet gant pakadoù personelaet.",
"Entity Culling Precheck": "Rakwiriañ distruj an entiteoù",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Kas a ra rakwiriad AABB ar moboù goude kement-mañ a voboù karget. Zero a ziweredeka anezhañ.",
"%s mobs": "%s mob",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grubi udaljeni teren u lokalnim svjetovima. Integrisani GPU-ovi se zaustavljaju na 128 komada.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Smanjeno crtanje dalekog terena grublje: brže na ovom integrisanom GPU-u.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Smanjeno crtanje dalekog terena je grublje, za GPU-ove usporava.",
"Field of view. Wider shows more terrain at once.": "Vidno polje. Šire prikazuje više terena odjednom.",
"Caps frames per second; a cap saves power and heat.": "Caps okvira u sekundi; kapa štedi energiju i toplotu.",
"Waits for the display's refresh: no tearing, a little latency.": "Čeka osvježavanje ekrana: nema kidanja, malo kašnjenja.",
"Size of menus and the HUD.": "Veličina menija i HUD-a.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Zaglađuje nazubljene ivice. FXAA: mekše. TAA: najglađe, pokretne stvari mogu zaostajati.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Čini svijet manjim i obnavlja ga pomoću TAA. Brže, mekše.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Izoštrava uvećanu sliku. Više: oštrije ivice, više treperenja.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR izlaz na HDR displeju: lava, vatra i sunčeva svetlost sijaju jače.",
"How frames reach the screen while VSync is off.": "Kako okviri dolaze do ekrana dok je VSync isključen.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efikasno spavanje do sljedećeg kadra; Precizno okretanje, poput vanile.",
"Fills the screen (borderless).": "Ispunjava ekran (bez ivica).",
"Brightens dark places.": "Osvjetljava tamna mjesta.",
"Lowers the frame rate when minimised, or idle too.": "Smanjuje brzinu kadrova kada je minimizirana ili u stanju mirovanja.",
"Sways the view while walking.": "Ljulja pogled dok hodate.",
"Fades newly loaded chunks in.": "Uklanja novoučitane komade.",
"Darkens the screen's edges.": "Zatamnjuje ivice ekrana.",
"Shows when the world saves.": "Prikazuje kada svijet spašava.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Ovaj server je u online modu: offline profili mu se ne mogu pridružiti, svaki igrač treba vlastiti Minecraft nalog.",
"horizon.splitscreen.own_account": "Ovaj server zahtijeva vlastiti Minecraft nalog za svakog igrača",
"horizon.splitscreen.local_only": "Drugi igrači se mogu pridružiti ovom serveru samo sa svojim Minecraft nalogom: offline profili igraju u vašem svijetu, na LAN-u ili na serveru na vašoj lokalnoj mreži.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Skriva modele iza neprozirnih blokova. Async provjerava off-thread; entiteti otkucaju.",
"Block Entities": "Blokirajte entitete",
"Shulker Boxes": "Kutije za šulkere",
"Reuses settled boxes; lid movement stays animated.": "Ponovno koristi postavljene kutije; pokreti poklopca ostaju animirani.",
@@ -616,6 +620,10 @@
"horizon.agent.session_opened": "Otvoreno za ovu sesiju od strane programa koji je pokrenuo igru.",
"horizon.agent.socket": "Socket: %s (kopirajte detalje za njegovu punu putanju)",
"horizon.agent.local_only": "Samo lokalno: agenti na ovom računaru, nikada mreža.",
"%s nits": "%s gnjida",
"Auto (%s nits)": "automatski (%s gnjida)",
"%.2f nits": "%.2f gnjida",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Skriva modele iza neprozirnih blokova. Ažuriranje entiteta. Isključeno sa prilagođenim paketima.",
"Entity Culling Precheck": "Prethodna provjera odbijanja entiteta",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Pokreće AABB prethodnu provjeru mobova nakon ovoliko učitanih mobova. Nula je isključuje.",
"%s mobs": "%s mobova",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreny llunyà gruixut en mons locals. Les GPU integrades s'aturen a 128 fragments.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Dibuixos reduïts molt llunyans més gruixuts: més ràpid en aquesta GPU integrada.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Els dibuixos reduïts són molt més gruixuts, per a les GPU s'alenteix.",
"Field of view. Wider shows more terrain at once.": "Camp de visió. Més ample mostra més terreny alhora.",
"Caps frames per second; a cap saves power and heat.": "Caps fotogrames per segon; una tapa estalvia energia i calor.",
"Waits for the display's refresh: no tearing, a little latency.": "Espera que la pantalla s'actualitzi: sense trencament, una mica de latència.",
"Size of menus and the HUD.": "Mida dels menús i l'HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Allisa les vores dentades. FXAA: més suau. TAA: més suau, les coses en moviment poden seguir.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Redueix el món i el reconstrueix amb TAA. Més ràpid, més suau.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Fa més nítida la imatge ampliada. Més alt: vores més nítides, més tremolor.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortida HDR en una pantalla HDR: la lava, el foc i la llum del sol brillen més.",
"How frames reach the screen while VSync is off.": "Com arriben els fotogrames a la pantalla mentre VSync està desactivat.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Dors eficients fins al següent fotograma; Girades precises, com la vainilla.",
"Fills the screen (borderless).": "Omple la pantalla (sense vores).",
"Brightens dark places.": "Il·lumina els llocs foscos.",
"Lowers the frame rate when minimised, or idle too.": "Redueix la velocitat de fotogrames quan es minimitza o també està inactiva.",
"Sways the view while walking.": "Oblida la vista mentre camina.",
"Fades newly loaded chunks in.": "Esvaeix els fragments que s'han carregat recentment.",
"Darkens the screen's edges.": "Enfosqueix les vores de la pantalla.",
"Shows when the world saves.": "Mostra quan el món salva.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Aquest servidor està en mode en línia: els perfils fora de línia no s'hi poden unir, cada jugador necessita el seu propi compte de Minecraft.",
"horizon.splitscreen.own_account": "Aquest servidor necessita el compte de Minecraft propi de cada jugador",
"horizon.splitscreen.local_only": "Els altres jugadors només s'hi poden unir amb el seu propi compte de Minecraft: els perfils fora de línia juguen al teu món, a la LAN o en un servidor de la teva xarxa local.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Amaga models darrere de blocs opacs. Async comprova fora de fil; les entitats marquen.",
"Block Entities": "Bloquejar entitats",
"Shulker Boxes": "Caixes Shulker",
"Reuses settled boxes; lid movement stays animated.": "Reutilitza caixes assentades; el moviment de la tapa es manté animat.",
@@ -616,6 +620,10 @@
"horizon.agent.session_opened": "Obert per a aquesta sessió pel programa que va iniciar el joc.",
"horizon.agent.socket": "Socket: %s (copieu els detalls del camí complet)",
"horizon.agent.local_only": "Només local: agents en aquest ordinador, mai la xarxa.",
"%s nits": "%s nits",
"Auto (%s nits)": "Automàtic (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Amaga models darrere de blocs opacs. Actualització d'entitats. Apaga amb paquets personalitzats.",
"Entity Culling Precheck": "Comprovació prèvia del culling d'entitats",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Executa la comprovació prèvia d'AABB dels mobs després d'aquest nombre de mobs carregats. Zero la desactiva.",
"%s mobs": "%s mobs",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Hrubý vzdálený terén v místních světech. Integrované GPU se zastaví na 128 blocích.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Snížené vykreslování daleko hrubšího terénu: rychlejší na tomto integrovaném GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Snížené vykresluje daleko hrubší terén, u GPU se zpomaluje.",
"Field of view. Wider shows more terrain at once.": "Zorné pole. Širší zobrazuje více terénu najednou.",
"Caps frames per second; a cap saves power and heat.": "Počet snímků za sekundu; čepice šetří energii a teplo.",
"Waits for the display's refresh: no tearing, a little latency.": "Čeká na obnovení displeje: žádné trhání, malá latence.",
"Size of menus and the HUD.": "Velikost menu a HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Vyhlazuje zubaté okraje. FXAA: měkčí. TAA: nejhladší, pohyblivé věci se mohou táhnout.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Zmenší svět a přestaví ho pomocí TAA. Rychlejší, měkčí.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Zaostří zvětšený obraz. Vyšší: ostřejší hrany, více třpytu.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Výstup HDR na displeji HDR: láva, oheň a sluneční světlo září jasněji.",
"How frames reach the screen while VSync is off.": "Jak se snímky dostanou na obrazovku, když je VSync vypnutá.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efektivní režim spánku až do dalšího snímku; Přesná rotace, jako vanilka.",
"Fills the screen (borderless).": "Vyplní obrazovku (bez okrajů).",
"Brightens dark places.": "Rozjasňuje tmavá místa.",
"Lowers the frame rate when minimised, or idle too.": "Při minimalizaci nebo nečinnosti sníží snímkovou frekvenci.",
"Sways the view while walking.": "Při chůzi kývá výhled.",
"Fades newly loaded chunks in.": "Nově načtené kousky zmizí.",
"Darkens the screen's edges.": "Ztmaví okraje obrazovky.",
"Shows when the world saves.": "Ukazuje, kdy svět zachraňuje.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Tento server je v online režimu: offline profily se k němu nemohou připojit, každý hráč potřebuje vlastní účet Minecraft.",
"horizon.splitscreen.own_account": "Tento server vyžaduje vlastní účet Minecraft pro každého hráče",
"horizon.splitscreen.local_only": "Ostatní hráči se k tomuto serveru mohou připojit jen s vlastním účtem Minecraft: offline profily hrají ve vašem světě, na LAN nebo na serveru ve vaší místní síti.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Skryje modely za neprůhledné bloky. Async kontroluje off-thread; entity klíštěte.",
"Block Entities": "Blokovat entity",
"Shulker Boxes": "Krabice Shulker",
"Reuses settled boxes; lid movement stays animated.": "Znovu používá usazené krabice; pohyb víka zůstává animovaný.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Otevřeno pro tuto relaci programem, který spustil hru.",
"horizon.agent.socket": "Socket: %s (zkopírujte podrobnosti pro jeho úplnou cestu)",
"horizon.agent.local_only": "Pouze místní: agenti na tomto počítači, nikdy v síti.",
"%s nits": "%s hnid",
"Auto (%s nits)": "Auto (%s nitů)",
"%.2f nits": "%.2f hnid",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Skryje modely za neprůhledné bloky. Aktualizace entit. Vypnuto s vlastními balíčky.",
"Entity Culling Precheck": "Předkontrola vyřazování entit",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Spustí předkontrolu AABB mobů po tomto počtu načtených mobů. Nula ji vypne.",
"%s mobs": "%s mobů",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Вырӑнти тӗнчери инҫетри ҫӗр лаптӑкӗ. Пӗрлештернӗ GPU-сем 128 татӑк ҫинче чарӑнаҫҫӗ.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ҫак пӗрлештернӗ GPU-ра пӗчӗклетни ҫӗре чылай хытӑрах туртать: хӑвӑртрах.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Пӗчӗклетни ҫӗре чылай хытӑрах туртать, GPU-семшӗн вӑл ерипенлетет.",
"Field of view. Wider shows more terrain at once.": "Курӑну уйӗ. Сарлакараххи пӗр харӑсах ытларах ҫӗре кӑтартать.",
"Caps frames per second; a cap saves power and heat.": "Ҫеккунтра кадрсене чакарать; ҫӗлӗк энергипе ӑшша перекетлет.",
"Waits for the display's refresh: no tearing, a little latency.": "Дисплей ҫӗнелессине кӗтет: ҫурӑлмасть, кӑштах кая юлать.",
"Size of menus and the HUD.": "Менюсен калӑпӑшӗ тата HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Шӑллӑ хӗррисене тикӗслетет. FXAA: ҫемҫерех. ТАА: чи тикӗс, куҫакан япаласем йӗрлеме пултараҫҫӗ.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Тӗнчене пӗчӗклетет те ӑна TAA-па ҫӗнӗрен тӑвать. Хӑвӑртрах, ҫемҫерех.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Ӳстернӗ сӑнӑн ҫивӗчӗшӗн. Пысӑкрах: хӗррисем ҫивӗчрех, йӑлтӑркка ытларах.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR дисплей ҫинче HDR тухӑҫӗ: лава, вут тата хӗвел ҫути ҫутӑрах ҫутатаҫҫӗ.",
"How frames reach the screen while VSync is off.": "VSync ӗҫлемен чухне кадрсем экран ҫине мӗнле ҫитеҫҫӗ.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Тепӗр кадрчченех лайӑх ҫывӑрать; Тӗрӗс ҫаврӑнусем, ваниль пек.",
"Fills the screen (borderless).": "Экрана тултарать (чикӗсӗр).",
"Brightens dark places.": "Тӗттӗм вырӑнсене ҫутатать.",
"Lowers the frame rate when minimised, or idle too.": "Кадрсен хӑвӑртлӑхне пӗчӗклетнӗ чухне е ӗҫлемен чухне те чакарать.",
"Sways the view while walking.": "Утнӑ чухне курӑнӑва суллантарать.",
"Fades newly loaded chunks in.": "Ҫӗнӗрен ҫӗкленӗ татӑксене ҫухатать.",
"Darkens the screen's edges.": "Экран хӗррисене тӗксӗмлетет.",
"Shows when the world saves.": "Тӗнче хӑҫан ҫӑлӑннине кӑтартать.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Ку сервер онлайн режимра: офлайн профильсем унта кӗреймеҫҫӗ, кашни выляканӑн хӑйӗн Minecraft аккаунчӗ кирлӗ.",
"horizon.splitscreen.own_account": "Ку сервера кашни выляканӑн хӑйӗн Minecraft аккаунчӗ кирлӗ",
"horizon.splitscreen.local_only": "Ытти вылякансем ҫак сервера хӑйсен Minecraft аккаунчӗпе кӑна кӗме пултараҫҫӗ: офлайн профильсем сирӗн тӗнчере, LAN-ра е сирӗн вырӑнти сетьри серверта выляҫҫӗ.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Модельсене тӗксӗм блоксем хыҫне пытарать. Асинхронлӑ ҫипрен тухнине тӗрӗслет; субъектсем тик.",
"Block Entities": "Блок субъекчӗсем",
"Shulker Boxes": "Шулькер ещӗкӗсем",
"Reuses settled boxes; lid movement stays animated.": "Ҫирӗплетнӗ ещӗксемпе тепӗр хут усӑ курать; хупӑлча хусканӑвӗ чӗрӗ юлать.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Ку сеанс валли вӑййа пуҫарса янӑ программа уҫнӑ.",
"horizon.agent.socket": "Сокет: %s (унӑн тулли ҫулӗ валли детальсене копиле)",
"horizon.agent.local_only": "Вырӑнти кӑна: ку компьютер ҫинчи агентсем, нихӑҫан та сеть мар.",
"%s nits": "%s нитс",
"Auto (%s nits)": "Авто (%s нит)",
"%.2f nits": "%.2f нитс",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Модельсене тӗксӗм блоксем хыҫне пытарать. Субъектсем ҫӗнелеҫҫӗ. Ятарлӑ пакетсемпе тухӑр.",
"Entity Culling Precheck": "Объектсен кульлингӗн умлӑ тӗрӗслевӗ",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Мобсен AABB умлӑ тӗрӗслевне ҫакӑн чухлӗ моб тиеннӗ хыҫҫӑн пурнӑҫлать. Ноль сӳнтерет.",
"%s mobs": "%s моб",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Tir garw pell yn y bydoedd lleol. Mae GPUs integredig yn stopio ar 128 talpiau.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Llai yn tynnu tir llawer mwy bras: yn gyflymach ar y GPU integredig hwn.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Mae llai yn tynnu tir llawer mwy bras, ar gyfer GPUs mae'n arafu.",
"Field of view. Wider shows more terrain at once.": "Maes golygfa. Yn ehangach yn dangos mwy o dir ar unwaith.",
"Caps frames per second; a cap saves power and heat.": "Fframiau capiau yr eiliad; mae cap yn arbed pŵer a gwres.",
"Waits for the display's refresh: no tearing, a little latency.": "Aros am adnewyddu'r arddangosfa: dim rhwygo, ychydig o hwyrni.",
"Size of menus and the HUD.": "Maint y bwydlenni a'r HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Llyfn ymylon miniog. FXAA: meddalach. TAA: mae'n bosibl y bydd pethau llyfn, symudol yn dilyn.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Yn gwneud y byd yn llai ac yn ei ailadeiladu gyda TAA. Cyflymach, meddalach.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Yn miniogi'r darlun a uwch-raddiwyd. Uwch: ymylon cliriach, mwy o ddisgleirdeb symudol.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Allbwn HDR ar arddangosfa HDR: lafa, tân a golau haul yn disgleirio'n fwy disglair.",
"How frames reach the screen while VSync is off.": "Sut mae fframiau'n cyrraedd y sgrin tra bod VSync i ffwrdd.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Cysgu'n effeithlon tan y ffrâm nesaf; Troelli manwl gywir, fel fanila.",
"Fills the screen (borderless).": "Yn llenwi'r sgrin (heb ffiniau).",
"Brightens dark places.": "Yn goleuo lleoedd tywyll.",
"Lowers the frame rate when minimised, or idle too.": "Yn gostwng y gyfradd ffrâm pan gaiff ei lleihau, neu'n segur hefyd.",
"Sways the view while walking.": "Troi'r olygfa wrth gerdded.",
"Fades newly loaded chunks in.": "Yn pylu darnau newydd eu llwytho i mewn.",
"Darkens the screen's edges.": "Tywyllu ymylon y sgrin.",
"Shows when the world saves.": "Yn dangos pan fydd y byd yn arbed.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Mae'r gweinydd hwn mewn modd ar-lein: ni all proffiliau all-lein ymuno ag ef, mae angen cyfrif Minecraft ei hun ar bob chwaraewr.",
"horizon.splitscreen.own_account": "Mae'r gweinydd hwn angen cyfrif Minecraft ei hun ar bob chwaraewr",
"horizon.splitscreen.local_only": "Ni all chwaraewyr eraill ymuno â'r gweinydd hwn ond gyda'u cyfrif Minecraft eu hunain: mae proffiliau all-lein yn chwarae yn eich byd, ar LAN neu ar weinydd ar eich rhwydwaith lleol.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Yn cuddio modelau y tu ôl i flociau afloyw. Mae Async yn gwirio oddi ar yr edau; endidau tic.",
"Block Entities": "Endidau Bloc",
"Shulker Boxes": "Blychau Shulker",
"Reuses settled boxes; lid movement stays animated.": "Yn ailddefnyddio blychau sefydlog; symudiad caead yn aros yn animeiddiedig.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Agorwyd ar gyfer y sesiwn hon gan y rhaglen a ddechreuodd y gêm.",
"horizon.agent.socket": "Soced: %s (copïwch y manylion ar gyfer ei lwybr llawn)",
"horizon.agent.local_only": "Lleol yn unig: asiantau ar y cyfrifiadur hwn, byth y rhwydwaith.",
"%s nits": "%s nit",
"Auto (%s nits)": "Awtomatig (%s nit)",
"%.2f nits": "%.2f nit",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Yn cuddio modelau y tu ôl i flociau afloyw. Diweddariad endidau. I ffwrdd â phecynnau arferol.",
"Entity Culling Precheck": "Rhagwiriad Difa Endidau",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Yn rhedeg rhagwiriad AABB y mobs ar ôl hyn o fobs wedi'u llwytho. Mae sero yn ei analluogi.",
"%s mobs": "%s mob",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Groft fjernt terræn i lokale verdener. Integrerede GPU'er stopper ved 128 bidder.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduceret trækker langt grovere terræn: hurtigere på denne integrerede GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduceret trækker terrænet langt grovere, for GPU'er bliver det langsommere.",
"Field of view. Wider shows more terrain at once.": "Synsfelt. Bredere viser mere terræn på én gang.",
"Caps frames per second; a cap saves power and heat.": "Caps billeder per sekund; en hætte sparer strøm og varme.",
"Waits for the display's refresh: no tearing, a little latency.": "Venter på skærmens opdatering: ingen rivning, lidt forsinkelse.",
"Size of menus and the HUD.": "Størrelse på menuer og HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Udglatter takkede kanter. FXAA: blødere. TAA: de glatteste ting i bevægelse kan spore.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Gør verden mindre og genopbygger den med TAA. Hurtigere, blødere.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Gør det opskalerede billede skarpere. Højere: skarpere kanter, mere flimmer.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-output på en HDR-skærm: lava, ild og sollys skinner kraftigere.",
"How frames reach the screen while VSync is off.": "Hvordan rammer når skærmen, mens VSync er slukket.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Effektiv sover indtil næste billede; Præcise spins, som vanilje.",
"Fills the screen (borderless).": "Fylder skærmen (uden kant).",
"Brightens dark places.": "Lysner mørke steder.",
"Lowers the frame rate when minimised, or idle too.": "Sænker billedhastigheden, når den minimeres, eller også inaktiv.",
"Sways the view while walking.": "Svinger udsigten, mens du går.",
"Fades newly loaded chunks in.": "Fader nyindlæste bidder ind.",
"Darkens the screen's edges.": "Gør skærmens kanter mørkere.",
"Shows when the world saves.": "Viser, når verden redder.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Denne server er i onlinetilstand: offline-profiler kan ikke deltage, og hver spiller skal have sin egen Minecraft-konto.",
"horizon.splitscreen.own_account": "Denne server kræver, at hver spiller har sin egen Minecraft-konto",
"horizon.splitscreen.local_only": "Andre spillere kan kun deltage på denne server med deres egen Minecraft-konto: offline-profiler spiller i din verden, på LAN eller på en server på dit lokale netværk.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Gemmer modeller bag uigennemsigtige blokke. Asynkron checker off-thread; enheder krydser af.",
"Block Entities": "Bloker enheder",
"Shulker Boxes": "Shulker kasser",
"Reuses settled boxes; lid movement stays animated.": "Genbruger aflejrede kasser; lågets bevægelse forbliver animeret.",
@@ -616,6 +620,10 @@
"horizon.agent.session_opened": "Åbnet til denne session af programmet, der startede spillet.",
"horizon.agent.socket": "Socket: %s (kopier detaljerne for dens fulde sti)",
"horizon.agent.local_only": "Kun lokalt: agenter på denne computer, aldrig netværket.",
"%s nits": "%s nits",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Gemmer modeller bag uigennemsigtige blokke. Enheder opdatering. Afsted med brugerdefinerede pakker.",
"Entity Culling Precheck": "Forudkontrol af entitetsnedskæring",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Kører AABB-forudkontrollen for mobs efter så mange indlæste mobs. Nul slår den fra.",
"%s mobs": "%s mobs",
@@ -415,6 +415,10 @@
"How far around you BlueMap tiles are downloaded. Next join.": "Wie weit um dich herum BlueMap-Kacheln heruntergeladen werden. Nächster Beitritt.",
"Hidden Faces": "Versteckte Flächen",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Lässt Blattflächen hinter zwei Laubschichten weg: schneller, gleiche Kronen.",
"%s nits": "%s Nits",
"Auto (%s nits)": "Auto (%s Nits)",
"%.2f nits": "%.2f Nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter undurchsichtigen Blöcken. Entitäten aktualisieren sich. Aus bei benutzerdefinierten Paketen.",
"Entity Culling Precheck": "Objekt-Culling-Vorprüfung",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Führt die AABB-Vorprüfung für Kreaturen nach dieser Anzahl geladener Kreaturen aus. Null deaktiviert sie.",
"%s mobs": "%s Kreaturen",
@@ -428,6 +432,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduziert: fernes Gelände gröber, für GPUs, die sonst bremsen.",
"Field of view. Wider shows more terrain at once.": "Sichtfeld. Ein größeres zeigt mehr Gelände auf einmal.",
"Caps frames per second; a cap saves power and heat.": "Begrenzt die Bilder pro Sekunde; spart Strom und Wärme.",
"Waits for the display's refresh: no tearing, a little latency.": "Wartet auf den Bildaufbau: kein Tearing, etwas Latenz.",
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
@@ -461,11 +466,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Schärft das hochskalierte Bild. Höher: schärfere Kanten, mehr Flimmern.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
"Fills the screen (borderless).": "Füllt den Bildschirm (randlos).",
"Brightens dark places.": "Hellt dunkle Orte auf.",
"Lowers the frame rate when minimised, or idle too.": "Senkt die Bildrate bei Minimierung oder auch bei Inaktivität.",
"Sways the view while walking.": "Lässt die Ansicht beim Gehen schwanken.",
"Fades newly loaded chunks in.": "Blendet neu geladene Chunks ein.",
"Darkens the screen's edges.": "Dunkelt die Bildschirmränder ab.",
"Shows when the world saves.": "Zeigt an, wann die Welt gespeichert wird.",
@@ -563,6 +570,7 @@
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteckt Modelle hinter Blöcken. Prüfung im Hintergrund; Objekte laufen weiter.",
"Leaf Culling": "Laub-Culling",
"Every leaf face. Vanilla's fancy leaves.": "Jede Blattfläche, wie Minecraft sie bei schönen Blättern zeichnet.",
"Distant Plant Dither": "Pflanzen-Dithering",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Χοντρό μακρινό έδαφος σε τοπικούς κόσμους. Οι ενσωματωμένες GPU σταματούν στα 128 κομμάτια.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Μειωμένες έλξεις πολύ πιο χοντρό έδαφος: πιο γρήγορα σε αυτήν την ενσωματωμένη GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Οι μειωμένες έλξεις πολύ πιο τραχύ εδάφους, για τις GPU επιβραδύνουν.",
"Field of view. Wider shows more terrain at once.": "Οπτικό πεδίο. Το ευρύτερο δείχνει περισσότερο έδαφος ταυτόχρονα.",
"Caps frames per second; a cap saves power and heat.": "Καπάκια καρέ ανά δευτερόλεπτο. ένα καπάκι εξοικονομεί ενέργεια και θερμότητα.",
"Waits for the display's refresh: no tearing, a little latency.": "Περιμένει για την ανανέωση της οθόνης: χωρίς σκίσιμο, λίγο λανθάνουσα κατάσταση.",
"Size of menus and the HUD.": "Το μέγεθος των μενού και το HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Λειαίνει τις οδοντωτές άκρες. FXAA: πιο μαλακό. ΤΑΑ: τα πιο ομαλά, κινούμενα πράγματα μπορεί να ακολουθούν.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Καθιστά τον κόσμο μικρότερο και τον ξαναχτίζει με ΤΑΑ. Πιο γρήγορα, πιο μαλακά.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Κάνει πιο ευκρινή την αναβαθμισμένη εικόνα. Υψηλότερο: πιο καθαρές άκρες, περισσότερο τρεμόπαιγμα.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Έξοδος HDR σε οθόνη HDR: η λάβα, η φωτιά και το φως του ήλιου λάμπουν πιο έντονα.",
"How frames reach the screen while VSync is off.": "Πώς τα καρέ φτάνουν στην οθόνη ενώ το VSync είναι απενεργοποιημένο.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Αποτελεσματικοί ύπνοι μέχρι το επόμενο καρέ. Ακριβές περιστροφές, όπως η βανίλια.",
"Fills the screen (borderless).": "Γεμίζει την οθόνη (χωρίς περιθώρια).",
"Brightens dark places.": "Φωτίζει τα σκοτεινά μέρη.",
"Lowers the frame rate when minimised, or idle too.": "Μειώνει τον ρυθμό καρέ όταν είναι ελαχιστοποιημένος ή και σε αδράνεια.",
"Sways the view while walking.": "Κουνάει τη θέα ενώ περπατάτε.",
"Fades newly loaded chunks in.": "Ξεθωριάζει τα πρόσφατα φορτωμένα κομμάτια.",
"Darkens the screen's edges.": "Σκουραίνει τα άκρα της οθόνης.",
"Shows when the world saves.": "Δείχνει όταν ο κόσμος σώζει.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Αυτός ο διακομιστής είναι σε λειτουργία online: τα προφίλ εκτός σύνδεσης δεν μπορούν να συνδεθούν, κάθε παίκτης χρειάζεται τον δικό του λογαριασμό Minecraft.",
"horizon.splitscreen.own_account": "Αυτός ο διακομιστής χρειάζεται τον λογαριασμό Minecraft κάθε παίκτη",
"horizon.splitscreen.local_only": "Άλλοι παίκτες μπορούν να συνδεθούν σε αυτόν τον διακομιστή μόνο με τον δικό τους λογαριασμό Minecraft: τα προφίλ εκτός σύνδεσης παίζουν στον κόσμο σας, σε LAN ή σε διακομιστή στο τοπικό σας δίκτυο.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Κρύβει μοντέλα πίσω από αδιαφανή μπλοκ. Ασύγχρονοι έλεγχοι εκτός νήματος. οντότητες τικ.",
"Block Entities": "Αποκλεισμός οντοτήτων",
"Shulker Boxes": "Κουτιά Shulker",
"Reuses settled boxes; lid movement stays animated.": "Επαναχρησιμοποιεί τα καθιζημένα κουτιά. Η κίνηση του καπακιού παραμένει κινούμενη.",
@@ -616,6 +620,10 @@
"horizon.agent.session_opened": "Άνοιξε για αυτήν την περίοδο λειτουργίας από το πρόγραμμα που ξεκίνησε το παιχνίδι.",
"horizon.agent.socket": "Υποδοχή: %s (αντιγράψτε τα στοιχεία για την πλήρη διαδρομή του)",
"horizon.agent.local_only": "Μόνο τοπικά: πράκτορες σε αυτόν τον υπολογιστή, ποτέ στο δίκτυο.",
"%s nits": "%s κόνιδες",
"Auto (%s nits)": "Αυτόματο (%s nits)",
"%.2f nits": "%.2f κόνιδες",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Κρύβει μοντέλα πίσω από αδιαφανή μπλοκ. Ενημέρωση οντοτήτων. Εκτός με προσαρμοσμένα πακέτα.",
"Entity Culling Precheck": "Προέλεγχος σφαγής οντοτήτων",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Εκτελεί τον προέλεγχο AABB όχλων μετά από τόσους φορτωμένους όχλους. Το μηδέν το απενεργοποιεί.",
"%s mobs": "%s όχλοι",
@@ -80,18 +80,6 @@
"horizon.controller.guide.pages": "Pages",
"horizon.controller.guide.categories": "Categories",
"horizon.controller.guide.tabs": "Tabs",
"horizon.touch.sprint": "Sprint",
"horizon.touch.jump": "Jump",
"horizon.touch.up": "Up",
"horizon.touch.down": "Down",
"horizon.touch.sneak": "Sneak",
"horizon.touch.dismount": "Dismount",
"horizon.touch.attack": "Attack",
"horizon.touch.use": "Use",
"horizon.touch.chat": "Chat",
"horizon.touch.settings": "Touch",
"horizon.touch.swap": "Swap",
"horizon.touch.shield": "Shield",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Look Sensitivity",
"horizon.options.invert_x": "Invert Look X",
@@ -328,7 +316,6 @@
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"Horizon Preset": "Horizon Preset",
"LOD Distance": "LOD Distance",
"Far Detail": "Far Detail",
"Cave Culling": "Cave Culling",
@@ -367,7 +354,6 @@
"Unlimited": "Unlimited",
"Auto": "Auto",
"Custom": "Custom",
"Full Performance": "Full Performance",
"Performance": "Performance",
"Balanced": "Balanced",
"Quality": "Quality",
@@ -415,6 +401,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduced draws far terrain coarser: faster on this integrated GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduced draws far terrain coarser, for GPUs it slows down.",
"Field of view. Wider shows more terrain at once.": "Field of view. Wider shows more terrain at once.",
"Caps frames per second; a cap saves power and heat.": "Caps frames per second; a cap saves power and heat.",
"Waits for the display's refresh: no tearing, a little latency.": "Waits for the display's refresh: no tearing, a little latency.",
"Size of menus and the HUD.": "Size of menus and the HUD.",
@@ -455,11 +442,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renders the world smaller and rebuilds it with TAA. Faster, softer.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR output on an HDR display: lava, fire and sunlight shine brighter.",
"How frames reach the screen while VSync is off.": "How frames reach the screen while VSync is off.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efficient sleeps until the next frame; Precise spins, like vanilla.",
"Fills the screen (borderless).": "Fills the screen (borderless).",
"Brightens dark places.": "Brightens dark places.",
"Lowers the frame rate when minimised, or idle too.": "Lowers the frame rate when minimised, or idle too.",
"Sways the view while walking.": "Sways the view while walking.",
"Fades newly loaded chunks in.": "Fades newly loaded chunks in.",
"Darkens the screen's edges.": "Darkens the screen's edges.",
"Shows when the world saves.": "Shows when the world saves.",
@@ -674,6 +663,7 @@
"horizon.splitscreen.online_only": "This server is in online mode: offline profiles can't join it, each player needs its own Minecraft account.",
"horizon.splitscreen.own_account": "This server needs each player's own Minecraft account",
"horizon.splitscreen.local_only": "Other players can join this server only with their own Minecraft account: offline profiles play in your world, on LAN or on a server on your local network.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Block Entities": "Block Entities",
"Shulker Boxes": "Shulker Boxes",
"Reuses settled boxes; lid movement stays animated.": "Reuses settled boxes; lid movement stays animated.",
@@ -691,8 +681,6 @@
"Reuses statue geometry until its block state changes.": "Reuses statue geometry until its block state changes.",
"Leaf Culling": "Leaf Culling",
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
"Parallel Tick": "Parallel Tick",
"Shares your world's tick among threads. Auto: only while that is faster.": "Shares your world's tick among threads. Auto: only while that is faster.",
"Opaque Ice": "Opaque Ice",
"Draws ice in the solid pass, without its see-through texture.": "Draws ice in the solid pass, without its see-through texture.",
"Distant Plant Dither": "Distant Plant Dither",
@@ -719,9 +707,6 @@
"Pick a shader pack. Picking the one in use turns shaders off.": "Pick a shader pack. Picking the one in use turns shaders off.",
"Changed options apply with Apply, or when you leave.": "Changed options apply with Apply, or when you leave.",
"Pack settings": "Pack settings",
"Whole pack": "Whole pack",
"Reset all": "Reset all",
"Every option is at its default already.": "Every option is at its default already.",
"Built-in settings": "Built-in settings",
"%s settings": "%s settings",
"Settings made by the pack's author: they change only this pack.": "Settings made by the pack's author: they change only this pack.",
@@ -760,6 +745,10 @@
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
"horizon.agent.local_only": "Local only: agents on this computer, never the network.",
"%s nits": "%s nits",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Entity Culling Precheck": "Entity Culling Precheck",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Runs the mob AABB precheck after this many loaded mobs. Zero disables it.",
"%s mobs": "%s mobs",
@@ -1140,34 +1129,6 @@
"horizon.recipe_transfer.no_room": "Inventory full",
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
"horizon.recipe_transfer.unsupported": "Server requires a known recipe",
"horizon.debug.pass_detail.title": "Horizon pass detail (%s)",
"horizon.debug.pass_detail.inside_passes": "timestamps inside passes",
"horizon.debug.pass_detail.separate_passes": "separate passes, adds GPU time",
"horizon.debug.pass_detail.part": "%s › %s: %s ms",
"horizon.debug.pass_detail.counters": "%s: %s fragments, %s per pixel, %s vertices, %s of %s triangles kept",
"horizon.debug.pass_detail.no_counters": "GPU counters: Vulkan only",
"horizon.perf.started": "Profiling the client and server. %s",
"horizon.perf.uploading": "Uploading the profile…",
"horizon.perf.shared": "Shared: %s %s",
"horizon.perf.upload_failed": "Could not share the profile: %s. %s",
"horizon.perf.upload_interrupted": "the upload worker stopped",
"horizon.perf.unavailable": "Opening and sharing profiles is not available in this build.",
"horizon.perf.share_missing": "No profile saved in this session yet. %s",
"horizon.perf.saved": "Profile saved (%s s). %s",
"horizon.perf.open_button": "[Open]",
"horizon.perf.open_hover": "Open this profile in your browser. It is served once to your own browser and goes nowhere else.",
"horizon.perf.share_button": "[Share]",
"horizon.perf.share_hover": "Upload this profile and get a link to share. Nothing is uploaded until you click this.",
"horizon.perf.opened": "Opened in your browser",
"horizon.perf.open_failed": "Could not open the profile: %s. %s",
"horizon.perf.not_found": "No saved profile named %s.",
"horizon.perf.error": "Profiler: %s",
"horizon.perf.not_horizon": "this server isn't running Horizon",
"horizon.perf.denied": "a server profile needs operator level 4",
"horizon.perf.already_running": "the server is already running a profile",
"horizon.perf.not_running": "no server profile of yours is running",
"horizon.perf.failed": "the server could not write the profile; see its log",
"horizon.perf.part_incomplete": "the server's part of the profile did not arrive whole",
"horizon.studio.timeline.freecam_on": "Freecam: ON",
"horizon.studio.timeline.freecam_off": "Freecam: OFF",
"horizon.studio.timeline.freecam_short": "Cam",
@@ -1203,25 +1164,5 @@
"horizon.studio.topbar.split_scene": "Split Scene",
"horizon.recipes.fuel.options": "Hold SHIFT to pin this tooltip, then click an ingredient to view recipes.",
"horizon.recipes.fuel.any": "Any Fuel",
"horizon.recipes.fuel.minecraft": "Minecraft",
"horizon.perf.idle": "Not profiling. %s",
"horizon.perf.marked": "Marked %s",
"horizon.perf.started.client": "Profiling the client. %s",
"horizon.perf.started.server": "Profiling the server. %s",
"horizon.perf.saving": "Saving the profile…",
"horizon.perf.running.client": "Profiling the client for %s s. %s",
"horizon.perf.running.server": "Profiling the server for %s s. %s",
"horizon.perf.running.both": "Profiling the client and server for %s s. %s",
"horizon.perf.client_only": "Profiling the client only: %s",
"horizon.perf.stop_button": "[Stop]",
"horizon.perf.stop_hover": "Stop profiling and save the profile.",
"horizon.perf.start_button": "[Start]",
"horizon.perf.start_hover": "Start profiling.",
"horizon.perf.folder_button": "[Folder]",
"horizon.perf.folder_hover": "Show %s in its folder.",
"horizon.perf.copy_button": "[Copy]",
"horizon.perf.copy_hover": "Copy the link.",
"horizon.perf.retry_button": "[Retry]",
"horizon.perf.retry_hover": "Try the upload again. The profile is still saved on your computer.",
"horizon.perf.link_hover": "Open the shared profile in your browser."
"horizon.recipes.fuel.minecraft": "Minecraft"
}
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Kruda malproksima tereno en lokaj mondoj. Integritaj GPUoj ĉesas ĉe 128 pecoj.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduktitaj tiradoj multe pli krudaj: pli rapide sur ĉi tiu integra GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduktita tirado multe pli kruda, por GPU-oj ĝi malrapidiĝas.",
"Field of view. Wider shows more terrain at once.": "Vidkampo. Pli larĝa montras pli da tereno samtempe.",
"Caps frames per second; a cap saves power and heat.": "Ĉapaj kadroj por sekundo; ĉapo ŝparas potencon kaj varmon.",
"Waits for the display's refresh: no tearing, a little latency.": "Atendas la refreŝigon de la ekrano: neniu ŝirado, iom da latenco.",
"Size of menus and the HUD.": "Grandeco de menuoj kaj la HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glatigas dentitajn randojn. FXAA: pli mola. TAA: plej glate, moviĝantaj aferoj povas spuri.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Malgrandigas la mondon kaj rekonstruas ĝin per TAA. Faster, pli mola.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Akutigas la altskaligitan bildon. Pli alta: pli klaraj randoj, pli da trembrilo.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-eligo sur HDR-ekrano: lafo, fajro kaj sunlumo brilas pli hele.",
"How frames reach the screen while VSync is off.": "Kiel kadroj atingas la ekranon dum VSync estas malŝaltita.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efika dormas ĝis la sekva kadro; Precizaj turnoj, kiel vanilo.",
"Fills the screen (borderless).": "Plenigas la ekranon (senborda).",
"Brightens dark places.": "Heligas malhelajn lokojn.",
"Lowers the frame rate when minimised, or idle too.": "Malaltigas la framfrekvencon kiam minimumigita, aŭ neaktiva ankaŭ.",
"Sways the view while walking.": "Ŝancelas la vidon dum marŝado.",
"Fades newly loaded chunks in.": "Fadas lastatempe ŝarĝitajn pecojn.",
"Darkens the screen's edges.": "Malheligas la randojn de la ekrano.",
"Shows when the world saves.": "Montras kiam la mondo savas.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Ĉi tiu servilo estas en reta reĝimo: eksterretaj profiloj ne povas aliĝi, ĉiu ludanto bezonas sian propran Minecraft-konton.",
"horizon.splitscreen.own_account": "Ĉi tiu servilo bezonas la propran Minecraft-konton de ĉiu ludanto",
"horizon.splitscreen.local_only": "Aliaj ludantoj povas aliĝi al ĉi tiu servilo nur per sia propra Minecraft-konto: eksterretaj profiloj ludas en via mondo, en LAN aŭ en servilo en via loka reto.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Kaŝas modelojn malantaŭ maldiafanaj blokoj. Async kontrolas eksterfadenon; entoj tiktas.",
"Block Entities": "Blokaj Entoj",
"Shulker Boxes": "Ŝulkeraj Kestoj",
"Reuses settled boxes; lid movement stays animated.": "Reuzas fiksitajn skatolojn; klapomovo restas vigla.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Malfermita por ĉi tiu sesio de la programo kiu komencis la ludon.",
"horizon.agent.socket": "Ingo: %s (kopiu la detalojn por ĝia plena vojo)",
"horizon.agent.local_only": "Loka nur: agentoj sur ĉi tiu komputilo, neniam la reto.",
"%s nits": "%s nitoj",
"Auto (%s nits)": "Aŭtomata (%s nits)",
"%.2f nits": "%.2f nitoj",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Kaŝas modelojn malantaŭ maldiafanaj blokoj. Entoj ĝisdatigas. For per kutimaj pakoj.",
"Entity Culling Precheck": "Antaŭkontrolo de enta kaŝado",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Plenumas la antaŭkontrolon AABB de monstroj post tiom da ŝargitaj monstroj. Nulo malŝaltas ĝin.",
"%s mobs": "%s monstroj",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreno lejano de baja resolución en mundos locales. Las GPU integradas se limitan a 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reducido dibuja el terreno lejano con menos detalle: más rápido en esta GPU integrada.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reducido dibuja el terreno lejano con menos detalle, para GPU a las que ralentiza.",
"Field of view. Wider shows more terrain at once.": "Campo de visión. Más amplio muestra más terreno a la vez.",
"Caps frames per second; a cap saves power and heat.": "Limita los fotogramas por segundo; un límite ahorra energía y calor.",
"Waits for the display's refresh: no tearing, a little latency.": "Espera al refresco de la pantalla: sin desgarro, algo de latencia.",
"Size of menus and the HUD.": "Tamaño de los menús y del HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza los bordes dentados. FXAA: más suave. TAA: el más suave, lo que se mueve puede dejar estela.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderiza el mundo más pequeño y lo reconstruye con TAA. Más rápido y más suave.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Aumenta la nitidez de la imagen reescalada. Más alto: bordes más nítidos y más parpadeo.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Salida HDR en una pantalla HDR: la lava, el fuego y la luz del sol brillan más.",
"How frames reach the screen while VSync is off.": "Cómo llegan los fotogramas a la pantalla con VSync desactivado.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente duerme hasta el siguiente fotograma; Preciso gira, como en vanilla.",
"Fills the screen (borderless).": "Llena la pantalla (sin bordes).",
"Brightens dark places.": "Ilumina los lugares oscuros.",
"Lowers the frame rate when minimised, or idle too.": "Reduce los fotogramas por segundo al minimizar o al estar inactivo.",
"Sways the view while walking.": "Balancea la vista al caminar.",
"Fades newly loaded chunks in.": "Hace aparecer gradualmente los chunks recién cargados.",
"Darkens the screen's edges.": "Oscurece los bordes de la pantalla.",
"Shows when the world saves.": "Muestra cuándo se guarda el mundo.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Este servidor está en modo en línea: los perfiles sin conexión no pueden unirse; cada jugador necesita su propia cuenta de Minecraft.",
"horizon.splitscreen.own_account": "Este servidor requiere la cuenta de Minecraft de cada jugador",
"horizon.splitscreen.local_only": "Otros jugadores solo pueden unirse a este servidor con su propia cuenta de Minecraft: los perfiles sin conexión juegan en tu mundo, en LAN o en un servidor de tu red local.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Oculta modelos tras bloques opacos. Asíncrono comprueba fuera del hilo; las entidades se actualizan.",
"Block Entities": "Entidades de bloque",
"Shulker Boxes": "Cajas de shulker",
"Reuses settled boxes; lid movement stays animated.": "Reutiliza las cajas quietas; el movimiento de la tapa sigue animado.",
@@ -634,6 +638,10 @@
"horizon.agent.session_opened": "Abierto para esta sesión por el programa que inició el juego.",
"horizon.agent.socket": "Socket: %s (copia los datos para ver su ruta completa)",
"horizon.agent.local_only": "Solo local: agentes en este equipo, nunca la red.",
"%s nits": "%s nits",
"Auto (%s nits)": "Automático (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
"Entity Culling Precheck": "Comprobación previa de oclusión de entidades",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Ejecuta la comprobación previa del AABB de los mobs tras esta cantidad de mobs cargados. Cero la desactiva.",
"%s mobs": "%s mobs",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Jäme kauge maastik kohalikes maailmades. Integreeritud GPU-de arv on 128 tükki.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Vähendatud joonistab maastikul jämedam: kiirem selle integreeritud GPU-ga.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Vähendatud tõmbab maastikku jämedamalt, GPU-de puhul aeglustab.",
"Field of view. Wider shows more terrain at once.": "Vaateväli. Laiem näitab korraga rohkem maastikku.",
"Caps frames per second; a cap saves power and heat.": "Caps kaadrit sekundis; kork säästab elektrit ja soojust.",
"Waits for the display's refresh: no tearing, a little latency.": "Ootab ekraani värskendust: ei rebene, väike latentsus.",
"Size of menus and the HUD.": "Menüüde suurus ja HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Silustab sakilised servad. FXAA: pehmem. TAA: kõige sujuvamad, liikuvad asjad võivad järele jääda.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Muudab maailma väiksemaks ja ehitab selle TAA abil uuesti üles. Kiirem, pehmem.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Teravdab suurendatud pilti. Kõrgem: selgemad servad, rohkem virvendust.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-väljund HDR-ekraanil: laava, tuli ja päikesevalgus säravad eredamalt.",
"How frames reach the screen while VSync is off.": "Kuidas raamid ekraanile jõuavad, kui VSync on välja lülitatud.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Tõhusad magamised kuni järgmise kaadrini; Täpsed keerutused, nagu vanilje.",
"Fills the screen (borderless).": "Täidab ekraani (ääristeta).",
"Brightens dark places.": "Teeb tumedad kohad heledamaks.",
"Lowers the frame rate when minimised, or idle too.": "Vähendab kaadrisagedust, kui see on minimeeritud või ka tühikäigul.",
"Sways the view while walking.": "Kõndib kõndides vaadet.",
"Fades newly loaded chunks in.": "Peidab äsja laaditud tükid sisse.",
"Darkens the screen's edges.": "Tumendab ekraani servi.",
"Shows when the world saves.": "Näitab, millal maailm päästab.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "See server on võrgurežiimis: võrguühenduseta profiilid ei saa sellega liituda, iga mängija vajab oma Minecrafti kontot.",
"horizon.splitscreen.own_account": "See server vajab iga mängija oma Minecrafti kontot",
"horizon.splitscreen.local_only": "Teised mängijad saavad selle serveriga liituda ainult oma Minecrafti kontoga: võrguühenduseta profiilid mängivad sinu maailmas, LAN-is või sinu kohaliku võrgu serveris.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Peidab mudelid läbipaistmatute plokkide taha. Async kontrollib lõimevälist; olemid tiksuvad.",
"Block Entities": "Blokeeri üksused",
"Shulker Boxes": "Shulkeri karbid",
"Reuses settled boxes; lid movement stays animated.": "Taaskasutab settinud kaste; kaane liikumine jääb animeeritud.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Selle seansi jaoks avas mängu käivitanud programm.",
"horizon.agent.socket": "Pistikupesa: %s (kopeerige selle täieliku tee üksikasjad)",
"horizon.agent.local_only": "Ainult kohalik: selles arvutis olevad agendid, mitte kunagi võrk.",
"%s nits": "%s nits",
"Auto (%s nits)": "Automaatne (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Peidab mudelid läbipaistmatute plokkide taha. Olemite värskendamine. Väljas kohandatud pakkidega.",
"Entity Culling Precheck": "Üksuste kärpimise eelkontroll",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Käivitab mobide AABB eelkontrolli pärast nii paljude mobide laadimist. Null lülitab selle välja.",
"%s mobs": "%s mobi",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Urruneko lur lodia tokiko munduetan. GPU integratuak 128 zatitan gelditzen dira.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Marrazki murrizketak lur urrunago lodiagoak: azkarrago GPU integratu honetan.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Marrazki murriztuak lur urrunago lodiagoak dira, GPUentzat moteldu egiten da.",
"Field of view. Wider shows more terrain at once.": "Ikus-eremua. Zabalerak lur gehiago erakusten du aldi berean.",
"Caps frames per second; a cap saves power and heat.": "Maiuskulak segundoko fotogramak; txano batek energia eta beroa aurrezten ditu.",
"Waits for the display's refresh: no tearing, a little latency.": "Pantaila freskatzeko zain dago: urratu gabe, latentzia pixka bat.",
"Size of menus and the HUD.": "Menuen tamaina eta HUD-a.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Ertz bitxiak leuntzen ditu. FXAA: bigunagoa. TAA: leunena, mugitzen diren gauzak atzetik joan daitezke.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Mundua txikiagotzen du eta TAArekin berreraikitzen du. Azkarrago, leunagoa.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Handitutako irudia zorrozten du. Handiagoa: ertz zorrotzagoak, dirdira gehiago.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR irteera HDR pantaila batean: labak, sua eta eguzki-argiak distiratsuagoak dira.",
"How frames reach the screen while VSync is off.": "Nola iristen diren fotogramak pantailara VSync itzalita dagoen bitartean.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Lo eraginkorra hurrengo fotograma arte; Bira zehatzak, bainila bezala.",
"Fills the screen (borderless).": "Pantaila betetzen du (ertzak gabe).",
"Brightens dark places.": "Leku ilunak argitzen ditu.",
"Lowers the frame rate when minimised, or idle too.": "Fotograma-abiadura murrizten du minimizatzen denean, edo inaktibo ere.",
"Sways the view while walking.": "Ikuspegia aldarazi egiten du oinez ibiltzean.",
"Fades newly loaded chunks in.": "Kargatutako zati berriak lausotzen ditu.",
"Darkens the screen's edges.": "Pantailaren ertzak iluntzen ditu.",
"Shows when the world saves.": "Munduak salbatzen duenean erakusten du.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Zerbitzari hau lineako moduan dago: lineaz kanpoko profilek ezin dute sartu, jokalari bakoitzak bere Minecraft kontua behar du.",
"horizon.splitscreen.own_account": "Zerbitzari honek jokalari bakoitzaren Minecraft kontua behar du",
"horizon.splitscreen.local_only": "Beste jokalariek beren Minecraft kontuarekin soilik sar daitezke zerbitzari honetan: lineaz kanpoko profilek zure munduan jokatzen dute, LANean edo zure tokiko sareko zerbitzari batean.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Bloke opakuen atzean ereduak ezkutatzen ditu. Async egiaztapenak off-thread; entitateak markatuta.",
"Block Entities": "Blokeatu Entitateak",
"Shulker Boxes": "Shulker Kutxak",
"Reuses settled boxes; lid movement stays animated.": "Kutxa finkatuak berrerabili; estalkien mugimendua animatuta geratzen da.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Saio honetarako irekia jokoa hasi zuen programak.",
"horizon.agent.socket": "Socketa: %s (kopiatu bere bide osoaren xehetasunak)",
"horizon.agent.local_only": "Tokiko soilik: ordenagailu honetako agenteak, inoiz ez sarekoak.",
"%s nits": "%s nit",
"Auto (%s nits)": "Autoa (%s nit)",
"%.2f nits": "%.2f nit",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Bloke opakuen atzean ereduak ezkutatzen ditu. Entitateak eguneratzea. Off pakete pertsonalizatuekin.",
"Entity Culling Precheck": "Entitate-mozketaren aurre-egiaztapena",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Mob AABB aurre-egiaztapena kargatutako mob kopuru honen ondoren exekutatzen du. Zeroak desaktibatzen du.",
"%s mobs": "%s mob",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "زمین های درشت دور در جهان های محلی. پردازنده های گرافیکی مجتمع در 128 تکه متوقف می شوند.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "کشش های کمتر در زمین های دور درشت تر: در این GPU یکپارچه سریع تر.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "کشش های کمتر در زمین های دور درشت تر است، برای پردازنده های گرافیکی سرعت آن کاهش می یابد.",
"Field of view. Wider shows more terrain at once.": "میدان دید. عریض تر، زمین های بیشتری را به یکباره نشان می دهد.",
"Caps frames per second; a cap saves power and heat.": "فریم بر ثانیه یک کلاه باعث صرفه جویی در انرژی و گرما می شود.",
"Waits for the display's refresh: no tearing, a little latency.": "منتظر تازه شدن نمایشگر است: بدون پارگی، کمی تأخیر.",
"Size of menus and the HUD.": "اندازه منوها و HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "لبه های دندانه دار را صاف می کند. FXAA: نرم تر. TAA: نرم ترین و متحرک ترین چیزها ممکن است دنبال شوند.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "جهان را کوچکتر می کند و آن را با TAA بازسازی می کند. سریع تر، نرم تر.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "تصویر ارتقایافته را تیز می‌کند. بیشتر: لبه‌های واضح‌تر، درخشش بیشتر.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "خروجی HDR در صفحه نمایش HDR: گدازه، آتش و نور خورشید روشن تر می تابد.",
"How frames reach the screen while VSync is off.": "چگونه فریم‌ها در حالی که VSync خاموش است به صفحه نمایش می‌رسند.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "خواب های کارآمد تا فریم بعدی. چرخش دقیق، مانند وانیل.",
"Fills the screen (borderless).": "صفحه را پر می کند (بدون حاشیه).",
"Brightens dark places.": "مکان های تاریک را روشن می کند.",
"Lowers the frame rate when minimised, or idle too.": "در صورت به حداقل رساندن یا بیکار بودن، نرخ فریم را کاهش می دهد.",
"Sways the view while walking.": "هنگام راه رفتن منظره را تغییر می دهد.",
"Fades newly loaded chunks in.": "تکه های تازه بارگذاری شده را محو می کند.",
"Darkens the screen's edges.": "لبه های صفحه نمایش را تیره می کند.",
"Shows when the world saves.": "زمانی نشان می دهد که جهان نجات می دهد.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "این سرور در حالت آنلاین است: نمایه‌های آفلاین نمی‌توانند بپیوندند، هر بازیکن به حساب Minecraft خودش نیاز دارد.",
"horizon.splitscreen.own_account": "این سرور به حساب Minecraft هر بازیکن نیاز دارد",
"horizon.splitscreen.local_only": "بازیکنان دیگر فقط با حساب Minecraft خودشان می‌توانند به این سرور بپیوندند: نمایه‌های آفلاین در دنیای شما، در LAN یا در سروری در شبکه محلی‌تان بازی می‌کنند.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "مدل ها را پشت بلوک های مات پنهان می کند. Async بررسی های خارج از موضوع. موجودیت ها تیک می زنند.",
"Block Entities": "مسدود کردن نهادها",
"Shulker Boxes": "جعبه های شولکر",
"Reuses settled boxes; lid movement stays animated.": "از جعبه های ته نشین شده مجدد استفاده می کند. حرکت درب متحرک باقی می ماند.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "برای این جلسه توسط برنامه شروع کننده بازی باز شد.",
"horizon.agent.socket": "سوکت: %s (جزئیات را برای مسیر کامل آن کپی کنید)",
"horizon.agent.local_only": "فقط محلی: عوامل روی این رایانه، هرگز شبکه.",
"%s nits": "%s نیت",
"Auto (%s nits)": "خودکار (%s نیت)",
"%.2f nits": "%.2f نیت",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "مدل ها را پشت بلوک های مات پنهان می کند. به روز رسانی نهادها خاموش با بسته های سفارشی.",
"Entity Culling Precheck": "پیش‌بررسی کالینگ موجودیت",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "پیش‌بررسی AABB موجودات را پس از این تعداد موجود بارگذاری‌شده اجرا می‌کند. صفر آن را غیرفعال می‌کند.",
"%s mobs": "%s موجود",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Karkea etäinen maasto paikallisissa maailmoissa. Integroidut GPU:t pysähtyvät 128 palaan.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Vähentynyt piirtää paljon karkeampaa maastoa: nopeampi tällä integroidulla grafiikkasuorittimella.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduced piirtää paljon maastoa karkeammin, GPU:illa se hidastaa.",
"Field of view. Wider shows more terrain at once.": "Näkökenttä. Leveämpi näyttää enemmän maastoa kerralla.",
"Caps frames per second; a cap saves power and heat.": "Caps ruutua sekunnissa; korkki säästää sähköä ja lämpöä.",
"Waits for the display's refresh: no tearing, a little latency.": "Odottaa näytön päivitystä: ei repeytymistä, pieni latenssi.",
"Size of menus and the HUD.": "Valikkojen koko ja HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Tasoittaa rosoiset reunat. FXAA: pehmeämpi. TAA: Tasaisimmat, liikkuvat asiat voivat seurata.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Pienentää maailmaa ja rakentaa sen uudelleen TAA:lla. Nopeampi, pehmeämpi.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Terävöittää skaalattua kuvaa. Korkeampi: terävämmät reunat, enemmän värettä.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-lähtö HDR-näytöllä: laava, tuli ja auringonvalo loistavat kirkkaammin.",
"How frames reach the screen while VSync is off.": "Kuinka kehykset saavuttavat näytön, kun VSync on pois päältä.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Tehokkaat unet seuraavaan ruutuun asti; Tarkat pyöräytykset, kuten vanilja.",
"Fills the screen (borderless).": "Täyttää näytön (reunaton).",
"Brightens dark places.": "Kirkastaa pimeitä paikkoja.",
"Lowers the frame rate when minimised, or idle too.": "Alentaa kuvataajuutta minimoituna tai myös tyhjäkäynnillä.",
"Sways the view while walking.": "Heiluttaa näkymää kävellessä.",
"Fades newly loaded chunks in.": "Häivyttää juuri ladatut palat sisään.",
"Darkens the screen's edges.": "Tummentaa näytön reunoja.",
"Shows when the world saves.": "Näyttää, milloin maailma pelastaa.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Tämä palvelin on online-tilassa: offline-profiilit eivät voi liittyä siihen, jokainen pelaaja tarvitsee oman Minecraft-tilinsä.",
"horizon.splitscreen.own_account": "Tämä palvelin vaatii jokaiselle pelaajalle oman Minecraft-tilin",
"horizon.splitscreen.local_only": "Muut pelaajat voivat liittyä tähän palvelimeen vain omalla Minecraft-tilillään: offline-profiilit pelaavat maailmassasi lähiverkossa tai paikallisverkkosi palvelimella.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Piilottaa mallit läpinäkymättömien lohkojen taakse. Async tarkistaa säiettä; entiteetit tikittää.",
"Block Entities": "Estä entiteetit",
"Shulker Boxes": "Shulker laatikot",
"Reuses settled boxes; lid movement stays animated.": "Käyttää uudelleen laskeutuneita laatikoita; kannen liike pysyy animoituna.",
@@ -616,6 +620,10 @@
"horizon.agent.session_opened": "Pelin aloittaneen ohjelman avaa tälle istunnolle.",
"horizon.agent.socket": "Socket: %s (kopioi sen koko polun tiedot)",
"horizon.agent.local_only": "Vain paikalliset: agentit tällä tietokoneella, ei koskaan verkko.",
"%s nits": "%s nitiä",
"Auto (%s nits)": "Automaattinen (%s nits)",
"%.2f nits": "%.2f nitiä",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Piilottaa mallit läpinäkymättömien lohkojen taakse. Kokonaisuudet päivitys. Pois mukautetuilla pakkauksilla.",
"Entity Culling Precheck": "Entiteettien karsinnan esitarkistus",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Suorittaa mobien AABB-esitarkistuksen tämän määrän ladattuja mobeja jälkeen. Nolla poistaa sen käytöstä.",
"%s mobs": "%s mobia",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Magaspang na malayong lupain sa mga lokal na mundo. Humihinto ang mga pinagsamang GPU sa 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ang pinababang kumukuha ng malayong lupain ay mas magaspang: mas mabilis sa pinagsamang GPU na ito.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ang pinababang kumukuha ng malayong lupain ay mas magaspang, para sa mga GPU ay bumagal ito.",
"Field of view. Wider shows more terrain at once.": "Larangan ng pananaw. Ang mas malawak ay nagpapakita ng mas maraming lupain nang sabay-sabay.",
"Caps frames per second; a cap saves power and heat.": "I-cap ang mga frame sa bawat segundo; ang takip ay nakakatipid ng kuryente at init.",
"Waits for the display's refresh: no tearing, a little latency.": "Naghihintay para sa pag-refresh ng display: walang punit, kaunting latency.",
"Size of menus and the HUD.": "Sukat ng mga menu at ang HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Makinis ang tulis-tulis na mga gilid. FXAA: mas malambot. TAA: Ang pinakamakinis, gumagalaw na mga bagay ay maaaring masunod.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Ginagawang mas maliit ang mundo at muling itinatayo ito gamit ang TAA. Mas mabilis, mas malambot.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Pinatatalas ang naka-upscale na larawan. Mas mataas: mas matalas na gilid, mas maraming shimmer.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR na output sa isang HDR display: lava, apoy at sikat ng araw ay kumikinang nang mas maliwanag.",
"How frames reach the screen while VSync is off.": "Paano naaabot ng mga frame ang screen habang naka-off ang VSync.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Mahusay na pagtulog hanggang sa susunod na frame; Mga tumpak na pag-ikot, tulad ng vanilla.",
"Fills the screen (borderless).": "Pinuno ang screen (walang hangganan).",
"Brightens dark places.": "Nagpapaliwanag ng mga madilim na lugar.",
"Lowers the frame rate when minimised, or idle too.": "Ibinababa ang frame rate kapag pinaliit, o idle din.",
"Sways the view while walking.": "Iniindayog ang tanawin habang naglalakad.",
"Fades newly loaded chunks in.": "Nagfades ang mga bagong na-load na chunks.",
"Darkens the screen's edges.": "Pinadidilim ang mga gilid ng screen.",
"Shows when the world saves.": "Ipinapakita kung kailan nagliligtas ang mundo.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Nasa online mode ang server na ito: hindi makakasali ang mga offline na profile, kailangan ng bawat manlalaro ng sarili niyang Minecraft account.",
"horizon.splitscreen.own_account": "Kailangan ng server na ito ng sariling Minecraft account ng bawat manlalaro",
"horizon.splitscreen.local_only": "Makakasali lamang ang ibang manlalaro sa server na ito gamit ang sarili nilang Minecraft account: ang mga offline na profile ay naglalaro sa iyong mundo, sa LAN o sa isang server sa iyong lokal na network.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Itinatago ang mga modelo sa likod ng mga opaque na bloke. Sinusuri ng Async ang off-thread; lagyan ng tsek ang mga entidad.",
"Block Entities": "I-block ang mga Entity",
"Shulker Boxes": "Mga Kahon ng Shulker",
"Reuses settled boxes; lid movement stays animated.": "Muling ginagamit ang mga nakaayos na kahon; nananatiling animated ang paggalaw ng talukap ng mata.",
@@ -616,6 +620,10 @@
"horizon.agent.session_opened": "Binuksan para sa session na ito ng program na nagsimula ng laro.",
"horizon.agent.socket": "Socket: %s (kopyahin ang mga detalye para sa buong path nito)",
"horizon.agent.local_only": "Lokal lamang: mga ahente sa computer na ito, hindi sa network.",
"%s nits": "%s nits",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Itinatago ang mga modelo sa likod ng mga opaque na bloke. Pag-update ng mga entity. I-off gamit ang mga custom na pack.",
"Entity Culling Precheck": "Paunang Pagsusuri ng Pag-cull ng Entity",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Pinapatakbo ang AABB precheck ng mob pagkatapos ng ganitong karaming na-load na mob. Pinapatay ito ng zero.",
"%s mobs": "%s mob",
@@ -301,6 +301,7 @@
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grovt fjarskotið lendi í lokalum heimum. Integreraðir GPU'ar steðga á 128 klumpum.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Minkað dregur langt lendi grovari: skjótari á hesum integreraða GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Minkað dregur langt lendið grovari, fyri GPU'ar tað bremsar.",
"Field of view. Wider shows more terrain at once.": "Sjónarringur. Breiðari vísir meira lendi í senn.",
"Caps frames per second; a cap saves power and heat.": "Hettar rammur um sekundið; hetta sparir streym og hita.",
"Waits for the display's refresh: no tearing, a little latency.": "Bíðar eftir at skjárin verður uppdateraður: einki rív, eitt sindur av latency.",
"Size of menus and the HUD.": "Stødd á valmyndum og HUD.",
@@ -338,11 +339,13 @@
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Slættar snøggaðar kantar. FXAA: mjúkari. TAA: slættast, rørandi ting kunnu slóða.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Ger heimin minni og umbyggir hann við TAA. Skjótari, mjúkari.",
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Ger uppstillaðu myndina skýrari. Hægri: skarpari kantar, meira glitran.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR útgangur á HDR skjái: lava, eldur og sólarljós skína ljósari.",
"How frames reach the screen while VSync is off.": "Hvussu rammur røkka skerminum meðan VSync er sløkt.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Effektivur svevur til næstu rammu; Nágreinilig snúningar, sum vanilja.",
"Fills the screen (borderless).": "Fyllir skermin (markleysur).",
"Brightens dark places.": "Lýsir myrk støð.",
"Lowers the frame rate when minimised, or idle too.": "Lækkar rammufrekvensin tá hon er minkað, ella idle eisini.",
"Sways the view while walking.": "Sveiggjar útsýnið, meðan tú gongur.",
"Fades newly loaded chunks in.": "Doyvir nýlastaðar klumpar í.",
"Darkens the screen's edges.": "Myrkir kantarnar á skerminum.",
"Shows when the world saves.": "Vísir tá heimurin bjargar.",
@@ -553,6 +556,7 @@
"horizon.splitscreen.online_only": "Hesin ambætarin er í online-stilling: offline-profilar kunnu ikki melda seg til hann, hvør spælari hevur brúk fyri síni egnu Minecraft kontu.",
"horizon.splitscreen.own_account": "Hesin ambætarin hevur brúk fyri hvørjum spælara egna Minecraft kontu",
"horizon.splitscreen.local_only": "Aðrir spælarar kunnu bert melda seg til hendan ambætaran við egnari Minecraft konto: offline profilar spæla í tínum heimi, á LAN ella á einum ambætara á tínum lokala neti.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Goymir modellir aftan fyri ógjøgnumskygdar blokkar. Async kannar uttan fyri tráðin; eindir merkja.",
"Block Entities": "Blokk eindir",
"Shulker Boxes": "Shulker kassar",
"Reuses settled boxes; lid movement stays animated.": "Endurnýtir avgjørdar kassar; lok rørslan heldur seg lívgaða.",
@@ -617,6 +621,10 @@
"horizon.agent.session_opened": "Opnað fyri hesa setuna av skránni sum byrjaði dystin.",
"horizon.agent.socket": "Socket: %s (kopiera smálutirnar fyri alla leiðina)",
"horizon.agent.local_only": "Einans lokalt: umboðsmenn á hesi telduni, ongantíð netið.",
"%s nits": "%s nitur",
"Auto (%s nits)": "Sjálvvirkandi (%s nits)",
"%.2f nits": "%.2f nitur",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Goymir modellir aftan fyri ógjøgnumskygdar blokkar. Eindir dagførast. Av við sersniðgivnum pakkum.",
"Entity Culling Precheck": "Forkanning av eindarskurði",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Koyrir AABB-forkanningina av mobbum eftir hesi nógvar lastaðar mobbar. Null sløkkir tað.",
"%s mobs": "%s mobbar",
Loaded 100 of 1048 files, more files were not shown because too many files have changed in this diff. Show more