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
560 changed files with 4403 additions and 63723 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 --"
+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
+1 -2
View File
@@ -102,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 |
@@ -214,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
Generated
+3 -341
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",
]
@@ -1530,8 +1482,6 @@ dependencies = [
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1611,8 +1561,6 @@ dependencies = [
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1667,8 +1615,6 @@ dependencies = [
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-perf",
"horizon-perf-format",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
@@ -1886,7 +1832,6 @@ dependencies = [
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-perf-share",
"horizon-platform",
"log",
"winresource",
@@ -1911,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",
@@ -2043,6 +1946,7 @@ dependencies = [
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-mcp",
"horizon-platform",
@@ -2126,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",
]
@@ -2143,7 +2042,6 @@ version = "0.1.0"
dependencies = [
"flate2",
"horizon-core",
"horizon-threads",
"libm",
"log",
"lz4_flex",
@@ -2385,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"
@@ -2620,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"
@@ -2728,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"
@@ -2803,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"
@@ -3389,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"
@@ -3729,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"
@@ -3763,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"
@@ -4152,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"
@@ -4716,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"
@@ -4997,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",
-2
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
-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(),
}
+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.
-4
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"
@@ -132,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) => {
@@ -465,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.
+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
@@ -853,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])),
@@ -1753,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));
+5 -5
View File
@@ -55,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 {
@@ -388,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
@@ -1401,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);
}
@@ -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;
@@ -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)));
}
+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
);
}
}
@@ -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()),
};
+32 -14
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 {
@@ -1802,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)
@@ -1834,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`).
@@ -1960,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
@@ -1998,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();
@@ -2039,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());
@@ -2072,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
@@ -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)>(),
),
}
}
}
+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());
}
}
+1 -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" }
@@ -135,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"]
@@ -181,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"] }
@@ -316,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",
@@ -708,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.",
@@ -1133,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",
@@ -1196,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"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 B

-14
View File
@@ -21,19 +21,5 @@ fn main() {
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
// Whether a native mod can run in a mob's turn (`game/creatures/mob_islands.rs`). The
// launcher adds trait packages to the workspace's members.
println!(
"cargo:rerun-if-changed={}",
root.join("Cargo.toml").display()
);
for dir in ["native-api", "native-crates"] {
if root.join(dir).is_dir() {
println!("cargo:rerun-if-changed={}", root.join(dir).display());
}
}
if let Some(found) = horizon_mod_api_gen::mob_turn(&root) {
println!("cargo:rustc-env=HORIZON_MOB_TURN={found}");
}
windows_exe::resources("horizon-client");
}
@@ -318,8 +318,6 @@ pub fn main() -> horizon_client::Result<()> {
Ok(())
}
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
// The star's `setExtendedLifetime` is no part of the loot.
drops::Drop::Aged(s, _) => writeln!(out, "{}", line(&s)),
};
}
}
@@ -237,7 +237,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
"AvoidEntity" | "CatAvoid" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
+14 -14
View File
@@ -1517,8 +1517,10 @@ fn predicate_references(
.unwrap_or("");
match &*full(kind) {
"minecraft:inverted" => predicate_references(&v["term"], w, active),
"minecraft:all_of" | "minecraft:any_of" => terms(v)
.iter()
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.try_for_each(|v| predicate_references(v, w, active)),
_ => Ok(()),
}
@@ -1527,16 +1529,6 @@ fn predicate_references(
}
}
/// `terms` as `HolderSetCodec` reads it: a list, or one holder alone; a `#tag` is not expanded.
fn terms(v: &Value) -> &[Value] {
match &v["terms"] {
Value::Array(list) => list,
Value::Null => &[],
Value::String(tag) if tag.starts_with('#') => &[],
one => std::slice::from_ref(one),
}
}
/// The existing condition traversal with a caller-owned leaf context.
/// Leaves run synchronously, in order, with normal boolean short-circuiting.
pub fn conditions_with(
@@ -1555,8 +1547,16 @@ pub fn conditions_with(
.unwrap_or("");
match &*full(ty) {
"minecraft:inverted" => !conditions_with(&v["term"], ctx, test),
"minecraft:all_of" => terms(v).iter().all(|t| conditions_with(t, ctx, test)),
"minecraft:any_of" => terms(v).iter().any(|t| conditions_with(t, ctx, test)),
"minecraft:all_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.all(|t| conditions_with(t, ctx, test)),
"minecraft:any_of" => v["terms"]
.as_array()
.into_iter()
.flatten()
.any(|t| conditions_with(t, ctx, test)),
_ => test(v, ctx),
}
}
+92 -824
View File
@@ -11,7 +11,6 @@ use std::sync::mpsc::{self, Sender};
use std::thread;
use std::time::{Duration, Instant};
use horizon_bench::compare::Ab;
use horizon_bench::{App, Frames, Kind, Report};
use serde_json::Value;
@@ -55,105 +54,14 @@ const FLIGHT_SECONDS: f64 = 12.0;
const SETTLE: Duration = Duration::from_secs(2);
const LOAD_LIMIT: Duration = Duration::from_secs(600);
/// Where the trip starts and ends: 512 blocks at 16 blocks/s, the pace that scrambles section ids.
const TRAVEL_FROM: [f64; 3] = [-960.0, 150.0, -176.0];
const TRAVEL_TO: [f64; 3] = [-960.0, 150.0, 336.0];
const TRAVEL_PITCH: f64 = 30.0;
const TRAVEL_SECONDS: f64 = 32.0;
const HOLD_BLOCKS: u32 = 4;
const BLOCK_SECONDS: f64 = 4.0;
const WARMUP_SECONDS: f64 = 2.0;
const PAIRS: u32 = 8;
/// Frame-rate settings the benchmark can set for a run; `a` is the default.
pub struct Switch {
pub name: &'static str,
pub word: &'static str,
pub key: &'static str,
pub a: &'static str,
pub b: &'static str,
}
pub const SWITCHES: [Switch; 4] = [
Switch {
name: "gpu-culling",
word: "gpu-culling",
key: "gpu_culling",
a: "off",
b: "on",
},
Switch {
name: "section-cull",
word: "section-cull",
key: "cull",
a: "on",
b: "off",
},
Switch {
name: "section-sides",
word: "section-sides",
key: "sides",
a: "on",
b: "off",
},
Switch {
name: "section-draws",
word: "section-draws",
key: "flat",
a: "flat",
b: "indirect",
},
];
/// What `--benchmark` runs.
pub enum Scenario {
/// `standard` flies every scene; a scene's name flies only that one.
Scenes(Vec<Scene>),
/// A 512-block flight, then held at its end.
Travel,
/// The flight with the switch's B applied, then its two values interleaved B A A B.
Ab(&'static Switch),
}
impl Scenario {
pub fn parse(name: &str) -> Result<Scenario, String> {
match name {
"standard" => Ok(Scenario::Scenes(SCENES.to_vec())),
"travel" => Ok(Scenario::Travel),
name if name.starts_with("ab:") => SWITCHES
.iter()
.find(|s| s.name == &name[3..])
.map(Scenario::Ab)
.ok_or_else(|| {
format!(
"unknown switch {}: {}",
&name[3..],
SWITCHES.map(|s| s.name).join(", ")
)
}),
name => SCENES
.iter()
.find(|s| s.0 == name)
.map(|s| Scenario::Scenes(vec![*s]))
.ok_or_else(|| {
format!("unknown benchmark {name}: standard, village, forest, ocean, mountain, travel or ab:SWITCH ({})", SWITCHES.map(|s| s.name).join(", "))
}),
}
/// `standard` flies every scene; a scene's name flies only that one.
pub fn scenes(scenario: &str) -> Option<Vec<Scene>> {
match scenario {
"standard" => Some(SCENES.to_vec()),
name => SCENES.iter().find(|s| s.0 == name).map(|s| vec![*s]),
}
}
/// `--benchmark-set SWITCH=VALUE`: the value must be one of the switch's two.
pub fn parse_set(value: &str) -> Result<(String, String), String> {
let (name, setting) = value.split_once('=').ok_or("expected SWITCH=VALUE")?;
let names = || SWITCHES.map(|s| s.name).join(", ");
let switch = SWITCHES
.iter()
.find(|s| s.name == name)
.ok_or_else(|| format!("unknown switch {name}: {}", names()))?;
if setting != switch.a && setting != switch.b {
return Err(format!("{name} is {} or {}", switch.a, switch.b));
}
Ok((name.to_owned(), setting.to_owned()))
}
/// What the game knows about the run before it starts.
pub struct Setup {
pub scenario: String,
@@ -165,10 +73,6 @@ pub struct Setup {
pub fps_cap: Option<u32>,
pub offscreen: bool,
pub game_dir: PathBuf,
/// `--benchmark-set`: a switch and its value, applied before the world opens.
pub set: Option<(String, String)>,
/// `--profile`: record a .hzperf capture of the run and embed the report in it.
pub profile: bool,
}
/// Starts the driver; the game quits when it is done, and the report's path is printed.
@@ -246,7 +150,8 @@ fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bo
}
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let scenario = Scenario::parse(&setup.scenario)?;
let scenes =
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
let mut report = Report::new(Kind::Game, setup.app.clone());
let pid = std::process::id();
report.conditions.load_avg_1m = horizon_bench::load_average();
@@ -286,12 +191,85 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
replace_world(&setup.game_dir)?;
control.call(&format!("open {WORLD} {SEED} creative"))?;
// The world opens on a worker: its folder may not exist yet when `open` replies.
std::fs::create_dir_all(setup.game_dir.join("saves").join(WORLD)).map_err(|e| e.to_string())?;
std::fs::write(
setup.game_dir.join("saves").join(WORLD).join(MARKER),
"Created by horizon-client --benchmark; replaced by the next run.\n",
)
.map_err(|e| e.to_string())?;
let loaded = wait_loaded(control, |_| true)?;
report.metric("startup.world_ms", &[ms(loaded - setup.started)]);
let mut steady = Vec::new();
match &scenario {
Scenario::Ab(switch) => keeping_switch(control, switch, || {
measure(control, &setup, &scenario, &mut report, &mut steady)
})?,
_ => measure(control, &setup, &scenario, &mut report, &mut steady)?,
for (name, start, end, pitch) in scenes {
control.call("time 6000")?;
let meshed = control.status()["meshed_sections"].as_f64().unwrap_or(0.0);
let generated = control.status()["generated_chunks"].as_f64().unwrap_or(0.0);
let [x, y, z] = start;
control.call(&format!("tp {x} {y} {z}"))?;
let teleported = Instant::now();
// A zero-length flight holds the player at the start, flying.
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {pitch}"))?;
control.call(&format!("look {:.3} {pitch}", yaw(start, end)))?;
let near = |p: &Value| {
let p: Vec<f64> = p
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.collect();
p.len() == 3 && (0..3).map(|i| (p[i] - start[i]).powi(2)).sum::<f64>() < 4.0
};
let loaded = wait_loaded(control, near)?;
let load = loaded - teleported;
report.metric(&format!("chunks.{name}.load_ms"), &[ms(load)]);
let status = control.status();
let meshes = status["meshed_sections"].as_f64().unwrap_or(0.0) - meshed;
let chunks = status["generated_chunks"].as_f64().unwrap_or(0.0) - generated;
report.metric(&format!("chunks.{name}.meshed_count"), &[meshes]);
report.metric(&format!("chunks.{name}.generated_count"), &[chunks]);
if load.as_secs_f64() > 0.0 {
report.metric(
&format!("chunks.{name}.generated_per_s"),
&[chunks / load.as_secs_f64()],
);
}
if !time_passes(control, name, &mut report)? {
thread::sleep(SETTLE);
}
let [ex, ey, ez] = end;
let begin = Instant::now();
let flight = control.call(&format!(
"fly {x} {y} {z} {ex} {ey} {ez} {FLIGHT_SECONDS} {pitch} raw"
))?;
steady.push((begin, Instant::now()));
let series = |key: &str| -> Vec<f32> {
flight[key]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.map(|v| v as f32)
.collect()
};
let frames = Frames::of(
name,
0,
&series("frame_ms"),
&series("cpu_ms"),
&series("gpu_ms"),
)
.ok_or("the flight drew no frames")?;
report.metric(&format!("fps.{name}.avg_fps"), &[frames.avg_fps]);
report.metric(&format!("fps.{name}.low_1pct_fps"), &[frames.low_1pct_fps]);
report.metric(
&format!("fps.{name}.low_01pct_fps"),
&[frames.low_01pct_fps],
);
report.frames.push(frames);
}
let status = control.status();
@@ -309,12 +287,8 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
c.vsync = Some(setup.vsync);
c.fps_cap = setup.fps_cap;
c.offscreen = Some(setup.offscreen);
let (repeats, warmup) = match scenario {
Scenario::Travel => (HOLD_BLOCKS, None),
Scenario::Ab(_) => (2 * PAIRS, Some(WARMUP_SECONDS)),
Scenario::Scenes(_) => (1, Some(SETTLE.as_secs_f64())),
};
(c.repeats, c.warmup_s) = (Some(repeats), warmup);
c.repeats = Some(1);
c.warmup_s = Some(SETTLE.as_secs_f64());
if let Some(bytes) = renderer["gpu_allocated_bytes"].as_f64() {
report.metric("memory.gpu_mb", &[bytes / f64::from(1 << 20)]);
}
@@ -338,342 +312,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
report.metric("game.binary_bytes", &[meta.len() as f64]);
}
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
let written = report
report
.write_to(&setup.game_dir.join("benchmarks"))
.map_err(|e| e.to_string())?;
if setup.profile {
match control.call(&stop_capture(&written)) {
Ok(done) => println!("Horizon: benchmark capture {}", text(&done["path"])),
Err(e) => eprintln!("Horizon: the benchmark capture failed: {e}"),
}
}
Ok(written)
}
/// Starts the capture the run's frames feed; its trigger is the benchmark's.
const START_CAPTURE: &str = "profile client start";
/// Ends the capture with the written report embedded in it.
fn stop_capture(report: &Path) -> String {
format!("profile client stop --bench {}", report.display())
}
/// Opens the benchmark world and flies the scenario in it.
fn measure(
control: &Control,
setup: &Setup,
scenario: &Scenario,
report: &mut Report,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<(), String> {
match (scenario, &setup.set) {
(Scenario::Ab(switch), _) => apply_set(control, switch.name, switch.b)?,
(_, Some((name, value))) => apply_set(control, name, value)?,
_ => {}
}
replace_world(&setup.game_dir)?;
control.call(&format!("open {WORLD} {SEED} creative"))?;
// The world opens on a worker: its folder may not exist yet when `open` replies.
std::fs::create_dir_all(setup.game_dir.join("saves").join(WORLD)).map_err(|e| e.to_string())?;
std::fs::write(
setup.game_dir.join("saves").join(WORLD).join(MARKER),
"Created by horizon-client --benchmark; replaced by the next run.\n",
)
.map_err(|e| e.to_string())?;
let loaded = wait_loaded(control, |_| true)?;
report.metric("startup.world_ms", &[ms(loaded - setup.started)]);
if setup.profile {
control.call(START_CAPTURE)?;
}
match scenario {
Scenario::Scenes(scenes) => {
for &(name, start, end, pitch) in scenes {
control.call("time 6000")?;
load_scene(control, name, start, end, pitch, report)?;
if !time_passes(control, name, report)? {
thread::sleep(SETTLE);
}
let frames = fly(control, name, start, end, FLIGHT_SECONDS, pitch, steady)?;
fps_metrics(report, name, std::slice::from_ref(&frames));
report.frames.push(frames);
}
}
Scenario::Travel => travel(control, report, steady)?,
Scenario::Ab(switch) => {
trip(control, report, steady)?;
let plan = Plan {
pairs: PAIRS,
warmup: Duration::from_secs_f64(WARMUP_SECONDS),
};
ab_blocks(control, switch, &plan, report, steady)?;
}
}
Ok(())
}
/// Reads a switch before the run and puts it back after, whatever the run did and however it ended.
fn keeping_switch<T>(
control: &Control,
switch: &Switch,
run: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
let initial = control.call(switch.word)?[switch.key].clone();
let result = run();
let restored = restore(control, switch, &initial);
match result {
Ok(value) => restored.map(|()| value),
Err(e) => Err(e),
}
}
/// Sends the arm whose reply has the value the switch had.
fn restore(control: &Control, switch: &Switch, initial: &Value) -> Result<(), String> {
if control.call(switch.word)?[switch.key] == *initial {
return Ok(());
}
for value in [switch.a, switch.b] {
if control.call(&format!("{} {value}", switch.word))?[switch.key] == *initial {
return Ok(());
}
}
Err(format!("{} could not be put back", switch.name))
}
/// Sets a switch with its control word; a refusal (GPU culling not offered) is the game's reason.
fn apply_set(control: &Control, name: &str, value: &str) -> Result<(), String> {
let switch = SWITCHES
.iter()
.find(|s| s.name == name)
.ok_or("unknown switch")?;
control.call(&format!("{} {value}", switch.word)).map(drop)
}
/// Teleports to a scene's start, holds there and waits for the view to load, reporting the load.
fn load_scene(
control: &Control,
name: &str,
start: [f64; 3],
end: [f64; 3],
pitch: f64,
report: &mut Report,
) -> Result<(), String> {
let meshed = control.status()["meshed_sections"].as_f64().unwrap_or(0.0);
let generated = control.status()["generated_chunks"].as_f64().unwrap_or(0.0);
let [x, y, z] = start;
control.call(&format!("tp {x} {y} {z}"))?;
let teleported = Instant::now();
// A zero-length flight holds the player at the start, flying.
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {pitch}"))?;
control.call(&format!("look {:.3} {pitch}", yaw(start, end)))?;
let loaded = wait_loaded(control, near(start))?;
let load = loaded - teleported;
report.metric(&format!("chunks.{name}.load_ms"), &[ms(load)]);
let status = control.status();
let meshes = status["meshed_sections"].as_f64().unwrap_or(0.0) - meshed;
let chunks = status["generated_chunks"].as_f64().unwrap_or(0.0) - generated;
report.metric(&format!("chunks.{name}.meshed_count"), &[meshes]);
report.metric(&format!("chunks.{name}.generated_count"), &[chunks]);
if load.as_secs_f64() > 0.0 {
report.metric(
&format!("chunks.{name}.generated_per_s"),
&[chunks / load.as_secs_f64()],
);
}
Ok(())
}
/// Whether a status position is within 2 blocks of `target`.
fn near(target: [f64; 3]) -> impl Fn(&Value) -> bool {
move |p| {
let p: Vec<f64> = p
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.collect();
p.len() == 3 && (0..3).map(|i| (p[i] - target[i]).powi(2)).sum::<f64>() < 4.0
}
}
/// Flies the camera from `start` to `end` and returns the flight's frames.
fn fly(
control: &Control,
phase: &str,
start: [f64; 3],
end: [f64; 3],
seconds: f64,
pitch: f64,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<Frames, String> {
let ([x, y, z], [ex, ey, ez]) = (start, end);
let begin = Instant::now();
let flight = control.call(&format!(
"fly {x} {y} {z} {ex} {ey} {ez} {seconds} {pitch} raw"
))?;
steady.push((begin, Instant::now()));
frames_of(&flight, phase, 0)
}
/// The frames of a `fly ... raw` or `bench ... raw` reply.
fn frames_of(reply: &Value, phase: &str, repeat: u32) -> Result<Frames, String> {
let series = |key: &str| -> Vec<f32> {
reply[key]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_f64)
.map(|v| v as f32)
.collect()
};
Frames::of(
phase,
repeat,
&series("frame_ms"),
&series("cpu_ms"),
&series("gpu_ms"),
)
.ok_or_else(|| format!("{phase} drew no frames"))
}
/// The phase's frame figures over its blocks (one for a flight).
fn fps_metrics(report: &mut Report, phase: &str, blocks: &[Frames]) {
let each = |f: fn(&Frames) -> f64| blocks.iter().map(f).collect::<Vec<_>>();
report.metric(&format!("fps.{phase}.avg_fps"), &each(|b| b.avg_fps));
report.metric(
&format!("fps.{phase}.low_1pct_fps"),
&each(|b| b.low_1pct_fps),
);
report.metric(
&format!("fps.{phase}.low_01pct_fps"),
&each(|b| b.low_01pct_fps),
);
}
/// The median CPU and GPU time of a frame over the phase's blocks, where measured.
fn timing_metrics(report: &mut Report, phase: &str, blocks: &[Frames]) {
let cpu: Vec<f64> = blocks.iter().filter_map(|b| b.cpu_p50_ms).collect();
let gpu: Vec<f64> = blocks.iter().filter_map(|b| b.gpu_p50_ms).collect();
report.metric(&format!("cpu.{phase}.p50_ms"), &cpu);
report.metric(&format!("gpu.{phase}.p50_ms"), &gpu);
}
/// The flight, then the view at its end loaded.
fn trip(
control: &Control,
report: &mut Report,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<(), String> {
for line in [
"command gamerule advance_time false",
"command gamerule advance_weather false",
"command weather clear",
"time 6000",
] {
control.call(line)?;
}
let (from, to) = (TRAVEL_FROM, TRAVEL_TO);
load_scene(control, "travel", from, to, TRAVEL_PITCH, report)?;
let flight = fly(
control,
"travel",
from,
to,
TRAVEL_SECONDS,
TRAVEL_PITCH,
steady,
)?;
let blocks = std::slice::from_ref(&flight);
fps_metrics(report, "travel", blocks);
timing_metrics(report, "travel", blocks);
report.frames.push(flight);
let [x, y, z] = to;
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {TRAVEL_PITCH}"))?;
control.call(&format!("look {:.3} {TRAVEL_PITCH}", yaw(from, to)))?;
wait_loaded(control, near(to))?;
Ok(())
}
/// The state that matters: fly 512 blocks over terrain that loads and unloads, then hold at the end.
fn travel(
control: &Control,
report: &mut Report,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<(), String> {
trip(control, report, steady)?;
let mut blocks = Vec::new();
for repeat in 0..HOLD_BLOCKS {
let held = control.call(&format!("bench {BLOCK_SECONDS} raw"))?;
blocks.push(frames_of(&held, "hold", repeat)?);
}
fps_metrics(report, "hold", &blocks);
timing_metrics(report, "hold", &blocks);
report.frames.extend(blocks);
Ok(())
}
struct Plan {
pairs: u32,
warmup: Duration,
}
/// The two values of a switch in B A A B order, a block each, then each arm's passes timed once.
fn ab_blocks(
control: &Control,
switch: &Switch,
plan: &Plan,
report: &mut Report,
steady: &mut Vec<(Instant, Instant)>,
) -> Result<(), String> {
let began = Instant::now();
let mut blocks = Vec::new();
for pair in 0..plan.pairs {
control.call("time 6000")?;
for (n, b) in [(0, pair % 2 == 0), (1, pair % 2 == 1)] {
let (phase, value) = if b {
("ab.b", switch.b)
} else {
("ab.a", switch.a)
};
control.call(&format!("{} {value}", switch.word))?;
thread::sleep(plan.warmup);
let held = control.call(&format!("bench {BLOCK_SECONDS} raw"))?;
blocks.push(frames_of(&held, phase, 2 * pair + n)?);
}
}
steady.push((began, Instant::now()));
for phase in ["ab.a", "ab.b"] {
let arm: Vec<Frames> = blocks
.iter()
.filter(|f| f.phase == phase)
.cloned()
.collect();
fps_metrics(report, phase, &arm);
timing_metrics(report, phase, &arm);
}
let ab = Ab::from_frames(
switch.name,
switch.a,
switch.b,
BLOCK_SECONDS,
plan.warmup.as_secs_f64(),
&blocks,
)?;
if ab.first_block_outside_range {
eprintln!(
"Horizon: benchmark warning: the first B block, measured before any switch, is outside the later B blocks: switching {} may reset what is measured",
switch.name
);
}
report.frames.extend(blocks);
report.ab = Some(ab);
for (scene, value) in [("ab_a", switch.a), ("ab_b", switch.b)] {
control.call(&format!("{} {value}", switch.word))?;
time_passes(control, scene, report)?;
}
Ok(())
.map_err(|e| e.to_string())
}
/// Removes the previous run's world, never one the benchmark didn't create.
@@ -831,65 +472,11 @@ impl RssSampler {
mod tests {
use super::*;
#[test]
fn the_capture_commands_are_the_ones_the_game_parses() {
use crate::client_perf::request::{self, ProfileSide, Request, StartOptions};
let words = |line: &str| request::parse(&line.split(' ').skip(1).collect::<Vec<_>>());
let start = words(START_CAPTURE).unwrap();
assert_eq!(start.side, Some(ProfileSide::Client));
assert_eq!(start.request, Request::Start(StartOptions::default()));
let path = Path::new("/home/a b/game/benchmarks/game-20261108-travel.json");
let stop = words(&stop_capture(path)).unwrap();
assert_eq!(stop.side, Some(ProfileSide::Client));
assert_eq!(
stop.request,
Request::Stop {
bench: Some(path.display().to_string())
}
);
}
#[test]
fn scenarios() {
let scenes = |name: &str| match Scenario::parse(name) {
Ok(Scenario::Scenes(scenes)) => scenes,
_ => panic!("{name} is not scenes"),
};
assert_eq!(scenes("standard").len(), 4);
assert_eq!(scenes("ocean")[0].0, "ocean");
assert!(matches!(Scenario::parse("travel"), Ok(Scenario::Travel)));
assert!(matches!(
Scenario::parse("ab:gpu-culling"),
Ok(Scenario::Ab(s)) if s.name == "gpu-culling"
));
let Err(e) = Scenario::parse("ab:nope") else {
panic!("nope is no switch")
};
assert!(
e.contains("gpu-culling") && e.contains("section-draws"),
"{e}"
);
let Err(e) = Scenario::parse("nether") else {
panic!("nether is no scenario")
};
assert!(e.contains("travel") && e.contains("mountain"), "{e}");
}
#[test]
fn a_set_names_a_switch_and_one_of_its_two_values() {
assert_eq!(
parse_set("gpu-culling=on"),
Ok(("gpu-culling".into(), "on".into()))
);
assert_eq!(
parse_set("section-draws=indirect"),
Ok(("section-draws".into(), "indirect".into()))
);
let nope = parse_set("gpu-culling=maybe").unwrap_err();
assert!(nope.contains("off or on"), "{nope}");
let unknown = parse_set("fog=on").unwrap_err();
assert!(unknown.contains("gpu-culling") && unknown.contains("section-draws"));
assert!(parse_set("gpu-culling").is_err());
assert_eq!(scenes("standard").unwrap().len(), 4);
assert_eq!(scenes("ocean").unwrap()[0].0, "ocean");
assert!(scenes("nether").is_none());
}
#[test]
@@ -1001,325 +588,6 @@ mod tests {
assert!(metrics.is_empty());
}
/// A fake game for the trip: the player stands where `tp` or `fly` put it and counts settle.
fn trip_server(rx: mpsc::Receiver<Request>) -> thread::JoinHandle<Vec<String>> {
thread::spawn(move || {
let (mut lines, mut at, mut polls) = (Vec::new(), [0.0_f64; 3], 0_u64);
while let Ok(request) = rx.recv() {
let words: Vec<&str> = request.line.split(' ').collect();
let reply = match words[0] {
"status" => {
polls += 1;
let mut s: Value =
serde_json::from_str(&status(400 + polls.min(3))).unwrap();
s["player"]["position"] = serde_json::json!(at);
s
}
"bench" | "fly" if words.contains(&"raw") => {
serde_json::json!({
"frames": 200,
"frame_ms": vec![5.0; 200],
"cpu_ms": vec![2.0; 200],
"gpu_ms": vec![4.0; 200],
})
}
_ => serde_json::json!({ "ok": true }),
};
if matches!(words[0], "tp" | "fly") {
let n: Vec<f64> = words[1..4].iter().filter_map(|w| w.parse().ok()).collect();
at = [n[0], n[1], n[2]];
polls = 0;
}
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
})
}
#[test]
fn the_trip_flies_512_blocks_then_holds_four_blocks_at_its_end() {
let (tx, rx) = mpsc::channel::<Request>();
let server = trip_server(rx);
let mut report = Report::new(Kind::Game, App::default());
let control = Control(tx);
let mut steady = Vec::new();
travel(&control, &mut report, &mut steady).expect("the trip runs");
drop(control);
let lines: Vec<String> = server
.join()
.unwrap()
.into_iter()
.filter(|l| l != "status")
.map(|l| l.replace("-0.000", "0.000"))
.collect();
// Facing +Z is yaw 0, which the formatter may print as -0.
let mut expected = vec![
"command gamerule advance_time false",
"command gamerule advance_weather false",
"command weather clear",
"time 6000",
"tp -960 150 -176",
"fly -960 150 -176 -960 150 -176 0.1 30",
"look 0.000 30",
"fly -960 150 -176 -960 150 336 32 30 raw",
"fly -960 150 336 -960 150 336 0.1 30",
"look 0.000 30",
];
expected.extend(["bench 4 raw"; 4]);
assert_eq!(lines, expected);
let phases: Vec<_> = report
.frames
.iter()
.map(|f| (f.phase.as_str(), f.repeat))
.collect();
assert_eq!(
phases,
[
("travel", 0),
("hold", 0),
("hold", 1),
("hold", 2),
("hold", 3)
]
);
assert_eq!(steady.len(), 1);
let metric = |name: &str| report.metrics.get(name).map(|s| (s.n, s.mean));
assert_eq!(metric("fps.travel.avg_fps"), Some((1, 200.0)));
assert_eq!(metric("fps.hold.avg_fps"), Some((4, 200.0)));
assert_eq!(metric("cpu.hold.p50_ms"), Some((4, 2.0)));
assert_eq!(metric("gpu.hold.p50_ms"), Some((4, 4.0)));
assert_eq!(metric("gpu.travel.p50_ms"), Some((1, 4.0)));
assert!(metric("chunks.travel.load_ms").is_some());
}
#[test]
fn a_refused_command_ends_the_trip_with_the_games_reason() {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
while let Ok(request) = rx.recv() {
let reply = if request.line.starts_with("command weather") {
serde_json::json!({ "error": "no such weather" })
} else {
serde_json::json!({ "ok": true })
};
let _ = request.reply.send(reply.to_string());
}
});
let mut report = Report::new(Kind::Game, App::default());
let control = Control(tx);
let error = travel(&control, &mut report, &mut Vec::new()).unwrap_err();
assert!(error.contains("no such weather"), "{error}");
assert!(report.frames.is_empty());
drop(control);
server.join().unwrap();
}
#[test]
fn a_refused_switch_is_the_games_reason() {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
let mut lines = Vec::new();
while let Ok(request) = rx.recv() {
let reply = match request.line.as_str() {
"gpu-culling on" => serde_json::json!({ "error": "not offered here" }),
_ => serde_json::json!({ "ok": true }),
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
});
let control = Control(tx);
apply_set(&control, "section-draws", "indirect").unwrap();
let error = apply_set(&control, "gpu-culling", "on").unwrap_err();
assert!(error.contains("not offered here"), "{error}");
drop(control);
assert_eq!(
server.join().unwrap(),
["section-draws indirect", "gpu-culling on"]
);
}
/// A fake game with GPU culling: `on` is its state; `fail_at` makes that `bench` (counted from 1) fail.
fn culling_server(
rx: mpsc::Receiver<Request>,
mut on: bool,
offered: bool,
fail_at: Option<usize>,
) -> thread::JoinHandle<(Vec<String>, bool)> {
thread::spawn(move || {
let (mut lines, mut benches) = (Vec::new(), 0);
while let Ok(request) = rx.recv() {
let reply = match request.line.as_str() {
"gpu-culling on" if !offered => {
serde_json::json!({ "error": "not offered here" })
}
"gpu-culling on" | "gpu-culling off" => {
on = request.line.ends_with("on");
serde_json::json!({ "gpu_culling": on })
}
"gpu-culling" => serde_json::json!({ "gpu_culling": on }),
"bench 4 raw" => {
benches += 1;
let gpu = if on { 5.0 } else { 3.0 };
if Some(benches) == fail_at {
serde_json::json!({ "error": "the window closed" })
} else {
serde_json::json!({
"frame_ms": vec![gpu + 1.0; 200],
"cpu_ms": vec![1.0; 200],
"gpu_ms": vec![gpu; 200],
})
}
}
"bench 2" => serde_json::json!({
"gpu_passes": {"World": if on { 1.5 } else { 0.5 }},
}),
_ => serde_json::json!({ "ok": true }),
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
(lines, on)
})
}
const GPU_CULLING: &Switch = &SWITCHES[0];
fn quick(pairs: u32) -> Plan {
Plan {
pairs,
warmup: Duration::ZERO,
}
}
fn vulkan_report() -> Report {
let gpu = horizon_bench::Gpu {
backend: "vulkan".into(),
..Default::default()
};
let hardware = horizon_bench::Hardware {
gpu: Some(gpu),
..Default::default()
};
Report::with_hardware(Kind::Game, App::default(), hardware)
}
#[test]
fn the_blocks_run_b_a_a_b_and_each_arm_is_timed_by_pass() {
let (tx, rx) = mpsc::channel::<Request>();
let server = culling_server(rx, false, true, None);
let mut report = vulkan_report();
let control = Control(tx);
keeping_switch(&control, GPU_CULLING, || {
ab_blocks(
&control,
GPU_CULLING,
&quick(6),
&mut report,
&mut Vec::new(),
)
})
.expect("the blocks run");
drop(control);
let (lines, on) = server.join().unwrap();
let arms: String = lines
.iter()
.take_while(|l| *l != "gpu-passes on")
.filter_map(|l| match l.as_str() {
"gpu-culling on" => Some('B'),
"gpu-culling off" => Some('A'),
_ => None,
})
.collect();
assert_eq!(arms, "BAABBAABBAABA");
assert_eq!(lines.iter().filter(|l| *l == "time 6000").count(), 6);
assert!(!on, "the initial value is back");
assert_eq!(lines.last().unwrap(), "gpu-culling off");
let positions: Vec<_> = report
.frames
.iter()
.map(|f| (f.phase.as_str(), f.repeat))
.collect();
assert_eq!(
positions[..4],
[("ab.b", 0), ("ab.a", 1), ("ab.a", 2), ("ab.b", 3)]
);
let ab = report.ab.as_ref().expect("an A/B run has its result");
let gpu = &ab.result["gpu_p50_ms"];
assert_eq!((gpu.pairs, gpu.a, gpu.b, gpu.diff), (6, 3.0, 5.0, 2.0));
assert_eq!(gpu.verdict, horizon_bench::compare::Verdict::Slower);
assert!(!ab.first_block_outside_range);
let metric = |name: &str| report.metrics.get(name).map(|s| (s.n, s.mean));
assert_eq!(metric("gpu.ab.a.p50_ms"), Some((6, 3.0)));
assert_eq!(metric("gpu.ab.b.p50_ms"), Some((6, 5.0)));
assert_eq!(metric("gpu_pass.ab_a.world_ms"), Some((1, 0.5)));
assert_eq!(metric("gpu_pass.ab_b.world_ms"), Some((1, 1.5)));
}
#[test]
fn a_setting_that_was_on_is_on_again_after_the_run() {
let (tx, rx) = mpsc::channel::<Request>();
let server = culling_server(rx, true, true, None);
let control = Control(tx);
keeping_switch(&control, GPU_CULLING, || {
ab_blocks(
&control,
GPU_CULLING,
&quick(2),
&mut vulkan_report(),
&mut Vec::new(),
)
})
.expect("the blocks run");
drop(control);
assert!(server.join().unwrap().1);
}
#[test]
fn the_setting_is_put_back_when_the_run_fails_midway() {
let (tx, rx) = mpsc::channel::<Request>();
// The 3rd block is A: the setting is off when it fails.
let server = culling_server(rx, true, true, Some(3));
let control = Control(tx);
let mut report = vulkan_report();
let error = keeping_switch(&control, GPU_CULLING, || {
ab_blocks(
&control,
GPU_CULLING,
&quick(6),
&mut report,
&mut Vec::new(),
)
})
.unwrap_err();
assert!(error.contains("the window closed"), "{error}");
assert!(report.frames.is_empty() && report.ab.is_none());
drop(control);
let (lines, on) = server.join().unwrap();
assert!(on, "on was the player's setting");
assert_eq!(lines.last().unwrap(), "gpu-culling on");
}
#[test]
fn a_refused_switch_ends_the_run_with_the_games_reason_and_changes_nothing() {
let (tx, rx) = mpsc::channel::<Request>();
let server = culling_server(rx, false, false, None);
let control = Control(tx);
let error = keeping_switch(&control, GPU_CULLING, || {
apply_set(&control, "gpu-culling", "on")
})
.unwrap_err();
assert!(error.contains("not offered here"), "{error}");
drop(control);
let (lines, on) = server.join().unwrap();
assert_eq!(lines, ["gpu-culling", "gpu-culling on", "gpu-culling"]);
assert!(!on);
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
@@ -589,22 +589,12 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("mcp", Scripting, "mcp [--paced]", "serve the Model Context Protocol on stdin/stdout, one tool per command",
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image. --paced stops a single-player world between tool calls: the pause menu opens after each action and closes before the next, so time passes only while an action (or wait) runs, not while the agent thinks; reading tools (state, inventory, entities, events...) leave it paused.",
["horizon-ctl mcp", "horizon-ctl mcp --paced"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS [raw]", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10", "horizon-ctl bench 4 raw"], &[p("seconds", Ty::Num, "how long to measure"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms; alloc_count and alloc_bytes in an alloc-count build)")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "A build with the alloc-count feature adds alloc: the window thread's allocations and KiB per frame and all threads' allocations per second, else null.", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms; alloc_count and alloc_bytes in an alloc-count build)")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off|detail]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench; detail adds dispatch times and, on Vulkan, fragment and vertex counts per world pass", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off", "detail"]), "on, off or detail; without it, only reports")], mcp: false, local: false),
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a .hzperf capture holding a timeline, for SECONDS or until stop",
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) is recorded on the thread that ran it, with its depth; the timeline stops after 60 seconds. trace stop replies with the file's path once it is whole. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. Open the file in the web UI. See docs/graphics.md.",
["horizon-ctl trace /tmp/frame.hzperf 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
cmd!("profile", Scripting, "profile [client|server] start [--seconds S] [--hz N] [--over MS] [--threads main|all] [--wall] [--gpu[=detail]] [--no-samples] [--timeline] | stop [--bench REPORT] | status | mark NAME | open FILE | share FILE", "capture the game's performance to a .hzperf file in reports/ in the game home",
"start records the frame times, the health history and the render thread's call trees until stop or --seconds (at most 600). stop replies with the file's path once the file is whole, and with the viewer's address (open_at) only in a build that has a part to open captures; open FILE serves the file once on 127.0.0.1 and opens the viewer on it, share FILE uploads it and replies with the link; both fail in a build without that part and work with every telemetry switch off; status says whether a capture runs, its age and its size; mark NAME puts a label at the current time. F3+O does start and stop from the keyboard; HORIZON_PROFILE=DIR records the first 10 minutes of a run into DIR. --gpu adds each render pass's median GPU time and --gpu=detail the dispatch times and, on Vulkan, fragment and vertex counts per pass; HORIZON_SHADER_PACK_TIMING and HORIZON_GPU_PASS_DETAIL start a capture with them. stop --bench REPORT embeds a benchmark report (benchmarks/*.json) in the capture; --benchmark --profile does this by itself. Read the file with horizon-perf (query gpu). With no side a single-player run captures both sides in one file (the window, and the server thread as its own section); client or server captures one. Connected to a Horizon server, server asks that server to capture its side: it needs operator level 4, only one server capture runs at a time, and the server keeps the file in its reports/ and logs who asked; a refusal is a warning in the window's own capture.",
["horizon-ctl profile start --seconds 30", "horizon-ctl profile mark loaded-the-nether", "horizon-ctl profile stop", "horizon-ctl profile open reports/profile-20251107-143844Z.hzperf"],
&[p("action", Ty::Enum(&["start", "stop", "status", "mark", "open", "share"]), "what to do"), opt("name", Ty::Rest, "mark: the label; open and share: the file"),
flag("side", Ty::Enum(&["client", "server"]), "client or server, for the window or the server (the single-player server thread, or the server joined); both when omitted"), flag("seconds", Ty::Int, "start: stop after this many seconds, 1 to 600"),
flag("hz", Ty::Int, "start: samples per second, 10 to 1000 (default 250)"), flag("over", Ty::Num, "start: sample only frames slower than this many milliseconds"),
flag("threads", Ty::Enum(&["main", "all"]), "start: the render thread (default) or every thread that opted in"), flag("wall", Ty::Bool, "start: also sample on the wall clock, to find waits"),
flag("gpu", Ty::Enum(&["passes", "detail"]), "start: also record each render pass's GPU time (detail: its parts and counters too)"),
flag("no-samples", Ty::Bool, "start: record frame times and health only"), flag("timeline", Ty::Bool, "start: also record every thread's spans, for 60 seconds at most"),
flag("bench", Ty::Str, "stop: the path of a benchmark report to embed in the capture")], mcp: true, local: false),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop",
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) shows on the thread that ran it, nested by time. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. See docs/graphics.md.",
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
cmd!("screenshot-sequence", Scripting, "screenshot-sequence FRAMES STEP_MS PATH|cancel", "draw a shader pack's first FRAMES frames on a set clock and write the last as a PNG",
"Needs HORIZON_STILL and a frozen world (docs/agent-control.md). The pack starts over, frame n's clock is n frames and (n - 1) × STEP_MS milliseconds, the PNG is written as screenshot PATH writes it, and the reply comes once it is. Relative paths are taken from horizon-ctl's directory. cancel stops a running sequence.",
["horizon-ctl screenshot-sequence 64 50 frame64.png", "horizon-ctl screenshot-sequence 64 16.666667 frame64.png", "horizon-ctl screenshot-sequence cancel"],
+1 -15
View File
@@ -71,10 +71,6 @@ pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f6
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
return true;
};
// A grouped mob turn: no mod hooks it (`native_client_hooks::Deferred`).
if crate::native_client_hooks::deferring() {
return true;
}
let mut cancel = false;
let at = [position.x, position.y, position.z];
hook(dimension, entity, kind, at, distance, &mut cancel);
@@ -91,8 +87,7 @@ fn dispatch(event: Event<'_>, damage: &mut f32) {
let contact = match event {
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
};
// A grouped mob turn reaches no mod's hook (`native_client_hooks::Deferred`).
if contact.block.starts_with("minecraft:") || crate::native_client_hooks::deferring() {
if contact.block.starts_with("minecraft:") {
return;
}
if let Some(dimension) = LEVEL.get()
@@ -218,15 +213,6 @@ mod tests {
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
swept_inside(17, c.kind, at, at, bounds, block);
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
// A grouped mob turn, on any thread: no mod hears its contacts or tramples.
let ((), _) = crate::native_client_hooks::deferring_turn(|| {
emit(Event::StepOn(c));
assert_eq!(fall(c, 12.0, 10.0), 10.0);
let _ = TRAMPLE.set(|_, _, _, _, _, cancel| *cancel = true);
assert!(trample_allowed(17, c.kind, c.position, 12.0));
});
assert_eq!(SEEN.get(), 8, "a grouped turn reaches no contact hook");
assert!(!trample_allowed(17, c.kind, c.position, 12.0));
});
let _ =
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
+7 -21
View File
@@ -123,32 +123,18 @@ pub fn spawn_egg_entity_data<'a>(
/// `EntityType.appendDefaultStackConfig` for a hanging entity or armor stand an
/// item places: `Entity.applyImplicitComponents` stores the item's
/// `minecraft:custom_name` as the entity's `CustomName` and its
/// `minecraft:custom_data` as its `data`, then `updateCustomEntityTag`
/// `minecraft:custom_data` as the entity's `data`, then `updateCustomEntityTag`
/// (`TypedEntityData.loadInto`) merges the item's `minecraft:entity_data` over
/// the entity's saved tag `own` when its type is `kind`.
pub fn stack_configured_entity(stack: &crate::items::ItemStack, kind: &str, own: &Tag) -> Tag {
stack_configured_entity_by(stack, kind, own, false)
}
/// [`stack_configured_entity`], `op` saying whether the user may load the
/// data of an op-only entity type (`EntityType.onlyOpCanSetNbt`).
pub fn stack_configured_entity_by(
stack: &crate::items::ItemStack,
kind: &str,
own: &Tag,
op: bool,
) -> Tag {
let mut tag = own.clone();
if let Tag::Compound(map) = &mut tag {
if let Some(name) = stack.data().and_then(|d| d.custom_name.clone()) {
map.insert("CustomName".into(), name);
}
if let Some(custom) = stack.data().and_then(|d| d.other("minecraft:custom_data")) {
map.insert("data".into(), custom.clone());
}
if let (Tag::Compound(map), Some(custom)) = (
&mut tag,
stack.data().and_then(|d| d.other("minecraft:custom_data")),
) {
map.insert("data".into(), custom.clone());
}
match spawn_egg_entity_data(stack, kind, op) {
match spawn_egg_entity_data(stack, kind, false) {
Some(data) => crate::data_cmd::merged(&tag, data),
None => tag,
}
+10 -13
View File
@@ -19,16 +19,16 @@ pub use horizon_client_render::{
effects, environment, extras, fast_hash, fluid_render, fog, font, gfx, graphics,
graphics_style, graphics_ui, hdr_calibration, hud, hud_kit, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
mark_sprite, menu_blur, menu_style, mesher, minimap, mipmap, mod_browser, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_formats, pack_hooks, pack_palette, pack_screen, packs,
pad_cursor, pad_glyph, par, particles, poi_sections, polytone_creative_tabs, polytone_post,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, social, sound_engine, sound_events, sound_preview, spans,
split_layout, split_map, split_profiles, sprites, streaming, structure_block, tab_list,
terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
weather, widgets, world, worldborder, zip,
menu_blur, menu_style, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
pack_browser, pack_formats, pack_hooks, pack_palette, pack_screen, packs, pad_cursor,
pad_glyph, par, particles, poi_sections, polytone_creative_tabs, polytone_post, post_chain,
potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
social, sound_engine, sound_events, sound_preview, spans, split_layout, split_map,
split_profiles, sprites, streaming, structure_block, tab_list, terrain_model, test_block,
test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets, world,
worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -43,7 +43,6 @@ pub mod boss_overlay;
pub mod breathing;
pub mod cauldron;
pub mod chunk_io;
pub mod client_perf;
pub mod clock;
pub mod command_storage;
pub mod confirm_screen;
@@ -108,7 +107,6 @@ pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_net;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
@@ -131,7 +129,6 @@ pub mod sound_playback;
pub use horizon_server_files as server;
pub mod controller_notice;
pub mod server_packs;
pub mod server_perf;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
-321
View File
@@ -1,321 +0,0 @@
//! The client's .hzperf captures (docs/perf-platform.md §4.1): the window's frames feed the always-on health history, and a capture writes it with the samples of the render thread.
use crate::gfx::pack_timing::{GpuReadout, TimedFrames};
use crate::server_perf::traffic::{Conn, Wire};
use crate::server_perf::{
self, CaptureEnd, CaptureOptions, CaptureStatus, MAX_WINDOW_S, Role, SamplerOptions, Saved,
ServerPerf, TickDetail, WorldCounts,
};
use horizon_perf_format::FieldSender;
use horizon_perf_format::v1::{
BenchResult, Machine, Side, Start, StopReason, Trigger, WarningCode,
};
use horizon_protocol::connection::Counters;
use request::{GpuSwitch, StartOptions, Threads};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
pub mod request;
#[cfg(test)]
mod tests;
/// A frame this slow enters the slow ring when `HORIZON_SPIKES` names no other threshold.
pub const SLOW_FRAME_MS: f32 = 50.0;
/// The variables that start a capture with the GPU passes timed: pass timing, and pass timing with its parts.
pub const ENV_PASS_TIMING: &str = "HORIZON_SHADER_PACK_TIMING";
pub const ENV_PASS_DETAIL: &str = "HORIZON_GPU_PASS_DETAIL";
/// The GPU switch a run's environment asks for; the detail variable wins over the timing one.
pub fn env_gpu(timing: Option<&OsStr>, detail: Option<&OsStr>) -> GpuSwitch {
if detail.is_some() {
GpuSwitch::Detail
} else if timing.is_some() {
GpuSwitch::Passes
} else {
GpuSwitch::Off
}
}
/// Where a client's captures go: `HORIZON_PROFILE` when set, else `reports/` in the game home.
pub fn reports_dir(profile: Option<&OsStr>, home: &Path) -> PathBuf {
match profile.filter(|p| !p.is_empty()) {
Some(dir) => PathBuf::from(dir),
None => home.join("reports"),
}
}
/// What a side on another thread needs to write into the running capture's file.
#[derive(Clone)]
pub struct Join {
pub sender: FieldSender,
pub path: PathBuf,
/// The instant every `t` of the capture counts from.
pub origin: Instant,
/// When the capture's window ends, and why it stops then.
pub ends: Option<(Instant, StopReason)>,
pub options: StartOptions,
pub trigger: Trigger,
}
/// The client's health history, always on, and the capture running from it, if any.
pub struct ClientPerf {
perf: ServerPerf,
epoch: Instant,
cpu_ms: Vec<f32>,
gpu_ms: Vec<f32>,
/// When the running capture's window ends, and why it stops then.
window: Option<(f64, StopReason)>,
/// The running capture's switches, which a side that joins it follows.
running: Option<(StartOptions, Trigger)>,
/// The server connection's byte counts while the window plays on one: the capture's own traffic.
connection: Option<Arc<Counters>>,
}
/// The window's one connection as the traffic recorder's wire list.
fn wire_of(connection: Option<&Counters>) -> Wire {
let order = Ordering::Relaxed;
connection
.map(|c| {
let count = |n: &std::sync::atomic::AtomicI64| n.load(order).max(0) as u64;
vec![Conn {
id: 1,
bytes: (count(&c.bytes_in), count(&c.bytes_out)),
..Conn::default()
}]
})
.unwrap_or_default()
}
impl ClientPerf {
pub fn new(slow_ms: f32) -> Self {
Self {
perf: ServerPerf::with_role(Role::CLIENT, 0.0, slow_ms),
epoch: Instant::now(),
cpu_ms: Vec::new(),
gpu_ms: Vec::new(),
window: None,
running: None,
connection: None,
}
}
/// The connection whose bytes a capture records, or None in a world of the window's own.
pub fn set_connection(&mut self, connection: Option<Arc<Counters>>) {
self.connection = connection;
}
/// Seconds since the game began, the clock every other time here is on.
pub fn now(&self) -> f64 {
self.epoch.elapsed().as_secs_f64()
}
pub fn capturing(&self) -> bool {
self.perf.capturing()
}
pub fn health(&self) -> &horizon_perf_format::HealthRecorder {
self.perf.health()
}
/// One rendered frame that took `wall_ms`, of which `cpu_ms` was the CPU's own work, ending at `t`.
pub fn frame(
&mut self,
t: f64,
wall_ms: f32,
cpu_ms: f32,
gpu_ms: Option<f32>,
detail: Option<TickDetail>,
world: impl FnOnce() -> WorldCounts,
) {
self.frame_with_gpu(t, wall_ms, cpu_ms, gpu_ms, detail, world, || None);
}
/// [`ClientPerf::frame`], where `gpu` is asked for the pass timings only when this frame ends the capture's window.
#[allow(clippy::too_many_arguments)]
pub fn frame_with_gpu(
&mut self,
t: f64,
wall_ms: f32,
cpu_ms: f32,
gpu_ms: Option<f32>,
detail: Option<TickDetail>,
world: impl FnOnce() -> WorldCounts,
gpu: impl FnOnce() -> Option<GpuReadout>,
) {
self.cpu_ms.push(cpu_ms);
self.gpu_ms.extend(gpu_ms);
if self.perf.gauges_due(t) {
self.perf
.set_frame_medians(median(&mut self.cpu_ms), median(&mut self.gpu_ms));
}
let connection = self.connection.as_deref();
self.perf
.tick(t, wall_ms, detail, world, || wire_of(connection));
if let Some((end, reason)) = self.window
&& t >= end
{
self.stop_with_gpu(t, reason, gpu());
}
}
/// The GPU frames timed since the last frame, with this frame's CPU ms, for a capture that times the GPU.
pub fn gpu_frames(&mut self, t: f64, cpu_ms: f32, frames: &TimedFrames) {
self.perf.gpu_frames(t, cpu_ms, frames);
}
/// Opens a capture in `dir`; call it on the render thread, which it samples.
pub fn start(
&mut self,
t: f64,
dir: &Path,
trigger: Trigger,
options: &StartOptions,
start: Start,
block_names: Vec<String>,
) -> std::io::Result<PathBuf> {
let capture = CaptureOptions {
window_s: options.seconds.map(f64::from),
trigger,
start,
samples: options.samples,
sampler: SamplerOptions {
hz: options.hz,
over_ms: options.over_ms,
wall: options.wall,
all_threads: options.threads == Threads::All,
},
block_names,
levels: Vec::new(),
wire: wire_of(self.connection.as_deref()),
gpu: options.gpu.mode(),
timeline: options.timeline.then(Default::default),
file: options.file.clone(),
};
let path = self.perf.start_capture(t, dir, capture)?;
self.window = Some(match options.seconds {
Some(s) => (t + f64::from(s), StopReason::Normal),
None => (t + MAX_WINDOW_S, StopReason::WindowCap),
});
self.running = Some((options.clone(), trigger));
Ok(path)
}
/// What the server thread needs to write its side into the running capture; None when none runs.
pub fn join(&self) -> Option<Join> {
let (sender, path, origin) = self.perf.joinable()?;
let (options, trigger) = self.running.clone()?;
let at = |t: f64| self.epoch + Duration::from_secs_f64(t.max(0.0));
Some(Join {
sender,
path,
origin: at(origin),
ends: self.window.map(|(end, reason)| (at(end), reason)),
options,
trigger,
})
}
/// Ends the running capture; its file is finished off this thread and comes back from [`ClientPerf::take_saved`].
pub fn stop(&mut self, t: f64, reason: StopReason) -> bool {
self.stop_with(t, reason, None, Vec::new())
}
/// [`ClientPerf::stop`], recording `gpu`'s pass timings if the capture asked for them.
pub fn stop_with_gpu(&mut self, t: f64, reason: StopReason, gpu: Option<GpuReadout>) -> bool {
self.stop_with(t, reason, gpu, Vec::new())
}
/// [`ClientPerf::stop_with_gpu`] with benchmark reports embedded in the capture.
pub fn stop_with(
&mut self,
t: f64,
reason: StopReason,
gpu: Option<GpuReadout>,
bench: Vec<BenchResult>,
) -> bool {
self.window = None;
self.running = None;
let end = CaptureEnd {
gpu,
bench,
wire: wire_of(self.connection.as_deref()),
..CaptureEnd::default()
};
self.perf.begin_stop(t, reason, end)
}
/// Whether a stopped capture's file is still being finished or waits to be taken.
pub fn saving(&self) -> bool {
self.perf.finishing()
}
/// The captures whose files are done; with `wait`, every one begun.
pub fn take_saved(&mut self, wait: bool) -> Vec<std::io::Result<Saved>> {
self.perf.take_saved(wait)
}
pub fn mark(&mut self, t: f64, name: &str) -> bool {
self.perf.mark(t, name)
}
/// Records a memory snapshot in the running capture; false when none runs.
pub fn memory_snapshot(&mut self, t: f64, label: &str, rows: &[(String, f64)]) -> bool {
self.perf.memory_snapshot(t, label, rows)
}
/// Records `code` in the running capture; false when none runs.
pub fn warn(&mut self, t: f64, code: WarningCode) -> bool {
self.perf.warn(t, code)
}
pub fn status(&self, t: f64) -> Option<CaptureStatus> {
self.perf.status(t)
}
/// The seconds left in the running capture's window.
pub fn remaining_s(&self, t: f64) -> Option<f64> {
self.window.map(|(end, _)| (end - t).max(0.0))
}
}
/// A benchmark report as the capture embeds it, minus its local-only label and paths; anything but a `horizon-benchmark-report` is refused.
pub fn bench_result(report_json: String) -> Result<BenchResult, String> {
let mut report = horizon_bench::Report::parse(&report_json)?;
report.label = None;
report.local.clear();
Ok(BenchResult {
side: Side::Client as i32,
format: horizon_bench::FORMAT.to_owned(),
report_json: report.to_json(),
corpus_id: String::new(),
})
}
/// The median of the values, which are cleared; None when there were none.
fn median(values: &mut Vec<f32>) -> Option<f32> {
values.sort_by(f32::total_cmp);
let rank = values.len().div_ceil(2);
let m = rank.checked_sub(1).map(|i| values[i]);
values.clear();
m
}
/// `Start` for a client capture: its build and machine; the capture fills the rest.
pub fn start_message(same_process: bool, gpu: Option<&str>) -> Start {
let mut machine: Machine = server_perf::machine(Some(
server_perf::hardware().unwrap_or_else(server_perf::hardware_now),
));
if let Some(gpu) = gpu {
machine.gpu = gpu.to_owned();
}
Start {
build: Some(server_perf::build()),
machine: Some(machine),
same_process,
..Start::default()
}
}
@@ -1,519 +0,0 @@
//! `horizon-ctl profile`: the words the game receives, as typed requests, and the typed replies.
use horizon_perf_format::v1::GpuMode;
use serde::{Deserialize, Serialize};
use std::ops::RangeInclusive;
use std::path::PathBuf;
use std::str::FromStr;
/// The longest name a mark takes; it is the one free text in a capture.
pub const MARK_NAME_MAX: usize = 100;
/// The sampler's rate bounds, per second.
pub const HZ_RANGE: RangeInclusive<u32> = 10..=1000;
/// Which side `profile` addresses; no side means every side the game can reach.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileSide {
Client,
Server,
}
impl FromStr for ProfileSide {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"client" => Ok(Self::Client),
"server" => Ok(Self::Server),
other => Err(format!("unknown side {other}; use client or server")),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Threads {
Main,
All,
}
impl FromStr for Threads {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"main" => Ok(Self::Main),
"all" => Ok(Self::All),
other => Err(format!("unknown threads {other}; use main or all")),
}
}
}
/// What `--gpu` records: nothing, the passes' times, or also their parts and counters.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GpuSwitch {
#[default]
Off,
Passes,
Detail,
}
impl FromStr for GpuSwitch {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"passes" => Ok(Self::Passes),
"detail" => Ok(Self::Detail),
other => Err(format!(
"unknown --gpu value {other}; use --gpu, --gpu=passes or --gpu=detail"
)),
}
}
}
impl GpuSwitch {
pub fn mode(self) -> GpuMode {
match self {
Self::Off => GpuMode::Off,
Self::Passes => GpuMode::Passes,
Self::Detail => GpuMode::Detail,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Start,
Stop,
Status,
Mark,
Open,
Share,
}
impl FromStr for Action {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"start" => Ok(Self::Start),
"stop" => Ok(Self::Stop),
"status" => Ok(Self::Status),
"mark" => Ok(Self::Mark),
"open" => Ok(Self::Open),
"share" => Ok(Self::Share),
other => Err(format!("unknown profile action {other}")),
}
}
}
/// The switches of `profile start`.
#[derive(Clone, Debug, PartialEq)]
pub struct StartOptions {
/// Unset runs until `stop`, or the longest window.
pub seconds: Option<u32>,
pub hz: Option<u32>,
pub over_ms: Option<f32>,
pub threads: Threads,
pub wall: bool,
pub samples: bool,
pub gpu: GpuSwitch,
/// Also record every thread's spans (`--timeline`), for 60 s at most.
pub timeline: bool,
/// Write the capture here instead of into the reports folder: `trace` and `HORIZON_TRACE` only, never a profile switch.
pub file: Option<PathBuf>,
}
impl Default for StartOptions {
fn default() -> Self {
Self {
seconds: None,
hz: None,
over_ms: None,
threads: Threads::Main,
wall: false,
samples: true,
gpu: GpuSwitch::Off,
timeline: false,
file: None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Request {
Start(StartOptions),
/// With the path of a benchmark report to embed in the capture.
Stop {
bench: Option<String>,
},
Status,
Mark(String),
/// Open the saved capture in the viewer, through the captures hook.
Open(PathBuf),
/// Upload the saved capture, through the captures hook; the reply is the link.
Share(PathBuf),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Parsed {
pub side: Option<ProfileSide>,
pub request: Request,
}
const USAGE: &str = "profile [client|server] start [--seconds S] [--hz N] [--over MS] [--threads main|all] [--wall] [--gpu[=detail]] [--no-samples] [--timeline] | stop [--bench REPORT] | status | mark NAME | open FILE | share FILE";
/// The words after `profile`; the side comes first or as `--side`, and the switches follow `start`.
pub fn parse(words: &[&str]) -> Result<Parsed, String> {
let (mut side, words) = match words.first().map(|w| w.parse::<ProfileSide>()) {
Some(Ok(side)) => (Some(side), &words[1..]),
_ => (None, words),
};
let Some((action, rest)) = words.split_first() else {
return Err(format!("usage: {USAGE}"));
};
let action: Action = action.parse().map_err(|e| format!("{e}; usage: {USAGE}"))?;
if action == Action::Mark {
return mark(side, rest);
}
if matches!(action, Action::Open | Action::Share) {
let file = rest.join(" ");
if file.is_empty() {
return Err(format!("{} needs a FILE; usage: {USAGE}", words[0]));
}
let file = PathBuf::from(file);
let request = match action {
Action::Open => Request::Open(file),
_ => Request::Share(file),
};
return Ok(Parsed {
side: None,
request,
});
}
let mut options = StartOptions::default();
let mut switched = false;
let mut bench = None;
let mut words = rest.iter().copied().peekable();
while let Some(word) = words.next() {
let mut value = || {
words
.next()
.ok_or_else(|| format!("{word} needs a value; usage: {USAGE}"))
};
match word {
"--side" => side = Some(value()?.parse()?),
"--seconds" => options.seconds = Some(number(word, value()?, 1..=600)?),
"--hz" => options.hz = Some(number(word, value()?, HZ_RANGE)?),
"--over" => {
let ms: f32 = value()?
.parse()
.map_err(|_| "--over must be a number of milliseconds".to_owned())?;
if !ms.is_finite() || ms <= 0.0 {
return Err("--over must be more than 0".into());
}
options.over_ms = Some(ms);
}
"--threads" => options.threads = value()?.parse()?,
"--wall" => options.wall = true,
"--gpu" => {
options.gpu = match words.next_if(|w| w.parse::<GpuSwitch>().is_ok()) {
Some(kind) => kind.parse()?,
None => GpuSwitch::Passes,
}
}
_ if word.starts_with("--gpu=") => options.gpu = word["--gpu=".len()..].parse()?,
"--no-samples" => options.samples = false,
"--timeline" => options.timeline = true,
"--bench" => {
// The line arrives split on whitespace, so a run of spaces or a tab in the path becomes one space.
let path = words.by_ref().collect::<Vec<_>>().join(" ");
if path.is_empty() {
return Err(format!("--bench needs a report path; usage: {USAGE}"));
}
bench = Some(path);
continue;
}
other => return Err(format!("unexpected {other}; usage: {USAGE}")),
}
switched |= word != "--side";
}
let request = match action {
Action::Start if bench.is_some() => {
return Err(format!("only stop takes --bench; usage: {USAGE}"));
}
Action::Start => Request::Start(options),
_ if switched => return Err(format!("only start takes options; usage: {USAGE}")),
Action::Stop => Request::Stop { bench },
_ if bench.is_some() => return Err(format!("only stop takes --bench; usage: {USAGE}")),
_ => Request::Status,
};
Ok(Parsed { side, request })
}
fn mark(mut side: Option<ProfileSide>, mut words: &[&str]) -> Result<Parsed, String> {
if let [flag @ "--side", value, rest @ ..] = words {
let _ = flag;
side = Some(value.parse()?);
words = rest;
}
let name = words.join(" ");
if name.is_empty() {
return Err("mark needs a NAME".into());
}
if name.chars().count() > MARK_NAME_MAX || name.chars().any(char::is_control) {
return Err(format!(
"a mark name is at most {MARK_NAME_MAX} characters without control characters"
));
}
Ok(Parsed {
side,
request: Request::Mark(name),
})
}
fn number(flag: &str, text: &str, range: RangeInclusive<u32>) -> Result<u32, String> {
match text.parse::<u32>() {
Ok(n) if range.contains(&n) => Ok(n),
_ => Err(format!(
"{flag} must be a whole number from {} to {}",
range.start(),
range.end()
)),
}
}
/// The reply to `profile start`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Started {
pub ok: bool,
/// The sides asked for, server first; the server thread's join is confirmed by `status` and `stop`.
pub sides: Vec<ProfileSide>,
pub path: String,
pub seconds: Option<u32>,
pub samples: bool,
pub gpu: GpuSwitch,
}
/// The reply to `profile stop`, once the file is whole.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Stopped {
pub ok: bool,
pub sides: Vec<ProfileSide>,
pub path: String,
/// The viewer page, when the build has a part that opens captures.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_at: Option<String>,
pub removed: Vec<String>,
}
/// The viewer page a registered captures hook offers.
pub fn viewer() -> Option<String> {
horizon_hooks::engine::captures().map(|c| c.viewer().to_owned())
}
/// The reply to `profile status`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Status {
pub ok: bool,
pub capturing: bool,
pub sides: Vec<ProfileSide>,
pub path: Option<String>,
pub elapsed_s: Option<f64>,
pub bytes: Option<u64>,
}
/// The reply of a deferred `profile stop` that failed.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Failed {
pub error: String,
}
/// The reply to `trace`: whether a timeline capture runs, and where it writes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Traced {
pub ok: bool,
pub tracing: bool,
pub file: Option<String>,
}
/// The reply to `profile open`: the viewer the capture was opened in.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Opened {
pub ok: bool,
pub viewer: String,
}
/// The reply to `profile share`: the capture's share link.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Shared {
pub ok: bool,
pub url: String,
}
/// The reply to `profile mark`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Marked {
pub ok: bool,
pub name: String,
/// Seconds on the game clock, which counts from when the game began, for every side.
pub t: f64,
}
#[cfg(test)]
mod tests {
use super::*;
fn p(line: &str) -> Result<Parsed, String> {
parse(&line.split_whitespace().collect::<Vec<_>>())
}
#[test]
fn open_and_share_take_a_file_with_spaces_kept() {
assert_eq!(
p("open reports/a.hzperf").unwrap().request,
Request::Open(PathBuf::from("reports/a.hzperf"))
);
assert_eq!(
p("share /tmp/my capture.hzperf").unwrap().request,
Request::Share(PathBuf::from("/tmp/my capture.hzperf"))
);
assert!(p("open").is_err());
assert!(p("share").is_err());
}
#[test]
fn the_side_comes_first_or_as_a_flag() {
let a = p("client start --seconds 30").unwrap();
let b = p("start --side client --seconds 30").unwrap();
assert_eq!(a, b);
assert_eq!(a.side, Some(ProfileSide::Client));
assert_eq!(p("start").unwrap().side, None);
assert_eq!(p("server stop").unwrap().side, Some(ProfileSide::Server));
assert_eq!(
p("stop --side client").unwrap().side,
Some(ProfileSide::Client)
);
}
#[test]
fn every_start_switch_lands_in_its_field() {
let Request::Start(o) = p(
"start --hz 500 --over 12.5 --threads all --wall --gpu=detail --no-samples --timeline --seconds 9",
)
.unwrap()
.request
else {
panic!("not a start");
};
assert_eq!(
o,
StartOptions {
seconds: Some(9),
hz: Some(500),
over_ms: Some(12.5),
threads: Threads::All,
wall: true,
samples: false,
gpu: GpuSwitch::Detail,
timeline: true,
file: None,
}
);
}
#[test]
fn a_mark_takes_the_rest_of_the_line_as_its_name() {
assert_eq!(
p("mark loaded the nether").unwrap().request,
Request::Mark("loaded the nether".into())
);
assert_eq!(
p("client mark --wall").unwrap(),
Parsed {
side: Some(ProfileSide::Client),
request: Request::Mark("--wall".into())
}
);
assert_eq!(
p("mark --side client the nether").unwrap(),
Parsed {
side: Some(ProfileSide::Client),
request: Request::Mark("the nether".into())
}
);
assert!(p("mark").is_err());
assert!(p("mark --side client").is_err());
assert!(p(&format!("mark {}", "x".repeat(MARK_NAME_MAX + 1))).is_err());
assert!(p(&format!("mark {}", "x".repeat(MARK_NAME_MAX))).is_ok());
}
#[test]
fn stop_takes_the_rest_of_the_line_as_a_report_path() {
assert_eq!(p("stop").unwrap().request, Request::Stop { bench: None });
let with = p("client stop --bench /home/a b/games/benchmarks/game-1.json").unwrap();
assert_eq!(with.side, Some(ProfileSide::Client));
assert_eq!(
with.request,
Request::Stop {
bench: Some("/home/a b/games/benchmarks/game-1.json".into())
}
);
for line in [
"stop --bench",
"start --bench x.json",
"status --bench x.json",
] {
assert!(p(line).is_err(), "{line:?} was accepted");
}
}
#[test]
fn bad_words_are_refused_with_the_usage() {
for line in [
"",
"client",
"begin",
"start --seconds 0",
"start --seconds 601",
"start --seconds",
"start --hz 5",
"start --over 0",
"start --over nan",
"start --threads some",
"start --gpu=all",
"start --gpu=",
"stop now",
"stop --wall",
"status --hz 100",
"start --side everyone",
] {
assert!(p(line).is_err(), "{line:?} was accepted");
}
assert!(p("start --gpu=all").unwrap_err().contains("--gpu=detail"));
assert!(
p("start --gpu fast")
.unwrap_err()
.contains("usage: profile")
);
}
#[test]
fn gpu_alone_records_the_passes_and_detail_asks_for_more() {
let gpu = |line: &str| match p(line).unwrap().request {
Request::Start(o) => o.gpu,
other => panic!("{other:?}"),
};
assert_eq!(gpu("start"), GpuSwitch::Off);
assert_eq!(gpu("start --gpu"), GpuSwitch::Passes);
assert_eq!(
gpu("client start --seconds 5 --gpu=detail"),
GpuSwitch::Detail
);
assert_eq!(gpu("start --gpu detail --wall"), GpuSwitch::Detail);
assert_eq!(gpu("start --gpu --wall"), GpuSwitch::Passes);
assert!(p("stop --gpu").is_err(), "only start takes options");
}
}
File diff suppressed because it is too large. Load diff
+9 -164
View File
@@ -10,7 +10,6 @@
//! writes `{"transport":"tcp","port":N,"token":"..."}` to the path, readable
//! only by its user; a client's first line must then be `auth TOKEN`.
use crate::block_entity_render::StaticCacheFrameStats;
use horizon_threads::alloc_count::{Counts, ON};
use std::{
io::{self, BufRead, BufReader, Read, Write},
net::{Ipv4Addr, TcpListener, TcpStream},
@@ -21,7 +20,6 @@ use std::{
mpsc,
},
thread,
time::Instant,
};
#[cfg(unix)]
@@ -499,7 +497,6 @@ mod tests {
}
use super::*;
use std::time::Duration;
/// The check agrees with the system at every length around its limit.
#[cfg(unix)]
@@ -605,22 +602,6 @@ mod tests {
assert!(same_token("abc", "abc") && !same_token("abc", "abd") && !same_token("a", "ab"));
}
#[test]
fn the_series_cover_every_frame_the_summary_counts() {
let mut stats = FrameStats::default();
for ms in [4.0, 5.0, 6.0] {
stats.record(ms);
stats.record_cpu(ms / 2.0);
}
let mut summary = stats.summary();
assert!(summary.get("frame_ms").is_none());
stats.add_series(&mut summary);
assert_eq!(summary["frame_ms"], serde_json::json!([4.0, 5.0, 6.0]));
assert_eq!(summary["cpu_ms"], serde_json::json!([2.0, 2.5, 3.0]));
assert_eq!(summary["gpu_ms"], serde_json::json!([]));
assert_eq!(summary["frames"], 3);
}
#[test]
fn frame_summary_reports_block_entity_cache_work() {
let mut stats = FrameStats::default();
@@ -644,67 +625,6 @@ mod tests {
assert_eq!(summary["static_entities_drawn_avg_per_frame"], 4.0);
assert_eq!(summary["dynamic_entities_drawn_avg_per_frame"], 3.0);
}
fn counts(allocs: u64, bytes: u64, frees: u64, freed: u64) -> Counts {
Counts {
allocs,
bytes,
frees,
freed,
}
}
#[test]
fn allocations_are_summarized_per_frame_and_per_second() {
let mut stats = FrameStats::default();
let start = Instant::now();
for (i, a) in [100u64, 200, 300, 400, 1000].into_iter().enumerate() {
// Window: 10 blocks kept; process: twice the blocks, 1 MiB per 100, 20 blocks and 1 MiB kept.
let window = counts(a, a * 1024, a - 10, 0);
let all = counts(
2 * a,
(2 * a / 100) << 20,
2 * a - 20,
((2 * a / 100) - 1) << 20,
);
stats.record_allocs(window, all, start + Duration::from_millis(100 * i as u64));
}
let alloc = stats.alloc_summary();
let window = &alloc["window_thread"];
assert_eq!(window["allocs_per_frame"]["avg"], 400.0);
assert_eq!(window["allocs_per_frame"]["p50"], 300.0);
assert_eq!(window["allocs_per_frame"]["p95"], 1000.0);
assert_eq!(window["allocs_per_frame"]["max"], 1000.0);
assert_eq!(window["kib_per_frame"]["p50"], 300.0);
assert_eq!(window["frees_per_frame_avg"], 390.0);
// The first frame only starts the clock: the rates cover the four after it, 0.4 s.
let process = &alloc["process"];
assert_eq!(process["allocs_per_s"], 9500.0);
assert_eq!(process["frees_per_s"], 9300.0);
assert_eq!(process["mib_per_s"], 95.0);
assert_eq!(process["net_mib"], 4.0);
assert_eq!(stats.alloc_counts(), [100, 200, 300, 400, 1000]);
}
#[test]
fn one_frame_has_counts_but_no_rate() {
let mut stats = FrameStats::default();
stats.record_allocs(counts(5, 64, 5, 64), counts(9, 99, 9, 99), Instant::now());
let alloc = stats.alloc_summary();
assert_eq!(alloc["window_thread"]["allocs_per_frame"]["max"], 5.0);
assert!(alloc["process"]["allocs_per_s"].is_null());
assert!(FrameStats::default().alloc_summary().is_null());
}
#[test]
fn the_summary_has_alloc_only_when_the_game_counts() {
let mut stats = FrameStats::default();
stats.record(10.0);
stats.record_allocs(counts(5, 64, 5, 64), Counts::default(), Instant::now());
assert_eq!(stats.summary()["alloc"].is_null(), !ON);
stats.reset();
assert!(stats.alloc_counts().is_empty() && stats.alloc_summary().is_null());
}
}
/// Frame durations since the last reset, summarized like the Java recorder,
@@ -721,15 +641,6 @@ pub struct FrameStats {
static_bytes_uploaded: u64,
static_entities_drawn: u64,
dynamic_entities_drawn: u64,
/// Window-thread allocations and bytes of each rendered frame (`alloc-count` builds).
allocs: Vec<u32>,
alloc_bytes: Vec<u64>,
/// Window-thread frees summed over the frames in `allocs`.
alloc_frees: u64,
/// When the first and the last frame since the reset were recorded.
alloc_span: Option<(Instant, Instant)>,
/// All threads' counts over the frames after the first, the time base of the rates.
alloc_process: Counts,
}
/// The parts of a frame's CPU time: world streaming and uploads, game ticks,
@@ -806,77 +717,17 @@ impl FrameStats {
self.static_entities_drawn += stats.static_entities_drawn;
self.dynamic_entities_drawn += stats.dynamic_entities_drawn;
}
/// Adds every frame's time (`frame_ms`, `cpu_ms`, `gpu_ms`) to a summary.
pub fn add_series(&self, summary: &mut serde_json::Value) {
summary["frame_ms"] = self.millis.clone().into();
summary["cpu_ms"] = self.cpu.clone().into();
summary["gpu_ms"] = self.gpu.clone().into();
/// Every recorded frame time, in milliseconds, in order.
pub fn frame_millis(&self) -> &[f32] {
&self.millis
}
/// One frame's allocations: this thread's since the last frame, all threads' since then.
pub fn record_allocs(&mut self, window: Counts, process: Counts, now: Instant) {
if self.allocs.len() < Self::LIMIT {
self.allocs
.push(u32::try_from(window.allocs).unwrap_or(u32::MAX));
self.alloc_bytes.push(window.bytes);
self.alloc_frees += window.frees;
}
match &mut self.alloc_span {
None => self.alloc_span = Some((now, now)),
Some((_, last)) => {
*last = now;
let sum = &mut self.alloc_process;
sum.allocs += process.allocs;
sum.bytes += process.bytes;
sum.frees += process.frees;
sum.freed += process.freed;
}
}
/// CPU time of every recorded frame, in milliseconds.
pub fn cpu_millis(&self) -> &[f32] {
&self.cpu
}
/// Window-thread allocations of every recorded frame, in order.
pub fn alloc_counts(&self) -> &[u32] {
&self.allocs
}
/// Window-thread bytes allocated in every recorded frame, in order.
pub fn alloc_bytes(&self) -> &[u64] {
&self.alloc_bytes
}
/// The `alloc` object of [`summary`](Self::summary), `null` before a frame was recorded.
fn alloc_summary(&self) -> serde_json::Value {
let n = self.allocs.len();
if n == 0 {
return serde_json::Value::Null;
}
let round = |v: f64| (v * 10.0).round() / 10.0;
let stats = |mut values: Vec<f64>| {
values.sort_by(f64::total_cmp);
let pick = |p: f64| values[((n as f64 * p).ceil() as usize).clamp(1, n) - 1];
serde_json::json!({
"avg": round(values.iter().sum::<f64>() / n as f64),
"p50": round(pick(0.5)),
"p95": round(pick(0.95)),
"max": round(values[n - 1]),
})
};
let per_second =
|amount: u64, seconds: f64| (seconds > 0.0).then(|| round(amount as f64 / seconds));
let seconds = self.alloc_span.map_or(0.0, |(first, last)| {
last.duration_since(first).as_secs_f64()
});
let mib = |bytes: u64| bytes as f64 / 1048576.0;
let process = &self.alloc_process;
serde_json::json!({
"window_thread": {
"allocs_per_frame": stats(self.allocs.iter().map(|&a| f64::from(a)).collect()),
"kib_per_frame": stats(self.alloc_bytes.iter().map(|&b| b as f64 / 1024.0).collect()),
"frees_per_frame_avg": round(self.alloc_frees as f64 / n as f64),
},
"process": {
"allocs_per_s": per_second(process.allocs, seconds),
"mib_per_s": (seconds > 0.0).then(|| round(mib(process.bytes) / seconds)),
"frees_per_s": per_second(process.frees, seconds),
"net_mib": round(mib(process.bytes) - mib(process.freed)),
},
})
/// GPU time of every frame the GPU timed, in milliseconds.
pub fn gpu_millis(&self) -> &[f32] {
&self.gpu
}
pub fn reset(&mut self) {
self.millis.clear();
@@ -888,11 +739,6 @@ impl FrameStats {
self.static_bytes_uploaded = 0;
self.static_entities_drawn = 0;
self.dynamic_entities_drawn = 0;
self.allocs.clear();
self.alloc_bytes.clear();
self.alloc_frees = 0;
self.alloc_span = None;
self.alloc_process = Counts::default();
}
pub fn summary(&self) -> serde_json::Value {
let mut sorted = self.millis.clone();
@@ -943,7 +789,6 @@ impl FrameStats {
"dynamic_entities_drawn": self.dynamic_entities_drawn,
"static_entities_drawn_avg_per_frame": self.static_entities_drawn as f64 / n as f64,
"dynamic_entities_drawn_avg_per_frame": self.dynamic_entities_drawn as f64 / n as f64,
"alloc": if ON { self.alloc_summary() } else { serde_json::Value::Null },
})
}
}
-154
View File
@@ -8,7 +8,6 @@ use crate::{
java_text,
text::Text,
};
use horizon_client_render::gfx::pack_timing::{Counters, DetailSupport, PartsMode, PassCounters};
use std::time::{Duration, Instant};
/// `extractLines`'s background: ARGB 0x90505050.
@@ -149,76 +148,6 @@ pub fn labelled_pass_lines(label: &str, passes: &[(String, f32)]) -> Vec<String>
lines
}
/// `n` in three digits and an SI suffix: `512`, `83.0k`, `6.30M`, `1.20G`.
pub fn compact_count(n: u64) -> String {
let (mut value, mut unit) = (n as f64, 0);
while value >= 999.5 && unit < 4 {
value /= 1000.0;
unit += 1;
}
let suffix = ["", "k", "M", "G", "T"][unit];
match value {
_ if unit == 0 => n.to_string(),
v if v < 9.995 => format!("{v:.2}{suffix}"),
v if v < 99.95 => format!("{v:.1}{suffix}"),
v => format!("{v:.0}{suffix}"),
}
}
/// F3's detail lines: each pass's parts, then the six passes with the most fragments and their segments.
pub fn pass_detail_lines(
parts: &[(String, Vec<(String, f32)>)],
counters: &[(String, PassCounters)],
support: DetailSupport,
) -> Vec<String> {
const SHOWN: usize = 6;
let tr = |key: &str, args: &[&dyn std::fmt::Display]| Text::tr(key, args).plain();
let mode = match support.parts {
PartsMode::InsidePasses => "horizon.debug.pass_detail.inside_passes",
PartsMode::SeparatePasses => "horizon.debug.pass_detail.separate_passes",
};
let mut lines = vec![tr("horizon.debug.pass_detail.title", &[&tr(mode, &[])])];
for (pass, parts) in parts {
for (part, ms) in parts {
let ms = format!("{ms:.2}");
lines.push(tr("horizon.debug.pass_detail.part", &[pass, part, &ms]));
}
}
if !support.counters {
lines.push(tr("horizon.debug.pass_detail.no_counters", &[]));
return lines;
}
let per_pixel = |fragments: u64, pixels: u64| match support.fragment_unit {
Some(unit) if pixels > 0 => {
format!("{:.2}", unit.pixels_of(fragments) as f64 / pixels as f64)
}
_ => "?".to_owned(),
};
let line = |name: &str, c: &Counters, pixels: u64| {
let (fragments, vertices) = (c.fragment_shader_invocations, c.vertex_shader_invocations);
tr(
"horizon.debug.pass_detail.counters",
&[
&name,
&compact_count(fragments),
&per_pixel(fragments, pixels),
&compact_count(vertices),
&compact_count(c.clipper_primitives_out),
&compact_count(c.clipper_invocations),
],
)
};
let mut busiest: Vec<&(String, PassCounters)> = counters.iter().collect();
busiest.sort_by_key(|c| std::cmp::Reverse(c.1.total.fragment_shader_invocations));
for (pass, c) in busiest.into_iter().take(SHOWN) {
lines.push(line(pass, &c.total, c.pixels));
for (part, counted) in &c.parts {
lines.push(format!(" {}", line(part, counted, c.pixels)));
}
}
lines
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
@@ -618,7 +547,6 @@ pub fn memory_lines(m: ProcessMemory, rate: i64) -> [String; 3] {
#[cfg(test)]
mod tests {
use super::*;
use horizon_client_render::gfx::pack_timing;
fn lines(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_owned()).collect()
@@ -656,88 +584,6 @@ mod tests {
assert_eq!(cache.fresh(start, layout).map(<[_]>::len), Some(0));
}
#[test]
fn counts_read_in_three_digits_with_an_si_suffix() {
for (n, want) in [
(0, "0"),
(999, "999"),
(1000, "1.00k"),
(83_049, "83.0k"),
(999_999, "1.00M"),
(6_300_000, "6.30M"),
(1_200_000_000, "1.20G"),
] {
assert_eq!(compact_count(n), want, "{n}");
}
}
fn counted(fragments: u64, parts: &[(&str, u64)]) -> PassCounters {
let c = |fragment_shader_invocations| Counters {
vertex_shader_invocations: 1000,
clipper_invocations: 400,
clipper_primitives_out: 300,
fragment_shader_invocations,
};
PassCounters {
total: c(fragments),
pixels: 1000,
parts: parts.iter().map(|p| (p.0.to_owned(), c(p.1))).collect(),
}
}
#[test]
fn pass_detail_lines_list_parts_then_the_six_busiest_passes_with_segments() {
let support = DetailSupport {
parts: PartsMode::InsidePasses,
encoder_stamps: true,
counters: true,
fragment_unit: Some(pack_timing::FragmentUnit::Quad),
};
let parts = vec![("GPU culling".to_owned(), vec![("cull".to_owned(), 0.3141)])];
let mut counters: Vec<(String, PassCounters)> = (1..=7)
.map(|i| (format!("P{i}"), counted(i * 100, &[])))
.collect();
counters[2].1 = counted(1_000_000, &[("a", 600_000), ("b", 400_000)]);
let (lines, _) = crate::lang::scoped(crate::lang::Language::load(&[], "en_us"), || {
pass_detail_lines(&parts, &counters, support)
});
assert_eq!(
lines,
[
"Horizon pass detail (timestamps inside passes)",
"GPU culling › cull: 0.31 ms",
"P3: 1.00M fragments, 4000.00 per pixel, 1.00k vertices, 300 of 400 triangles kept",
" a: 600k fragments, 2400.00 per pixel, 1.00k vertices, 300 of 400 triangles kept",
" b: 400k fragments, 1600.00 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P7: 700 fragments, 2.80 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P6: 600 fragments, 2.40 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P5: 500 fragments, 2.00 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P4: 400 fragments, 1.60 per pixel, 1.00k vertices, 300 of 400 triangles kept",
"P2: 200 fragments, 0.80 per pixel, 1.00k vertices, 300 of 400 triangles kept",
]
);
}
#[test]
fn pass_detail_lines_say_so_when_the_device_has_no_counters() {
let support = DetailSupport {
parts: PartsMode::SeparatePasses,
encoder_stamps: false,
counters: false,
fragment_unit: None,
};
let (lines, _) = crate::lang::scoped(crate::lang::Language::load(&[], "en_us"), || {
pass_detail_lines(&[], &[], support)
});
assert_eq!(
lines,
[
"Horizon pass detail (separate passes, adds GPU time)",
"GPU counters: Vulkan only"
]
);
}
#[test]
fn looking_at_lines_match_vanilla() {
let props = [
-22
View File
@@ -75,12 +75,6 @@ fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
impl ChunkGenerator for DebugTerrain {
type Worker = ();
fn worker(&self) {}
/// DebugLevelSource's proto is already its decorated column. Keep a copy
/// for lighting a finished neighbour outside the kept area, matching the
/// vanilla FEATURES neighbour path.
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
proto.downcast_ref::<Chunk>().cloned()
}
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
/// writes the barriers and the grid.
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
@@ -134,20 +128,4 @@ mod tests {
assert_eq!(grid_state(1000, 65, 1), 0);
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
}
#[test]
fn light_view_keeps_a_debug_proto_column() {
let terrain = DebugTerrain {
shape: LevelShape::OVERWORLD,
biome: 0,
barrier: 0,
states: 0,
};
let mut chunk = Chunk::empty(-2, 5);
chunk.set(0, 0, 0, 42);
let proto: crate::streaming::Proto = Box::new(chunk);
let view = terrain.light_view(&proto).expect("debug proto light view");
assert_eq!((view.x, view.z), (-2, 5));
assert_eq!(view.block(0, 0, 0).0, 42);
}
}
+4 -93
View File
@@ -1652,7 +1652,9 @@ pub fn mob_to_nbt(
if let Some(saddle) = m.saddle_item() {
equipment.insert("saddle".into(), crate::save::item_to_nbt(&saddle));
}
// Body slot: harness, armour, a cube's block, a wolf's armor; emptied, it leaves the earlier save.
// A happy ghast wears its harness in the body slot (and a nautilus
// its armour, a sulfur cube its swallowed block, a wolf its armor,
// with the durability it lost).
if crate::mobs::species::body_slot(m.species())
&& let Some(id) = &m.extra.body
{
@@ -1662,8 +1664,6 @@ pub fn mob_to_nbt(
stack.extra = m.extra.body_extra.clone();
}
equipment.insert("body".into(), crate::save::item_to_nbt(&stack));
} else if crate::mobs::species::body_slot(m.species()) {
equipment.remove(&"body".into());
}
// Vanilla saves `equipment` only when something is equipped.
if equipment.is_empty() {
@@ -2867,7 +2867,7 @@ pub fn evoker_fangs_to_nbt(
motion: raw.map(motion).unwrap_or(Vec3::ZERO),
rotation: [yaw, raw.map(rotation).unwrap_or([0.0; 2])[1]],
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
fall_distance: 0.0,
}
.tag(raw);
put(&mut tag, "Warmup", Tag::Int(warmup));
@@ -4163,28 +4163,6 @@ mod tests {
);
}
/// `EvokerFangs` extends `Entity` and never moves: `Entity.saveWithoutId`
/// writes the `fall_distance` it loaded, and 0 for fangs a spell made.
#[test]
fn evoker_fangs_keep_their_loaded_fall_distance() {
let raw = horizon_world::nbt::parse_snbt(
"{id:\"minecraft:evoker_fangs\",Pos:[1d,200d,1d],fall_distance:5.0d,Warmup:100}",
)
.unwrap()
.0;
let at = super::Vec3::new(1.0, 200.0, 1.0);
let fall = |raw: Option<&super::Tag>| match super::evoker_fangs_to_nbt(
at, 0.0, 99, None, [1; 4], raw,
)
.get("fall_distance")
{
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
other => panic!("no fall_distance: {other:?}"),
};
assert_eq!(fall(Some(&raw)), 5.0);
assert_eq!(fall(None), 0.0);
}
/// `Shulker` reads `AttachFace` as a byte before
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
@@ -4571,37 +4549,6 @@ mod tests {
assert_eq!(id("head"), "minecraft:iron_helmet");
}
/// A body item taken off (`/item replace entity <cube> armor.body with minecraft:air`)
/// is not saved again from the entity's earlier save: vanilla writes `equipment.body` only
/// while the slot holds something (#12038).
#[test]
fn an_emptied_body_slot_is_not_saved_from_the_earlier_nbt() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:sulfur_cube".into();
ty.species = crate::mobs::Species::SulfurCube;
let types = [("minecraft:sulfur_cube".into(), kind)].into();
let raw = Tag::compound([
("id", string("minecraft:sulfur_cube")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
(
"equipment",
Tag::compound([(
"body",
Tag::compound([
("id", string("minecraft:oak_planks")),
("count", Tag::Int(1)),
]),
)]),
),
]);
let mut m = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert_eq!(m.extra.body.as_deref(), Some("minecraft:oak_planks"));
m.extra.body = None;
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None, |_| None);
assert!(saved.get("equipment").is_none(), "{saved:?}");
}
/// A wolf loads its armor (`equipment.body`) into the body slot with the
/// durability it lost, and a helmet next to it into the head slot.
#[test]
@@ -5567,34 +5514,6 @@ mod tests {
}
}
/// `Goat` reads `HasLeftHorn` and `HasRightHorn` with `getBooleanOr(key,
/// true)`: a number is true when its byte value is not 0, so an int 256
/// (byte 0) is a lost horn, as `0b` is.
#[test]
fn a_goats_horn_flags_are_read_as_bytes() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:goat".into();
ty.species = crate::mobs::Species::Goat;
let types = [("minecraft:goat".into(), kind)].into();
let lost = |left: Option<Tag>| {
let mut fields = vec![
("id", string("minecraft:goat")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
];
fields.extend(left.map(|v| ("HasLeftHorn", v)));
mob_from_nbt(&Tag::compound(fields), &types, 1, None)
.unwrap()
.extra
.lost_horns
};
assert_eq!(lost(None), [false, false]);
assert_eq!(lost(Some(Tag::Byte(1))), [false, false]);
assert_eq!(lost(Some(Tag::Byte(0))), [true, false]);
assert_eq!(lost(Some(Tag::Int(256))), [true, false]);
assert_eq!(lost(Some(Tag::Int(257))), [false, false]);
}
#[test]
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
let uuid = [104, 0, 0, 1];
@@ -8084,14 +8003,6 @@ mod fireball_nbt_tests {
("Facing", Tag::Byte(1)),
]);
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
// BlockAttachedEntity.readAdditionalSaveData: a block_pos 16 blocks or
// more from the Pos block is refused for that block.
let mut far = painting.clone();
put(&mut far, "block_pos", Tag::IntArray(vec![28, 80, 22]));
assert_eq!(painting_from_nbt(&far).map(|p| p.0), Some([28, 80, 6]));
let mut near = painting.clone();
put(&mut near, "block_pos", Tag::IntArray(vec![28, 80, 21]));
assert_eq!(painting_from_nbt(&near).map(|p| p.0), Some([28, 80, 21]));
}
#[test]
+21 -68
View File
@@ -13,8 +13,7 @@ fn loaded_pitch(raw: Option<&Tag>) -> f32 {
0.0
}
}
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`, after
/// Entity.saveWithoutId's common fields.
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = p.bounds();
let base = Base {
@@ -27,12 +26,8 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
),
motion: Vec3::ZERO,
rotation: [f32::from(p.facing) * 90.0, loaded_pitch(raw)],
// BlockAttachedEntity.tick never moves a painting (nor runs baseTick), so its
// onGround and fallDistance stay the ones Entity.load read.
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
fall_distance: raw
.and_then(|raw| float(raw.get("fall_distance")))
.unwrap_or(0.0),
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(&mut tag, "facing", Tag::Byte(p.facing as i8));
@@ -40,37 +35,21 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
put(&mut tag, "block_pos", Tag::IntArray(p.pos.to_vec()));
tag
}
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved and
/// closer than 16 blocks to the block holding `Pos`; else (a summoned one
/// has none) `setPos` puts it at that block.
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved; a
/// summoned one has none, and `setPos` puts its block at the block holding
/// the summon position.
fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
let stored = match tag.get("block_pos") {
match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
_ => None,
};
let Some(at) = position(tag) else {
return stored;
};
let here = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
// Vec3i.closerThan(blockPosition(), 16): the squared distance under 256.
let near = |p: &[i32; 3]| {
(0..3)
.map(|i| (f64::from(p[i]) - f64::from(here[i])).powi(2))
.sum::<f64>()
< 256.0
};
Some(stored.filter(near).unwrap_or(here))
}
/// The block position and facing of a saved painting (`HangingEntity`'s
/// `facing`, a 2D direction).
pub fn painting_placement_from_nbt(tag: &Tag) -> Option<([i32; 3], u8)> {
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
Some((pos, facing))
_ => {
let at = position(tag)?;
Some([
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
])
}
}
}
/// `Direction.from3DDataValue` / `from2DDataValue`: the byte read by
/// `LEGACY_ID_CODEC` indexes the table as `abs(data % modulus)`, never clamped.
@@ -80,7 +59,8 @@ fn wrap_data_value(raw: impl TryInto<i64> + Copy, modulus: i64) -> u8 {
}
/// The block position, facing and variant (if saved) of a painting.
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, Option<String>)> {
let (pos, facing) = painting_placement_from_nbt(tag)?;
let pos = attached_block_pos(tag)?;
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
Some((pos, facing, text(tag.get("variant"))))
}
/// ItemFrame.addAdditionalSaveData.
@@ -94,14 +74,10 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
),
// BlockAttachedEntity.tick never runs super.tick, so the Motion, onGround and
// fallDistance that Entity.load read stay.
motion: raw.map_or(Vec3::ZERO, loaded_motion),
motion: Vec3::ZERO,
rotation: [0.0, 0.0],
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
fall_distance: raw
.and_then(|raw| float(raw.get("fall_distance")))
.unwrap_or(0.0),
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
@@ -172,27 +148,4 @@ mod tests {
]);
assert_eq!(painting_from_nbt(&tag).unwrap().1, 1);
}
#[test]
fn item_frame_keeps_loaded_motion_on_ground_and_fall_distance() {
let tag = Tag::compound([
("id", string("minecraft:item_frame")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Facing", Tag::Byte(2)),
("Motion", doubles(&[0.5, 20.0, -0.25])),
("OnGround", Tag::Byte(1)),
("fall_distance", Tag::Double(3.5f64.to_bits())),
]);
let frame = item_frame_from_nbt(&tag, 1).unwrap();
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&tag));
// A component beyond 10 loads as 0, the others stay.
assert_eq!(motion(&saved), Vec3::new(0.5, 0.0, -0.25));
assert!(flag(&saved, "OnGround"));
assert_eq!(float(saved.get("fall_distance")), Some(3.5));
// A frame made in play has nothing loaded: zeros.
let made = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
assert_eq!(motion(&made), Vec3::ZERO);
assert!(!flag(&made, "OnGround"));
assert_eq!(float(made.get("fall_distance")), Some(0.0));
}
}
+1
View File
@@ -33,6 +33,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
-23
View File
@@ -317,12 +317,6 @@ pub fn settings_from_json(
impl ChunkGenerator for FlatTerrain {
type Worker = ();
fn worker(&self) {}
/// FlatLevelSource's proto is already its decorated column. Keep a copy
/// for lighting a finished neighbour outside the kept area, just as the
/// vanilla pipeline does for FEATURES neighbours.
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
proto.downcast_ref::<Chunk>().cloned()
}
fn lod_surface(&self, _: &mut (), _: i32, _: i32) -> Option<crate::lod::Surface> {
self.surface
}
@@ -616,21 +610,4 @@ mod tests {
.is_none()
);
}
#[test]
fn light_view_keeps_a_flat_proto_column() {
let terrain = FlatTerrain {
shape: LevelShape::OVERWORLD,
layers: Vec::new(),
biome: 0,
surface: None,
void_platform: None,
};
let mut chunk = Chunk::empty(3, -4);
chunk.set(0, 0, 0, 42);
let proto: crate::streaming::Proto = Box::new(chunk);
let view = terrain.light_view(&proto).expect("flat proto light view");
assert_eq!((view.x, view.z), (3, -4));
assert_eq!(view.block(0, 0, 0).0, 42);
}
}
+68 -90
View File
@@ -219,7 +219,6 @@ mod lan_far;
mod lan_idle;
mod lan_mobs;
mod lan_mount_menu;
mod lan_op;
mod lan_particles;
mod lan_pvp;
mod lan_quiet;
@@ -363,8 +362,6 @@ mod paintings;
mod pandas;
mod particle_culling;
mod passengers;
mod perf_link;
mod perf_remote;
mod persist;
mod perspective;
mod physical_trigger;
@@ -387,8 +384,6 @@ mod potent_sulfur;
mod potions;
mod powder_snow;
mod prediction;
mod profile_capture;
mod profile_lines;
mod pumpkin;
mod quick_actions;
mod raids;
@@ -476,6 +471,7 @@ mod trial_spawner;
mod trial_visuals;
mod trident;
mod tutorial;
mod tweaks;
mod ui_control;
mod use_hand;
mod using_item;
@@ -2229,7 +2225,7 @@ struct Game<'a> {
/// `AbstractContainerScreen`'s quick craft and skip-release state.
inv_clicks: horizon_client::container_screen::Clicks,
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
tweaks: extras::mouse_tweaks::Tweaks,
tweaks: tweaks::Tweaks,
/// The wheel and hovered slot of `BundleMouseActions`.
bundle_mouse: bundles::BundleMouse,
/// The last inventory click, for the double click that gathers a stack.
@@ -2326,8 +2322,6 @@ struct Game<'a> {
debug: bool,
/// `horizon-ctl gpu-passes`: time Horizon's own passes on the GPU with F3 shut.
gpu_passes: bool,
/// `horizon-ctl gpu-passes detail`: also dispatch times and counters.
gpu_pass_detail: bool,
screen: Screen,
observed_screen: Option<native_hooks::ObservedScreen>,
window: Option<Arc<Window>>,
@@ -2365,6 +2359,9 @@ struct Game<'a> {
/// `SpectatorGui`: the menu a spectator opens with the hotbar keys.
spectator_gui: horizon_client::spectator_menu::SpectatorGui,
previous: Instant,
/// When the last frame's work ended: what runs before the next one starts (`idle`, the
/// event loop) shows in HORIZON_SPIKES as the time between frames.
frame_end: Instant,
/// FramerateLimitTracker.latestInputTime.
last_input: Instant,
/// Controllers (Horizon's own).
@@ -2808,11 +2805,6 @@ struct Game<'a> {
/// When an agent's last command came.
agent_last: Option<Instant>,
frame_stats: horizon_client::control::FrameStats,
/// This thread's and all threads' allocation counts at the last rendered frame.
alloc_mark: Option<(
horizon_threads::alloc_count::Counts,
horizon_threads::alloc_count::Counts,
)>,
/// This play session's frame and tick times, for opt-in performance statistics.
perf: horizon_client::telemetry::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
@@ -2821,7 +2813,7 @@ struct Game<'a> {
screenshot_sequence: Option<screenshot_sequence::Sequence>,
/// `render-capture DIR`: replied once the renderer wrote the scene.
pending_render_capture: Option<std::sync::mpsc::Sender<String>>,
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>, bool)>,
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
/// Recent frame times for the performance overlay.
frame_graph: horizon_client::perf_overlay::FrameGraph,
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
@@ -2877,8 +2869,6 @@ struct Game<'a> {
input_trace: input_trace::InputTrace,
/// A repro recording, export or replay (game/repro.rs).
repro: repro::Repro,
/// The health history and client captures (game/profile_capture.rs).
profile: profile_capture::Profile,
/// `HORIZON_TICK_SCRIPT`'s scenario (game/tick_script.rs).
tick_script: tick_script::TickScript,
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
@@ -7210,14 +7200,14 @@ impl Game<'_> {
laps.lap("eg_objects");
// Particles (ParticleEngine.extract): camera-facing quads.
self.mark_stage(out.len(), "PARTICLES");
let world = &self.world;
let particle_mode = self.settings.horizon.particle_culling;
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
self.particles.render_in_view(
self.particles.render(
camera,
self.particle_rotation(),
alpha as f32,
self.view_frustum.as_ref(),
&|b| self.world.light(b[0], b[1], b[2]),
&|b| world.light(b[0], b[1], b[2]),
&mut out,
);
} else {
@@ -7240,7 +7230,7 @@ impl Game<'_> {
true,
frustum.as_ref(),
&mut |min, max| particle_culling.hidden(min, max),
&|b| self.world.light(b[0], b[1], b[2]),
&|b| world.light(b[0], b[1], b[2]),
&mut out,
);
}
@@ -8188,16 +8178,19 @@ impl Game<'_> {
.ok_or("missing lit block state")?;
state.properties.insert("lit".into(), "true".into());
self.level_set(hit.pos, registry.resolve(&state)?)?;
let sound = if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
};
let pitch = horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
self.audio.play(
if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
},
Some(at(hit.pos)),
1.0,
horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
),
);
self.play_block_use_sound(sound, at(hit.pos), pitch);
// FlintAndSteelItem / FireChargeItem.useOn: BLOCK_CHANGE at the lit block,
// and ItemStack.useOn's used statistic.
self.post_block_game_event(
@@ -8228,7 +8221,7 @@ impl Game<'_> {
item == "fire_charge",
&mut self.world.random,
);
self.play_block_use_sound(sound, at(target), pitch);
self.audio.play(sound, Some(at(target)), 1.0, pitch);
// BLOCK_PLACE: flint and steel names the clicked block, a fire charge the
// fire. Then ItemStack.useOn's used statistic.
let placed_at = if item == "fire_charge" {
@@ -9769,14 +9762,9 @@ impl Game<'_> {
let debug_passes = self.debug_lists_gpu_passes();
let result = match self.renderer.as_mut() {
Some(renderer) => {
use horizon_client::client_perf::request::GpuSwitch;
let capture_gpu = self.profile.gpu();
let recording = capture_gpu != GpuSwitch::Off;
renderer.set_pass_timing(debug_passes || self.gpu_passes || recording);
renderer.set_keep_timed_frames(recording);
renderer.set_pass_detail(self.gpu_pass_detail || capture_gpu == GpuSwitch::Detail);
// `gpu_passes` and a capture's `--gpu` are measurements: they take every sample they can.
renderer.set_pass_timing_gap(if self.gpu_passes || recording {
renderer.set_pass_timing(debug_passes || self.gpu_passes);
// `gpu_passes` is a measurement: it takes every sample it can.
renderer.set_pass_timing_gap(if self.gpu_passes {
Duration::ZERO
} else {
horizon_client::debug_overlay::GPU_TIMING_GAP
@@ -11059,8 +11047,12 @@ impl Game<'_> {
self.observe_fullscreen();
self.sync_ime();
let now = Instant::now();
horizon_threads::work::tick_start();
let frame_cpu = self
.options
.spikes
.and_then(|_| horizon_client::spans::thread_cpu());
let elapsed = now.duration_since(self.previous);
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
self.studio_fly(elapsed);
@@ -11148,19 +11140,46 @@ impl Game<'_> {
result => result?,
};
horizon_client::spans::time("pr_cursor", || self.apply_cursor_icon());
// The frame's spans, taken every frame they are recorded so they never pile up.
// The frame's spans, taken every frame they are recorded so they never
// pile up: the slow-frame line's, and the session's (`perf.frame`).
let frame_spans = if horizon_client::spans::recording_here() {
horizon_client::spans::take_raw()
} else {
Vec::new()
};
let mut perf_times = None;
if horizon_client::spans::trace::due() {
horizon_client::spans::trace::stop();
}
if let Some(threshold) = self.options.spikes
&& let Some(r) = self.renderer.as_ref()
{
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
// Spans timed between frames (`idle`) come first.
let spans = horizon_client::spans::merge(&frame_spans);
let between = between.as_secs_f32() * 1000.0;
if cpu > threshold || between > threshold {
// The render thread's own CPU time in the frame:
// far below the wall time, the frame waited.
let worked = horizon_client::spans::thread_cpu()
.zip(frame_cpu)
.map_or_else(String::new, |(end, start)| {
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
});
let gap = if between > threshold {
format!(", {between:.1} ms before it")
} else {
String::new()
};
log::info!(
"Horizon: slow frame {cpu:.1} ms{worked}{gap} ({steps} ticks): {}",
horizon_client::spans::describe(&spans)
);
}
}
if let Some(r) = self.renderer.as_mut() {
let cpu = now.elapsed().saturating_sub(r.last_wait);
horizon_threads::work::tick_end(cpu.as_nanos() as u64);
if rendered {
let ms = |d: Duration| d.as_secs_f32() * 1000.0;
perf_times = Some((ms(cpu), r.gpu_times.last().copied()));
self.frame_graph.record(
ms(elapsed),
ms(cpu),
@@ -11240,17 +11259,6 @@ impl Game<'_> {
}
if rendered {
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
if let Some((cpu, gpu)) = perf_times {
self.record_perf_frame(
elapsed.as_secs_f32() * 1000.0,
cpu,
gpu,
&frame_spans,
);
}
if horizon_threads::alloc_count::ON {
self.record_frame_allocs();
}
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
if self.frames == 0 {
// The launcher times Play to this line (launcher/src/perf.rs).
@@ -11291,6 +11299,7 @@ impl Game<'_> {
self.fps_frames = 0;
self.fps_at = Instant::now();
}
self.frame_end = Instant::now();
if self.options.frames.is_some_and(|f| self.frames >= f)
|| self
.options
@@ -11374,9 +11383,6 @@ impl<'a> Game<'a> {
let inventory = vec![None; 36];
let now = Instant::now();
let look = options.look;
let slow_frame_ms = options
.spikes
.unwrap_or(horizon_client::client_perf::SLOW_FRAME_MS);
let particles = horizon_client::particles::ParticleEngine::new(
&root,
&gui.atlas.sprites,
@@ -11493,7 +11499,6 @@ impl<'a> Game<'a> {
pause: false,
debug: false,
gpu_passes: false,
gpu_pass_detail: false,
screen: Screen::Playing,
observed_screen: None,
window: None,
@@ -11516,6 +11521,7 @@ impl<'a> Game<'a> {
started: now,
spectator_gui: Default::default(),
previous: now,
frame_end: now,
last_input: now,
pad: Default::default(),
frames: 0,
@@ -11769,7 +11775,6 @@ impl<'a> Game<'a> {
agent_control_on: false,
agent_last: None,
frame_stats: Default::default(),
alloc_mark: None,
perf: Default::default(),
pending_screenshot: None,
screenshot_sequence: None,
@@ -11780,7 +11785,6 @@ impl<'a> Game<'a> {
events: Default::default(),
input_trace: Default::default(),
repro: Default::default(),
profile: profile_capture::Profile::new(slow_frame_ms),
tick_script: tick_script::TickScript::from_env(),
agent: agent::Agent::default(),
view_frustum: None,
@@ -12275,7 +12279,10 @@ pub fn run_with() -> Result<()> {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
// A dedicated server times its spans always, so its slow ring holds the steps of a lag spike (microseconds a tick).
// A dedicated server writes its slow ticks to the log with the spans
// they spent their time in (`HORIZON_SPIKES`'s line; the variable still
// sets another threshold), so a lag spike players felt can be traced
// from the log alone. Timing the spans costs microseconds a tick.
if options.spikes.is_none()
&& options.execution_role == execution_role::ExecutionRole::DedicatedServer
{
@@ -12586,35 +12593,6 @@ mod subtitles;
#[cfg(test)]
#[path = "game_tests.rs"]
mod tests;
#[cfg(test)]
mod test_neighbourhood {
//! Shared fixture step for tests that tick entities on prepared data.
use super::*;
impl Game<'_> {
/// Loads the chunks within 2 columns of `(cx, cz)` that are not loaded yet.
/// Entity ticking waits for every chunk in that 5x5 neighbourhood
/// (`ChunkMap.prepareEntityTickingChunk`), so a fixture that ticks mobs,
/// items or TNT in a chunk calls this after setting up its own chunks.
pub(crate) fn load_ticking_neighbourhood(&mut self, cx: i32, cz: i32) {
for z in cz - 2..=cz + 2 {
for x in cx - 2..=cx + 2 {
if self.world.chunks.contains_key(&(x, z)) {
continue;
}
self.world.chunks.insert(
(x, z),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(x, z)),
);
// A chunk that is already registered keeps its ticks.
let _ = self
.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(x, z));
}
}
}
}
}
fn write_level_for_role(
level: &mut Level,
@@ -51,7 +51,6 @@ fn game() -> Game<'static> {
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.load_ticking_neighbourhood(0, 0);
for command in [
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
@@ -2709,7 +2709,6 @@ mod postbaseline_lodestone_controls {
game.block_ticks
.add_empty_chunk(BlockPos::new(64, 0, 64).chunk())
.unwrap();
game.load_ticking_neighbourhood(0, 0);
assert!(game.run_command("fill 64 199 64 79 199 79 stone"));
assert!(game.run_command(&format!("summon armor_stand 65.5 200.0 65.5 {{UUID:[I;42321,0,0,102],Health:20f,NoGravity:1b,equipment:{{mainhand:{}}}}}",item("minecraft:compass",tracked,100,1))));
let id = |n| {
@@ -15,28 +15,6 @@ use horizon_client::{
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
/// The block entity type a sample is attributed to while its ticker runs.
fn ticker_name(entity: &BlockEntity) -> &'static str {
match entity {
BlockEntity::Chest(_) => "hopper",
BlockEntity::Crafter(_) => "crafter",
BlockEntity::Jukebox(_) => "jukebox",
BlockEntity::Beacon(_) => "beacon",
BlockEntity::CreakingHeart(_) => "creaking_heart",
BlockEntity::Spawner(_) => "mob_spawner",
BlockEntity::TrialSpawner(_) => "trial_spawner",
BlockEntity::Vault(_) => "vault",
BlockEntity::Beehive(_) => "beehive",
BlockEntity::Furnace(_) => "furnace",
BlockEntity::Brewing(_) => "brewing_stand",
BlockEntity::DaylightDetector => "daylight_detector",
BlockEntity::Campfire(_) => "campfire",
BlockEntity::Mod(_) => "mod_block_entity",
BlockEntity::PotentSulfur(_) => "potent_sulfur",
_ => "block_entity",
}
}
impl Game<'_> {
pub(super) fn tick_block_entities(&mut self) -> Result<()> {
self.tick_mount_menu();
@@ -87,12 +65,7 @@ impl Game<'_> {
{
continue;
}
horizon_threads::work::set_chunk(pos.0 >> 4, pos.2 >> 4);
let entity = self.world.block_entities.get(&pos);
horizon_threads::work::set_entity(horizon_threads::work::entity_id(
entity.map_or("", ticker_name),
));
match entity {
match self.world.block_entities.get(&pos) {
Some(BlockEntity::Chest(items)) if items.len() == HOPPER_SLOTS => {
self.tick_hopper(pos)
}
@@ -127,7 +100,6 @@ impl Game<'_> {
_ => {}
}
}
horizon_threads::work::clear_work();
// Items move between passes: the columns hold for this one only.
self.item_columns = None;
self.pass_mobs = None;
@@ -224,7 +224,6 @@ impl Game<'_> {
if !debug {
self.tick_scheduled()?;
}
horizon_threads::work::clear_work();
self.with_level(|b, level, ticks| {
// The masks the random ticks will read, built on helper threads first while the
// parallel tick is on (`HORIZON_PARALLEL_TICK`): the draws and ticks don't depend on them.
@@ -272,7 +271,6 @@ impl Game<'_> {
}
});
});
horizon_threads::work::clear_work();
laps = horizon_client::spans::laps();
let sent = self.lan_broadcast_changes();
laps.lap("tb_broadcast");
+2 -79
View File
@@ -1508,6 +1508,7 @@ impl Game<'_> {
Vec3::new(b.position.x, top, b.position.z)
}
}
/// `BoatItem.use`: aim at a block or the water surface.
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
@@ -1515,7 +1516,6 @@ impl Game<'_> {
b.previous_yaw = yaw;
}
}
/// `BoatItem.use`: aim at a block or the water surface.
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(boat) = self.boat_to_place(stack) else {
return false;
@@ -1528,7 +1528,7 @@ impl Game<'_> {
self.world.modified.insert(key);
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
self.boats.push(boat);
self.apply_item_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
@@ -1765,82 +1765,6 @@ impl Game<'_> {
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
self.hurt_boat_result(i, damage, creative).unwrap_or(false)
}
/// Whether a spectator must be refused this boat's menu.
pub(super) fn boat_loot_pending(&self, id: u32) -> bool {
matches!(
self.persist.raw.get(&id),
Some(horizon_world::nbt::Tag::Compound(map)) if map.contains_key(&"LootTable".into())
)
}
/// `ContainerEntity.unpackChestVehicleLootTable`: slot access rolls once;
/// only a player opening the menu fires `CriteriaTriggers.GENERATE_LOOT`, before filling.
pub(super) fn unpack_boat_loot(&mut self, i: usize, player: bool) {
use horizon_world::nbt::Tag;
let id = self.boats[i].id;
let (table, seed) = {
let Some(Tag::Compound(map)) = self.persist.raw.get(&id) else {
return;
};
let Some(table) = crate::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
else {
return;
};
let seed = match map.get(&"LootTableSeed".into()) {
Some(Tag::Long(seed)) => Some(*seed),
_ => None,
};
(table, seed)
};
if player {
self.trigger_loot_table(&table);
}
// Advancement rewards can remove the boat or shift its index.
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
return;
};
if let Some(Tag::Compound(map)) = self.persist.raw.get_mut(&id) {
map.remove(&"LootTable".into());
map.remove(&"LootTableSeed".into());
}
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, id as i32);
self.store_cart_items(key);
let loot = crate::structure_blocks::loot_data(&self.options.resources, &self.pack_files());
let b = &self.boats[i];
let at = Vec3::new(b.position.x, b.position.y, b.position.z);
let biome = self.biome_name_at(at).unwrap_or_default();
let pos = (
b.position.x.floor() as i32,
b.position.y.floor() as i32,
b.position.z.floor() as i32,
);
crate::structure_blocks::roll_container(
&mut self.boats[i].items,
loot.as_deref(),
table,
seed,
pos,
&biome,
);
if let Some(horizon_client::containers::BlockEntity::Chest(items)) =
self.world.block_entities.get_mut(&key)
{
items.clone_from(&self.boats[i].items);
}
}
/// `AbstractChestBoat.remove`: destroying removals spill the live inventory.
pub(super) fn kill_boat(&mut self, id: u32) {
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
self.unpack_boat_loot(i, false);
let b = self.boats.remove(i);
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
}
}
/// None means a canceled hit; Some records whether an accepted hit destroyed it.
pub(super) fn hurt_boat_result(
&mut self,
@@ -1864,7 +1788,6 @@ impl Game<'_> {
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
self.unpack_boat_loot(i, false);
let b = self.boats.remove(i);
// `AbstractChestBoat.remove` spills the chest through
// `Containers.dropContents` for every destroying removal reason
@@ -18,7 +18,7 @@ impl Game<'_> {
/// `Entity.checkBelowWorld` for the boat at `i`: true when it fell out of
/// the world and is gone.
pub(in crate::game) fn boat_below_world(&mut self, i: usize) -> bool {
if !below_world(self.boats[i].position.y, self.world.shape.min_y) {
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
return false;
}
self.eject_boat_mobs(i, true);
@@ -41,7 +41,7 @@ impl Game<'_> {
kind: &str,
) -> Option<usize> {
let i = cart_at(&self.carts, id, slot)?;
if !below_world(self.carts[i].position.y, self.world.shape.min_y) {
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
return Some(i);
}
self.eject_cart_mobs(i);
@@ -68,51 +68,44 @@ mod tests {
assert!(below_world(-64.5, 0));
}
/// A tick discards vehicles below each dimension's own floor, even when
/// its chunk storage extends lower, and spills the container's contents.
/// Vehicles on the line or above it survive this tick.
/// A tick discards the boat and the chest minecart under -128 in the
/// overworld, before they move, and spills the cart's slots; the two on
/// the line or above it stay, whatever their fall this tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tick_discards_the_vehicles_under_the_world() {
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
for min_y in [-64, 0] {
let mut g = crate::game::native_machines::tests::server_game();
g.world.shape.min_y = min_y;
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
let line = min_y - 64;
for summon in [
format!("summon oak_boat 0.5 {line} 0.5"),
format!("summon oak_boat 4.5 {} 4.5", f64::from(line) - 0.5),
format!("summon minecart 6.5 {} 6.5", line + 1),
format!(
"summon chest_minecart 2.5 {} 2.5 {{Items:[{{Slot:0b,id:\"minecraft:diamond\",count:3}}]}}",
line - 1
),
] {
assert!(g.run_command(&summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
let mut g = crate::game::native_machines::tests::server_game();
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
for summon in [
"summon oak_boat 0.5 -128 0.5",
"summon oak_boat 4.5 -128.5 4.5",
"summon minecart 6.5 -127 6.5",
"summon chest_minecart 2.5 -129 2.5 {Items:[{Slot:0b,id:\"minecraft:diamond\",count:3}]}",
] {
assert!(g.run_command(summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
}
#[test]
+1 -1
View File
@@ -202,7 +202,7 @@ impl Game<'_> {
}
// SuggestCommand's insertText does nothing on a book; a server's
// text never opens a file.
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) | ClickEvent::Profiler(_) => {}
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) => {}
}
}
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
@@ -54,9 +54,6 @@ fn game() -> Game<'static> {
.chunks
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
}
for x in 0..2 {
game.load_ticking_neighbourhood(x, 0);
}
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 6.5)).unwrap());
let net = NetConfig {
+1 -50
View File
@@ -33,18 +33,6 @@ pub(super) fn chat_tree(
None => horizon_client::command::vanilla::game_dispatcher(),
}
}
/// The arguments of a typed `/profiler` the client answers itself: only when the chat tree's `profiler` is Horizon's, else the server or mod that owns the name runs it.
fn horizon_profiler_args<'a>(
message: &'a str,
server_tree: Option<&horizon_client::command::Dispatcher>,
) -> Option<&'a str> {
let args = message
.strip_prefix('/')
.and_then(super::profile_capture::profiler_args)?;
server_tree
.is_none_or(|tree| tree.has_horizon_profiler())
.then_some(args)
}
use winit::{
event::KeyEvent,
keyboard::{KeyCode, PhysicalKey},
@@ -167,7 +155,7 @@ impl Game<'_> {
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
};
with_studio_commands(&with_native_commands(base, &names)).with_profiler()
with_studio_commands(&with_native_commands(base, &names))
}
}
@@ -1415,12 +1403,6 @@ impl Game<'_> {
return;
}
self.ui.chat.add_recent(&message);
if let Some(args) =
horizon_profiler_args(&message, self.remote.as_ref().map(|r| &r.chat_tree))
{
self.profiler_chat(args);
return;
}
// On a server (single player's own included) the server runs it,
// unless a client mod owns the command.
if self.remote.is_some() {
@@ -1548,14 +1530,6 @@ impl Game<'_> {
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
Some(ClickEvent::Profiler(command)) => {
if let Some(args) = command
.strip_prefix('/')
.and_then(super::profile_capture::profiler_args)
{
self.profiler_chat(args);
}
}
// Screen.defaultHandleGameClickEvent: the dialog opens over the
// screen, and closes back to it.
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
@@ -2126,29 +2100,6 @@ const LINK_COPY: usize = 1;
mod tests {
use super::*;
#[test]
fn a_typed_profiler_is_the_clients_unless_the_server_owns_the_name() {
let vanilla = horizon_client::command::vanilla::game_dispatcher();
let ours = vanilla.with_profiler();
assert_eq!(
super::horizon_profiler_args("/profiler share", Some(&ours)),
Some(" share")
);
assert_eq!(super::horizon_profiler_args("/profiler", None), Some(""));
assert_eq!(
super::horizon_profiler_args("/profilers", Some(&ours)),
None
);
let mut server = vanilla.clone();
let node = server.literal(0, "profiler");
server.executes(node, "server:profiler");
let theirs = server.with_profiler();
assert_eq!(
super::horizon_profiler_args("/profiler share", Some(&theirs)),
None
);
}
/// A mod's text reaches the chat box as typing would leave it: emoji and
/// accents stay, control characters, DEL and the section sign go.
#[test]
+137 -11
View File
@@ -4,7 +4,6 @@ mod admin;
pub(super) mod compute;
mod data;
mod display_names;
mod target_position;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
@@ -311,7 +310,7 @@ impl Game<'_> {
self.command_source = outer;
ok
}
pub(in crate::game) fn syntax_failure(&mut self, e: &CommandError, command: &str) {
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
self.command_failure(e.message.clone());
let Some(input) = &e.input else { return };
let cursor = e.cursor.min(input.len());
@@ -452,7 +451,9 @@ impl Game<'_> {
// the server's own source, "Server".
let who = match self.command_source.and_then(|s| s.entity) {
Some(entity) => self.target_name(entity),
None if self.console_broadcast.is_some() => self.server_name(),
None if self.console_broadcast.is_some() => {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" }.to_owned())
}
None => self.player_display_name(),
};
let line = Text::translate("chat.type.admin", &[who, message])
@@ -537,6 +538,118 @@ impl Game<'_> {
conn.and_then(|c| self.command_guest(c))
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
}
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
Target::Item(id) => self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
.orbs
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
let (lo, hi) = f.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
let (lo, hi) = p.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map(|b| [b.position.x, b.position.y, b.position.z]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Other(id) => self
.others
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
Target::Knot(id) => self.natural_knot(id).map(|fence| {
let p = horizon_client::mobs::leash::knot_state(fence).position;
[p.x, p.y, p.z]
}),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
match t {
Target::Player | Target::Guest(_) => "minecraft:player".into(),
@@ -1738,7 +1851,7 @@ impl Game<'_> {
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
(c.position, c.fall.0) = (v, true); // performTeleport: setOnGround(true)
c.position = v;
}
}
Target::Dragon(id) => {
@@ -1749,8 +1862,6 @@ impl Game<'_> {
}
Target::Tnt(id) => {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
// `PrimedTnt.teleport` includes same-level command teleports.
t.used_portal = true;
t.position = v;
t.previous = v;
if let Some(r) = rotation {
@@ -2376,7 +2487,17 @@ impl Game<'_> {
self.item_frames.retain(|f| f.id != id);
}
Target::Painting(id) => self.paintings.retain(|p| p.id != id),
Target::Boat(id) => self.kill_boat(id),
Target::Boat(id) => {
// AbstractBoat.remove: a killed boat drops its lead.
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
// AbstractChestBoat.remove, after the live menu writes
// back its slots.
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
}
}
Target::Cart(id) => {
self.release_vehicle_container(id);
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
@@ -2524,9 +2645,14 @@ impl Game<'_> {
self.next_entity += 1;
let at = Vec3::new(pos[0], pos[1], pos[2]);
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
let time = super::dragon::Crystal::start_time(at, drawn, self.clock.still());
let crystal = super::dragon::Crystal::new(self.next_entity, at, true, time);
self.crystals.push(crystal);
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: at,
show_bottom: true,
time: super::dragon::Crystal::start_time(at, drawn, self.clock.still()),
beam: None,
invulnerable: false,
});
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
@@ -4296,7 +4422,7 @@ impl Game<'_> {
.map(|cart| cart.command.name())
})
})
.unwrap_or_else(|| self.server_name()),
.unwrap_or_else(|| Text::literal("Server")),
},
None => self.player_display_name(),
};
@@ -697,27 +697,6 @@ impl Game<'_> {
self.carts[i] = cart;
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Painting(id) => {
// Painting.readAdditionalSaveData over HangingEntity's: the
// facing, the block it hangs on and a known variant
// (`VariantUtils.readVariant(..).ifPresent`: an unknown or
// unreadable one keeps the painting's).
let uuid = self.uuid_for(id);
let (pos, facing) =
entity_nbt::painting_placement_from_nbt(&tag).ok_or_else(stale)?;
let known = entity_nbt::painting_from_nbt(&tag)
.and_then(|(_, _, v)| self.painting_variants.get(&v?).cloned());
let p = self
.paintings
.iter_mut()
.find(|p| p.id == id)
.ok_or_else(stale)?;
(p.pos, p.facing) = (pos, facing);
if let Some(v) = known {
(p.variant, p.width, p.height) = (v.id, v.width, v.height);
}
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Other(id) => {
let uuid = self.uuid_for(id);
let marker = self
@@ -769,7 +748,6 @@ impl Game<'_> {
| Target::Item(id)
| Target::Stand(id)
| Target::Frame(id)
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Tnt(id)
@@ -1567,7 +1545,7 @@ impl Game<'_> {
let before = self.next_entity;
let mut tag = tag;
crate::game::persist::mark_fresh(&mut tag);
self.world.queue_entity_nbt(key, vec![tag]);
self.world.loaded_entities.push((key, vec![tag]));
self.spawn_loaded_entities();
waiting.append(&mut self.world.loaded_entities);
self.world.loaded_entities = waiting;
@@ -61,12 +61,6 @@ impl Game<'_> {
.unwrap_or(Target::Player)
}
/// The name of the server's own source: "Rcon" for a command an RCON client sent
/// (`RconConsoleSource`), "Server" otherwise (`MinecraftServer.createCommandSourceStack`).
pub(in crate::game) fn server_name(&self) -> Text {
Text::literal(if self.rcon_source { "Rcon" } else { "Server" })
}
/// `PlayerList.getPlayerByName`: names are not case sensitive. Horizon:
/// on a server where a mod gave players nicknames
/// (`native_set_nickname`), a name no player has finds the player going
@@ -1,116 +0,0 @@
use super::{Game, Target};
impl Game<'_> {
pub(in crate::game) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| [m.position.x, m.position.y, m.position.z]),
Target::Item(id) => self
.dropped
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
.orbs
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
let (lo, hi) = f.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
let (lo, hi) = p.bounds();
[
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
]
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map(|b| [b.position.x, b.position.y, b.position.z]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Other(id) => self
.others
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Cushion(id) => self
.cushions
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Falling(id) => self
.falling
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Rocket(id) => self
.rockets
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
Target::Knot(id) => self.natural_knot(id).map(|fence| {
let p = horizon_client::mobs::leash::knot_state(fence).position;
[p.x, p.y, p.z]
}),
}
}
}
+39 -167
View File
@@ -1,7 +1,6 @@
//! Commands from the control socket (`horizon-ctl`), run on the game thread.
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::gfx::pack_timing;
use horizon_client::held::Source;
use horizon_client::{Result, items::ItemStack};
use horizon_core::geometry::Vec3;
@@ -60,9 +59,8 @@ const HELP: &[&str] = &[
"chat-log [N] the newest N chat messages (default 10)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr video-vanilla)",
"stats [reset] [raw] frame-time summary since the last reset (raw: every frame time too)",
"profile [client|server] start|stop|status|mark NAME capture the performance of the window and, in single player, its server thread to reports/ (horizon-ctl profile --help)",
"narration the narrator's status and the last messages it was asked to say",
"bench SECONDS [raw] reset stats, wait, then reply with the summary (raw: every frame time too)",
"bench SECONDS reset stats, wait, then reply with the summary",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
"open WORLD [SEED] [creative] open or create a world from the title screen",
"save save the world",
@@ -127,7 +125,9 @@ impl Game<'_> {
summary["tick_ms"] = (flight.tick_ms.iter().sum::<f32>() / n).into();
summary["tick_ms_max"] = flight.tick_ms.iter().copied().fold(0.0, f32::max).into();
if flight.raw {
self.add_raw_series(&mut summary);
summary["frame_ms"] = self.frame_stats.frame_millis().into();
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
}
let _ = flight.reply.send(summary.to_string());
}
@@ -167,14 +167,6 @@ impl Game<'_> {
.collect(),
);
}
if let Some(r) = self.renderer.as_ref().filter(|r| r.pass_detail())
&& let Some(support) = r.pass_detail_support()
&& let Value::Object(detail) =
pack_timing::detail_json(&r.own_pass_parts(), &r.own_pass_counters(), support)
&& let Some(summary) = summary.as_object_mut()
{
summary.extend(detail);
}
self.add_async_occlusion_stats(&mut summary);
summary
}
@@ -185,23 +177,6 @@ impl Game<'_> {
}
}
/// Adds the allocations since the last rendered frame to the frame stats; the first frame has no mark.
pub(super) fn record_frame_allocs(&mut self) {
use horizon_threads::alloc_count::{here, process};
let now = (here(), process());
if let Some((window, all)) = self.alloc_mark.replace(now) {
self.frame_stats
.record_allocs(now.0.since(window), now.1.since(all), Instant::now());
}
}
/// Every frame's times, and its allocations in builds that count (`raw`).
fn add_raw_series(&self, summary: &mut Value) {
self.frame_stats.add_series(summary);
if horizon_threads::alloc_count::ON {
summary["alloc_count"] = self.frame_stats.alloc_counts().into();
summary["alloc_bytes"] = self.frame_stats.alloc_bytes().into();
}
}
fn reset_frame_stats(&mut self) {
self.frame_stats.reset();
if let Some(culler) = &self.async_occlusion_culler {
@@ -209,21 +184,16 @@ impl Game<'_> {
}
}
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
if let Some((deadline, reply, raw)) = self.pending_bench.take() {
if let Some((deadline, reply)) = self.pending_bench.take() {
if Instant::now() >= deadline {
let mut summary = self.summary_with_passes();
if raw {
self.add_raw_series(&mut summary);
}
let _ = reply.send(summary.to_string());
let _ = reply.send(self.summary_with_passes().to_string());
} else {
self.pending_bench = Some((deadline, reply, raw));
self.pending_bench = Some((deadline, reply));
}
}
horizon_client::widgets::end_frame(self.frames);
self.narrate_screen();
self.poll_repro(event_loop);
self.poll_profile();
self.advance_agent();
for request in self.ui_wait.ready() {
self.answer(request, event_loop);
@@ -421,21 +391,12 @@ impl Game<'_> {
if let Some(value) = self.repro_command(&words, line, reply)? {
return Ok(value);
}
if words[0] == "profile" {
return self.profile_command(&words[1..], reply);
}
if words[0] == "trace" {
return self.trace_command(&words[1..], reply);
}
if let Some(value) = self.saves_control(&words, line, reply)? {
return Ok(value);
}
if let Some(value) = self.options_control(&words, line)? {
return Ok(Some(value));
}
if let Some(value) = self.lan_op_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.lan_control(&words)? {
return Ok(Some(value));
}
@@ -500,17 +461,35 @@ impl Game<'_> {
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
}
"gpu-passes" => {
(self.gpu_passes, self.gpu_pass_detail) = gpu_pass_switches(
words.get(1).copied(),
(self.gpu_passes, self.gpu_pass_detail),
)?;
let support = self.renderer.as_ref().and_then(|r| r.pass_detail_support());
Ok(Some(gpu_passes_reply(
self.gpu_passes,
self.gpu_pass_detail,
support,
)))
match words.get(1).copied() {
None => {}
Some("on") => self.gpu_passes = true,
Some("off") => self.gpu_passes = false,
Some(other) => return Err(format!("unknown gpu pass mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
}
// Every thread's spans as a timeline Perfetto opens (`spans::trace`).
"trace" => match words.get(1).copied() {
None => Ok(Some(
json!({ "ok": true, "tracing": horizon_client::spans::trace::on() }),
)),
Some("stop") => Ok(Some(
json!({ "ok": true, "events": horizon_client::spans::trace::stop() }),
)),
Some(file) => {
let seconds = match words.get(2) {
None => None,
Some(s) => Some(
s.parse::<u32>()
.map_err(|_| "trace [FILE [SECONDS] | stop]")?,
),
};
horizon_client::spans::trace::start(std::path::Path::new(file), seconds)
.map_err(|e| format!("trace {file}: {e}"))?;
Ok(Some(json!({ "ok": true, "tracing": true, "file": file })))
}
},
"render-capture" => match words.get(1).copied() {
None => {
let r = self.renderer.as_ref().ok_or("no renderer")?;
@@ -600,7 +579,7 @@ impl Game<'_> {
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"hello" => Ok(Some(hello())),
"status" => Ok(Some(self.control_status())),
"memory" => Ok(Some(self.memory_value())),
"memory" => Ok(Some(self.memory_report())),
"stall" => {
let millis: u64 = arg(1)?.parse()?;
if millis > 5000 {
@@ -1209,7 +1188,9 @@ impl Game<'_> {
"stats" => {
let mut summary = self.summary_with_passes();
if words[1..].contains(&"raw") {
self.add_raw_series(&mut summary);
summary["frame_ms"] = self.frame_stats.frame_millis().into();
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
}
if words[1..].contains(&"reset") {
self.reset_frame_stats();
@@ -1229,7 +1210,6 @@ impl Game<'_> {
self.pending_bench = Some((
Instant::now() + Duration::from_secs_f64(seconds),
reply.clone(),
words[2..].contains(&"raw"),
));
Ok(None)
}
@@ -1793,35 +1773,6 @@ fn key_code(name: &str) -> Option<KeyCode> {
})
}
/// The (times, detail) switches `gpu-passes [on|off|detail]` leaves, given the current ones.
fn gpu_pass_switches(word: Option<&str>, now: (bool, bool)) -> Result<(bool, bool)> {
match word {
None => Ok(now),
Some("on") => Ok((true, false)),
Some("detail") => Ok((true, true)),
Some("off") => Ok((false, false)),
Some(other) => Err(format!("unknown gpu pass mode {other}").into()),
}
}
/// The `gpu-passes` reply: the switches and what detail can show on this device.
fn gpu_passes_reply(
passes: bool,
detail: bool,
support: Option<pack_timing::DetailSupport>,
) -> Value {
let shown = support.map(pack_timing::support_json);
let field = |key: &str, none: Value| shown.as_ref().map_or(none, |s| s[key].clone());
json!({
"ok": true,
"gpu_passes": passes,
"detail": detail,
"parts_mode": field("parts_mode", Value::Null),
"counters": field("counters", json!(false)),
"fragment_unit": field("fragment_unit", Value::Null),
})
}
/// The title's own screens (not one opened over it, as Options is) take the
/// text: the world name, the seed, a server's address.
pub(super) fn title_owns_text(screen: &Screen, title_up: bool) -> bool {
@@ -1859,39 +1810,6 @@ mod tests {
assert_eq!(names.len(), features.len(), "a group is named twice");
}
#[test]
fn gpu_passes_detail_replies_detail_and_on_turns_it_off_again() {
let support = pack_timing::DetailSupport {
parts: pack_timing::PartsMode::SeparatePasses,
encoder_stamps: false,
counters: true,
fragment_unit: Some(pack_timing::FragmentUnit::Quad),
};
let (passes, detail) = gpu_pass_switches(Some("detail"), (false, false)).unwrap();
let reply = gpu_passes_reply(passes, detail, Some(support));
assert_eq!(reply["detail"], true);
assert_eq!(reply["gpu_passes"], true);
assert_eq!(reply["parts_mode"], "separate_passes");
assert_eq!(reply["counters"], true);
assert_eq!(reply["fragment_unit"], "quad");
let (passes, detail) = gpu_pass_switches(Some("on"), (passes, detail)).unwrap();
let reply = gpu_passes_reply(passes, detail, None);
assert_eq!(
(reply["gpu_passes"].as_bool(), reply["detail"].as_bool()),
(Some(true), Some(false))
);
assert_eq!(
(reply["parts_mode"].is_null(), reply["counters"].as_bool()),
(true, Some(false))
);
assert_eq!(
gpu_pass_switches(Some("off"), (true, true)).unwrap(),
(false, false)
);
assert_eq!(gpu_pass_switches(None, (true, true)).unwrap(), (true, true));
assert!(gpu_pass_switches(Some("nope"), (false, false)).is_err());
}
#[test]
fn taps_and_ups_always_release_their_key() {
for line in ["key shift tap", "key shift up", "key Shift up"] {
@@ -1907,50 +1825,4 @@ mod tests {
assert_eq!(released_modifier(line), None, "{line}");
}
}
#[test]
fn a_benchmark_profile_only_goes_with_a_benchmark() {
let parse = |args: &[&str]| {
super::super::Options::parse(args.iter().map(|a| a.to_string())).map(|o| o.unwrap())
};
assert!(
parse(&["--benchmark", "village", "--profile"])
.unwrap()
.benchmark_profile
);
assert!(
!parse(&["--benchmark", "village"])
.unwrap()
.benchmark_profile
);
assert!(parse(&["--profile"]).is_err());
}
#[test]
fn a_benchmark_set_only_goes_with_the_trip() {
let parse = |args: &[&str]| {
super::super::Options::parse(args.iter().map(|a| a.to_string())).map(|o| o.unwrap())
};
let set = parse(&["--benchmark", "travel", "--benchmark-set", "gpu-culling=on"]).unwrap();
assert_eq!(set.benchmark_set, Some(("gpu-culling".into(), "on".into())));
let before = parse(&["--benchmark-set", "gpu-culling=on", "--benchmark", "travel"]);
assert!(before.is_ok());
for args in [
&[
"--benchmark",
"standard",
"--benchmark-set",
"gpu-culling=on",
][..],
&["--benchmark-set", "gpu-culling=on"],
&[
"--benchmark",
"travel",
"--benchmark-set",
"gpu-culling=auto",
],
] {
assert!(parse(args).is_err(), "{args:?}");
}
}
}
+14 -14
View File
@@ -1080,7 +1080,7 @@ impl Game<'_> {
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
dimension_type: self.dimension_type(),
world_seed: self.level.seed,
sea_level: horizon_world::level::sea_level_of(self.level_key()),
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
@@ -1280,9 +1280,10 @@ impl Game<'_> {
.is_some()
.then_some(self.level.player_uuid)
.flatten(),
mods: round.listening() || !horizon_client::mod_api::mods().is_empty(),
ticking: &|| Box::new(self.entity_ticking_area()),
};
let mut group = match islands.plan(&mobs, &snapshot, &turns, &start) {
let mut group = match islands.plan(&mobs, &snapshot, &turns) {
Some(plan) => islands.run(plan, mobs.into(), snapshot, start, &mut turns, &shared),
None => {
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
@@ -1877,9 +1878,10 @@ impl Game<'_> {
for item in stacks {
// Every death drop is its own ItemEntity, and the drop entity's
// own random (not the level's) gives it the constructor's
// horizontal push (its motion otherwise stays [0, 0.2, 0] and the
// drops land stacked at the mob's centre, without disturbing the
// level random that the orbs' merge ids and block ticks read next).
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
// the drops land stacked at the mob's centre, without disturbing
// the level random that the orbs' merge ids and block ticks read
// next).
self.spawn_item_own(item, pos, 10);
}
// LivingEntity.dropExperience: a mod may change the mob's experience
@@ -1919,9 +1921,8 @@ impl Game<'_> {
self.spawn_item_own(item, pos, 10);
}
}
drops::Drop::Aged(s, age) => self.spawn_item_aged(from_loot(&s), pos, age),
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
_ => {}
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
}
}
}
@@ -3233,7 +3234,11 @@ impl Game<'_> {
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
let Some(at) = self.mobs.iter().find(|m| m.id == mob).map(|m| {
[m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i32)
[
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
]
}) else {
continue;
};
@@ -3244,11 +3249,7 @@ impl Game<'_> {
.filter(|_| !names.is_empty())
.and_then(|t| t.locate(&names, at, 50));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.extra.home = found.as_ref().map(|(_, p)| *p);
}
// DolphinSwimToTreasureGoal.start: entity event 38 once it has a treasure.
if found.is_some() {
self.mob_event_particles(mob, 38);
m.extra.home = found.map(|(_, p)| p);
}
}
MobEvent::EffectPlayer {
@@ -9050,7 +9051,6 @@ mod h169_glide_travel_tests {
.unwrap();
}
}
game.load_ticking_neighbourhood(0, 0);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
game.creative = true;
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
@@ -64,9 +64,6 @@ pub(super) struct Group<M: MobSlot> {
pub corpses: Vec<Mob>,
pub gone: Vec<Gone>,
pub guest_events: Vec<(u32, u32, MobEvent)>,
/// The mod hooks its turns kept for the join, with each turn's place in the whole list (a
/// group of the parallel tick with mods loaded: `native_client_hooks::Deferred`).
pub deferred: Vec<(usize, horizon_client::native_client_hooks::Deferred)>,
}
/// What decides each turn besides the group, read at the turn as the serial loop reads it.
@@ -80,7 +77,7 @@ pub(super) struct Turns<'t, 'w> {
/// The one player's position, when the level has one: every mob's nearest.
pub single_player: Option<Option<Vec3>>,
/// Whether a mod canceled a mob's tick.
pub canceled: &'t (dyn Fn(u32) -> bool + Sync),
pub canceled: &'t dyn Fn(u32) -> bool,
/// `getCurrentDifficultyAt` a position.
pub difficulty: &'t (dyn Fn([f64; 3]) -> LocalDifficulty + Sync),
/// The last chunk's difficulty: mobs come in runs of one chunk.
@@ -140,7 +137,6 @@ impl<M: MobSlot> Group<M> {
corpses: Vec::new(),
gone: Vec::new(),
guest_events: Vec::new(),
deferred: Vec::new(),
}
}
@@ -177,17 +173,7 @@ impl<M: MobSlot> Group<M> {
foreign.reader.set(at);
}
if let Some(foreign) = foreign {
foreign.in_seed_order(at, || {
if foreign.defers {
let ((), kept) =
horizon_client::native_client_hooks::deferring_turn(|| {
self.turn(i, turns, shared);
});
self.deferred.extend(kept.into_iter().map(|d| (at, d)));
} else {
self.turn(i, turns, shared);
}
});
foreign.in_seed_order(at, || self.turn(i, turns, shared));
} else {
self.turn(i, turns, shared);
}
@@ -347,7 +333,9 @@ impl<M: MobSlot> Group<M> {
};
w.controlling_player_movement = controlling_player_movement;
self.ticked.insert(self.mobs[i].mob().id);
let sampled = super::super::tick_profile::start_mob(&self.mobs[i].mob().kind);
// `horizon-report`'s cost by mob type (tick_profile.rs).
let sampled =
super::super::tick_profile::start().map(|t| (t, self.mobs[i].mob().kind.clone()));
let kept = horizon_client::mobs::riders::tick(
&mut self.mobs,
i,
@@ -355,7 +343,9 @@ impl<M: MobSlot> Group<M> {
&mut self.random,
&mut self.events,
);
super::super::tick_profile::end_mob(shared.level_id, sampled);
if let Some((started, kind)) = sampled {
super::super::tick_profile::add(shared.level_id, &kind, started);
}
let m = self.mobs[i].mob_mut();
if seen_guest.is_none() {
self.player = w.player;
@@ -377,10 +367,7 @@ impl<M: MobSlot> Group<M> {
shared.guest_ids,
self.player.as_ref(),
);
// A grouped turn runs while no mod hooks the change (`horizon_mod_api_gen::mob_turn`).
if let Some((player, mob)) = named
&& !horizon_client::native_client_hooks::deferring()
{
if let Some((player, mob)) = named {
let kind = super::super::native_hooks::mob_kind(m);
let mut cancel = false;
Game::native_living_change_target(
File diff suppressed because it is too large. Load diff
@@ -5,14 +5,9 @@ use super::super::{
};
use horizon_client::{mobs::Species, scoreboard::teams_allow_damage};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::{Clientbound, GameEvent};
/// `ClientboundGameEventPacket.PUFFER_FISH_STING`.
const PUFFER_FISH_STING: u8 = 9;
impl Game<'_> {
/// `Pufferfish.playerTouch`: accepted player damage, the sting game event, then poison with
/// this fish as source.
/// `Pufferfish.playerTouch`: accepted player damage, then poison with this fish as source.
pub(in crate::game) fn touch_pufferfish(&mut self) {
if self.remote.is_some() || self.spectator() {
return;
@@ -51,11 +46,10 @@ impl Game<'_> {
m.extra.size,
[m.position.x, m.position.y, m.position.z],
self.mob_entity(m),
m.silent,
)
})
.collect();
for (id, puff, at, source, silent) in touching {
for (id, puff, at, source) in touching {
if !self.hurt_player_from(
(1 + puff) as f32,
Some(at),
@@ -64,10 +58,6 @@ impl Game<'_> {
) {
continue;
}
// `if (!this.isSilent())` inside the `hurtServer` branch: before the poison.
if !silent {
self.puffer_fish_sting();
}
let changes = self.potions.effects.changes;
self.give_player_effect("minecraft:poison", 60 * puff, 0);
if self.potions.effects.changes != changes {
@@ -75,17 +65,4 @@ impl Game<'_> {
}
}
}
/// `ServerPlayer.connection.send(ClientboundGameEventPacket(PUFFER_FISH_STING, 0.0F))`: the
/// stung player's own client plays it (`ClientPacketListener.handleGameEvent`). A server has
/// no player of its own, so the packet is the only way to reach it.
fn puffer_fish_sting(&mut self) {
self.guest_packets.push(Clientbound::GameEvent(GameEvent {
event: PUFFER_FISH_STING,
param: 0.0,
}));
}
}
#[cfg(test)]
#[path = "pufferfish_sting_tests.rs"]
mod pufferfish_sting_tests;
@@ -1,95 +0,0 @@
//! `Pufferfish.playerTouch`: the sting game event (`ClientboundGameEventPacket.PUFFER_FISH_STING`)
//! goes out only inside the `hurtServer` branch, and only when the fish is not
//! silent; the poison follows it.
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
use horizon_client::mobs::Mob;
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::Clientbound;
/// A game whose window player touches one puffed (state 2) pufferfish. The
/// server acts for the guest `lan_acting`: `playerTouch` runs there, and the
/// event it sends reaches that player's own client.
fn game_with(silent: bool) -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
false,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.creative = false;
game.lan_acting = Some(7);
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 8.5)).unwrap());
let kind = game.res.mob_types()["minecraft:pufferfish"].clone();
let mut fish = Mob::new(9001, kind, Vec3::new(8.5, 66.0, 8.5), 0.0);
fish.extra.size = 2;
fish.silent = silent;
game.mobs.push(fish);
game
}
/// The game events the stung player's client was sent, as `(event, parameter)`.
fn stings(game: &Game<'_>) -> Vec<(u8, f32)> {
game.guest_packets
.iter()
.filter_map(|packet| match packet {
Clientbound::GameEvent(e) => Some((e.event, e.param)),
_ => None,
})
.collect()
}
fn health(game: &Game<'_>) -> f32 {
game.player.as_ref().unwrap().health.health()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_sting_event_goes_out_once_per_accepted_hit() {
let mut g = game_with(false);
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "1 + PuffState 2 of damage landed");
assert_eq!(stings(&g), vec![(9, 0.0)], "PUFFER_FISH_STING, parameter 0");
let poison = g.potions.effects.get("poison").expect("poisoned");
assert_eq!((poison.duration, poison.amplifier), (120, 0));
// A second touch inside the hurt cooldown is refused (`hurtServer` is
// false): no damage and no event.
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "the cooldown refused the second hit");
assert_eq!(stings(&g).len(), 1, "a refused hit sends nothing");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_silent_fish_damages_and_poisons_without_the_sting() {
let mut g = game_with(true);
g.touch_pufferfish();
assert_eq!(health(&g), 17.0, "the damage still lands");
assert!(g.potions.effects.get("poison").is_some(), "the poison too");
assert!(stings(&g).is_empty(), "a silent fish sends no event");
}
@@ -174,19 +174,6 @@ impl Game<'_> {
if self.debug && !own_lines.is_empty() {
d.group("Horizon passes", own_lines);
}
let detail_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
let support = r.pass_detail_support().filter(|_| r.pass_detail());
support.map_or_else(Vec::new, |support| {
overlay::pass_detail_lines(
&r.own_pass_parts(),
&r.own_pass_counters(),
support,
)
})
});
if self.debug && !detail_lines.is_empty() {
d.group("Horizon pass detail", detail_lines);
}
}
"tps" => self.debug_tps_line(d),
// DebugEntryChunkGeneration, where the world is generated
+112 -403
View File
@@ -21,7 +21,6 @@ use horizon_client::{
},
text::Text,
};
use horizon_perf_format::v1::{StopReason, Trigger};
use horizon_protocol::text::translatable;
use horizon_world::nbt::{JavaString, Tag};
use std::{
@@ -38,7 +37,7 @@ use std::{
pub(super) mod events;
mod management;
mod predicate_graph;
pub(super) mod report;
mod report;
mod status;
mod transfer;
@@ -308,17 +307,6 @@ fn overload_warning(behind: Duration, tick: Duration) -> String {
)
}
/// `ServerGamePacketListenerImpl.tick`'s idle kick: a player whose last action is more than
/// `minutes` (`player-idle-timeout`, none when 0 or less) behind, counted here in ticks, is kicked,
/// unless the End poem is showing (`ServerPlayer.wonGame`): its client sends no action until it
/// asks to respawn.
fn idles_out(idle_ticks: u64, minutes: i32, won_game: bool) -> bool {
let Ok(minutes @ 1..) = u64::try_from(minutes) else {
return false;
};
!won_game && idle_ticks > minutes * 1200
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
@@ -539,28 +527,6 @@ fn parse_whitelist_bypass(text: &str) -> std::result::Result<Vec<String>, String
Ok(out)
}
impl Lists {
/// The console's `/ban` of one player (`BanPlayerCommands.banPlayers`): whether they are banned now.
/// One banned already (`isBanned`) is left as they are, their first reason kept.
fn ban(&mut self, user: &NameAndId, reason: Option<String>) -> bool {
!self.bans.contains(user)
&& self.bans.add(BanEntry::new(
user.clone(),
Some("Server".into()),
None,
reason,
))
}
/// The console's `/ban-ip` (`BanIpCommands.banIp`): whether the address is banned now. One banned
/// already (`isBanned`) is left as it is.
fn ban_ip(&mut self, ip: &str, reason: Option<String>) -> bool {
let ip = ip.to_owned();
!self.ip_bans.contains(&ip)
&& self
.ip_bans
.add(BanEntry::new(ip, Some("Server".into()), None, reason))
}
fn load() -> Self {
let mut l = Self {
ops: OpList::new("ops.json"),
@@ -692,7 +658,9 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
/// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it).
const FAR_PROPERTY: &str = "horizon-max-view-distance";
/// A tick slower than this, in milliseconds, enters the slow ring (five ticks' worth, a stall players feel) unless `HORIZON_SPIKES` names another.
/// A dedicated server logs a tick slower than this, in milliseconds, with
/// its spans, unless `HORIZON_SPIKES` names another threshold: five ticks'
/// worth, a stall players feel as rubber-banding.
pub(super) const SLOW_TICK_MS: f32 = 250.0;
/// `MinecraftServer.OVERLOADED_WARNING_INTERVAL_NANOS` plus 100 ticks: the
/// "Can't keep up!" warning comes at most this often.
@@ -838,19 +806,9 @@ struct ServerState {
op_level: i32,
/// `horizon-report`: the ticks being recorded.
report: Option<report::Recording>,
/// When the running capture takes its next memory snapshot, and how many it has taken.
memory_due: Option<(f64, u32)>,
/// The always-on health history and slow ring, and the `.hzperf` a report writes from them.
perf: horizon_client::server_perf::ServerPerf,
/// The perf clock's zero: the tick loop's start.
perf_epoch: Instant,
/// The Horizon switches a capture records.
horizon: horizon_perf_format::v1::HorizonOptions,
/// View and simulation distance, and the player limit, for the report.
view: (u32, u32),
max_players: usize,
/// `network-compression-threshold`, for the capture's settings.
compression: i32,
/// The mutable `gamemode` server property (`DedicatedServer.gameMode`).
game_mode: i32,
/// The mutable `force-gamemode` server property (`DedicatedServer.forceGameMode`).
@@ -897,28 +855,6 @@ fn auto_save(state: &ServerState) -> bool {
state.lists.lock().unwrap().autosave
}
/// `MinecraftServer.tickServer`'s pause-when-empty count. `pause_after` is its
/// `pauseWhenEmptySeconds() * 20`; while that is above 0, a tick with no player and no
/// `/tick sprint` running (`ServerTickRateManager.isSprinting`) adds one to `empty_ticks`, and any
/// other tick sets it back to 0. A `/pregen` task keeps the server busy as a player would: paused,
/// nothing would post-process its chunks. Returns whether the server is paused this tick.
fn count_empty_tick(
empty_ticks: &mut i32,
pause_after: i32,
players: usize,
pregen: bool,
sprinting: bool,
) -> bool {
if pause_after > 0 {
if players == 0 && !pregen && !sprinting {
*empty_ticks += 1;
} else {
*empty_ticks = 0;
}
}
pause_after > 0 && *empty_ticks >= pause_after
}
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
pub(super) fn set_auto_save(lists: &Mutex<Lists>, enabled: bool) -> bool {
let mut l = lists.lock().unwrap();
@@ -1103,17 +1039,9 @@ impl Game<'_> {
// now rather than at the first tick (reading parses functions; it runs none), and a broken
// predicate graph stops the start before anything listens (a partial check: predicate_graph.rs).
self.ensure_datapacks();
let store = &self.fns.store;
let tag = |id: &str| {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
store
.files
.contains_key(&format!("tags/{ns}:predicate/{path}"))
};
if let Err(error) = predicate_graph::check(&store.predicates, tag) {
if let Err(error) = predicate_graph::check(&self.fns.store.predicates) {
let level = match error {
predicate_graph::GraphError::Unbound(_)
| predicate_graph::GraphError::UnboundTags(_) => "ERROR",
predicate_graph::GraphError::Unbound(_) => "ERROR",
predicate_graph::GraphError::Recursive(_) => "WARN",
};
for line in error.lines() {
@@ -1307,7 +1235,6 @@ impl Game<'_> {
}
self.sync_ops(&lists);
let anticheat = load_anticheat();
let anticheat_on = anticheat.enabled;
let chat_filter = load_chat_filter();
self.set_pvp_legacy_rules(load_combat());
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
@@ -1365,44 +1292,12 @@ impl Game<'_> {
game_mode: props.game_mode,
force_game_mode: props.force_game_mode,
report: None,
memory_due: None,
perf: horizon_client::server_perf::ServerPerf::new(
0.0,
self.options.spikes.unwrap_or(SLOW_TICK_MS),
),
perf_epoch: Instant::now(),
horizon: horizon_client::server_perf::horizon_options(
if self.lan.as_ref().is_some_and(|h| h.hub.far.is_some()) {
far as u32
} else {
0
},
horizon_client::lod_net::distance_property(
props.raw.get(horizon_client::lod_net::PROPERTY),
),
anticheat_on,
),
compression: props.network_compression_threshold,
view: (
props.view_distance.clamp(2, 32) as u32,
props.simulation_distance.clamp(2, 32) as u32,
),
max_players: props.max_players.max(0) as usize,
};
horizon_client::server_perf::warm_hardware();
if let Some(file) = std::env::var_os("HORIZON_TRACE").filter(|f| !f.is_empty()) {
self.record_report(
&mut state,
&report::Plan {
seconds: report::MAX_SECONDS,
samples: false,
timeline: true,
file: Some(file.into()),
trigger: Trigger::Env,
sampler: Default::default(),
},
);
}
let mut next = Instant::now();
// The panic that ended the tick loop, when one did.
let mut crashed: Option<String> = None;
@@ -1434,13 +1329,16 @@ impl Game<'_> {
// Vanilla reads pauseWhenEmptySeconds on every tick, so management changes take effect
// without restarting the server.
let pause_after = state.pause_when_empty_seconds.wrapping_mul(20);
let paused = count_empty_tick(
&mut state.empty_ticks,
pause_after,
players,
self.pregen_level().is_some(),
self.clock.sprinting(),
);
if pause_after > 0 {
// A `/pregen` task keeps the server busy as a player would:
// paused, nothing would post-process its chunks.
if players == 0 && self.pregen_level().is_none() {
state.empty_ticks += 1;
} else {
state.empty_ticks = 0;
}
}
let paused = pause_after > 0 && state.empty_ticks >= pause_after;
state.paused = paused;
if paused && state.empty_ticks == pause_after {
log(
@@ -1463,32 +1361,22 @@ impl Game<'_> {
// MinecraftServer.tickServer: while paused only the connections run.
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
// report and still stops the server (saves). A panic here does the same.
let slow_ms = state.perf.slow_threshold_ms();
let capturing = state.perf.capturing();
let mut tick_detail = None;
let ticked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut detail = |timing: super::levels::TickTiming| {
if capturing || timing.wall_ms > slow_ms {
tick_detail = Some(horizon_client::server_perf::TickDetail {
cpu_ms: timing.cpu_ms,
spans: timing.raw,
});
let ticked =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
match &mut state.report {
Some(recording) => {
let sampled = recording.sample_next();
super::tick_profile::set_sampling(sampled);
let ticked =
self.tick_server_spans(paused, |wall, cpu, spans, recorded| {
recording.add(wall, cpu, spans, recorded, sampled)
});
super::tick_profile::set_sampling(false);
ticked
}
None => self.tick_server_timed(paused),
}
};
match &mut state.report {
Some(recording) => {
let sampled = recording.sample_next();
super::tick_profile::set_sampling(sampled);
let ticked = self.tick_server_spans(paused, |timing| {
recording.add(sampled);
detail(timing)
});
super::tick_profile::set_sampling(false);
ticked
}
None => self.tick_server_spans(paused, detail),
}
}));
}));
match ticked {
Ok(result) => result?,
Err(payload) => {
@@ -1496,12 +1384,19 @@ impl Game<'_> {
break;
}
}
if state.report.as_ref().is_some_and(report::Recording::done) {
self.finish_perf_capture(&mut state, StopReason::Normal);
if state.report.as_ref().is_some_and(report::Recording::done)
&& let Some(recording) = state.report.take()
{
self.finish_report(recording, state.view, state.max_players);
}
if horizon_client::spans::trace::due()
&& let Some(events) = horizon_client::spans::trace::stop()
{
log(
"INFO",
&format!("Trace written: {events} events (reports/)"),
);
}
self.serve_perf_requests(&mut state);
let done = report::log_saved(&mut state.perf, false);
self.perf_captures_done(done);
// DedicatedServer.sendLowDiskSpaceWarning: the operators hear of it
// (no window drains the toasts here).
if horizon_client::chunk_io::take().low_disk_space
@@ -1517,14 +1412,6 @@ impl Game<'_> {
}
// MinecraftServer.tickServer records each tick's time, for /tick query (and the status).
self.clock.record(tick_start.elapsed());
state.perf.tick(
state.perf_epoch.elapsed().as_secs_f64(),
tick_start.elapsed().as_secs_f32() * 1000.0,
tick_detail.take(),
|| self.perf_world_counts(),
|| self.wire_bytes(),
);
self.poll_server_memory_snapshot(&mut state);
// The far ring's brake, once a second on the average tick.
if braked.elapsed() >= Duration::from_secs(1)
&& let Some(host) = self.lan.as_mut()
@@ -1550,9 +1437,6 @@ impl Game<'_> {
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
}
self.finish_perf_capture(&mut state, StopReason::Shutdown);
let done = report::log_saved(&mut state.perf, true);
self.perf_captures_done(done);
match crashed {
Some(message) => self.stop_after_crash(&message),
None => self.stop_dedicated(),
@@ -1638,15 +1522,12 @@ impl Game<'_> {
let Some(host) = self.lan.as_mut() else {
return;
};
let limit = u64::try_from(minutes).unwrap_or(0) * 1200;
let idle: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| {
let idle_ticks = host.hub.ticks.saturating_sub(g.last_action_tick);
let won_game = host.play.get(&g.conn).is_some_and(|play| play.won_game);
idles_out(idle_ticks, minutes, won_game)
})
.filter(|g| host.hub.ticks.saturating_sub(g.last_action_tick) > limit)
.map(|g| g.conn)
.collect();
for conn in idle {
@@ -1755,17 +1636,9 @@ impl Game<'_> {
/// bound to the overworld, whichever level the last server tick left in
/// place (the last one ticked, often the nether). Its commands run there
/// and the level in place is put back afterwards.
/// The source is the server's own: no entity, at the world spawn, its feedback heard
/// (the console's and an RCON client's always is).
fn as_console_in_overworld<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
let back = console_level(self.dim).and_then(|dim| self.enter_command_level(dim));
let source = super::commands::execute::Source {
silent: false,
..self.server_source()
};
let outer = self.command_source.replace(source);
let out = self.as_console(f);
self.command_source = outer;
if let Some(back) = back {
self.enter_command_level(back);
}
@@ -1798,63 +1671,73 @@ impl Game<'_> {
state.lists.lock().unwrap().stop = true;
}
// Horizon's own, on the console only: a performance report (report.rs).
"horizon-report" | "horizon-trace" => {
let trace = command == "horizon-trace";
let parsed = if trace {
report::parse_trace(&rest)
} else {
report::parse(&rest)
"horizon-report" if rest.first() == Some(&"stop") => match state.report.take() {
Some(recording) => {
log("INFO", "Stopping the performance report: writing it now");
self.finish_report(recording, state.view, state.max_players);
}
None => log("INFO", "No performance report is being recorded"),
},
"horizon-report" if rest.first() == Some(&"status") => match &state.report {
Some(recording) => log("INFO", &recording.status()),
None => log("INFO", "No performance report is being recorded"),
},
"horizon-report" => {
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
None => Ok(60),
Some(Ok(s)) if (1..=report::MAX_SECONDS).contains(&s) => Ok(s),
Some(_) => Err(()),
};
match parsed {
Err(()) if trace => log(
match (seconds, &state.report) {
(Err(()), _) => log(
"INFO",
&format!(
"horizon-trace [SECONDS | stop]: 1 to {} seconds, 30 by default",
"horizon-report [SECONDS | stop | status]: 1 to {} seconds, 60 by default",
report::MAX_SECONDS
),
),
Err(()) => log(
"INFO",
&format!(
"horizon-report [SECONDS | stop | status] [--no-samples] [--timeline]: 1 to {} seconds, 60 by default",
report::MAX_SECONDS
),
),
Ok(report::Command::Stop) if state.report.is_some() => {
log("INFO", "Stopping the performance report: writing it now");
self.finish_perf_capture(state, StopReason::Normal);
(Ok(_), Some(_)) => {
log("INFO", "A performance report is already being recorded")
}
Ok(report::Command::Stop) => {
log("INFO", "No performance report is being recorded")
}
Ok(report::Command::Status) => match &state.report {
Some(recording) => log("INFO", &recording.status()),
None => log("INFO", "No performance report is being recorded"),
},
Ok(report::Command::Record {
seconds,
samples,
timeline,
}) if state.report.is_none() => {
self.record_report(
state,
&report::Plan {
seconds,
samples,
timeline,
file: None,
trigger: Trigger::Console,
sampler: Default::default(),
},
);
(Ok(seconds), None) => {
state.report = Some(report::Recording::start(seconds, self.wire_bytes()));
log(
"INFO",
&format!("Recording a performance report for {seconds} s (reports/)"),
);
}
Ok(report::Command::Record { .. }) => {
log("INFO", "A performance report is already being recorded")
}
}
}
// Horizon's own, on the console only: every thread's spans as a timeline (report.rs).
"horizon-trace" if rest.first() == Some(&"stop") => {
match horizon_client::spans::trace::stop() {
Some(events) => log("INFO", &format!("Trace stopped: {events} events written")),
None => log("INFO", "No trace is being written"),
}
}
"horizon-trace" => {
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
None => Some(30),
Some(Ok(s)) if (1..=report::TRACE_MAX_SECONDS).contains(&s) => Some(s),
Some(_) => None,
};
match seconds.map(report::start_trace) {
None => log(
"INFO",
&format!(
"horizon-trace [SECONDS | stop]: 1 to {} seconds, 30 by default",
report::TRACE_MAX_SECONDS
),
),
Some(Ok(path)) => log(
"INFO",
&format!(
"Writing a trace for {} s to {} (open it in ui.perfetto.dev)",
seconds.unwrap_or_default(),
path.display()
),
),
Some(Err(e)) => log("ERROR", &format!("Could not start a trace: {e}")),
}
}
"save-all" => {
@@ -1983,7 +1866,12 @@ impl Game<'_> {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().ban(&user, reason.clone());
let added = state.lists.lock().unwrap().bans.add(BanEntry::new(
user.clone(),
Some("Server".into()),
None,
reason.clone(),
));
if added {
self.console_reply(Text::translate(
"commands.ban.success",
@@ -2023,7 +1911,12 @@ impl Game<'_> {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().ban_ip(&ip, reason.clone());
let added = state.lists.lock().unwrap().ip_bans.add(BanEntry::new(
ip.clone(),
Some("Server".into()),
None,
reason.clone(),
));
if !added {
self.console_reply(Text::translate("commands.banip.failed", &[]));
return;
@@ -2641,23 +2534,6 @@ mod tests {
assert!(parse_whitelist_bypass("not-a-uuid-at-all").is_err());
}
/// ServerGamePacketListenerImpl.tick: a player idle past `player-idle-timeout` minutes is kicked,
/// unless the End poem is showing (`ServerPlayer.wonGame`), which sends no action.
#[test]
fn a_player_watching_the_end_credits_is_not_kicked_for_idling() {
use super::idles_out;
// 5 minutes: 6000 ticks. One more is over the limit.
assert!(!idles_out(6000, 5, false));
assert!(idles_out(6001, 5, false));
assert!(!idles_out(6001, 5, true));
assert!(!idles_out(1_000_000, 5, true));
// The credits are over (won_game false again): the same idle time kicks.
assert!(idles_out(1_000_000, 5, false));
// 0 or less: no timeout.
assert!(!idles_out(1_000_000, 0, false));
assert!(!idles_out(1_000_000, -3, false));
}
/// `SaveAllCommand.saveAll`: a save that throws is the `ERROR_FAILED` the
/// source is told about, not a log line the caller never sees.
#[test]
@@ -2682,51 +2558,6 @@ mod tests {
);
}
/// MinecraftServer.tickServer: a tick only counts toward pause-when-empty with no player and
/// no `/tick sprint` running (`ServerTickRateManager.isSprinting`); a sprint on an empty server
/// keeps the count at 0, so it is never paused, and the count starts again after it.
#[test]
fn a_sprint_on_an_empty_server_does_not_count_toward_the_pause() {
let pause_after = 60 * 20;
let mut empty_ticks = 0;
// One server tick: (players, a `/pregen` task, a `/tick sprint`) -> paused.
let tick = |empty_ticks: &mut i32, players, pregen, sprinting| {
super::count_empty_tick(empty_ticks, pause_after, players, pregen, sprinting)
};
for _ in 0..2000 {
assert!(!tick(&mut empty_ticks, 0, false, true));
}
assert_eq!(empty_ticks, 0);
// The sprint ended: 1200 empty ticks later the server pauses, not before.
for n in 1..=pause_after {
assert_eq!(
tick(&mut empty_ticks, 0, false, false),
n == pause_after,
"tick {n}"
);
}
// A sprint that starts while paused resumes the server at once.
assert!(!tick(&mut empty_ticks, 0, false, true));
assert_eq!(empty_ticks, 0);
// A player, or a `/pregen` task, resets the count too.
empty_ticks = 5;
assert!(!tick(&mut empty_ticks, 1, false, false));
assert_eq!(empty_ticks, 0);
empty_ticks = 5;
assert!(!tick(&mut empty_ticks, 0, true, false));
assert_eq!(empty_ticks, 0);
// pause-when-empty-seconds=0 never pauses and leaves the count alone.
empty_ticks = 5;
assert!(!super::count_empty_tick(
&mut empty_ticks,
0,
0,
false,
false
));
assert_eq!(empty_ticks, 5);
}
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
/// named as unknown, so the crash line is never empty.
#[test]
@@ -2836,46 +2667,6 @@ mod tests {
}
}
/// `BanPlayerCommands.banPlayers` skips a player who `isBanned`, and `BanIpCommands.banIp` refuses an
/// address that is: the console's `/ban` and `/ban-ip` of one banned already change nothing (the first
/// reason stays, and the console hears "Nothing changed"), whatever reason they give.
#[test]
fn the_console_does_not_ban_again_who_is_banned_already() {
use super::{BanList, Lists, NameAndId};
let dir = std::env::temp_dir().join(format!("horizon-console-ban-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut lists = Lists {
ops: super::OpList::new(dir.join("ops.json")),
white: super::WhiteList::new(dir.join("whitelist.json")),
bans: BanList::new(dir.join("banned-players.json")),
ip_bans: super::IpBanList::new(dir.join("banned-ips.json")),
use_whitelist: false,
enforce_whitelist: false,
op_level: 4,
player_idle_timeout: 0,
stop: false,
autosave: true,
whitelist_bypass: Vec::new(),
};
let alpha = NameAndId {
id: 0x5ca1ab1e,
name: "Alpha".into(),
};
assert!(lists.ban(&alpha, Some("cheating".into())));
assert!(!lists.ban(&alpha, Some("again".into())));
assert!(!lists.ban(&alpha, None));
let reason = lists.bans.get(&alpha).and_then(|b| b.reason.clone());
assert_eq!(reason.as_deref(), Some("cheating"));
assert!(lists.ban_ip("1.2.3.4", Some("spam".into())));
assert!(!lists.ban_ip("1.2.3.4", Some("more spam".into())));
let reason = lists
.ip_bans
.get(&"1.2.3.4".to_owned())
.and_then(|b| b.reason.clone());
assert_eq!(reason.as_deref(), Some("spam"));
let _ = fs::remove_dir_all(&dir);
}
/// A temporary ban (`/tempban`, the chat filter's) keeps the player out and says when it ends;
/// once it has ended, or after `/pardon`, they come in.
#[test]
@@ -2959,85 +2750,3 @@ mod tests {
fs::remove_dir_all(dir).unwrap();
}
}
#[cfg(test)]
mod console_source_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
/// Runs `command` as the console does and returns whether it worked and every message it printed.
fn console(game: &mut Game<'_>, command: &str) -> (bool, String) {
game.chat_capture = Some(Vec::new());
let ok = game.as_console_in_overworld(|g| g.run_command(command));
let said = game.chat_capture.take().unwrap_or_default();
let said = said
.iter()
.map(|t| t.component().to_string())
.collect::<Vec<_>>()
.join("\n");
(ok, said)
}
/// `MinecraftServer.createCommandSourceStack`: the console's source has no entity and is named "Server"
/// (an RCON client's, `RconConsoleSource`, "Rcon"): `/me` says so, and `teammsg`, which needs an entity,
/// refuses it (`permissions.requires.entity`) instead of running as the player in place.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn the_console_runs_as_the_server_not_the_player_in_place() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
let (ok, said) = console(&mut game, "me waves");
assert!(
ok && said.contains("chat.type.emote") && said.contains("\"Server\""),
"{said}"
);
game.rcon_source = true;
let (ok, said) = console(&mut game, "me waves");
assert!(ok && said.contains("\"Rcon\""), "{said}");
game.rcon_source = false;
let (ok, said) = console(&mut game, "teammsg hi");
assert!(
!ok && said.contains("permissions.requires.entity"),
"{said}"
);
// The console's source is put back after each line.
assert!(game.command_source.is_none());
}
}
@@ -1,16 +1,13 @@
//! A partial check of the world's predicate graph before a dedicated server is Ready.
//!
//! Vanilla refuses to start a world whose packs' predicates reference a predicate no pack defines
//! (`MappedRegistry.freeze`: "Unbound values in registry"), name a predicate tag no pack defines
//! ("Unbound tags in registry"), or reference each other in a cycle
//! (`MappedRegistry.freeze`: "Unbound values in registry"), or reference each other in a cycle
//! (`ReloadableServerRegistries.validateLootRegistries`: a reference "is recursively called").
//!
//! This is new, partial validation, not a port of `RegistryDataLoader` or `ValidationContext`. It
//! follows a predicate's holders only where the condition codecs hold one: `all_of` and `any_of`
//! `terms`, and `inverted` `term`. A holder is an identifier (a reference) or an inline condition,
//! which is followed in the same way. `terms` is a `HolderSetCodec`: a list of holders, one holder
//! alone (`compactListCodec`, at `.terms[0]`), or a `#tag`. A tag must be defined; the entries of a
//! defined tag are not followed. Every other condition type and field, an identifier that does
//! which is followed in the same way. Every other condition type and field, an identifier that does
//! not parse, and the holders of loot tables, advancements and item modifiers are not checked.
use horizon_client_core::identifier::parse_identifier;
use serde_json::Value;
@@ -23,8 +20,6 @@ const REGISTRY: &str = "minecraft:predicate";
pub(super) enum GraphError {
/// Referenced ids that no predicate binds.
Unbound(BTreeSet<String>),
/// Predicate tags that a `terms` names and no pack defines.
UnboundTags(BTreeSet<String>),
/// References that re-enter a predicate while it is being validated.
Recursive(Vec<Recursion>),
}
@@ -44,10 +39,6 @@ impl GraphError {
"Unbound values in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
ids.iter().cloned().collect::<Vec<_>>().join(", ")
)],
GraphError::UnboundTags(ids) => vec![format!(
"Unbound tags in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
ids.iter().cloned().collect::<Vec<_>>().join(", ")
)],
GraphError::Recursive(found) => found
.iter()
.map(|r| {
@@ -71,16 +62,14 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
return Vec::new();
};
match ty.as_str() {
"minecraft:all_of" | "minecraft:any_of" => match condition.get("terms") {
Some(Value::Array(terms)) => terms
.iter()
.enumerate()
.map(|(i, term)| (format!(".terms[{i}]"), term))
.collect(),
Some(Value::String(tag)) if tag.starts_with('#') => Vec::new(),
Some(term) => vec![(".terms[0]".to_owned(), term)],
None => Vec::new(),
},
"minecraft:all_of" | "minecraft:any_of" => condition
.get("terms")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(|(i, term)| (format!(".terms[{i}]"), term))
.collect(),
"minecraft:inverted" => condition
.get("term")
.map(|term| vec![(".term".to_owned(), term)])
@@ -89,22 +78,12 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
}
}
/// The predicate tag an `all_of` or `any_of` names as its `terms` (`#ns:path`).
fn tag(condition: &Value) -> Option<String> {
let ty = parse_identifier(condition.get("type")?.as_str()?)?;
if !matches!(ty.as_str(), "minecraft:all_of" | "minecraft:any_of") {
return None;
}
parse_identifier(condition.get("terms")?.as_str()?.strip_prefix('#')?)
}
/// Every reference and tag a condition holds, inline conditions included.
fn references(condition: &Value, out: &mut Vec<String>, tags: &mut Vec<String>) {
tags.extend(tag(condition));
/// Every reference a condition holds, inline conditions included.
fn references(condition: &Value, out: &mut Vec<String>) {
for (_, holder) in holders(condition) {
match holder {
Value::String(name) => out.extend(parse_identifier(name)),
Value::Object(_) => references(holder, out, tags),
Value::Object(_) => references(holder, out),
_ => {}
}
}
@@ -146,17 +125,12 @@ fn walk<'v>(
}
}
/// The world's predicates (by normalized id, after pack precedence) and whether a pack defines a
/// predicate tag: missing references first, then missing tags, as the registry freezes before
/// anything is validated, then recursive references.
pub(super) fn check(
predicates: &BTreeMap<String, Value>,
has_tag: impl Fn(&str) -> bool,
) -> Result<(), GraphError> {
/// The world's predicates (by normalized id, after pack precedence): missing references first, as
/// the registry freezes before anything is validated, then recursive ones.
pub(super) fn check(predicates: &BTreeMap<String, Value>) -> Result<(), GraphError> {
let mut referenced = Vec::new();
let mut tags = Vec::new();
for condition in predicates.values() {
references(condition, &mut referenced, &mut tags);
references(condition, &mut referenced);
}
let unbound: BTreeSet<String> = referenced
.into_iter()
@@ -165,10 +139,6 @@ pub(super) fn check(
if !unbound.is_empty() {
return Err(GraphError::Unbound(unbound));
}
let unbound: BTreeSet<String> = tags.into_iter().filter(|id| !has_tag(id)).collect();
if !unbound.is_empty() {
return Err(GraphError::UnboundTags(unbound));
}
let mut found = Vec::new();
for (id, condition) in predicates {
let mut stack = vec![id.as_str()];
@@ -223,7 +193,7 @@ mod tests {
// An identifier that does not parse is not checked here.
("a:v", json!({"type": "inverted", "term": "Not An Id"})),
]);
assert_eq!(check(&predicates, |_| false), Ok(()));
assert_eq!(check(&predicates), Ok(()));
}
#[test]
@@ -235,7 +205,7 @@ mod tests {
),
("a:c", json!({"type": "inverted", "term": "a:c"})),
]);
let error = check(&predicates, |_| false).unwrap_err();
let error = check(&predicates).unwrap_err();
assert_eq!(
error,
GraphError::Unbound(["a:gone".to_owned(), "b:none".to_owned()].into())
@@ -264,7 +234,7 @@ mod tests {
json!({"type": "any_of", "terms": [{"type": "inverted", "term": "prg:self"}]}),
),
]);
let mut lines = check(&predicates, |_| false).unwrap_err().lines();
let mut lines = check(&predicates).unwrap_err().lines();
lines.sort();
// The two cycle lines are the ones vanilla 26.3 logged for this pack.
assert_eq!(
@@ -276,74 +246,4 @@ mod tests {
]
);
}
#[test]
fn a_lone_terms_holder_is_terms_0_and_a_tag_must_be_defined() {
let target = json!({"type": "minecraft:all_of", "terms": []});
// Vanilla 26.3 starts a world whose `terms` is one bound holder alone.
let valid = graph(&[
("prg:target", target.clone()),
("prg:ref", json!({"type": "all_of", "terms": "prg:target"})),
(
"prg:inline",
json!({"type": "any_of", "terms": {"type": "inverted", "term": "prg:target"}}),
),
(
"prg:tagged",
json!({"type": "any_of", "terms": "#prg:some"}),
),
]);
assert_eq!(check(&valid, |tag| tag == "prg:some"), Ok(()));
let missing = graph(&[
("prg:target", target.clone()),
(
"prg:ref",
json!({"type": "minecraft:all_of", "terms": "prg:missing"}),
),
(
"prg:tagged",
json!({"type": "all_of", "terms": "#prg:none"}),
),
]);
// Missing values first, then missing tags: the lines vanilla 26.3 logged for each.
assert_eq!(
check(&missing, |_| false).unwrap_err().lines(),
[
"Unbound values in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:missing]"
]
);
let tags = graph(&[
("prg:target", target.clone()),
(
"prg:ref",
json!({"type": "minecraft:all_of", "terms": "#prg:none"}),
),
]);
assert_eq!(
check(&tags, |_| false).unwrap_err().lines(),
[
"Unbound tags in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:none]"
]
);
let cycle = graph(&[
("prg:target", target),
(
"prg:ref",
json!({"type": "minecraft:any_of", "terms": "prg:loop"}),
),
(
"prg:loop",
json!({"type": "minecraft:inverted", "term": "prg:ref"}),
),
]);
let mut lines = check(&cycle, |_| false).unwrap_err().lines();
lines.sort();
assert_eq!(
lines,
[
"Found loot table element validation problem in {prg:loop@minecraft:predicate}.term->{prg:ref@minecraft:predicate}.terms[0]: prg:loop of type minecraft:predicate is recursively called",
"Found loot table element validation problem in {prg:ref@minecraft:predicate}.terms[0]->{prg:loop@minecraft:predicate}.term: prg:ref of type minecraft:predicate is recursively called",
]
);
}
}
Loaded 100 of 560 files, more files were not shown because too many files have changed in this diff. Show more