mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-12 13:03:44 -10:00
Compare commits
196
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851cf36dfc | ||
|
|
f24ee1accf | ||
|
|
327daa3899 | ||
|
|
ba91f75cb2 | ||
|
|
5164ada2a6 | ||
|
|
d0c091ae1d | ||
|
|
b789c435d0 | ||
|
|
7798130a18 | ||
|
|
474e9eb4c8 | ||
|
|
9fd3efbf81 | ||
|
|
c26677ed47 | ||
|
|
47b40ecd35 | ||
|
|
cccf79f63a | ||
|
|
77b12a46b1 | ||
|
|
3da49d7a02 | ||
|
|
eae9be3b4f | ||
|
|
f21daf7263 | ||
|
|
21a8b3bff1 | ||
|
|
1979dc6dc2 | ||
|
|
b2df8cee34 | ||
|
|
ec1fb9a182 | ||
|
|
cec503f33e | ||
|
|
5ccb6cd15b | ||
|
|
91491459d5 | ||
|
|
4cce8f8484 | ||
|
|
c7227eb3f7 | ||
|
|
fb85eb77e0 | ||
|
|
a3efbe017d | ||
|
|
239b96621a | ||
|
|
08b55819b1 | ||
|
|
d27e19390d | ||
|
|
ad491915a5 | ||
|
|
f356ceda41 | ||
|
|
add317d016 | ||
|
|
b944c970c3 | ||
|
|
3030aa3652 | ||
|
|
f65b2574e1 | ||
|
|
f80e0cfbf0 | ||
|
|
b97a7d0dec | ||
|
|
b6832dc940 | ||
|
|
7942b6d4c3 | ||
|
|
c7a26889ff | ||
|
|
46b588e72a | ||
|
|
2843049551 | ||
|
|
9e7925e2e9 | ||
|
|
81b07fb2d6 | ||
|
|
bd94713fdd | ||
|
|
a86ab9d4bc | ||
|
|
ccf6b2c913 | ||
|
|
e301d92133 | ||
|
|
fc9eb64042 | ||
|
|
fc9acc4a74 | ||
|
|
79b1df4051 | ||
|
|
31ae839a81 | ||
|
|
226fb118f8 | ||
|
|
4390baae27 | ||
|
|
aa3df3b4f7 | ||
|
|
51fa349fe4 | ||
|
|
034ffdbfef | ||
|
|
28fa20f13d | ||
|
|
ac784c6832 | ||
|
|
9cddcfb071 | ||
|
|
4039f0eeb6 | ||
|
|
c8213de26b | ||
|
|
e48ac4159f | ||
|
|
18c2782391 | ||
|
|
eb150b0297 | ||
|
|
88cc456f65 | ||
|
|
b2d4f65e77 | ||
|
|
bc8e4f3188 | ||
|
|
043ac0d8d9 | ||
|
|
219a8f22b0 | ||
|
|
d1049cff8d | ||
|
|
868acf52a0 | ||
|
|
b97ee11dfb | ||
|
|
3c44b85f13 | ||
|
|
aac293db9b | ||
|
|
a328e9c5db | ||
|
|
4272268cbc | ||
|
|
3ef0ebf52f | ||
|
|
3d11f0b6c6 | ||
|
|
db571535c2 | ||
|
|
7d9d35c729 | ||
|
|
6354608451 | ||
|
|
1fc4ccd439 | ||
|
|
ce9fdee6d4 | ||
|
|
8d1b4c8cca | ||
|
|
fee41659bd | ||
|
|
b0154fc77d | ||
|
|
2dbff9aefe | ||
|
|
dee8aecd7a | ||
|
|
3a0a7390bf | ||
|
|
e434d37311 | ||
|
|
544600c10a | ||
|
|
75e0f7387b | ||
|
|
285846c5ee | ||
|
|
9df9c5bde2 | ||
|
|
28561fd634 | ||
|
|
f3613a539d | ||
|
|
04a9f56511 | ||
|
|
5ea2d3c0fb | ||
|
|
be3db9da27 | ||
|
|
0d6d1f1f66 | ||
|
|
bc4559b024 | ||
|
|
993525d333 | ||
|
|
3bd70dbc06 | ||
|
|
0b4c261d0c | ||
|
|
caac772100 | ||
|
|
1d7e15c969 | ||
|
|
68685775d9 | ||
|
|
e87894a81d | ||
|
|
35d8ee793f | ||
|
|
78caa5b352 | ||
|
|
3597945405 | ||
|
|
87623d9714 | ||
|
|
71a09a89c0 | ||
|
|
18c344ff3c | ||
|
|
1b28cc6feb | ||
|
|
af837708aa | ||
|
|
ba04e6991d | ||
|
|
56cf9c3301 | ||
|
|
05925da3cd | ||
|
|
a072c2275f | ||
|
|
4e27118c3b | ||
|
|
415797f30a | ||
|
|
c283f0ce85 | ||
|
|
5bc42b54cf | ||
|
|
980844073d | ||
|
|
780a965ae2 | ||
|
|
7b7afe9a8f | ||
|
|
dc191e58df | ||
|
|
9f08382773 | ||
|
|
b787473097 | ||
|
|
89feab9f8f | ||
|
|
dd5fd7e80d | ||
|
|
e715b5b743 | ||
|
|
4dbc406616 | ||
|
|
c5a342f764 | ||
|
|
6efe76a432 | ||
|
|
958af813dd | ||
|
|
da474503d8 | ||
|
|
50386e55df | ||
|
|
fc1972ffe7 | ||
|
|
05b2a7afee | ||
|
|
4e71e99f98 | ||
|
|
8fc4c27855 | ||
|
|
35ddec5e9e | ||
|
|
9102edfb56 | ||
|
|
be4be0ce6b | ||
|
|
d7b9e267e0 | ||
|
|
769cdcd232 | ||
|
|
3d9ef21f68 | ||
|
|
6634e4ba98 | ||
|
|
ad01e692c9 | ||
|
|
1547313299 | ||
|
|
9874954fd0 | ||
|
|
2e68167379 | ||
|
|
14f833917a | ||
|
|
9ca66bdb02 | ||
|
|
0da566c2ed | ||
|
|
8883d77504 | ||
|
|
838d140cbc | ||
|
|
d2825d34f8 | ||
|
|
e6f80a4ee4 | ||
|
|
1d4d4feaf5 | ||
|
|
ff458a358d | ||
|
|
c61ca75ca4 | ||
|
|
cc52c95887 | ||
|
|
e0f6c4f5e8 | ||
|
|
91813c5ae0 | ||
|
|
a8a0d2cc25 | ||
|
|
8e88e778a6 | ||
|
|
7b5cf13614 | ||
|
|
1514d3d16a | ||
|
|
3e093c2c6f | ||
|
|
e9db26a19a | ||
|
|
267d39c511 | ||
|
|
3b3de54fa3 | ||
|
|
a1cf28d100 | ||
|
|
517546d1af | ||
|
|
cb7a5c204e | ||
|
|
7833ed6c05 | ||
|
|
8b7cc25b66 | ||
|
|
0689bc6505 | ||
|
|
e9e7050f32 | ||
|
|
afdac528fc | ||
|
|
875c7d7a2b | ||
|
|
9a2931de68 | ||
|
|
52128e9f79 | ||
|
|
e635a9b0b4 | ||
|
|
0d21d07827 | ||
|
|
b91b5516f3 | ||
|
|
be1de92536 | ||
|
|
b3be633edf | ||
|
|
ae1555ab94 | ||
|
|
ee3a790501 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# `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 --"
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets --locked -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --locked --no-default-features -- -D warnings
|
||||
cargo +stable clippy -p horizon-client -p horizon-game --all-targets --locked --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test --locked, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
|
||||
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
|
||||
cargo build --release --locked -p horizon-game --no-default-features --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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
|
||||
@@ -77,7 +77,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
|
||||
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`), also the dedicated server (`--dedicated`); `horizon-game` builds its executable. Re-exports `horizon-client-lib` for existing mod paths. |
|
||||
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
@@ -102,6 +102,7 @@ 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 |
|
||||
@@ -132,7 +133,7 @@ python3 tools/pr_ready.py # the self-check: fmt, clippy -D warn
|
||||
python3 tools/pr_ready.py --harnesses # also the affected harnesses (by default only when you change a harness, oracle, probe or the runner, as in CI)
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
cargo build --profile fast -p horizon-game # the game (bin horizon-client), optimized, quick to link: to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
@@ -186,9 +187,8 @@ python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
|
||||
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
|
||||
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
|
||||
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
|
||||
state stays separate from the library tests. This compiles gameplay twice for executable tests.
|
||||
(`crates/horizon-game/src/main.rs`) contains only the allocator, mod API marker and a call to
|
||||
`horizon_game::main()`, which runs `entry::main` with the platform's parts registered.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
@@ -246,7 +246,7 @@ When you change how the server and its clients talk (packets, tracking, guests,
|
||||
it there. Other gameplay fixes are covered by their own harness and CI's parity job:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-game -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ here.
|
||||
|
||||
```sh
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
|
||||
cargo run --release -p horizon-client
|
||||
cargo run --release -p horizon-game
|
||||
```
|
||||
|
||||
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
|
||||
|
||||
Generated
+344
-3
@@ -18,6 +18,15 @@ 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"
|
||||
@@ -129,6 +138,12 @@ 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"
|
||||
@@ -264,6 +279,12 @@ 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"
|
||||
@@ -559,7 +580,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1011,6 +1032,12 @@ 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"
|
||||
@@ -1109,6 +1136,18 @@ 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"
|
||||
@@ -1246,6 +1285,15 @@ 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"
|
||||
@@ -1426,7 +1474,7 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-telemetry",
|
||||
"horizon-system",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1482,6 +1530,8 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1561,6 +1611,8 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1615,6 +1667,8 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
@@ -1715,8 +1769,11 @@ name = "horizon-game"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-client",
|
||||
"horizon-core",
|
||||
"horizon-mods",
|
||||
"horizon-threads",
|
||||
"serde_json",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1832,6 +1889,7 @@ dependencies = [
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-perf-share",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
@@ -1856,12 +1914,54 @@ 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",
|
||||
@@ -1946,7 +2046,6 @@ dependencies = [
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-mcp",
|
||||
"horizon-platform",
|
||||
@@ -2030,9 +2129,14 @@ 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",
|
||||
]
|
||||
|
||||
@@ -2042,6 +2146,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"horizon-core",
|
||||
"horizon-threads",
|
||||
"libm",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
@@ -2283,6 +2388,15 @@ 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"
|
||||
@@ -2509,6 +2623,72 @@ 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"
|
||||
@@ -2551,6 +2731,28 @@ 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"
|
||||
@@ -2604,6 +2806,12 @@ 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"
|
||||
@@ -3184,6 +3392,15 @@ 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"
|
||||
@@ -3515,6 +3732,16 @@ 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"
|
||||
@@ -3539,6 +3766,96 @@ 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"
|
||||
@@ -3838,6 +4155,12 @@ 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"
|
||||
@@ -4396,6 +4719,18 @@ 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"
|
||||
@@ -4665,6 +5000,12 @@ 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"
|
||||
|
||||
@@ -37,6 +37,9 @@ 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",
|
||||
|
||||
@@ -119,7 +119,7 @@ git clone https://github.com/horizon-rust/horizon.git
|
||||
cd horizon
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
|
||||
cargo run --release -p horizon-client
|
||||
cargo run --release -p horizon-game
|
||||
```
|
||||
|
||||
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
|
||||
|
||||
@@ -35,6 +35,12 @@ 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"];
|
||||
|
||||
@@ -9,5 +9,7 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
# 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" }
|
||||
@@ -0,0 +1,434 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -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_telemetry::system::command_output as output;
|
||||
use horizon_system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -62,6 +62,7 @@ pub struct Display {
|
||||
pub refresh_hz: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
let probes = probes();
|
||||
@@ -69,12 +70,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu: horizon_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_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -85,13 +86,14 @@ 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_telemetry::system::storage(game_dir).map(str::to_owned),
|
||||
storage: horizon_system::storage(game_dir).map(str::to_owned),
|
||||
..Hardware::detect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the telemetry probes read (`horizon_telemetry::system`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// What `horizon_system::Machine` reads.
|
||||
struct Probes {
|
||||
cores: Option<u16>,
|
||||
base: Option<u32>,
|
||||
@@ -99,27 +101,31 @@ struct Probes {
|
||||
form: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn probes() -> Probes {
|
||||
let s = horizon_telemetry::payload::System::detect();
|
||||
let s = horizon_system::Machine::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_some(s.form),
|
||||
form: (!s.form.is_empty()).then(|| s.form.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[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_telemetry::system::power()
|
||||
horizon_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()?;
|
||||
@@ -132,6 +138,7 @@ 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| {
|
||||
@@ -140,17 +147,19 @@ 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_telemetry::system::windows_version()
|
||||
horizon_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(test)]
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -11,11 +11,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod compare;
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{power_source, Display, Gpu, Hardware};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use hardware::power_source;
|
||||
pub use hardware::{Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use horizon_system::{cpu_seconds, load_average, rss_bytes};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
@@ -236,6 +240,9 @@ 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>,
|
||||
@@ -246,6 +253,7 @@ 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())
|
||||
}
|
||||
@@ -263,6 +271,7 @@ impl Report {
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
ab: None,
|
||||
label: None,
|
||||
local: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ 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.
|
||||
@@ -202,7 +204,12 @@ impl Text {
|
||||
}
|
||||
}
|
||||
pub fn styled(s: impl Into<String>, style: Style) -> Self {
|
||||
Self::literal(s).with_style(&style)
|
||||
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
|
||||
}
|
||||
pub fn colored(s: impl Into<String>, color: u32) -> Self {
|
||||
Self::styled(s, Style::color(color))
|
||||
@@ -619,6 +626,9 @@ 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));
|
||||
}
|
||||
@@ -772,6 +782,27 @@ 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.
|
||||
|
||||
@@ -27,6 +27,8 @@ 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"
|
||||
@@ -130,6 +132,8 @@ 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"] }
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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" }
|
||||
@@ -74,6 +75,9 @@ 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.
|
||||
@@ -83,6 +87,7 @@ 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,13 +6,14 @@
|
||||
//! [--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]`
|
||||
//! [--world SAVE --leaf-culling MODE] [--memory on] [--gpu-detail on|off]`
|
||||
//! `--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::{
|
||||
@@ -24,6 +25,7 @@ use horizon_client_render::{
|
||||
gfx::{
|
||||
PreparedTextures, Renderer, ScreenshotRequest,
|
||||
capture::{self, AtlasCheck, Scene},
|
||||
pack_timing,
|
||||
panorama::Faces,
|
||||
},
|
||||
graphics::LeafCullingMode,
|
||||
@@ -61,7 +63,10 @@ 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::Allocator = horizon_threads::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;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Options {
|
||||
@@ -84,6 +89,7 @@ struct Options {
|
||||
world: Option<PathBuf>,
|
||||
leaf_culling: LeafCullingMode,
|
||||
memory: bool,
|
||||
gpu_detail: bool,
|
||||
}
|
||||
|
||||
fn on_off(value: &str) -> Result<bool, String> {
|
||||
@@ -136,6 +142,7 @@ 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()
|
||||
@@ -400,6 +407,23 @@ 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),
|
||||
@@ -417,6 +441,9 @@ 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"
|
||||
@@ -424,6 +451,11 @@ 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,
|
||||
@@ -454,6 +486,7 @@ 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(())
|
||||
|
||||
@@ -247,9 +247,11 @@ mod pack_post;
|
||||
mod pack_shadow;
|
||||
mod pack_sky;
|
||||
mod pack_textures;
|
||||
pub(crate) mod pack_timing;
|
||||
pub 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;
|
||||
@@ -338,42 +340,6 @@ 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
|
||||
@@ -1474,10 +1440,14 @@ 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 `HORIZON_SHADER_PACK_TIMING`).
|
||||
/// Time the shader pack's passes on the GPU (F3, or a capture made with `--gpu`).
|
||||
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.
|
||||
@@ -5086,8 +5056,10 @@ impl Renderer {
|
||||
pack_post: None,
|
||||
pack_loading: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
pass_timing: false,
|
||||
pass_detail: false,
|
||||
pass_timing_gap: std::time::Duration::ZERO,
|
||||
keep_timed_frames: false,
|
||||
own_timing: None,
|
||||
pack_entity_rest: Vec::new(),
|
||||
cloud_bindings,
|
||||
@@ -7449,51 +7421,6 @@ 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`).
|
||||
@@ -8102,6 +8029,8 @@ 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 {
|
||||
@@ -8521,6 +8450,7 @@ 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
|
||||
@@ -9869,6 +9799,7 @@ 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");
|
||||
@@ -10393,7 +10324,8 @@ impl Renderer {
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
let world_pixels = pixel_count(world_depth);
|
||||
let first_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,
|
||||
@@ -10413,9 +10345,16 @@ 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
|
||||
@@ -10502,16 +10441,15 @@ impl Renderer {
|
||||
.forget_lifetime();
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass
|
||||
pack_timing::CountedPass::plain(pass)
|
||||
};
|
||||
// The occlusion test's pipeline draws into the world's colour alone.
|
||||
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
|
||||
let mut pass =
|
||||
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
|
||||
.forget_lifetime();
|
||||
let mut pass = after_culling_pass(encoder, world_view, world_depth, true, None)
|
||||
.forget_lifetime();
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass
|
||||
pack_timing::CountedPass::plain(pass)
|
||||
};
|
||||
if let Some(surface) = &surface {
|
||||
drop(pass);
|
||||
@@ -10565,6 +10503,7 @@ 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);
|
||||
@@ -10597,6 +10536,7 @@ 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.
|
||||
@@ -10606,6 +10546,8 @@ 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());
|
||||
@@ -10619,10 +10561,12 @@ 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);
|
||||
@@ -10660,6 +10604,7 @@ 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, &[]);
|
||||
}
|
||||
@@ -10684,6 +10629,8 @@ 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());
|
||||
@@ -10696,6 +10643,7 @@ impl Renderer {
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
world_pixels,
|
||||
),
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -10705,6 +10653,7 @@ 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(
|
||||
@@ -10752,6 +10701,8 @@ 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());
|
||||
@@ -10764,6 +10715,7 @@ impl Renderer {
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
world_pixels,
|
||||
),
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -10772,6 +10724,7 @@ 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);
|
||||
@@ -10798,6 +10751,7 @@ 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);
|
||||
@@ -10814,6 +10768,7 @@ 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),
|
||||
);
|
||||
@@ -10839,6 +10794,7 @@ 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(..));
|
||||
@@ -10850,6 +10806,7 @@ 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(..));
|
||||
@@ -10859,6 +10816,7 @@ 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(..));
|
||||
@@ -10874,6 +10832,7 @@ 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),
|
||||
);
|
||||
@@ -10909,6 +10868,7 @@ 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,
|
||||
@@ -10929,6 +10889,7 @@ 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(..));
|
||||
@@ -10941,6 +10902,7 @@ 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(..));
|
||||
@@ -10954,6 +10916,7 @@ 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(..));
|
||||
@@ -10966,6 +10929,7 @@ 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);
|
||||
@@ -10976,6 +10940,7 @@ 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(..));
|
||||
@@ -10992,6 +10957,7 @@ 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;
|
||||
@@ -11017,20 +10983,24 @@ 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);
|
||||
@@ -11058,6 +11028,7 @@ 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);
|
||||
@@ -11231,9 +11202,11 @@ 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);
|
||||
}
|
||||
@@ -11266,6 +11239,8 @@ 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,
|
||||
@@ -11280,8 +11255,11 @@ 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, &[]);
|
||||
@@ -11304,6 +11282,7 @@ impl Renderer {
|
||||
translucent_draws,
|
||||
);
|
||||
}
|
||||
pass.split(self.own_timing.as_mut(), "after translucent");
|
||||
}
|
||||
}
|
||||
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
|
||||
@@ -11311,6 +11290,7 @@ 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, &[]);
|
||||
@@ -11339,6 +11319,7 @@ 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(..));
|
||||
@@ -11361,9 +11342,11 @@ 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(..));
|
||||
@@ -11374,6 +11357,7 @@ 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, &[]);
|
||||
@@ -11385,6 +11369,7 @@ 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()
|
||||
@@ -11396,6 +11381,7 @@ 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);
|
||||
@@ -11408,6 +11394,7 @@ 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(..));
|
||||
@@ -11420,9 +11407,11 @@ 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);
|
||||
@@ -12410,6 +12399,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some(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 {
|
||||
|
||||
@@ -71,7 +71,7 @@ use super::{
|
||||
Frustum, SectionKey,
|
||||
arena::{Arena, try_create},
|
||||
occlusion::Reach,
|
||||
pack_timing::PassTiming,
|
||||
pack_timing::{PassTiming, compute_parts},
|
||||
resident,
|
||||
submit::Queue,
|
||||
upload::Uploads,
|
||||
@@ -374,6 +374,21 @@ 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
|
||||
@@ -1190,12 +1205,13 @@ 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)
|
||||
self.prepare_buffers(device, queue, uploads, encoder, input, timing)
|
||||
.unwrap_or(Prepared::OutOfMemory)
|
||||
}
|
||||
|
||||
@@ -1206,6 +1222,7 @@ 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:
|
||||
@@ -1287,6 +1304,9 @@ 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
|
||||
@@ -1352,6 +1372,9 @@ 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 {
|
||||
@@ -1403,23 +1426,24 @@ 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);
|
||||
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);
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The kept sections' solid draws, a count draw per page, with the quad
|
||||
@@ -1494,19 +1518,22 @@ 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);
|
||||
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);
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// After the frame's world passes (phase 2's counts too), in the same
|
||||
@@ -2720,6 +2747,11 @@ 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())
|
||||
{
|
||||
@@ -2727,7 +2759,7 @@ mod tests {
|
||||
}
|
||||
eprintln!("GPU culling on {} ({name})", adapter.get_info().name);
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features: needed,
|
||||
required_features: needed | timing,
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()
|
||||
@@ -2834,6 +2866,11 @@ 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;
|
||||
@@ -2981,6 +3018,9 @@ 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,
|
||||
@@ -3000,11 +3040,18 @@ mod tests {
|
||||
camera: camera + DVec3::Y * 2.0e7,
|
||||
..input
|
||||
};
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up);
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up, None);
|
||||
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);
|
||||
let prepared = gpu.prepare(
|
||||
device,
|
||||
queue,
|
||||
&mut uploads,
|
||||
&mut encoder,
|
||||
&input,
|
||||
timing.as_mut(),
|
||||
);
|
||||
assert_eq!(prepared, Prepared::Gpu, "case {case}");
|
||||
match case {
|
||||
4 => assert!(
|
||||
@@ -3060,12 +3107,15 @@ mod tests {
|
||||
culled += 1;
|
||||
}
|
||||
}
|
||||
gpu.dispatch(&mut encoder, None);
|
||||
gpu.dispatch(&mut encoder, timing.as_mut());
|
||||
// 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);
|
||||
@@ -3124,6 +3174,18 @@ 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!(
|
||||
@@ -3136,6 +3198,113 @@ 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
|
||||
@@ -3377,7 +3546,7 @@ struct Quad {
|
||||
view_proj: Mat4::IDENTITY,
|
||||
occlude: false,
|
||||
};
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input, None);
|
||||
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
|
||||
let mut params = tables(&input, &mut words).expect("the planes fit");
|
||||
params.pages = gpu.pass_pages;
|
||||
|
||||
@@ -696,13 +696,16 @@ impl Oit {
|
||||
),
|
||||
] {
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
let 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
@@ -0,0 +1,169 @@
|
||||
//! 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, startup_step, surface_format,
|
||||
timing,
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, pack_timing, 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,6 +135,7 @@ 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)
|
||||
|
||||
@@ -239,17 +239,17 @@ impl Visbuffer {
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
clear: bool,
|
||||
timing: Option<&mut PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
mut timing: Option<&mut PassTiming>,
|
||||
) -> super::pack_timing::CountedPass<'e> {
|
||||
let (ids, _) = self.ids.as_ref().expect("fitted before the world pass");
|
||||
let name = if clear {
|
||||
"Visibility IDs"
|
||||
} else {
|
||||
"Visibility IDs after culling"
|
||||
};
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon visibility buffer"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(name)),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(name)),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: ids,
|
||||
resolve_target: None,
|
||||
@@ -272,7 +272,8 @@ 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
|
||||
|
||||
@@ -1401,9 +1401,9 @@ impl HudAtlas {
|
||||
edition.3 = pixels;
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
// 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() {
|
||||
// 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()) {
|
||||
if !images.iter().any(|i| i.0 == image.0) {
|
||||
images.push(image);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ 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;
|
||||
@@ -102,6 +103,9 @@ 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;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//! 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))
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
@@ -58,10 +52,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) || trace::on()
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || timeline::on()
|
||||
}
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
|
||||
/// print it.
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): spans recorded
|
||||
/// by `record_here` alone keep wall time only.
|
||||
#[inline]
|
||||
fn cpu_if_enabled() -> Option<Duration> {
|
||||
if ENABLED.load(Ordering::Relaxed) {
|
||||
@@ -82,31 +76,41 @@ 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 };
|
||||
return Guard {
|
||||
open: None,
|
||||
_heap,
|
||||
outer,
|
||||
};
|
||||
}
|
||||
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 ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let elapsed = started.elapsed();
|
||||
let ms = 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));
|
||||
trace::span(name, started, ms);
|
||||
timeline::span(name, started, elapsed, depth);
|
||||
push(Span {
|
||||
name,
|
||||
ms,
|
||||
@@ -115,6 +119,22 @@ 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| {
|
||||
@@ -125,22 +145,6 @@ 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
|
||||
@@ -173,34 +177,36 @@ 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>)>);
|
||||
pub struct Laps(Option<(Instant, Option<Duration>, usize)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled())))
|
||||
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled(), recorded())))
|
||||
}
|
||||
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)) = &mut self.0 else {
|
||||
let Some((started, cpu, mark)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
let now_cpu = cpu.and_then(|_| thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let elapsed = now - *started;
|
||||
let ms = elapsed.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);
|
||||
trace::span(name, *started, ms);
|
||||
timeline::span(name, *started, elapsed, depth);
|
||||
nest_since(*mark, depth);
|
||||
push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
});
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
(*started, *cpu, *mark) = (now, now_cpu, recorded());
|
||||
}
|
||||
}
|
||||
/// The CPU time this thread has used, where the platform tells it: a tick
|
||||
@@ -283,173 +289,176 @@ pub fn merge(spans: &[Span]) -> Vec<Span> {
|
||||
sums.sort_by(|a, b| b.ms.total_cmp(&a.ms));
|
||||
sums
|
||||
}
|
||||
/// `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 {
|
||||
/// 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 {
|
||||
use std::{
|
||||
cell::Cell,
|
||||
fs::File,
|
||||
io::{BufWriter, Write as _},
|
||||
path::Path,
|
||||
cell::RefCell,
|
||||
sync::{
|
||||
Mutex, PoisonError,
|
||||
Arc, Mutex, PoisonError,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
time::{Duration, 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 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! {
|
||||
/// 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);
|
||||
static GENERATION: AtomicU32 = AtomicU32::new(0);
|
||||
static RUN: Mutex<Option<Run>> = Mutex::new(None);
|
||||
|
||||
struct Sink {
|
||||
file: BufWriter<File>,
|
||||
struct Run {
|
||||
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;
|
||||
}
|
||||
threads: Vec<Arc<Mutex<Thread>>>,
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
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>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static MINE: RefCell<Option<Mine>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(old) = slot.take() {
|
||||
close(old);
|
||||
/// 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;
|
||||
}
|
||||
*slot = Some(sink);
|
||||
TRACES.fetch_add(1, Ordering::Relaxed);
|
||||
*run = Some(Run {
|
||||
origin,
|
||||
threads: Vec::new(),
|
||||
});
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
ON.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
true
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// Stops recording and returns what the threads finished since the last drain.
|
||||
pub fn finish() -> Vec<ThreadEvents> {
|
||||
ON.store(false, Ordering::Relaxed);
|
||||
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
let events = sink.events;
|
||||
close(sink);
|
||||
Some(events)
|
||||
let run = RUN.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
run.as_ref().map_or_else(Vec::new, take_all)
|
||||
}
|
||||
|
||||
/// 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 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()
|
||||
}
|
||||
|
||||
fn close(mut sink: Sink) {
|
||||
let _ = sink.file.write_all(b"\n]\n");
|
||||
let _ = sink.file.flush();
|
||||
/// 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
|
||||
}
|
||||
};
|
||||
Some(Mine {
|
||||
generation: GENERATION.load(Ordering::Relaxed),
|
||||
origin: run.origin,
|
||||
thread,
|
||||
})
|
||||
}
|
||||
|
||||
/// A span of this thread from `started`, `ms` long.
|
||||
pub(super) fn span(name: &'static str, started: Instant, ms: f32) {
|
||||
/// 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) {
|
||||
if !on() {
|
||||
return;
|
||||
}
|
||||
let tid = THREAD.with(|t| {
|
||||
if t.get() == 0 {
|
||||
t.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,6 +663,46 @@ 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]
|
||||
@@ -680,8 +729,7 @@ mod tests {
|
||||
assert!(super::take_notes().is_empty(), "taking them clears them");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Spans of one name add up, the slowest first; a span that waited keeps its small CPU time.
|
||||
#[test]
|
||||
fn spans_sum_by_name_and_show_waits() {
|
||||
super::SPANS.with(|s| {
|
||||
@@ -723,7 +771,6 @@ 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());
|
||||
}
|
||||
|
||||
@@ -843,6 +890,22 @@ 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")))]
|
||||
@@ -865,4 +928,74 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,37 +28,7 @@ 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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
pub mod job_stats;
|
||||
/// 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
|
||||
@@ -1070,6 +1040,8 @@ 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;
|
||||
@@ -1683,6 +1655,7 @@ 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());
|
||||
@@ -1708,6 +1681,7 @@ 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`).
|
||||
@@ -1757,6 +1731,7 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
hz_laps.lap("st_results");
|
||||
self.free(stale);
|
||||
let mut removed = Vec::new();
|
||||
if sweep {
|
||||
@@ -1827,6 +1802,7 @@ 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)
|
||||
@@ -1858,6 +1834,7 @@ 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`).
|
||||
@@ -1983,7 +1960,9 @@ 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
|
||||
@@ -2019,6 +1998,7 @@ 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();
|
||||
@@ -2059,6 +2039,7 @@ 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());
|
||||
@@ -2091,6 +2072,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! 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,6 +1,5 @@
|
||||
[package]
|
||||
name = "horizon-client"
|
||||
default-run = "horizon-client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -26,6 +25,7 @@ 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" }
|
||||
@@ -134,7 +134,9 @@ 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 = []
|
||||
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"]
|
||||
# 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"]
|
||||
@@ -143,18 +145,10 @@ windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
[[bin]]
|
||||
name = "horizon-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "datapack_biome_sync"
|
||||
path = "tests/datapack_biome_sync.rs"
|
||||
|
||||
[[test]]
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "mod_api_hooks"
|
||||
path = "tests/mod_api_hooks.rs"
|
||||
@@ -169,7 +163,8 @@ path = "examples/wind_burst_hook_probe.rs"
|
||||
|
||||
[build-dependencies]
|
||||
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
|
||||
# The build script runs on the host, but embeds resources based on the target OS.
|
||||
# The build script runs on the host, but embeds resources based on the target OS (for the
|
||||
# launcher's native-mod game package, which uses this build script).
|
||||
winresource = "0.1"
|
||||
|
||||
[lints]
|
||||
@@ -178,5 +173,6 @@ 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"] }
|
||||
@@ -1129,6 +1129,34 @@
|
||||
"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",
|
||||
@@ -1164,5 +1192,25 @@
|
||||
"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.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."
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 335 B |
@@ -1,5 +1,7 @@
|
||||
//! Generates the native mod API, and gives the Windows executable Horizon's icon and its stack
|
||||
//! (`packaging/windows/exe.rs`, shared with the official executable's build script).
|
||||
//! Generates the native mod API. The launcher's native-mod game package (`horizon-native-game`,
|
||||
//! which owns the `horizon-client` executable of a build with native mods) runs this build script
|
||||
//! too: there it also gives the Windows executable Horizon's icon and its stack
|
||||
//! (`packaging/windows/exe.rs`). horizon-client itself has no executable (horizon-game's).
|
||||
#[path = "../../packaging/windows/exe.rs"]
|
||||
mod windows_exe;
|
||||
|
||||
@@ -21,5 +23,7 @@ fn main() {
|
||||
if !staged.is_empty() {
|
||||
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
|
||||
}
|
||||
windows_exe::resources("horizon-client");
|
||||
if std::env::var("CARGO_PKG_NAME").as_deref() != Ok("horizon-client") {
|
||||
windows_exe::resources("horizon-client");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
|
||||
@@ -54,14 +55,105 @@ const FLIGHT_SECONDS: f64 = 12.0;
|
||||
const SETTLE: Duration = Duration::from_secs(2);
|
||||
const LOAD_LIMIT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// `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]),
|
||||
/// 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(", "))
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--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,
|
||||
@@ -73,6 +165,10 @@ 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.
|
||||
@@ -150,8 +246,7 @@ fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bo
|
||||
}
|
||||
|
||||
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
let scenes =
|
||||
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
|
||||
let scenario = Scenario::parse(&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();
|
||||
@@ -191,85 +286,12 @@ 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();
|
||||
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);
|
||||
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)?,
|
||||
}
|
||||
|
||||
let status = control.status();
|
||||
@@ -287,8 +309,12 @@ 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);
|
||||
c.repeats = Some(1);
|
||||
c.warmup_s = Some(SETTLE.as_secs_f64());
|
||||
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);
|
||||
if let Some(bytes) = renderer["gpu_allocated_bytes"].as_f64() {
|
||||
report.metric("memory.gpu_mb", &[bytes / f64::from(1 << 20)]);
|
||||
}
|
||||
@@ -312,9 +338,342 @@ 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);
|
||||
report
|
||||
let written = report
|
||||
.write_to(&setup.game_dir.join("benchmarks"))
|
||||
.map_err(|e| e.to_string())
|
||||
.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(())
|
||||
}
|
||||
|
||||
/// Removes the previous run's world, never one the benchmark didn't create.
|
||||
@@ -472,11 +831,65 @@ 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() {
|
||||
assert_eq!(scenes("standard").unwrap().len(), 4);
|
||||
assert_eq!(scenes("ocean").unwrap()[0].0, "ocean");
|
||||
assert!(scenes("nether").is_none());
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -588,6 +1001,325 @@ 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,12 +589,22 @@ 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", "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!("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!("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"],
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -43,6 +43,7 @@ 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;
|
||||
@@ -107,6 +108,7 @@ 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;
|
||||
@@ -129,6 +131,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
//! `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
@@ -10,6 +10,7 @@
|
||||
//! 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},
|
||||
@@ -20,6 +21,7 @@ use std::{
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -497,6 +499,7 @@ mod tests {
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The check agrees with the system at every length around its limit.
|
||||
#[cfg(unix)]
|
||||
@@ -602,6 +605,22 @@ 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();
|
||||
@@ -625,6 +644,67 @@ 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,
|
||||
@@ -641,6 +721,15 @@ 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,
|
||||
@@ -717,17 +806,77 @@ impl FrameStats {
|
||||
self.static_entities_drawn += stats.static_entities_drawn;
|
||||
self.dynamic_entities_drawn += stats.dynamic_entities_drawn;
|
||||
}
|
||||
/// Every recorded frame time, in milliseconds, in order.
|
||||
pub fn frame_millis(&self) -> &[f32] {
|
||||
&self.millis
|
||||
/// 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();
|
||||
}
|
||||
/// CPU time of every recorded frame, in milliseconds.
|
||||
pub fn cpu_millis(&self) -> &[f32] {
|
||||
&self.cpu
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// GPU time of every frame the GPU timed, in milliseconds.
|
||||
pub fn gpu_millis(&self) -> &[f32] {
|
||||
&self.gpu
|
||||
/// 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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.millis.clear();
|
||||
@@ -739,6 +888,11 @@ 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();
|
||||
@@ -789,6 +943,7 @@ 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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.
|
||||
@@ -148,6 +149,76 @@ 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;
|
||||
@@ -547,6 +618,7 @@ 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()
|
||||
@@ -584,6 +656,88 @@ 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 = [
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
|
||||
@@ -219,6 +219,7 @@ 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;
|
||||
@@ -362,6 +363,8 @@ mod paintings;
|
||||
mod pandas;
|
||||
mod particle_culling;
|
||||
mod passengers;
|
||||
mod perf_link;
|
||||
mod perf_remote;
|
||||
mod persist;
|
||||
mod perspective;
|
||||
mod physical_trigger;
|
||||
@@ -384,6 +387,8 @@ mod potent_sulfur;
|
||||
mod potions;
|
||||
mod powder_snow;
|
||||
mod prediction;
|
||||
mod profile_capture;
|
||||
mod profile_lines;
|
||||
mod pumpkin;
|
||||
mod quick_actions;
|
||||
mod raids;
|
||||
@@ -2322,6 +2327,8 @@ 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>>,
|
||||
@@ -2359,9 +2366,6 @@ 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).
|
||||
@@ -2805,6 +2809,11 @@ 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.
|
||||
@@ -2813,7 +2822,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>)>,
|
||||
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>, bool)>,
|
||||
/// 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`.
|
||||
@@ -2869,6 +2878,8 @@ 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).
|
||||
@@ -9762,9 +9773,14 @@ impl Game<'_> {
|
||||
let debug_passes = self.debug_lists_gpu_passes();
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => {
|
||||
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 {
|
||||
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 {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
horizon_client::debug_overlay::GPU_TIMING_GAP
|
||||
@@ -11047,12 +11063,8 @@ impl Game<'_> {
|
||||
self.observe_fullscreen();
|
||||
self.sync_ime();
|
||||
let now = Instant::now();
|
||||
let frame_cpu = self
|
||||
.options
|
||||
.spikes
|
||||
.and_then(|_| horizon_client::spans::thread_cpu());
|
||||
horizon_threads::work::tick_start();
|
||||
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);
|
||||
@@ -11140,46 +11152,19 @@ 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 slow-frame line's, and the session's (`perf.frame`).
|
||||
// The frame's spans, taken every frame they are recorded so they never pile up.
|
||||
let frame_spans = if horizon_client::spans::recording_here() {
|
||||
horizon_client::spans::take_raw()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut perf_times = None;
|
||||
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),
|
||||
@@ -11259,6 +11244,17 @@ 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).
|
||||
@@ -11299,7 +11295,6 @@ 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
|
||||
@@ -11383,6 +11378,9 @@ 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,
|
||||
@@ -11499,6 +11497,7 @@ impl<'a> Game<'a> {
|
||||
pause: false,
|
||||
debug: false,
|
||||
gpu_passes: false,
|
||||
gpu_pass_detail: false,
|
||||
screen: Screen::Playing,
|
||||
observed_screen: None,
|
||||
window: None,
|
||||
@@ -11521,7 +11520,6 @@ impl<'a> Game<'a> {
|
||||
started: now,
|
||||
spectator_gui: Default::default(),
|
||||
previous: now,
|
||||
frame_end: now,
|
||||
last_input: now,
|
||||
pad: Default::default(),
|
||||
frames: 0,
|
||||
@@ -11775,6 +11773,7 @@ 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,
|
||||
@@ -11785,6 +11784,7 @@ 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,
|
||||
@@ -12279,10 +12279,7 @@ pub fn run_with() -> Result<()> {
|
||||
horizon_threads::catch_stop_signals();
|
||||
}
|
||||
let started = Instant::now();
|
||||
// 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.
|
||||
// A dedicated server times its spans always, so its slow ring holds the steps of a lag spike (microseconds a tick).
|
||||
if options.spikes.is_none()
|
||||
&& options.execution_role == execution_role::ExecutionRole::DedicatedServer
|
||||
{
|
||||
|
||||
@@ -15,6 +15,28 @@ 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();
|
||||
@@ -65,7 +87,12 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match self.world.block_entities.get(&pos) {
|
||||
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 {
|
||||
Some(BlockEntity::Chest(items)) if items.len() == HOPPER_SLOTS => {
|
||||
self.tick_hopper(pos)
|
||||
}
|
||||
@@ -100,6 +127,7 @@ 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,6 +224,7 @@ 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.
|
||||
@@ -271,6 +272,7 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
});
|
||||
horizon_threads::work::clear_work();
|
||||
laps = horizon_client::spans::laps();
|
||||
let sent = self.lan_broadcast_changes();
|
||||
laps.lap("tb_broadcast");
|
||||
|
||||
@@ -1765,6 +1765,82 @@ 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,
|
||||
@@ -1788,6 +1864,7 @@ 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
|
||||
|
||||
@@ -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::SuggestCommand(_) | ClickEvent::OpenFile(_) | ClickEvent::Profiler(_) => {}
|
||||
}
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
|
||||
@@ -33,6 +33,18 @@ 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},
|
||||
@@ -155,7 +167,7 @@ impl Game<'_> {
|
||||
Some(tree) => tree,
|
||||
None => horizon_client::command::vanilla::game_dispatcher(),
|
||||
};
|
||||
with_studio_commands(&with_native_commands(base, &names))
|
||||
with_studio_commands(&with_native_commands(base, &names)).with_profiler()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1403,6 +1415,12 @@ 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() {
|
||||
@@ -1530,6 +1548,14 @@ 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),
|
||||
@@ -2100,6 +2126,29 @@ 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]
|
||||
|
||||
@@ -310,7 +310,7 @@ impl Game<'_> {
|
||||
self.command_source = outer;
|
||||
ok
|
||||
}
|
||||
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
|
||||
pub(in crate::game) 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());
|
||||
@@ -2487,17 +2487,7 @@ impl Game<'_> {
|
||||
self.item_frames.retain(|f| f.id != id);
|
||||
}
|
||||
Target::Painting(id) => self.paintings.retain(|p| p.id != 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::Boat(id) => self.kill_boat(id),
|
||||
Target::Cart(id) => {
|
||||
self.release_vehicle_container(id);
|
||||
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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;
|
||||
@@ -59,8 +60,9 @@ 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 reset stats, wait, then reply with the summary",
|
||||
"bench SECONDS [raw] reset stats, wait, then reply with the summary (raw: every frame time too)",
|
||||
"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",
|
||||
@@ -125,9 +127,7 @@ 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 {
|
||||
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();
|
||||
self.add_raw_series(&mut summary);
|
||||
}
|
||||
let _ = flight.reply.send(summary.to_string());
|
||||
}
|
||||
@@ -167,6 +167,14 @@ 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
|
||||
}
|
||||
@@ -177,6 +185,23 @@ 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 {
|
||||
@@ -184,16 +209,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
if let Some((deadline, reply)) = self.pending_bench.take() {
|
||||
if let Some((deadline, reply, raw)) = self.pending_bench.take() {
|
||||
if Instant::now() >= deadline {
|
||||
let _ = reply.send(self.summary_with_passes().to_string());
|
||||
let mut summary = self.summary_with_passes();
|
||||
if raw {
|
||||
self.add_raw_series(&mut summary);
|
||||
}
|
||||
let _ = reply.send(summary.to_string());
|
||||
} else {
|
||||
self.pending_bench = Some((deadline, reply));
|
||||
self.pending_bench = Some((deadline, reply, raw));
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -391,12 +421,21 @@ 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));
|
||||
}
|
||||
@@ -461,35 +500,17 @@ impl Game<'_> {
|
||||
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
|
||||
}
|
||||
"gpu-passes" => {
|
||||
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 })))
|
||||
(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,
|
||||
)))
|
||||
}
|
||||
// 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")?;
|
||||
@@ -579,7 +600,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_report())),
|
||||
"memory" => Ok(Some(self.memory_value())),
|
||||
"stall" => {
|
||||
let millis: u64 = arg(1)?.parse()?;
|
||||
if millis > 5000 {
|
||||
@@ -1188,9 +1209,7 @@ impl Game<'_> {
|
||||
"stats" => {
|
||||
let mut summary = self.summary_with_passes();
|
||||
if words[1..].contains(&"raw") {
|
||||
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();
|
||||
self.add_raw_series(&mut summary);
|
||||
}
|
||||
if words[1..].contains(&"reset") {
|
||||
self.reset_frame_stats();
|
||||
@@ -1210,6 +1229,7 @@ impl Game<'_> {
|
||||
self.pending_bench = Some((
|
||||
Instant::now() + Duration::from_secs_f64(seconds),
|
||||
reply.clone(),
|
||||
words[2..].contains(&"raw"),
|
||||
));
|
||||
Ok(None)
|
||||
}
|
||||
@@ -1773,6 +1793,35 @@ 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 {
|
||||
@@ -1810,6 +1859,39 @@ 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"] {
|
||||
@@ -1825,4 +1907,50 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,9 +333,7 @@ impl<M: MobSlot> Group<M> {
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
self.ticked.insert(self.mobs[i].mob().id);
|
||||
// `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 sampled = super::super::tick_profile::start_mob(&self.mobs[i].mob().kind);
|
||||
let kept = horizon_client::mobs::riders::tick(
|
||||
&mut self.mobs,
|
||||
i,
|
||||
@@ -343,9 +341,7 @@ impl<M: MobSlot> Group<M> {
|
||||
&mut self.random,
|
||||
&mut self.events,
|
||||
);
|
||||
if let Some((started, kind)) = sampled {
|
||||
super::super::tick_profile::add(shared.level_id, &kind, started);
|
||||
}
|
||||
super::super::tick_profile::end_mob(shared.level_id, sampled);
|
||||
let m = self.mobs[i].mob_mut();
|
||||
if seen_guest.is_none() {
|
||||
self.player = w.player;
|
||||
|
||||
@@ -174,6 +174,19 @@ 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
|
||||
|
||||
@@ -21,6 +21,7 @@ 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::{
|
||||
@@ -37,7 +38,7 @@ use std::{
|
||||
pub(super) mod events;
|
||||
mod management;
|
||||
mod predicate_graph;
|
||||
mod report;
|
||||
pub(super) mod report;
|
||||
mod status;
|
||||
mod transfer;
|
||||
|
||||
@@ -658,9 +659,7 @@ 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 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.
|
||||
/// A tick slower than this, in milliseconds, enters the slow ring (five ticks' worth, a stall players feel) unless `HORIZON_SPIKES` names another.
|
||||
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.
|
||||
@@ -806,9 +805,19 @@ 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`).
|
||||
@@ -1235,6 +1244,7 @@ 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`.
|
||||
@@ -1292,12 +1302,44 @@ 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;
|
||||
@@ -1361,22 +1403,32 @@ 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 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),
|
||||
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,
|
||||
});
|
||||
}
|
||||
}));
|
||||
};
|
||||
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) => {
|
||||
@@ -1384,19 +1436,12 @@ impl Game<'_> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
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/)"),
|
||||
);
|
||||
if state.report.as_ref().is_some_and(report::Recording::done) {
|
||||
self.finish_perf_capture(&mut state, StopReason::Normal);
|
||||
}
|
||||
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
|
||||
@@ -1412,6 +1457,14 @@ 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()
|
||||
@@ -1437,6 +1490,9 @@ 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(),
|
||||
@@ -1671,73 +1727,63 @@ impl Game<'_> {
|
||||
state.lists.lock().unwrap().stop = true;
|
||||
}
|
||||
// Horizon's own, on the console only: a performance report (report.rs).
|
||||
"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(()),
|
||||
"horizon-report" | "horizon-trace" => {
|
||||
let trace = command == "horizon-trace";
|
||||
let parsed = if trace {
|
||||
report::parse_trace(&rest)
|
||||
} else {
|
||||
report::parse(&rest)
|
||||
};
|
||||
match (seconds, &state.report) {
|
||||
(Err(()), _) => log(
|
||||
match parsed {
|
||||
Err(()) if trace => log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"horizon-report [SECONDS | stop | status]: 1 to {} seconds, 60 by default",
|
||||
"horizon-trace [SECONDS | stop]: 1 to {} seconds, 30 by default",
|
||||
report::MAX_SECONDS
|
||||
),
|
||||
),
|
||||
(Ok(_), Some(_)) => {
|
||||
log("INFO", "A performance report is already being recorded")
|
||||
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(seconds), None) => {
|
||||
state.report = Some(report::Recording::start(seconds, self.wire_bytes()));
|
||||
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(),
|
||||
},
|
||||
);
|
||||
log(
|
||||
"INFO",
|
||||
&format!("Recording a performance report for {seconds} s (reports/)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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}")),
|
||||
Ok(report::Command::Record { .. }) => {
|
||||
log("INFO", "A performance report is already being recorded")
|
||||
}
|
||||
}
|
||||
}
|
||||
"save-all" => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -937,7 +937,7 @@ impl Game<'_> {
|
||||
Some(ClickEvent::CopyToClipboard(value)) => Click::CopyToClipboard(value),
|
||||
Some(ClickEvent::ShowDialog(dialog)) => Click::ShowDialog(dialog),
|
||||
Some(ClickEvent::Custom { id, payload }) => Click::Custom { id, payload },
|
||||
Some(ClickEvent::OpenFile(_)) | None => return Ok(()),
|
||||
Some(ClickEvent::OpenFile(_) | ClickEvent::Profiler(_)) | None => return Ok(()),
|
||||
};
|
||||
let after = open.dialog.after_action;
|
||||
return self.run_dialog_action(Some(click), after);
|
||||
|
||||
@@ -133,6 +133,7 @@ impl Game<'_> {
|
||||
|
||||
/// `EvokerFangs.tick`.
|
||||
pub(super) fn tick_fangs(&mut self) {
|
||||
horizon_threads::work::clear_work();
|
||||
let mut list = std::mem::take(&mut self.fangs.list);
|
||||
list.retain_mut(|f| {
|
||||
f.warmup = f.warmup.wrapping_sub(1);
|
||||
|
||||
@@ -36,6 +36,8 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) mod capture;
|
||||
|
||||
/// How long the window waits for the server thread to answer a `call`.
|
||||
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// The error of a `call` without an answer: the server stopped, or it was
|
||||
@@ -678,6 +680,7 @@ impl Game<'_> {
|
||||
opening: Opening,
|
||||
) -> Result<()> {
|
||||
let result = self.run_integrated_inner(launch, flags, tasks, ready, opening);
|
||||
self.finish_server_capture();
|
||||
if let Err(error) = &result {
|
||||
self.native_players_leaving();
|
||||
self.native_lifecycle_close_failed(&error.to_string());
|
||||
@@ -711,6 +714,13 @@ impl Game<'_> {
|
||||
let stopped = self.stop_integrated();
|
||||
return result.and(stopped);
|
||||
}
|
||||
let slow_ms = self
|
||||
.options
|
||||
.spikes
|
||||
.unwrap_or(super::dedicated::SLOW_TICK_MS);
|
||||
self.profile
|
||||
.server
|
||||
.get_or_insert_with(|| capture::ServerSide::new(slow_ms));
|
||||
self.integrated_tick_loop(&flags)
|
||||
}
|
||||
|
||||
@@ -746,11 +756,11 @@ impl Game<'_> {
|
||||
was_paused = paused;
|
||||
if paused {
|
||||
// Only the connections run.
|
||||
self.tick_server_timed(true)?;
|
||||
self.tick_server_measured(true)?;
|
||||
} else {
|
||||
self.follow_distances(flags);
|
||||
let started = Instant::now();
|
||||
self.tick_server_timed(false)?;
|
||||
self.tick_server_measured(false)?;
|
||||
// MinecraftServer.tickServer's tick times, for /tick query.
|
||||
self.clock.record(started.elapsed());
|
||||
self.debug_charts.log_tick(started, started.elapsed());
|
||||
@@ -787,7 +797,19 @@ impl Game<'_> {
|
||||
self.publish_load_view();
|
||||
self.wait_for_next_server_tick(&mut next, paused);
|
||||
}
|
||||
if failure.is_none() {
|
||||
self.finish_server_capture();
|
||||
}
|
||||
if let Some(error) = failure {
|
||||
let finished = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.finish_server_capture();
|
||||
}));
|
||||
if let Err(payload) = finished {
|
||||
log::error!(
|
||||
"Horizon: the server capture could not be closed after the crash: {}",
|
||||
super::dedicated::panic_message(payload.as_ref())
|
||||
);
|
||||
}
|
||||
let stopped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.stop_after_crash(&error.to_string())
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
//! The server thread's side of a capture (docs/perf-platform.md §4.1): it writes into the window's file, or into one of its own.
|
||||
use super::super::dedicated::{SLOW_TICK_MS, report::Recording};
|
||||
use super::super::perf_remote::Done;
|
||||
use super::super::profile_capture::{reply_text, shown};
|
||||
use super::super::{Game, tick_profile};
|
||||
use horizon_client::client_perf::{
|
||||
Join,
|
||||
request::{Failed, ProfileSide, StartOptions, Stopped, Threads},
|
||||
};
|
||||
use horizon_client::server_perf::{
|
||||
self, CaptureEnd, CaptureOptions, MAX_WINDOW_S, Role, SamplerOptions, ServerPerf, TickDetail,
|
||||
};
|
||||
use horizon_client::spans;
|
||||
use horizon_perf_format::{
|
||||
files,
|
||||
v1::{GpuMode, Start, StopReason, Trigger},
|
||||
};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
mpsc::Sender,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
/// How far the server thread has got with the side of a capture the window asked for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(in crate::game) enum Phase {
|
||||
/// Asked for, not yet picked up between two ticks.
|
||||
Asked,
|
||||
Running,
|
||||
Ended,
|
||||
/// Refused or failed to begin; nothing of this side is in the file.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A [`Phase`] the window and the server thread share.
|
||||
pub(in crate::game) struct Live(AtomicU8);
|
||||
|
||||
impl Live {
|
||||
pub(in crate::game) fn new() -> Arc<Self> {
|
||||
Arc::new(Self(AtomicU8::new(Phase::Asked as u8)))
|
||||
}
|
||||
|
||||
pub(in crate::game) fn get(&self) -> Phase {
|
||||
match self.0.load(Ordering::Relaxed) {
|
||||
0 => Phase::Asked,
|
||||
1 => Phase::Running,
|
||||
2 => Phase::Ended,
|
||||
_ => Phase::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::game) fn set(&self, phase: Phase) {
|
||||
self.0.store(phase as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether this side is, or is about to be, writing.
|
||||
pub(in crate::game) fn active(&self) -> bool {
|
||||
matches!(self.get(), Phase::Asked | Phase::Running)
|
||||
}
|
||||
|
||||
/// Whether the file holds, or will hold, this side.
|
||||
pub(in crate::game) fn wrote(&self) -> bool {
|
||||
self.get() != Phase::Failed
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a server capture is written.
|
||||
pub(in crate::game) enum Target {
|
||||
/// The window's file, which the window finishes.
|
||||
Join(Join),
|
||||
/// A file of its own, as a server-only capture needs.
|
||||
Own {
|
||||
options: StartOptions,
|
||||
trigger: Trigger,
|
||||
dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// The server thread's health history and the capture it writes, if any.
|
||||
pub(in crate::game) struct ServerSide {
|
||||
perf: ServerPerf,
|
||||
epoch: Instant,
|
||||
recording: Option<Recording>,
|
||||
/// When the capture's window ends on this thread's clock, and why it stops then.
|
||||
window: Option<(f64, StopReason)>,
|
||||
/// Where this side is, for the window to read.
|
||||
live: Option<Arc<Live>>,
|
||||
/// Whether the file is this side's to finish.
|
||||
owner: bool,
|
||||
/// The `profile stop` waiting for the file, on an owner.
|
||||
reply: Option<Sender<String>>,
|
||||
spans_were_on: bool,
|
||||
}
|
||||
|
||||
impl ServerSide {
|
||||
pub(in crate::game) fn new(slow_ms: f32) -> Self {
|
||||
Self {
|
||||
perf: ServerPerf::with_role(Role::INTEGRATED, 0.0, slow_ms),
|
||||
epoch: Instant::now(),
|
||||
recording: None,
|
||||
window: None,
|
||||
live: None,
|
||||
owner: false,
|
||||
reply: None,
|
||||
spans_were_on: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn now(&self) -> f64 {
|
||||
self.epoch.elapsed().as_secs_f64()
|
||||
}
|
||||
|
||||
/// `instant` on this thread's clock, negative when it is before the clock began.
|
||||
fn at(&self, instant: Instant) -> f64 {
|
||||
match instant.checked_duration_since(self.epoch) {
|
||||
Some(after) => after.as_secs_f64(),
|
||||
None => -self.epoch.duration_since(instant).as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ended(&self) {
|
||||
if let Some(live) = &self.live {
|
||||
live.set(Phase::Ended);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Begins this side of a capture, between two ticks; the path of the file.
|
||||
pub(in crate::game) fn start_server_capture(
|
||||
&mut self,
|
||||
target: Target,
|
||||
live: Arc<Live>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let slow_ms = self.options.spikes.unwrap_or(SLOW_TICK_MS);
|
||||
let mut side = self
|
||||
.profile
|
||||
.server
|
||||
.take()
|
||||
.unwrap_or_else(|| ServerSide::new(slow_ms));
|
||||
if !side.perf.capturing() && side.perf.finishing() {
|
||||
let done: Vec<Done> = side
|
||||
.perf
|
||||
.take_saved(true)
|
||||
.into_iter()
|
||||
.filter_map(|saved| server_saved(&mut side, saved))
|
||||
.collect();
|
||||
self.perf_captures_done(done);
|
||||
}
|
||||
let began = self.begin_server_capture(&mut side, target);
|
||||
if began.is_err() {
|
||||
live.set(Phase::Failed);
|
||||
} else {
|
||||
live.set(Phase::Running);
|
||||
side.live = Some(live);
|
||||
}
|
||||
self.profile.server = Some(side);
|
||||
began
|
||||
}
|
||||
|
||||
fn begin_server_capture(
|
||||
&self,
|
||||
side: &mut ServerSide,
|
||||
target: Target,
|
||||
) -> Result<PathBuf, String> {
|
||||
if side.perf.capturing() || side.perf.finishing() {
|
||||
return Err("a server capture is already running or still being saved".into());
|
||||
}
|
||||
let (options, trigger) = match &target {
|
||||
Target::Join(join) => (join.options.clone(), join.trigger),
|
||||
Target::Own {
|
||||
options, trigger, ..
|
||||
} => (options.clone(), *trigger),
|
||||
};
|
||||
let owner = matches!(target, Target::Own { .. });
|
||||
let capture = self.server_capture_options(&options, trigger);
|
||||
let t = side.now();
|
||||
let was_on = spans::recording_here();
|
||||
spans::record_here(true);
|
||||
let (began, window) = match target {
|
||||
Target::Join(join) => {
|
||||
let origin = side.at(join.origin);
|
||||
let window = join.ends.map(|(at, reason)| (side.at(at), reason));
|
||||
let path = join.path.clone();
|
||||
let began = side
|
||||
.perf
|
||||
.join_capture(t, origin, join.sender, join.path, capture);
|
||||
(began.map(|()| path), window)
|
||||
}
|
||||
Target::Own { dir, .. } => {
|
||||
let window = match options.seconds {
|
||||
Some(s) => (t + f64::from(s), StopReason::Normal),
|
||||
None => (t + MAX_WINDOW_S, StopReason::WindowCap),
|
||||
};
|
||||
(side.perf.start_capture(t, &dir, capture), Some(window))
|
||||
}
|
||||
};
|
||||
let path = match began {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
spans::record_here(was_on);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
side.owner = owner;
|
||||
side.spans_were_on = was_on;
|
||||
side.recording = Some(Recording::here(options.seconds.unwrap_or(0)));
|
||||
side.window = window;
|
||||
log::info!("Horizon: profiling the server thread to {}", path.display());
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// What a capture of this server records as it starts.
|
||||
fn server_capture_options(&self, options: &StartOptions, trigger: Trigger) -> CaptureOptions {
|
||||
let (view, simulation, max_players, players) =
|
||||
self.lan.as_ref().map_or((0, 0, 1, 0), |h| {
|
||||
let c = &h.hub.config;
|
||||
(
|
||||
c.view_distance,
|
||||
c.simulation_distance,
|
||||
c.max_players,
|
||||
h.hub.guests.len(),
|
||||
)
|
||||
});
|
||||
let start = Start {
|
||||
build: Some(server_perf::build()),
|
||||
machine: Some(server_perf::machine(Some(
|
||||
server_perf::hardware().unwrap_or_else(server_perf::hardware_now),
|
||||
))),
|
||||
settings: Some(server_perf::settings(
|
||||
view.max(0) as u32,
|
||||
simulation.max(0) as u32,
|
||||
max_players as u32,
|
||||
-1,
|
||||
)),
|
||||
game_rules: Some(server_perf::game_rules(&self.ui.rules)),
|
||||
same_process: true,
|
||||
players: players as u32,
|
||||
..Start::default()
|
||||
};
|
||||
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: self.block_names(),
|
||||
levels: self.level_counts(),
|
||||
wire: self.wire_bytes(),
|
||||
gpu: GpuMode::Unspecified,
|
||||
timeline: options.timeline.then(Default::default),
|
||||
file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends this side of the running capture, between two ticks; `reply` hears of an owned file once it is whole.
|
||||
pub(in crate::game) fn stop_server_capture(
|
||||
&mut self,
|
||||
reason: StopReason,
|
||||
reply: Option<Sender<String>>,
|
||||
) {
|
||||
let Some(mut side) = self.profile.server.take() else {
|
||||
return;
|
||||
};
|
||||
if side.perf.capturing() {
|
||||
side.reply = reply.or(side.reply.take());
|
||||
self.end_server_capture(&mut side, reason);
|
||||
} else if let Some(reply) = reply {
|
||||
let _ = reply.send(reply_text(&Failed {
|
||||
error: "no capture is running".into(),
|
||||
}));
|
||||
}
|
||||
self.profile.server = Some(side);
|
||||
}
|
||||
|
||||
/// Writes the spans, workers and world and hands the file off to finish; false when no capture runs here.
|
||||
fn end_server_capture(&self, side: &mut ServerSide, reason: StopReason) -> bool {
|
||||
if !side.perf.capturing() {
|
||||
return false;
|
||||
}
|
||||
// The spans from between ticks are not a tick's, and the price check counts from none.
|
||||
spans::take_raw();
|
||||
let end = side
|
||||
.recording
|
||||
.take()
|
||||
.map_or_else(CaptureEnd::default, |r| self.capture_end(&r));
|
||||
spans::record_here(side.spans_were_on);
|
||||
side.window = None;
|
||||
let t = side.now();
|
||||
side.perf.begin_stop(t, reason, end);
|
||||
if !side.owner {
|
||||
side.ended();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Labels the moment in the running capture.
|
||||
pub(in crate::game) fn mark_server_capture(&mut self, name: &str) {
|
||||
if let Some(side) = &mut self.profile.server {
|
||||
let t = side.now();
|
||||
side.perf.mark(t, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a server capture runs or is being saved on this thread.
|
||||
pub(in crate::game) fn server_capture_busy(&self) -> bool {
|
||||
self.profile
|
||||
.server
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.perf.capturing() || s.perf.finishing())
|
||||
}
|
||||
|
||||
/// One tick of the server thread, timed for the health history and the capture.
|
||||
pub(super) fn tick_server_measured(&mut self, paused: bool) -> crate::Result<()> {
|
||||
self.serve_perf_asked();
|
||||
let Some(mut side) = self.profile.server.take() else {
|
||||
return self.tick_server_timed(paused);
|
||||
};
|
||||
let slow_ms = side.perf.slow_threshold_ms();
|
||||
let capturing = side.perf.capturing();
|
||||
let sampled = side.recording.as_ref().is_some_and(Recording::sample_next);
|
||||
tick_profile::set_sampling(sampled);
|
||||
let mut detail = None;
|
||||
let started = Instant::now();
|
||||
let result = self.tick_server_spans(paused, |timing| {
|
||||
if capturing || timing.wall_ms > slow_ms {
|
||||
detail = Some(TickDetail {
|
||||
cpu_ms: timing.cpu_ms,
|
||||
spans: timing.raw,
|
||||
});
|
||||
}
|
||||
});
|
||||
let wall_ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
tick_profile::set_sampling(false);
|
||||
if let Some(recording) = &mut side.recording {
|
||||
recording.add(sampled);
|
||||
}
|
||||
let t = side.now();
|
||||
side.perf.tick(
|
||||
t,
|
||||
wall_ms,
|
||||
detail,
|
||||
|| self.perf_world_counts(),
|
||||
|| self.wire_bytes(),
|
||||
);
|
||||
if let Some((end, reason)) = side.window
|
||||
&& t >= end
|
||||
{
|
||||
self.end_server_capture(&mut side, reason);
|
||||
}
|
||||
let done: Vec<Done> = side
|
||||
.perf
|
||||
.take_saved(false)
|
||||
.into_iter()
|
||||
.filter_map(|saved| server_saved(&mut side, saved))
|
||||
.collect();
|
||||
self.profile.server = Some(side);
|
||||
self.perf_captures_done(done);
|
||||
result
|
||||
}
|
||||
|
||||
/// The server is closing: ends the capture, and waits for the file when it is this side's.
|
||||
pub(in crate::game) fn finish_server_capture(&mut self) {
|
||||
let Some(mut side) = self.profile.server.take() else {
|
||||
return;
|
||||
};
|
||||
self.end_server_capture(&mut side, StopReason::Shutdown);
|
||||
let mut done = Vec::new();
|
||||
if side.owner {
|
||||
for saved in side.perf.take_saved(true) {
|
||||
done.extend(server_saved(&mut side, saved));
|
||||
}
|
||||
}
|
||||
side.ended();
|
||||
drop(side);
|
||||
self.perf_captures_done(done);
|
||||
}
|
||||
}
|
||||
|
||||
/// A capture of this side finished: an owner logs the file and tells the window that waits for it.
|
||||
fn server_saved(side: &mut ServerSide, saved: io::Result<server_perf::Saved>) -> Option<Done> {
|
||||
if !side.owner {
|
||||
if let Err(e) = saved {
|
||||
log::error!("Horizon: could not write the server side of the capture: {e}");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
side.ended();
|
||||
let reply = side.reply.take();
|
||||
match saved {
|
||||
Ok(saved) => {
|
||||
for path in &saved.deleted {
|
||||
log::info!("Horizon: removed the oldest capture {}", path.display());
|
||||
}
|
||||
log::info!("Horizon: {}", files::saved_line(&saved.path));
|
||||
let removed: Vec<String> = saved.deleted.iter().map(|p| shown(p)).collect();
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(reply_text(&Stopped {
|
||||
ok: true,
|
||||
sides: vec![ProfileSide::Server],
|
||||
path: shown(&saved.path),
|
||||
open_at: horizon_client::client_perf::request::viewer(),
|
||||
removed: removed.clone(),
|
||||
}));
|
||||
}
|
||||
Some(Done::Saved {
|
||||
path: shown(&saved.path),
|
||||
file: saved.path.clone(),
|
||||
removed,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Horizon: could not write the performance capture: {e}");
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(reply_text(&Failed {
|
||||
error: e.to_string(),
|
||||
}));
|
||||
}
|
||||
Some(Done::Failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.tick_dropped_items(&mut round);
|
||||
horizon_threads::work::clear_work();
|
||||
self.native_tick_post_batch(round);
|
||||
}
|
||||
/// An item that joined during this tick's entity pass: it first ticks on
|
||||
@@ -57,7 +58,9 @@ impl Game<'_> {
|
||||
// `ItemUtils.onContainerDestroyed`: what a destroyed item held, and
|
||||
// where it lay, for adding to the level once its own tick is done.
|
||||
let mut destroyed: Vec<(ItemStack, Vec3)> = Vec::new();
|
||||
let item_type = horizon_threads::work::entity_id("minecraft:item");
|
||||
for i in 0..items.len() {
|
||||
horizon_threads::work::set_entity(item_type);
|
||||
if removed[i] {
|
||||
// Merged into an earlier item before its own tick: it never ticked.
|
||||
round.not_ticked(items[i].id);
|
||||
|
||||
@@ -1310,7 +1310,7 @@ impl Game<'_> {
|
||||
Ok(Some(json!({ "ok": true })))
|
||||
}
|
||||
Some("status") => Ok(Some(self.lan_status())),
|
||||
_ => Err("lan open|close|status".into()),
|
||||
_ => Err("lan open|close|status|op".into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ use winit::keyboard::KeyCode;
|
||||
|
||||
mod guest_body;
|
||||
mod horizon_channels;
|
||||
use horizon_channels::horizon_channel;
|
||||
|
||||
#[cfg(test)]
|
||||
mod crack_feedback_tests;
|
||||
@@ -1606,7 +1605,7 @@ impl Game<'_> {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.unpack_boat_loot(i);
|
||||
self.unpack_boat_loot(i, true);
|
||||
}
|
||||
// `createMenu` (ChestMenu.threeRows or a HopperMenu), and
|
||||
// `MenuProvider.getDisplayName`, the vehicle's name key.
|
||||
@@ -1636,64 +1635,6 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// `ContainerEntity.unpackChestVehicleLootTable` for a chest boat: the table
|
||||
/// its raw NBT still holds rolls into the slots on the first open (an empty
|
||||
/// boat, as a command leaves one), and `player_generates_container_loot`
|
||||
/// fires for the player who opened it.
|
||||
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())
|
||||
)
|
||||
}
|
||||
|
||||
fn unpack_boat_loot(&mut self, i: usize) {
|
||||
use horizon_world::nbt::Tag;
|
||||
let id = self.boats[i].id;
|
||||
let (table, seed) = {
|
||||
let Some(Tag::Compound(map)) = self.persist.raw.get_mut(&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,
|
||||
};
|
||||
// The table is spent even when nothing can be rolled into it (the
|
||||
// loot data is not loaded): vanilla clears it before filling.
|
||||
map.remove(&"LootTable".into());
|
||||
map.remove(&"LootTableSeed".into());
|
||||
(table, seed)
|
||||
};
|
||||
// `LootTable.fill` sets the slots it rolls and leaves the rest; a boat
|
||||
// that already holds something keeps it, so no table is applied over it.
|
||||
if self.boats[i].items.iter().any(Option::is_some) {
|
||||
return;
|
||||
}
|
||||
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.clone(),
|
||||
seed,
|
||||
pos,
|
||||
&biome,
|
||||
);
|
||||
self.trigger_loot_table(&table);
|
||||
}
|
||||
|
||||
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
|
||||
/// it no longer counts among the openers of the lid, and the lid's close
|
||||
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
|
||||
@@ -2134,22 +2075,8 @@ impl Game<'_> {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
// Plugin channels a client registers in play (minecraft:register), from a dead player
|
||||
// too; the packet goes on as before.
|
||||
if let S::CustomPayload(p) = &packet
|
||||
&& let Some(register) = horizon_client::lan::channel_registration(&p.channel)
|
||||
{
|
||||
self.guest_channels(host, gi, &p.data, register);
|
||||
}
|
||||
// A plugin channel's message on a channel that isn't one of Horizon's own (the match below
|
||||
// takes those) is for the mods, from a dead player too: vanilla's handleCustomPayload doesn't
|
||||
// look at health.
|
||||
let packet = match packet {
|
||||
S::CustomPayload(p) if !horizon_channel(&p.channel) => {
|
||||
self.guest_custom_payload(host, conn, p.channel, p.data);
|
||||
return;
|
||||
}
|
||||
packet => packet,
|
||||
let Some(packet) = self.guest_plugin_channels(host, conn, gi, packet) else {
|
||||
return;
|
||||
};
|
||||
// A dead player acts no more, but the death screen still respawns and chats: its
|
||||
// commands run as anyone's (ServerGamePacketListenerImpl.handleChatCommand doesn't
|
||||
@@ -2884,6 +2811,9 @@ impl Game<'_> {
|
||||
S::CustomPayload(p) if p.channel == horizon_client::lod_net::CHANNEL => {
|
||||
self.guest_far_terrain(host, conn, &p.data)
|
||||
}
|
||||
S::CustomPayload(p) if p.channel == horizon_client::perf_net::CHANNEL => {
|
||||
self.guest_perf(host, conn, &p.data)
|
||||
}
|
||||
S::CustomPayload(p) if p.channel == horizon_client::lan::recipes::TRANSFER_CHANNEL => {
|
||||
self.guest_recipe_transfer(host, conn, &p.data);
|
||||
}
|
||||
@@ -6853,7 +6783,7 @@ mod guest_close_listener_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod horizon_channel_tests {
|
||||
use super::horizon_channel;
|
||||
use super::horizon_channels::horizon_channel;
|
||||
|
||||
#[test]
|
||||
fn horizon_channels_reach_their_handlers_and_others_go_to_the_mods() {
|
||||
@@ -6865,6 +6795,7 @@ mod horizon_channel_tests {
|
||||
assert!(horizon_channel(horizon_protocol::quiet::CHANNEL));
|
||||
assert!(horizon_channel(horizon_protocol::mod_net::CHANNEL));
|
||||
assert!(horizon_channel(horizon_client::lod_net::CHANNEL));
|
||||
assert!(horizon_channel(horizon_client::perf_net::CHANNEL));
|
||||
assert!(!horizon_channel("mymod:hello"));
|
||||
assert!(!horizon_channel("minecraft:brand"));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! The plugin channels Horizon handles itself, before the mods.
|
||||
use super::super::{Game, lan::LanHost};
|
||||
use horizon_protocol::packets::play::Serverbound as S;
|
||||
|
||||
/// Whether a guest's plugin channel message is for one of Horizon's own handlers in
|
||||
/// `lan_guest_packet` (its `CustomPayload` arms), not for the mods.
|
||||
@@ -7,7 +9,37 @@ pub(super) fn horizon_channel(channel: &str) -> bool {
|
||||
horizon_protocol::quiet::CHANNEL,
|
||||
horizon_protocol::mod_net::CHANNEL,
|
||||
horizon_client::lod_net::CHANNEL,
|
||||
horizon_client::perf_net::CHANNEL,
|
||||
horizon_client::lan::recipes::TRANSFER_CHANNEL,
|
||||
]
|
||||
.contains(&channel)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A guest's plugin channel registrations and its messages for the mods; the packet when Horizon handles it.
|
||||
pub(super) fn guest_plugin_channels(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
gi: usize,
|
||||
packet: S,
|
||||
) -> Option<S> {
|
||||
// Plugin channels a client registers in play (minecraft:register), from a dead player
|
||||
// too; the packet goes on as before.
|
||||
if let S::CustomPayload(p) = &packet
|
||||
&& let Some(register) = horizon_client::lan::channel_registration(&p.channel)
|
||||
{
|
||||
self.guest_channels(host, gi, &p.data, register);
|
||||
}
|
||||
// A plugin channel's message on a channel that isn't one of Horizon's own (`lan_guest_packet`'s
|
||||
// match takes those) is for the mods, from a dead player too: vanilla's handleCustomPayload doesn't
|
||||
// look at health.
|
||||
match packet {
|
||||
S::CustomPayload(p) if !horizon_channel(&p.channel) => {
|
||||
self.guest_custom_payload(host, conn, p.channel, p.data);
|
||||
None
|
||||
}
|
||||
packet => Some(packet),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! `horizon-ctl lan op`: a connected guest's operator level, which remote profiling checks.
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `horizon-ctl lan op NAME LEVEL`; `None` for any other command.
|
||||
pub(super) fn lan_op_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
use serde_json::json;
|
||||
if words.first() != Some(&"lan") || words.get(1) != Some(&"op") {
|
||||
return Ok(None);
|
||||
}
|
||||
let usage = "lan op NAME LEVEL (0 to 4)";
|
||||
let (Some(name), Some(level)) = (
|
||||
words.get(2),
|
||||
words
|
||||
.get(3)
|
||||
.and_then(|l| l.parse::<u8>().ok())
|
||||
.filter(|l| *l <= 4),
|
||||
) else {
|
||||
return Err(usage.into());
|
||||
};
|
||||
let name = (*name).to_owned();
|
||||
let found = match &self.integrated {
|
||||
Some(server) => server
|
||||
.call(move |g| g.lan_set_op(&name, level))
|
||||
.ok_or(super::integrated::NO_ANSWER)?,
|
||||
None => self.lan_set_op(&name, level),
|
||||
};
|
||||
if found {
|
||||
Ok(Some(json!({ "ok": true })))
|
||||
} else {
|
||||
Err("no guest of that name is connected".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl lan op`: the connected guest `name` gets operator level `level`; false when none is called that.
|
||||
pub(super) fn lan_set_op(&mut self, name: &str, level: u8) -> bool {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let Some(uuid) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.name == name)
|
||||
.map(|g| g.uuid)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
host.hub.ops.insert(uuid, level);
|
||||
host.hub.resend_commands();
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ pub(super) struct Options {
|
||||
pub(super) no_pause: bool,
|
||||
/// Control socket path (`--control`), for `horizon-ctl`.
|
||||
pub(super) control: Option<PathBuf>,
|
||||
/// `HORIZON_SPIKES=MS`: print the timed spans of frames slower than MS.
|
||||
/// `HORIZON_SPIKES=MS`: the slow ring's threshold, and spans on for every thread; nothing is logged.
|
||||
pub(super) spikes: Option<f32>,
|
||||
/// Render into a texture instead of the window (`--offscreen`), so a
|
||||
/// GPU the display cannot present from can still be benchmarked. The
|
||||
@@ -63,6 +63,10 @@ pub(super) struct Options {
|
||||
pub(super) transfers: Option<horizon_client::lan::arrivals::Settings>,
|
||||
/// `--benchmark SCENARIO`: run the benchmark, write its report and quit.
|
||||
pub(super) benchmark: Option<String>,
|
||||
/// `--benchmark-set SWITCH=VALUE`: a setting for the benchmark's run, with `--benchmark travel`.
|
||||
pub(super) benchmark_set: Option<(String, String)>,
|
||||
/// `--profile`: with `--benchmark`, record a .hzperf capture of the run that embeds the report.
|
||||
pub(super) benchmark_profile: bool,
|
||||
/// `--dev-mod PATH` (repeatable): content mods always on.
|
||||
pub(super) dev_mods: Vec<PathBuf>,
|
||||
/// What this Game executes; independent of its current server connection.
|
||||
@@ -216,6 +220,8 @@ impl Options {
|
||||
dedicated: None,
|
||||
transfers: None,
|
||||
benchmark: None,
|
||||
benchmark_set: None,
|
||||
benchmark_profile: false,
|
||||
dev_mods: Vec::new(),
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
fullscreen: false,
|
||||
@@ -228,7 +234,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --timeout-seconds N Exit with an error after N seconds (testing; default 120 with --frames, else none)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --transfers SETTINGS A dedicated server takes a proxy's hand-over requests (server.toml [transfer], docs/server.md)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --timeout-seconds N Exit with an error after N seconds (testing; default 120 with --frames, else none)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain, travel, ab:SWITCH), write its report to benchmarks/ and quit\n --profile With --benchmark: also write a performance capture (reports/*.hzperf in the game home) that embeds the report\n --benchmark-set S=V With --benchmark travel: set a graphics switch (gpu-culling, section-cull, section-sides, section-draws) to one of its two values first\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --transfers SETTINGS A dedicated server takes a proxy's hand-over requests (server.toml [transfer], docs/server.md)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -238,6 +244,7 @@ impl Options {
|
||||
return Ok(None);
|
||||
}
|
||||
"--fullscreen" => o.fullscreen = true,
|
||||
"--profile" => o.benchmark_profile = true,
|
||||
"--session-stdin" => o.session_stdin = true,
|
||||
"--dedicated" => {
|
||||
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
|
||||
@@ -324,14 +331,13 @@ impl Options {
|
||||
"--player-dir" => {}
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
if horizon_client::benchmark::scenes(&value).is_none() {
|
||||
return Err(format!(
|
||||
"unknown benchmark {value}: standard, village, forest, ocean or mountain"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
horizon_client::benchmark::Scenario::parse(&value)?;
|
||||
o.benchmark = Some(value);
|
||||
}
|
||||
"--benchmark-set" => {
|
||||
o.benchmark_set =
|
||||
Some(horizon_client::benchmark::parse_set(&value)?)
|
||||
}
|
||||
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
|
||||
"--name" => o.player_name = Some(value),
|
||||
"--session-file" => o.session_file = Some(value),
|
||||
@@ -362,6 +368,12 @@ impl Options {
|
||||
}
|
||||
}
|
||||
}
|
||||
if o.benchmark_set.is_some() && o.benchmark.as_deref() != Some("travel") {
|
||||
return Err("--benchmark-set needs --benchmark travel".into());
|
||||
}
|
||||
if o.benchmark_profile && o.benchmark.is_none() {
|
||||
return Err("--profile needs --benchmark".into());
|
||||
}
|
||||
Ok(Some(o))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,14 @@ impl Default for Pass {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a server tick took and what it spent the time in.
|
||||
pub(super) struct TickTiming {
|
||||
pub wall_ms: f32,
|
||||
pub cpu_ms: Option<f32>,
|
||||
/// As recorded in the tick, each span after the spans inside it; the ones from between ticks are left out.
|
||||
pub raw: Vec<horizon_client::spans::Span>,
|
||||
}
|
||||
|
||||
/// The levels not in place, by dimension.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Levels {
|
||||
@@ -162,6 +170,13 @@ impl Levels {
|
||||
self.parked.values().map(|level| &level.world)
|
||||
}
|
||||
|
||||
/// The streamers of the levels not in place.
|
||||
pub(super) fn parked_streamings(
|
||||
&self,
|
||||
) -> impl Iterator<Item = &horizon_client::streaming::Streaming> {
|
||||
self.parked.values().map(|level| &level.streaming)
|
||||
}
|
||||
|
||||
/// The world of the level not in place, in `dim`, to change.
|
||||
pub(super) fn parked_world_mut(&mut self, dim: Dim) -> Option<&mut World> {
|
||||
self.parked.get_mut(&dim).map(|level| &mut level.world)
|
||||
@@ -617,6 +632,11 @@ impl Game<'_> {
|
||||
for (k, dim) in dims.into_iter().enumerate() {
|
||||
// Each level's share of the tick (the first one also reads the network and runs the
|
||||
// tick's shared parts): `horizon-report`'s `level_*` spans.
|
||||
horizon_threads::work::set_dim(match dim {
|
||||
Dim::Overworld => 1,
|
||||
Dim::Nether => 2,
|
||||
Dim::End => 3,
|
||||
});
|
||||
let _level_span = horizon_client::spans::guard(match dim {
|
||||
Dim::Overworld => "level_overworld",
|
||||
Dim::Nether => "level_the_nether",
|
||||
@@ -720,22 +740,30 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
|
||||
/// tick longer than MS is logged with its thread CPU time and spans.
|
||||
/// `tick_server` as a server thread runs it, timed whenever spans are on.
|
||||
pub(super) fn tick_server_timed(&mut self, paused: bool) -> Result<()> {
|
||||
self.tick_server_spans(paused, |_, _, _, _| {})
|
||||
self.tick_server_spans(paused, |_| {})
|
||||
}
|
||||
|
||||
/// `tick_server_timed`, handing the tick's wall time, thread CPU time
|
||||
/// and spans (milliseconds, summed by name) to `record` while spans are on, with how many
|
||||
/// spans the tick itself recorded: a dedicated server's performance report turns them on to
|
||||
/// record its ticks.
|
||||
/// `tick_server_timed`, handing the tick's timing to `record` while spans are on.
|
||||
pub(super) fn tick_server_spans(
|
||||
&mut self,
|
||||
paused: bool,
|
||||
record: impl FnOnce(f32, Option<f32>, Vec<horizon_client::spans::Span>, usize),
|
||||
record: impl FnOnce(TickTiming),
|
||||
) -> Result<()> {
|
||||
if self.options.spikes.is_none() && !horizon_client::spans::enabled() {
|
||||
let started = Instant::now();
|
||||
horizon_threads::work::tick_start();
|
||||
let result = self.tick_server_recorded(paused, record);
|
||||
horizon_threads::work::tick_end(started.elapsed().as_nanos() as u64);
|
||||
result
|
||||
}
|
||||
|
||||
fn tick_server_recorded(
|
||||
&mut self,
|
||||
paused: bool,
|
||||
record: impl FnOnce(TickTiming),
|
||||
) -> Result<()> {
|
||||
if self.options.spikes.is_none() && !horizon_client::spans::recording_here() {
|
||||
return self.tick_server(paused);
|
||||
}
|
||||
// Spans recorded since the last tick ran between ticks (the chunk pump of the wait): they
|
||||
@@ -749,26 +777,28 @@ impl Game<'_> {
|
||||
.map(|(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
// Taken every tick: nothing else takes this thread's spans.
|
||||
let raw = horizon_client::spans::take_raw();
|
||||
let in_tick = raw.len();
|
||||
let spans = if between.is_empty() {
|
||||
horizon_client::spans::merge(&raw)
|
||||
} else {
|
||||
horizon_client::spans::merge(&[between, raw].concat())
|
||||
horizon_client::spans::merge(&[&between[..], &raw[..]].concat())
|
||||
};
|
||||
// The spans go to the slow ring; whose packets or events took the tick stays in the owner's log.
|
||||
let notes = horizon_client::spans::take_notes();
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& wall > threshold
|
||||
&& !notes.is_empty()
|
||||
{
|
||||
let cpu = cpu.map_or_else(String::new, |c| format!(", cpu {c:.1} ms"));
|
||||
log::info!(
|
||||
"Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}{}",
|
||||
self.mobs.len(),
|
||||
horizon_client::spans::describe(&spans),
|
||||
"Horizon: {wall:.1} ms tick was busy with: {}",
|
||||
slowest_steps(¬es)
|
||||
);
|
||||
}
|
||||
self.native_server_tick_timed(wall, cpu, &spans);
|
||||
record(wall, cpu, spans, in_tick);
|
||||
record(TickTiming {
|
||||
wall_ms: wall,
|
||||
cpu_ms: cpu,
|
||||
raw,
|
||||
});
|
||||
result
|
||||
}
|
||||
|
||||
@@ -985,17 +1015,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A slow tick's line's end: the steps it noted as its slowest (`spans::note`), whose packets
|
||||
/// or which player's event, or nothing.
|
||||
/// The steps a slow tick noted as its slowest (`spans::note`), whose packets or which player's event.
|
||||
fn slowest_steps(notes: &[(f32, String)]) -> String {
|
||||
if notes.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let list: Vec<String> = notes
|
||||
.iter()
|
||||
.map(|(ms, what)| format!("{what} {ms:.1}"))
|
||||
.collect();
|
||||
format!("; slowest: {}", list.join(", "))
|
||||
list.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1054,6 +1080,85 @@ mod tests {
|
||||
assert_eq!(nether.size(), 100.0);
|
||||
}
|
||||
|
||||
struct LogCapture;
|
||||
static LOGGED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
impl log::Log for LogCapture {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
LOGGED.lock().unwrap().push(record.args().to_string());
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// 300 slow ticks in ten seconds log no Horizon slow-tick line, and the ring holds the last 256.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn slow_ticks_enter_the_ring_and_not_the_log() {
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::server_perf::{ServerPerf, TickDetail, WorldCounts};
|
||||
log::set_logger(&LogCapture).expect("no other logger in this test run");
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
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;
|
||||
options.spikes = Some(0.0);
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
let mut perf = ServerPerf::new(0.0, 0.0);
|
||||
for i in 0..300 {
|
||||
let mut detail = None;
|
||||
let started = Instant::now();
|
||||
game.tick_server_spans(false, |timing| {
|
||||
detail = Some(TickDetail {
|
||||
cpu_ms: timing.cpu_ms,
|
||||
spans: timing.raw,
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
let wall_ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
perf.tick(
|
||||
f64::from(i) / 30.0,
|
||||
wall_ms,
|
||||
detail,
|
||||
WorldCounts::default,
|
||||
Vec::new,
|
||||
);
|
||||
}
|
||||
let kept: Vec<u64> = perf.slow_records().map(|r| r.counter).collect();
|
||||
assert_eq!(kept, (45..=300).collect::<Vec<u64>>());
|
||||
let logged = LOGGED.lock().unwrap();
|
||||
assert!(
|
||||
logged.iter().all(|line| !line.contains("slow tick")),
|
||||
"{logged:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A game on a scratch world folder, the way `dedicated.rs` opens one
|
||||
/// (`load_world` with an absolute folder).
|
||||
fn open_game(
|
||||
|
||||
@@ -339,6 +339,8 @@ pub(super) fn start_game<'a>(
|
||||
fps_cap: None,
|
||||
offscreen: game.options.offscreen,
|
||||
game_dir: std::env::current_dir()?,
|
||||
set: game.options.benchmark_set.clone(),
|
||||
profile: game.options.benchmark_profile,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1549,7 +1549,9 @@ impl Game<'_> {
|
||||
let mut items = if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
std::mem::take(&mut c.items)
|
||||
} else {
|
||||
std::mem::take(&mut self.boats.iter_mut().find(|b| b.id == id)?.items)
|
||||
let i = self.boats.iter().position(|b| b.id == id)?;
|
||||
self.unpack_boat_loot(i, false);
|
||||
std::mem::take(&mut self.boats[i].items)
|
||||
};
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, id as i32);
|
||||
let (view, open) = self.lend_items(key, &mut items);
|
||||
@@ -2243,12 +2245,149 @@ mod mod_container_tests {
|
||||
#[cfg(test)]
|
||||
mod hopper_cart_transfer_tests {
|
||||
use super::super::{
|
||||
boats::Boat,
|
||||
minecarts::{CartKind, Minecart},
|
||||
native_machines::tests::server_game,
|
||||
};
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn chest_boat_automation_does_not_award_player_loot() {
|
||||
use horizon_client::advancements::Tree;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut game = server_game();
|
||||
game.options.dedicated = None;
|
||||
let files = vec![(
|
||||
"advancement/parity:boat_loot".into(),
|
||||
vec![br#"{"criteria":{"loot":{"trigger":"minecraft:player_generates_container_loot","conditions":{"loot_tables":"minecraft:chests/spawn_bonus_chest"}}}}"#.to_vec()],
|
||||
)];
|
||||
game.adv.tree = Arc::new(Tree::load_with(
|
||||
&game.options.resources.join("data"),
|
||||
&PackOverlay { files: &files },
|
||||
));
|
||||
game.adv.loaded = true;
|
||||
let ai = game.adv.tree.by_id["parity:boat_loot"];
|
||||
for player in [false, true] {
|
||||
game.next_entity += 1;
|
||||
let id = game.next_entity;
|
||||
game.boats
|
||||
.push(Boat::new(id, "oak".into(), Vec3::new(8.5, 65.0, 8.5), 0.0).with_chest());
|
||||
game.persist.raw.insert(
|
||||
id,
|
||||
Tag::compound([
|
||||
(
|
||||
"LootTable",
|
||||
Tag::String("minecraft:chests/spawn_bonus_chest".into()),
|
||||
),
|
||||
("LootTableSeed", Tag::Long(73)),
|
||||
]),
|
||||
);
|
||||
let i = game.boats.len() - 1;
|
||||
game.unpack_boat_loot(i, player);
|
||||
assert_eq!(game.adv.progress.done(&game.adv.tree.list[ai]), player);
|
||||
// Opening a container automation already unpacked awards nothing.
|
||||
game.unpack_boat_loot(i, true);
|
||||
assert_eq!(game.adv.progress.done(&game.adv.tree.list[ai]), player);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn chest_boat_slot_access_fills_only_empty_slots_once() {
|
||||
let mut game = server_game();
|
||||
let sentinel = Some(ItemStack::new("minecraft:diamond", 17));
|
||||
for open in [false, true] {
|
||||
for occupied in [1, 24, 27] {
|
||||
game.next_entity += 1;
|
||||
let id = game.next_entity;
|
||||
let mut boat =
|
||||
Boat::new(id, "oak".into(), Vec3::new(8.5, 65.0, 8.5), 0.0).with_chest();
|
||||
if occupied > 1 {
|
||||
boat.items.fill(sentinel.clone());
|
||||
if occupied == 24 {
|
||||
for slot in [0, 13, 26] {
|
||||
boat.items[slot] = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
boat.items[12] = sentinel.clone();
|
||||
}
|
||||
let key = (
|
||||
i32::MAX - 15,
|
||||
super::super::minecarts::CART_KEY_Y,
|
||||
id as i32,
|
||||
);
|
||||
if open {
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Chest(boat.items.clone()));
|
||||
// The menu copy is authoritative, even if the entity is stale.
|
||||
boat.items.fill(None);
|
||||
}
|
||||
game.boats.push(boat);
|
||||
game.persist.raw.insert(
|
||||
id,
|
||||
Tag::compound([
|
||||
(
|
||||
"LootTable",
|
||||
Tag::String("minecraft:chests/spawn_bonus_chest".into()),
|
||||
),
|
||||
("LootTableSeed", Tag::Long(73)),
|
||||
]),
|
||||
);
|
||||
let items = game
|
||||
.with_entity_container(id, |g, _| {
|
||||
g.world.block_entities.get(&key).unwrap().slots().to_vec()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(items[12], sentinel);
|
||||
if occupied == 27 {
|
||||
assert!(items.iter().all(|item| item == &sentinel));
|
||||
} else if occupied == 24 {
|
||||
// Pinned 26.3 LootTable.fill, seed 73, only these slots empty.
|
||||
for (slot, item, count) in
|
||||
[(0, "stick", 1), (13, "salmon", 1), (26, "acacia_log", 2)]
|
||||
{
|
||||
assert_eq!(
|
||||
items[slot],
|
||||
Some(ItemStack::new(&format!("minecraft:{item}"), count))
|
||||
);
|
||||
}
|
||||
assert_eq!(items.iter().filter(|item| *item == &sentinel).count(), 24);
|
||||
} else {
|
||||
assert!(items.iter().flatten().count() > 1, "pending loot was lost");
|
||||
}
|
||||
assert!(game.persist.raw[&id].get("LootTable").is_none());
|
||||
assert!(game.persist.raw[&id].get("LootTableSeed").is_none());
|
||||
assert_eq!(game.boats.last().unwrap().items, items);
|
||||
game.with_entity_container(id, |g, _| {
|
||||
if let Some(BlockEntity::Chest(items)) = g.world.block_entities.get_mut(&key) {
|
||||
items.fill(None);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
game.with_entity_container(id, |g, _| {
|
||||
assert!(
|
||||
g.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.unwrap()
|
||||
.slots()
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
game.release_vehicle_container(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #8668: a hopper minecart takes one item a tick from the container
|
||||
/// above. `MinecartHopper.tick` has no transfer cooldown in 26.3: it calls
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
//! `horizon-ctl memory`: what holds the game's memory, by category. The
|
||||
//! process's resident set and the allocator's committed bytes say how much;
|
||||
//! the census of the client's columns, the integrated server's levels, the
|
||||
//! renderer's bookkeeping and the GPU allocator's resources by label say
|
||||
//! where it goes. Bytes the census doesn't reach show as the difference.
|
||||
//! the census of the columns, the server's entities, players and chunk send
|
||||
//! caches, the renderer's bookkeeping and the GPU allocator's resources by
|
||||
//! label say where it goes. `unattributed_mb` is what the named parts leave.
|
||||
use super::Game;
|
||||
use horizon_client::chunk::ColumnCensus;
|
||||
use horizon_client::fast_hash::FxHashSet;
|
||||
use horizon_client::lan::hub::Hub;
|
||||
use horizon_client::world::World;
|
||||
use serde_json::{Value, json};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::mem::size_of;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MB: f64 = 1048576.0;
|
||||
/// Entity types and players listed apart; the rest only count in the totals.
|
||||
const TOP: usize = 30;
|
||||
const TOP_PLAYERS: usize = 50;
|
||||
|
||||
fn mb(bytes: impl TryInto<u64>) -> f64 {
|
||||
let bytes: u64 = bytes.try_into().unwrap_or(0);
|
||||
(bytes as f64 / MB * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
fn mb_signed(bytes: i64) -> f64 {
|
||||
(bytes as f64 / MB * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
fn vec_bytes<T>(v: &Vec<T>) -> usize {
|
||||
v.capacity() * size_of::<T>()
|
||||
}
|
||||
|
||||
fn string_bytes(s: &Option<String>) -> usize {
|
||||
s.as_ref().map_or(0, String::capacity)
|
||||
}
|
||||
|
||||
/// The columns of `worlds`, each section counted once however many columns
|
||||
/// (or worlds) share it.
|
||||
fn census<'a>(worlds: impl IntoIterator<Item = &'a World>) -> ColumnCensus {
|
||||
@@ -30,21 +49,42 @@ fn census<'a>(worlds: impl IntoIterator<Item = &'a World>) -> ColumnCensus {
|
||||
census
|
||||
}
|
||||
|
||||
fn census_json(c: &ColumnCensus) -> Value {
|
||||
json!({
|
||||
"total_mb": mb(c.total_bytes()),
|
||||
"columns": c.columns,
|
||||
"sections": c.sections,
|
||||
"unique_sections": c.unique_sections,
|
||||
"uniform_block_sections": c.uniform_block_sections,
|
||||
"light_arrays": c.light_arrays,
|
||||
"blocks_mb": mb(c.block_bytes),
|
||||
"biomes_mb": mb(c.biome_bytes),
|
||||
"light_mb": mb(c.light_bytes),
|
||||
"heightmaps_mb": mb(c.heightmap_bytes),
|
||||
"section_records_mb": mb(c.section_record_bytes),
|
||||
"column_records_mb": mb(c.column_bytes),
|
||||
})
|
||||
#[derive(Serialize)]
|
||||
struct Columns {
|
||||
total_mb: f64,
|
||||
columns: usize,
|
||||
sections: usize,
|
||||
unique_sections: usize,
|
||||
uniform_block_sections: usize,
|
||||
light_arrays: usize,
|
||||
blocks_mb: f64,
|
||||
biomes_mb: f64,
|
||||
light_mb: f64,
|
||||
heightmaps_mb: f64,
|
||||
section_records_mb: f64,
|
||||
column_records_mb: f64,
|
||||
#[serde(skip)]
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Columns {
|
||||
fn new(c: &ColumnCensus) -> Self {
|
||||
Self {
|
||||
total_mb: mb(c.total_bytes()),
|
||||
columns: c.columns,
|
||||
sections: c.sections,
|
||||
unique_sections: c.unique_sections,
|
||||
uniform_block_sections: c.uniform_block_sections,
|
||||
light_arrays: c.light_arrays,
|
||||
blocks_mb: mb(c.block_bytes),
|
||||
biomes_mb: mb(c.biome_bytes),
|
||||
light_mb: mb(c.light_bytes),
|
||||
heightmaps_mb: mb(c.heightmap_bytes),
|
||||
section_records_mb: mb(c.section_record_bytes),
|
||||
column_records_mb: mb(c.column_bytes),
|
||||
bytes: c.total_bytes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sections of `worlds` that are also in `others` (by address), and
|
||||
@@ -69,6 +109,324 @@ fn shared<'a>(
|
||||
(count, bytes)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SharedSections {
|
||||
sections: usize,
|
||||
mb: f64,
|
||||
#[serde(skip)]
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Generation {
|
||||
protos: usize,
|
||||
protos_mb: f64,
|
||||
proto_blocks_mb: f64,
|
||||
proto_section_lists_mb: f64,
|
||||
proto_heightmaps_mb: f64,
|
||||
proto_other_terrain_mb: f64,
|
||||
proto_biomes_mb: f64,
|
||||
proto_records_mb: f64,
|
||||
proto_sections_by_bits: [usize; 16],
|
||||
light_views: usize,
|
||||
light_views_mb: f64,
|
||||
/// Name, entries and megabytes where the cache can say.
|
||||
caches: Vec<(&'static str, usize, Option<f64>)>,
|
||||
stages: Vec<(&'static str, usize)>,
|
||||
#[serde(skip)]
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Generation {
|
||||
fn of(streaming: &horizon_client::streaming::Streaming) -> Self {
|
||||
let c = streaming.proto_census();
|
||||
let [blocks, lists, heightmaps, other, biomes, records] = c.bytes;
|
||||
let protos: usize = c.bytes.iter().sum();
|
||||
let cached: usize = c.caches.iter().filter_map(|(_, _, bytes)| *bytes).sum();
|
||||
Self {
|
||||
protos: c.protos,
|
||||
protos_mb: mb(protos),
|
||||
proto_blocks_mb: mb(blocks),
|
||||
proto_section_lists_mb: mb(lists),
|
||||
proto_heightmaps_mb: mb(heightmaps),
|
||||
proto_other_terrain_mb: mb(other),
|
||||
proto_biomes_mb: mb(biomes),
|
||||
proto_records_mb: mb(records),
|
||||
proto_sections_by_bits: c.sections_by_bits,
|
||||
light_views: c.light_views,
|
||||
light_views_mb: mb(c.light_view_bytes),
|
||||
caches: c
|
||||
.caches
|
||||
.iter()
|
||||
.map(|(name, n, bytes)| (*name, *n, bytes.map(mb)))
|
||||
.collect(),
|
||||
stages: streaming.stages().to_vec(),
|
||||
bytes: protos + c.light_view_bytes + cached,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entities of every level by registry id: how many, and the bytes of their records.
|
||||
#[derive(Serialize)]
|
||||
struct Entities {
|
||||
count: usize,
|
||||
total_mb: f64,
|
||||
/// The `TOP` types holding the most bytes: id, count, megabytes.
|
||||
by_type: Vec<(String, usize, f64)>,
|
||||
#[serde(skip)]
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
/// The connected players: queues and what each is owed or holds, the biggest first.
|
||||
#[derive(Default, Serialize)]
|
||||
struct Players {
|
||||
count: usize,
|
||||
total_mb: f64,
|
||||
/// Name, packets and megabytes waiting to be sent.
|
||||
send_queue: Vec<(String, usize, f64)>,
|
||||
chunks_waiting: Vec<(String, usize)>,
|
||||
chunks_sent: Vec<(String, usize)>,
|
||||
tracked_entities: Vec<(String, usize)>,
|
||||
#[serde(skip)]
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
struct Cached {
|
||||
entries: usize,
|
||||
size_mb: f64,
|
||||
}
|
||||
|
||||
/// What the hub keeps to send chunks: encoded sections, heightmaps and compressed frames.
|
||||
#[derive(Default, Serialize)]
|
||||
struct ChunkSend {
|
||||
total_mb: f64,
|
||||
sections: Cached,
|
||||
heightmaps: Cached,
|
||||
frames: Cached,
|
||||
#[serde(skip)]
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Queue {
|
||||
len: usize,
|
||||
size_mb: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Process {
|
||||
resident_mb: Option<f64>,
|
||||
allocator_commit_mb: f64,
|
||||
allocator_peak_commit_mb: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Allocator {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
live_heap_mb: Option<f64>,
|
||||
commit_mb: f64,
|
||||
peak_commit_mb: f64,
|
||||
/// Committed minus the pages in use.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
overhead_mb: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FarTiles {
|
||||
active: usize,
|
||||
cached: usize,
|
||||
kept: usize,
|
||||
kept_mb: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Gpu {
|
||||
allocated_mb: f64,
|
||||
reserved_mb: f64,
|
||||
by_label_mb: Vec<(String, f64)>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Renderer {
|
||||
section_arenas_mb: f64,
|
||||
section_vertices_mb: f64,
|
||||
cpu: BTreeMap<String, f64>,
|
||||
gpu: Option<Gpu>,
|
||||
}
|
||||
|
||||
/// A serving game's own parts: the columns it holds, its generation, entities, players, chunk send caches and queues.
|
||||
struct ServerSide {
|
||||
columns: Columns,
|
||||
addresses: FxHashSet<usize>,
|
||||
generation: Generation,
|
||||
entities: Entities,
|
||||
players: Players,
|
||||
chunk_send: ChunkSend,
|
||||
light_queue: Queue,
|
||||
generation_queue: Queue,
|
||||
queue_bytes: usize,
|
||||
}
|
||||
|
||||
impl ServerSide {
|
||||
/// The bytes of every part this side names.
|
||||
fn bytes(&self) -> usize {
|
||||
self.columns.bytes
|
||||
+ self.generation.bytes
|
||||
+ self.entities.bytes
|
||||
+ self.players.bytes
|
||||
+ self.chunk_send.bytes
|
||||
+ self.queue_bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// How often a running capture records what holds the memory.
|
||||
pub(super) const MEMORY_EVERY_S: f64 = 10.0;
|
||||
|
||||
/// `horizon-ctl memory`'s report. Every `*_mb` is rounded to a tenth; the parts' bytes are summed unrounded.
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct MemoryReport {
|
||||
process: Process,
|
||||
allocator: Allocator,
|
||||
render_distance: i32,
|
||||
/// The window's columns; a dedicated server has no window and names them `columns`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
client_columns: Option<Columns>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
columns: Option<Columns>,
|
||||
server_columns: Option<Columns>,
|
||||
shared_with_server: Option<SharedSections>,
|
||||
server_generation: Option<Generation>,
|
||||
entities: Option<Entities>,
|
||||
players: Option<Players>,
|
||||
chunk_send: Option<ChunkSend>,
|
||||
light_queue: Option<Queue>,
|
||||
generation_queue: Option<Queue>,
|
||||
far_tiles: Option<FarTiles>,
|
||||
renderer: Option<Renderer>,
|
||||
/// A build with the `alloc-census` feature: the live heap by the
|
||||
/// startup step, span or pool job that allocated it, else its thread.
|
||||
heap_by_tag_mb: Option<Vec<(String, f64)>>,
|
||||
/// The columns, generation, entities, players, chunk send caches, queues, renderer CPU data and far tiles together.
|
||||
named_mb: f64,
|
||||
/// Resident memory minus `named_mb`: what no census reaches (it can be negative where the parts overlap).
|
||||
unattributed_mb: Option<f64>,
|
||||
}
|
||||
|
||||
/// `resident - named` in megabytes, None without a resident size.
|
||||
fn unattributed(resident: Option<u64>, named: usize) -> Option<f64> {
|
||||
resident.map(|r| mb_signed(r as i64 - named as i64))
|
||||
}
|
||||
|
||||
/// `horizon-ctl memory`'s report as a capture's rows: `a.b` paths, `key[name]` for `[name, value]` lists.
|
||||
pub(super) fn flatten_report(report: &Value) -> Vec<(String, f64)> {
|
||||
let mut rows = Vec::new();
|
||||
flatten(String::new(), report, &mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
fn flatten(path: String, value: &Value, rows: &mut Vec<(String, f64)>) {
|
||||
match value {
|
||||
Value::Number(n) => rows.extend(n.as_f64().map(|n| (path, n))),
|
||||
Value::Object(fields) => {
|
||||
for (key, inner) in fields {
|
||||
let next = if path.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{path}.{key}")
|
||||
};
|
||||
flatten(next, inner, rows);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
let Some([Value::String(name), count, rest @ ..]) =
|
||||
item.as_array().map(Vec::as_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
rows.extend(count.as_f64().map(|n| (format!("{path}[{name}]"), n)));
|
||||
if let Some(mb) = rest.first().and_then(Value::as_f64) {
|
||||
rows.push((format!("{path}_mb[{name}]"), mb));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `limit` rows with the biggest key first, ties by name.
|
||||
fn biggest<K: Copy + Ord, V>(mut rows: Vec<(String, K, V)>, limit: usize) -> Vec<(String, K, V)> {
|
||||
rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
rows.truncate(limit);
|
||||
rows
|
||||
}
|
||||
|
||||
/// The hub's players: what waits in each one's send queue and what the hub tracks for it.
|
||||
fn player_census(hub: &Hub) -> Players {
|
||||
let set = |len: usize| len * (size_of::<(i32, i32)>() + 1);
|
||||
let mut rows = Vec::new();
|
||||
let mut bytes = 0usize;
|
||||
for g in &hub.guests {
|
||||
let (packets, queued) = g.sender.queued();
|
||||
bytes += queued
|
||||
+ set(g.sent_chunks.capacity())
|
||||
+ set(g.far_sent_chunks.capacity())
|
||||
+ vec_bytes(&g.chunk_queue)
|
||||
+ vec_bytes(&g.far_queue)
|
||||
+ g.tracker.heap_bytes();
|
||||
rows.push((
|
||||
g.name.clone(),
|
||||
[
|
||||
queued,
|
||||
packets,
|
||||
g.chunk_queue.len() + g.far_queue.len(),
|
||||
g.sent_chunks.len() + g.far_sent_chunks.len(),
|
||||
g.tracker.len(),
|
||||
],
|
||||
));
|
||||
}
|
||||
let top = |by: usize| {
|
||||
let ranked: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|(name, v)| (name.clone(), v[by], v[0]))
|
||||
.collect();
|
||||
biggest(ranked, TOP_PLAYERS)
|
||||
};
|
||||
Players {
|
||||
count: hub.guests.len(),
|
||||
total_mb: mb(bytes),
|
||||
send_queue: top(0)
|
||||
.into_iter()
|
||||
.map(|(name, queued, _)| {
|
||||
let packets = rows.iter().find(|r| r.0 == name).map_or(0, |r| r.1[1]);
|
||||
(name, packets, mb(queued))
|
||||
})
|
||||
.collect(),
|
||||
chunks_waiting: top(2).into_iter().map(|(n, v, _)| (n, v)).collect(),
|
||||
chunks_sent: top(3).into_iter().map(|(n, v, _)| (n, v)).collect(),
|
||||
tracked_entities: top(4).into_iter().map(|(n, v, _)| (n, v)).collect(),
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk_send_census(hub: &Hub) -> ChunkSend {
|
||||
let [sections, heights] = hub.encoder.cache_census();
|
||||
let frames = horizon_client::lan::conn::shared_frame_census();
|
||||
let cached = |(entries, bytes): (usize, usize)| Cached {
|
||||
entries,
|
||||
size_mb: mb(bytes),
|
||||
};
|
||||
let bytes = sections.1 + heights.1 + frames.1;
|
||||
ChunkSend {
|
||||
total_mb: mb(bytes),
|
||||
sections: cached(sections),
|
||||
heightmaps: cached(heights),
|
||||
frames: cached(frames),
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn all_worlds(&self) -> impl Iterator<Item = &World> {
|
||||
std::iter::once(&self.world).chain(self.levels.parked_worlds())
|
||||
@@ -85,95 +443,326 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn memory_report(&self) -> Value {
|
||||
/// The entities of every level by type; a record's bytes are its struct and the buffers its fields own.
|
||||
fn entity_census(&self) -> Entities {
|
||||
fn size<T>(_: &[T]) -> usize {
|
||||
size_of::<T>()
|
||||
}
|
||||
let record = [
|
||||
size(self.dropped.as_slice()),
|
||||
size(self.orbs.as_slice()),
|
||||
size(self.creatures.projectiles.as_slice()),
|
||||
size(self.carts.as_slice()),
|
||||
size(self.boats.as_slice()),
|
||||
];
|
||||
let mut by_type: BTreeMap<String, (usize, usize)> = BTreeMap::new();
|
||||
let mut level = |mobs: &[horizon_client::mobs::Mob], loose: [usize; 5]| {
|
||||
for m in mobs.iter().filter(|m| !m.removed) {
|
||||
let bytes = size_of::<horizon_client::mobs::Mob>()
|
||||
+ size_of_val(&*m.ai)
|
||||
+ vec_bytes(&m.outbox)
|
||||
+ string_bytes(&m.custom_name)
|
||||
+ string_bytes(&m.standing_on)
|
||||
+ string_bytes(&m.skin)
|
||||
+ string_bytes(&m.cape);
|
||||
let row = by_type.entry(m.kind.id.clone()).or_default();
|
||||
row.0 += 1;
|
||||
row.1 += bytes;
|
||||
}
|
||||
let loose = super::dedicated::report::LOOSE_ENTITIES
|
||||
.into_iter()
|
||||
.zip(loose)
|
||||
.zip(record);
|
||||
for ((id, n), size) in loose.filter(|((_, n), _)| *n > 0) {
|
||||
let row = by_type.entry(id.to_owned()).or_default();
|
||||
row.0 += n;
|
||||
row.1 += n * size;
|
||||
}
|
||||
};
|
||||
level(
|
||||
&self.mobs,
|
||||
[
|
||||
self.dropped.len(),
|
||||
self.orbs.len(),
|
||||
self.creatures.projectiles.len(),
|
||||
self.carts.len(),
|
||||
self.boats.len(),
|
||||
],
|
||||
);
|
||||
for dim in self.levels.parked_dims() {
|
||||
if let Some(mobs) = self.levels.parked_mobs(dim) {
|
||||
let loose = self.levels.parked_entity_counts(dim).unwrap_or_default();
|
||||
level(mobs, loose);
|
||||
}
|
||||
}
|
||||
let count = by_type.values().map(|r| r.0).sum();
|
||||
let bytes = by_type.values().map(|r| r.1).sum();
|
||||
let rows = by_type.into_iter().map(|(id, (n, b))| (id, b, n)).collect();
|
||||
Entities {
|
||||
count,
|
||||
total_mb: mb(bytes),
|
||||
by_type: biggest(rows, TOP)
|
||||
.into_iter()
|
||||
.map(|(id, b, n)| (id, n, mb(b)))
|
||||
.collect(),
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// This game's own server parts: it is a dedicated server, or the single-player server thread.
|
||||
fn server_side(&self) -> ServerSide {
|
||||
let (players, chunk_send) = match &self.lan {
|
||||
Some(host) => (player_census(&host.hub), chunk_send_census(&host.hub)),
|
||||
None => (Players::default(), ChunkSend::default()),
|
||||
};
|
||||
let (mut generation, mut light) = {
|
||||
let q = self.streaming.queue_census();
|
||||
(q.generation, q.light)
|
||||
};
|
||||
for streaming in self.levels.parked_streamings() {
|
||||
let q = streaming.queue_census();
|
||||
generation = (generation.0 + q.generation.0, generation.1 + q.generation.1);
|
||||
light = (light.0 + q.light.0, light.1 + q.light.1);
|
||||
}
|
||||
ServerSide {
|
||||
columns: Columns::new(&self.level_census()),
|
||||
addresses: self.section_addresses(),
|
||||
generation: Generation::of(&self.streaming),
|
||||
entities: self.entity_census(),
|
||||
players,
|
||||
chunk_send,
|
||||
light_queue: Queue {
|
||||
len: light.0,
|
||||
size_mb: mb(light.1),
|
||||
},
|
||||
generation_queue: Queue {
|
||||
len: generation.0,
|
||||
size_mb: mb(generation.1),
|
||||
},
|
||||
queue_bytes: generation.1 + light.1,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn memory_report(&self) -> MemoryReport {
|
||||
let (commit, peak_commit) = horizon_threads::allocator_commit();
|
||||
let client = self.level_census();
|
||||
let live = horizon_threads::allocator_stats::live_bytes();
|
||||
let resident = horizon_threads::process_memory::resident_bytes();
|
||||
let dedicated = self.options.dedicated.is_some();
|
||||
// The single-player server thread's own levels: a second copy of the
|
||||
// columns both hold, unless they share sections.
|
||||
let server = self
|
||||
.integrated
|
||||
.as_ref()
|
||||
.and_then(|i| i.call(|g| (g.level_census(), g.section_addresses())));
|
||||
// The server thread's generation: proto chunks waiting for their
|
||||
// neighbours, and the light jobs' views of them.
|
||||
let generation = self.integrated.as_ref().and_then(|i| {
|
||||
i.call(|g| {
|
||||
let c = g.streaming.proto_census();
|
||||
let [blocks, lists, heightmaps, other, biomes, records] = c.bytes;
|
||||
json!({
|
||||
"protos": c.protos,
|
||||
"protos_mb": mb(c.bytes.iter().sum::<usize>()),
|
||||
"proto_blocks_mb": mb(blocks),
|
||||
"proto_section_lists_mb": mb(lists),
|
||||
"proto_heightmaps_mb": mb(heightmaps),
|
||||
"proto_other_terrain_mb": mb(other),
|
||||
"proto_biomes_mb": mb(biomes),
|
||||
"proto_records_mb": mb(records),
|
||||
"proto_sections_by_bits": c.sections_by_bits,
|
||||
"light_views": c.light_views,
|
||||
"light_views_mb": mb(c.light_view_bytes),
|
||||
"caches": c.caches.iter().map(|(name, n, bytes)| json!([name, n, bytes.map(mb)])).collect::<Vec<_>>(),
|
||||
"stages": g.streaming.stages().iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
});
|
||||
let server = if dedicated {
|
||||
Some(self.server_side())
|
||||
} else {
|
||||
self.integrated
|
||||
.as_ref()
|
||||
.and_then(|i| i.call(|g| g.server_side()))
|
||||
};
|
||||
let window = (!dedicated).then(|| self.level_census());
|
||||
// The window's sections the server thread holds too (lan/handoff.rs).
|
||||
let shared = server.as_ref().map(|(_, at)| {
|
||||
let (sections, bytes) = shared(self.all_worlds(), at);
|
||||
json!({ "sections": sections, "mb": mb(bytes) })
|
||||
let shared = server.as_ref().filter(|_| !dedicated).map(|s| {
|
||||
let (sections, bytes) = shared(self.all_worlds(), &s.addresses);
|
||||
SharedSections {
|
||||
sections,
|
||||
mb: mb(bytes),
|
||||
bytes,
|
||||
}
|
||||
});
|
||||
let renderer = self.renderer.as_ref().map(|r| {
|
||||
let [arenas, vertices] = r.section_vertex_bytes();
|
||||
let cpu: serde_json::Map<String, Value> = r
|
||||
.cpu_census()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (format!("{k}_mb"), mb(v).into()))
|
||||
.collect();
|
||||
let cpu = r.cpu_census();
|
||||
let cpu_bytes: usize = cpu.iter().map(|(_, v)| *v).sum();
|
||||
let gpu = r
|
||||
.gpu_memory_by_label()
|
||||
.map(|(allocated, reserved, labels)| {
|
||||
json!({
|
||||
"allocated_mb": mb(allocated),
|
||||
"reserved_mb": mb(reserved),
|
||||
"by_label_mb": labels
|
||||
.iter()
|
||||
.take(40)
|
||||
.map(|(label, bytes)| json!([label, mb(*bytes)]))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
.map(|(allocated, reserved, labels)| Gpu {
|
||||
allocated_mb: mb(allocated),
|
||||
reserved_mb: mb(reserved),
|
||||
by_label_mb: labels
|
||||
.iter()
|
||||
.take(40)
|
||||
.map(|(label, bytes)| (label.to_string(), mb(*bytes)))
|
||||
.collect(),
|
||||
});
|
||||
json!({
|
||||
"section_arenas_mb": mb(arenas),
|
||||
"section_vertices_mb": mb(vertices),
|
||||
"cpu": cpu,
|
||||
"gpu": gpu,
|
||||
})
|
||||
let renderer = Renderer {
|
||||
section_arenas_mb: mb(arenas),
|
||||
section_vertices_mb: mb(vertices),
|
||||
cpu: cpu
|
||||
.into_iter()
|
||||
.map(|(k, v)| (format!("{k}_mb"), mb(v)))
|
||||
.collect(),
|
||||
gpu,
|
||||
};
|
||||
(renderer, cpu_bytes)
|
||||
});
|
||||
json!({
|
||||
"process": {
|
||||
"resident_mb": horizon_threads::process_memory::resident_bytes().map(mb),
|
||||
"allocator_commit_mb": mb(commit),
|
||||
"allocator_peak_commit_mb": mb(peak_commit),
|
||||
let far = self.lod.as_ref().map(|lod| {
|
||||
let ((active, cached), (kept, kept_bytes)) = (lod.stats(), lod.kept());
|
||||
let tiles = FarTiles {
|
||||
active,
|
||||
cached,
|
||||
kept,
|
||||
kept_mb: mb(kept_bytes),
|
||||
};
|
||||
(tiles, kept_bytes)
|
||||
});
|
||||
let named = window.as_ref().map_or(0, ColumnCensus::total_bytes)
|
||||
+ server.as_ref().map_or(0, ServerSide::bytes)
|
||||
+ renderer.as_ref().map_or(0, |r| r.1)
|
||||
+ far.as_ref().map_or(0, |f| f.1);
|
||||
let named = named.saturating_sub(shared.as_ref().map_or(0, |s| s.bytes));
|
||||
let mut report = MemoryReport {
|
||||
process: Process {
|
||||
resident_mb: resident.map(mb),
|
||||
allocator_commit_mb: mb(commit),
|
||||
allocator_peak_commit_mb: mb(peak_commit),
|
||||
},
|
||||
"render_distance": self.streaming.radius,
|
||||
"client_columns": census_json(&client),
|
||||
"server_columns": server.as_ref().map(|(c, _)| census_json(c)),
|
||||
"shared_with_server": shared,
|
||||
"server_generation": generation,
|
||||
"far_tiles": self.lod.as_ref().map(|lod| {
|
||||
let (active, cached) = lod.stats();
|
||||
json!({ "active": active, "cached": cached })
|
||||
}),
|
||||
"renderer": renderer,
|
||||
// A build with the `alloc-census` feature: the live heap by the
|
||||
// startup step, span or pool job that allocated it, else its thread.
|
||||
"heap_by_tag_mb": horizon_threads::HEAP_CENSUS.then(|| {
|
||||
allocator: Allocator {
|
||||
live_heap_mb: live.map(mb),
|
||||
commit_mb: mb(commit),
|
||||
peak_commit_mb: mb(peak_commit),
|
||||
overhead_mb: live.map(|live| mb(commit.saturating_sub(live))),
|
||||
},
|
||||
render_distance: self.streaming.radius,
|
||||
client_columns: window.as_ref().map(Columns::new),
|
||||
columns: None,
|
||||
server_columns: None,
|
||||
shared_with_server: shared,
|
||||
server_generation: None,
|
||||
entities: None,
|
||||
players: None,
|
||||
chunk_send: None,
|
||||
light_queue: None,
|
||||
generation_queue: None,
|
||||
far_tiles: far.map(|f| f.0),
|
||||
renderer: renderer.map(|r| r.0),
|
||||
heap_by_tag_mb: horizon_threads::HEAP_CENSUS.then(|| {
|
||||
horizon_threads::heap_census::census()
|
||||
.into_iter()
|
||||
.take(60)
|
||||
.map(|(tag, bytes)| json!([tag, mb(bytes.max(0) as u64)]))
|
||||
.collect::<Vec<_>>()
|
||||
.map(|(tag, bytes)| (tag.to_string(), mb(bytes.max(0) as u64)))
|
||||
.collect()
|
||||
}),
|
||||
})
|
||||
named_mb: mb(named),
|
||||
unattributed_mb: unattributed(resident, named),
|
||||
};
|
||||
if let Some(s) = server {
|
||||
if dedicated {
|
||||
report.columns = Some(s.columns);
|
||||
} else {
|
||||
report.server_columns = Some(s.columns);
|
||||
}
|
||||
report.server_generation = Some(s.generation);
|
||||
report.entities = Some(s.entities);
|
||||
report.players = Some(s.players);
|
||||
report.chunk_send = Some(s.chunk_send);
|
||||
report.light_queue = Some(s.light_queue);
|
||||
report.generation_queue = Some(s.generation_queue);
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// The report as JSON, for the control socket and the capture's rows.
|
||||
pub(super) fn memory_value(&self) -> Value {
|
||||
serde_json::to_value(self.memory_report()).unwrap_or(Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{biggest, flatten_report, unattributed};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn unattributed_is_resident_minus_the_named_bytes_and_may_be_negative() {
|
||||
let mib = 1 << 20;
|
||||
assert_eq!(
|
||||
unattributed(Some(900 * mib), 600 * mib as usize),
|
||||
Some(300.0)
|
||||
);
|
||||
assert_eq!(unattributed(Some(10 * mib), 25 * mib as usize), Some(-15.0));
|
||||
assert_eq!(unattributed(None, 5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_biggest_rows_come_first_with_ties_by_name() {
|
||||
let rows = vec![
|
||||
("b".to_string(), 2, ()),
|
||||
("a".to_string(), 2, ()),
|
||||
("c".to_string(), 9, ()),
|
||||
];
|
||||
let names: Vec<_> = biggest(rows, 2).into_iter().map(|r| r.0).collect();
|
||||
assert_eq!(names, ["c", "a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_servers_report_names_its_entities_and_players_and_leaves_the_rest_unattributed() {
|
||||
use crate::game::test_pos_guest_tests::{GUEST, game};
|
||||
use horizon_client::mobs::Mob;
|
||||
let mut game = game();
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
for (id, kind) in [(1, "cow"), (2, "cow"), (3, "pig")] {
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get(&format!("minecraft:{kind}"))
|
||||
.unwrap()
|
||||
.clone();
|
||||
game.mobs.push(Mob::new(
|
||||
id,
|
||||
kind,
|
||||
horizon_core::geometry::Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
game.lan.as_ref().unwrap().hub.guests[0]
|
||||
.sender
|
||||
.send(vec![0; 3 << 20]);
|
||||
let report = game.memory_report();
|
||||
let by_type: std::collections::BTreeMap<_, _> = report
|
||||
.entities
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.by_type
|
||||
.iter()
|
||||
.map(|(id, n, _)| (id.as_str(), *n))
|
||||
.collect();
|
||||
assert_eq!((by_type["minecraft:cow"], by_type["minecraft:pig"]), (2, 1));
|
||||
let players = report.players.as_ref().unwrap();
|
||||
assert_eq!(players.count, 1);
|
||||
assert_eq!(players.send_queue[0].0, GUEST);
|
||||
assert_eq!(players.send_queue[0].2, 3.0);
|
||||
assert!(report.client_columns.is_none() && report.columns.is_some());
|
||||
let resident = report.process.resident_mb.unwrap();
|
||||
let rest = report.unattributed_mb.unwrap();
|
||||
assert!(
|
||||
(rest - (resident - report.named_mb)).abs() <= 0.15,
|
||||
"{rest} {resident} {}",
|
||||
report.named_mb
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_flattens_to_paths_and_named_list_entries() {
|
||||
let report = json!({
|
||||
"process": { "resident_mb": 12.5, "note": "skipped" },
|
||||
"render_distance": 16,
|
||||
"missing": null,
|
||||
"by_label_mb": [["chunks", 3.0], ["bare"]],
|
||||
"caches": [["blocks", 7, 1.5]],
|
||||
});
|
||||
let mut rows = flatten_report(&report);
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let expected = [
|
||||
("by_label_mb[chunks]", 3.0),
|
||||
("caches[blocks]", 7.0),
|
||||
("caches_mb[blocks]", 1.5),
|
||||
("process.resident_mb", 12.5),
|
||||
("render_distance", 16.0),
|
||||
];
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.map(|(p, v)| (p.as_str(), *v))
|
||||
.collect::<Vec<_>>(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1914,6 +1914,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let kind = self.carts[i].kind.id();
|
||||
horizon_threads::work::set_entity(horizon_threads::work::entity_id(kind));
|
||||
if !self.native_tick_pre_one(&mut round, id, kind) {
|
||||
continue;
|
||||
}
|
||||
@@ -1952,6 +1953,7 @@ impl Game<'_> {
|
||||
// After the ticked cart is stored: a mod reads and changes the cart as it is now.
|
||||
self.native_tick_post_one(&round, id, kind);
|
||||
}
|
||||
horizon_threads::work::clear_work();
|
||||
// On the server for every cart, the ridden ones too.
|
||||
let mut hint = 0;
|
||||
for id in ids {
|
||||
|
||||
@@ -8026,7 +8026,7 @@ impl Game<'_> {
|
||||
mod_api::dispatch_server_packets(self, mod_api::mods(), player, packets, outbound);
|
||||
}
|
||||
/// After each pass of the server tick (paused ones too) while its spans
|
||||
/// are recorded: always on a dedicated server, whose slow-tick log keeps
|
||||
/// are recorded: always on a dedicated server, whose slow ring keeps
|
||||
/// them on, elsewhere while `native_spans(true)`, `HORIZON_SPIKES` or a
|
||||
/// performance report asks for them. `wall_ms` is the pass's wall time,
|
||||
/// `cpu_ms` the server thread's CPU time in it (None where the system
|
||||
|
||||
@@ -918,6 +918,7 @@ impl<'a> Game<'a> {
|
||||
}
|
||||
spawned
|
||||
};
|
||||
horizon_threads::work::set_chunk(chunk.0, chunk.1);
|
||||
spawn_selection::spawn_for_chunk(
|
||||
&level,
|
||||
&mut random,
|
||||
@@ -931,6 +932,7 @@ impl<'a> Game<'a> {
|
||||
}
|
||||
scratch.spawn_players = state.into_players();
|
||||
}
|
||||
horizon_threads::work::clear_work();
|
||||
spawn_selection::attempt_counters(true);
|
||||
laps.lap("sn_attempts");
|
||||
self.world.random = random;
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! `profile` against the server this window is connected to (docs/perf-platform.md §4.2): the request over `horizon:perf`, its answer, and the warnings a refusal leaves in the client's capture.
|
||||
use super::Game;
|
||||
use super::profile_capture::{reply_text, reports_dir, shown};
|
||||
use crate::Result;
|
||||
use horizon_client::client_perf::request::{
|
||||
Failed, GpuSwitch, ProfileSide, StartOptions, Started, Stopped,
|
||||
};
|
||||
use horizon_client::perf_net::{CHANNEL, Outcome, Refusal, Reply, Request, Unsent, Wanted};
|
||||
use horizon_perf_format::{FieldSender, PartMerger, RawSend, files, v1::WarningCode};
|
||||
use horizon_protocol::packets::{configuration::CustomPayload, play::Serverbound as S};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const MARK_SERVER_STARTED: &str = "server_started";
|
||||
|
||||
fn refusal_text(why: Refusal) -> String {
|
||||
let (key, default) = match why {
|
||||
Refusal::Denied => (
|
||||
"horizon.perf.denied",
|
||||
"a server profile needs operator level 4",
|
||||
),
|
||||
Refusal::AlreadyRunning => (
|
||||
"horizon.perf.already_running",
|
||||
"the server is already running a profile",
|
||||
),
|
||||
Refusal::NotRunning => (
|
||||
"horizon.perf.not_running",
|
||||
"no server profile of yours is running",
|
||||
),
|
||||
Refusal::Failed => (
|
||||
"horizon.perf.failed",
|
||||
"the server could not write the profile; see its log",
|
||||
),
|
||||
};
|
||||
horizon_client::lang::get_or(key, default)
|
||||
}
|
||||
|
||||
fn not_horizon_text() -> String {
|
||||
horizon_client::lang::get_or(
|
||||
"horizon.perf.not_horizon",
|
||||
"this server isn't running Horizon",
|
||||
)
|
||||
}
|
||||
|
||||
fn unsent_text(why: Unsent) -> String {
|
||||
match why {
|
||||
Unsent::NotHorizon => not_horizon_text(),
|
||||
Unsent::Busy => "a server profile is already running or still being saved".to_owned(),
|
||||
Unsent::NothingToStop => "no server profile is running".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn warning_of(why: Refusal) -> Option<WarningCode> {
|
||||
match why {
|
||||
Refusal::Denied => Some(WarningCode::ServerDenied),
|
||||
Refusal::AlreadyRunning => Some(WarningCode::ServerAlreadyRunning),
|
||||
Refusal::NotRunning | Refusal::Failed => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The window capture's file, which the server's part is written into.
|
||||
pub(super) struct Sink {
|
||||
pub(super) sender: FieldSender,
|
||||
pub(super) origin: Instant,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
/// Closes the file's server side as far as it got, and records why it is short.
|
||||
fn incomplete(&self, merger: &PartMerger) {
|
||||
for field in merger.cut_short(self.origin.elapsed().as_secs_f64()) {
|
||||
self.sender.send(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Dest {
|
||||
/// Appended to the window's file, whole fields at a time; `held` waits for room in the writer's channel.
|
||||
Window { sink: Sink, held: Vec<u8> },
|
||||
/// Saved as it came, as a file of its own.
|
||||
Own { file: File, path: PathBuf },
|
||||
}
|
||||
|
||||
/// The server's file as it arrives.
|
||||
pub(super) struct Incoming {
|
||||
merger: PartMerger,
|
||||
dest: Dest,
|
||||
reply: Option<Sender<String>>,
|
||||
/// The last chunk has arrived and was checked whole.
|
||||
whole: bool,
|
||||
}
|
||||
|
||||
fn part_text() -> String {
|
||||
horizon_client::lang::get_or(
|
||||
"horizon.perf.part_incomplete",
|
||||
"the server's part of the profile did not arrive whole",
|
||||
)
|
||||
}
|
||||
|
||||
fn unix_secs() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether this window is a client of a server it does not run itself.
|
||||
pub(super) fn profiles_remote_server(&self) -> bool {
|
||||
self.integrated.is_none() && self.remote.is_some()
|
||||
}
|
||||
|
||||
fn server_brand(&self) -> &str {
|
||||
self.remote.as_ref().map_or("", |r| r.brand.as_str())
|
||||
}
|
||||
|
||||
fn send_perf(&self, request: &Request) {
|
||||
self.send(S::CustomPayload(CustomPayload {
|
||||
channel: CHANNEL.into(),
|
||||
data: request.encode(),
|
||||
}));
|
||||
}
|
||||
|
||||
/// `profile server start` on a server: the answer comes back through `reply`.
|
||||
pub(super) fn start_remote_only(
|
||||
&mut self,
|
||||
options: &StartOptions,
|
||||
reply: &Sender<String>,
|
||||
) -> Result<Option<serde_json::Value>> {
|
||||
if !self.profiles_remote_server() {
|
||||
return Err("profile server: no server to reach; open a world or join a server".into());
|
||||
}
|
||||
if self.profile.perf.capturing() || self.profile.perf.saving() {
|
||||
return Err("a capture is already running or still being saved".into());
|
||||
}
|
||||
let brand = self.server_brand().to_owned();
|
||||
self.profile
|
||||
.server_link
|
||||
.ask(&brand, Instant::now())
|
||||
.map_err(unsent_text)?;
|
||||
self.send_perf(&Request::Start(Wanted::from(options)));
|
||||
self.profile.server_reply = Some(reply.clone());
|
||||
self.profile.server_joined = false;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// A window capture on a server also asks the server for its side; a refusal is a warning in the file.
|
||||
pub(super) fn join_remote_server(&mut self, options: &StartOptions) {
|
||||
if !self.profiles_remote_server() {
|
||||
return;
|
||||
}
|
||||
let brand = self.server_brand().to_owned();
|
||||
match self.profile.server_link.ask(&brand, Instant::now()) {
|
||||
Ok(()) => {
|
||||
self.send_perf(&Request::Start(Wanted::from(options)));
|
||||
self.profile.server_joined = true;
|
||||
}
|
||||
Err(Unsent::NotHorizon) => self.warn_server(WarningCode::ServerNotHorizon),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_server(&mut self, code: WarningCode) {
|
||||
let t = self.profile.perf.now();
|
||||
self.profile.perf.warn(t, code);
|
||||
}
|
||||
|
||||
/// `profile server stop` on a server: the file's name comes back through `reply`.
|
||||
pub(super) fn stop_remote_only(&mut self, reply: &Sender<String>) -> Result<()> {
|
||||
self.profile
|
||||
.server_link
|
||||
.stop(Instant::now())
|
||||
.map_err(unsent_text)?;
|
||||
self.send_perf(&Request::Stop);
|
||||
self.profile.server_reply = Some(reply.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window capture ends: so does the server's side that asked with it.
|
||||
pub(super) fn stop_joined_server(&mut self) {
|
||||
if self.profile.server_joined && self.profile.server_link.stop(Instant::now()).is_ok() {
|
||||
self.send_perf(&Request::Stop);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `horizon:perf` payload from the server.
|
||||
pub(super) fn remote_perf_reply(&mut self, data: &[u8]) {
|
||||
let Ok(reply) = Reply::decode(data) else {
|
||||
log::debug!("Horizon: a bad server profile reply");
|
||||
return;
|
||||
};
|
||||
if let Reply::Chunk(chunk) = reply {
|
||||
self.part_chunk(&chunk);
|
||||
return;
|
||||
}
|
||||
let waiting = self.profile.server_reply.take();
|
||||
let joined = self.profile.server_joined;
|
||||
match self.profile.server_link.answer(reply, Instant::now()) {
|
||||
Outcome::Started {
|
||||
path,
|
||||
seconds,
|
||||
samples,
|
||||
} => {
|
||||
if joined {
|
||||
let t = self.profile.perf.now();
|
||||
self.profile.perf.mark(t, MARK_SERVER_STARTED);
|
||||
self.profile.server_sink = self.profile.perf.join().map(|j| Sink {
|
||||
sender: j.sender,
|
||||
origin: j.origin,
|
||||
});
|
||||
}
|
||||
if let Some(waiting) = waiting {
|
||||
let _ = waiting.send(reply_text(&Started {
|
||||
ok: true,
|
||||
sides: vec![ProfileSide::Server],
|
||||
path,
|
||||
seconds,
|
||||
samples,
|
||||
gpu: GpuSwitch::Off,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Outcome::Finished { path, bytes, .. } => {
|
||||
log::info!("Horizon: the server saved its profile to {path}");
|
||||
self.profile.server_joined = false;
|
||||
if bytes == 0 {
|
||||
self.profile.server_reply = waiting;
|
||||
self.abandon_part();
|
||||
} else {
|
||||
self.begin_part(waiting);
|
||||
}
|
||||
}
|
||||
Outcome::TooLarge { bytes } => {
|
||||
log::warn!("Horizon: the server announced a {bytes}-byte profile; it is not taken");
|
||||
self.profile.server_joined = false;
|
||||
self.profile.server_reply = waiting;
|
||||
self.abandon_part();
|
||||
}
|
||||
Outcome::Refused(why) => {
|
||||
if joined {
|
||||
if let Some(code) = warning_of(why) {
|
||||
self.warn_server(code);
|
||||
}
|
||||
self.profile.server_joined = false;
|
||||
self.profiler_client_only(&refusal_text(why));
|
||||
}
|
||||
if self.profile.incoming.is_some() || self.profile.server_sink.is_some() {
|
||||
self.profile.server_reply = waiting;
|
||||
self.abandon_part();
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Horizon: the server refused the profile: {}",
|
||||
refusal_text(why)
|
||||
);
|
||||
if let Some(waiting) = waiting {
|
||||
let _ = waiting.send(reply_text(&Failed {
|
||||
error: refusal_text(why),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Outcome::Stray => self.profile.server_reply = waiting,
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's file is about to arrive: into the window's capture if it still holds the file open, else into one of its own.
|
||||
fn begin_part(&mut self, waiting: Option<Sender<String>>) {
|
||||
let dest = match self.profile.server_sink.take() {
|
||||
Some(sink) => Ok(Dest::Window {
|
||||
sink,
|
||||
held: Vec::new(),
|
||||
}),
|
||||
None => files::create(&reports_dir(), unix_secs())
|
||||
.map(|(file, path)| Dest::Own { file, path }),
|
||||
};
|
||||
match dest {
|
||||
Ok(dest) => {
|
||||
self.profile.incoming = Some(Incoming {
|
||||
merger: PartMerger::new(),
|
||||
dest,
|
||||
reply: waiting,
|
||||
whole: false,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the server's profile cannot be saved: {e}");
|
||||
self.profile.server_link.reset();
|
||||
if let Some(waiting) = waiting {
|
||||
let _ = waiting.send(reply_text(&Failed {
|
||||
error: e.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The next bytes of the server's file.
|
||||
fn part_chunk(&mut self, chunk: &[u8]) {
|
||||
use horizon_client::perf_net::Received;
|
||||
if self.profile.incoming.is_none() {
|
||||
return;
|
||||
}
|
||||
let received = self.profile.server_link.chunk(chunk.len(), Instant::now());
|
||||
let step = match received {
|
||||
Received::Stray => return,
|
||||
Received::TooMuch => Err("the server sent more than it announced".to_owned()),
|
||||
Received::More | Received::Last => self.take_part(chunk, received == Received::Last),
|
||||
};
|
||||
if let Err(why) = step {
|
||||
log::warn!("Horizon: the server's profile part is unusable: {why}");
|
||||
self.abandon_part();
|
||||
}
|
||||
}
|
||||
|
||||
fn take_part(&mut self, chunk: &[u8], last: bool) -> std::result::Result<(), String> {
|
||||
let Some(incoming) = &mut self.profile.incoming else {
|
||||
return Ok(());
|
||||
};
|
||||
let fields = incoming.merger.push(chunk).map_err(|e| e.to_string())?;
|
||||
match &mut incoming.dest {
|
||||
Dest::Window { held, .. } => held.extend_from_slice(&fields),
|
||||
Dest::Own { file, .. } => file.write_all(chunk).map_err(|e| e.to_string())?,
|
||||
}
|
||||
if last {
|
||||
incoming.merger.finish().map_err(|e| e.to_string())?;
|
||||
incoming.whole = true;
|
||||
}
|
||||
self.settle_part()
|
||||
}
|
||||
|
||||
/// Hands the window's writer what the server's file has yielded, and completes the transfer once all of it is in.
|
||||
fn settle_part(&mut self) -> std::result::Result<(), String> {
|
||||
let Some(incoming) = &mut self.profile.incoming else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Dest::Window { sink, held } = &mut incoming.dest
|
||||
&& !held.is_empty()
|
||||
{
|
||||
match sink.sender.try_send_raw(std::mem::take(held)) {
|
||||
RawSend::Sent => {}
|
||||
RawSend::Full(back) => *held = back,
|
||||
RawSend::Closed => return Err("the capture's file is closed".to_owned()),
|
||||
}
|
||||
}
|
||||
let drained = match &incoming.dest {
|
||||
Dest::Window { held, .. } => held.is_empty(),
|
||||
Dest::Own { .. } => true,
|
||||
};
|
||||
if incoming.whole && drained {
|
||||
self.part_done();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The server's file is whole: the window's capture can finish, or the file of its own is kept.
|
||||
fn part_done(&mut self) {
|
||||
let Some(Incoming { dest, reply, .. }) = self.profile.incoming.take() else {
|
||||
return;
|
||||
};
|
||||
let Dest::Own { file, path } = dest else {
|
||||
log::info!("Horizon: the server's profile is part of this capture");
|
||||
return;
|
||||
};
|
||||
let kept = file.sync_all().and_then(|()| {
|
||||
let dir = path.parent().map(PathBuf::from).unwrap_or_default();
|
||||
files::retain(&dir, &path, files::KEEP_FILES, files::KEEP_BYTES)
|
||||
});
|
||||
let text = match kept {
|
||||
Ok(deleted) => {
|
||||
log::info!("Horizon: {}", files::saved_line(&path));
|
||||
reply_text(&Stopped {
|
||||
ok: true,
|
||||
sides: vec![ProfileSide::Server],
|
||||
path: shown(&path),
|
||||
open_at: horizon_client::client_perf::request::viewer(),
|
||||
removed: deleted.iter().map(|p| shown(p)).collect(),
|
||||
})
|
||||
}
|
||||
Err(e) => reply_text(&Failed {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's part will not arrive whole: what did arrive stays in the window's capture, with the warning, or the file of its own is removed.
|
||||
pub(super) fn abandon_part(&mut self) {
|
||||
let waiting = self.profile.server_reply.take();
|
||||
let sink = self.profile.server_sink.take();
|
||||
if let Some(Incoming {
|
||||
dest,
|
||||
reply,
|
||||
merger,
|
||||
..
|
||||
}) = self.profile.incoming.take()
|
||||
{
|
||||
match dest {
|
||||
Dest::Window { sink, held } => {
|
||||
if !held.is_empty() {
|
||||
let _ = sink.sender.try_send_raw(held);
|
||||
}
|
||||
sink.incomplete(&merger);
|
||||
}
|
||||
Dest::Own { file, path } => {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(path);
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(reply_text(&Failed { error: part_text() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(sink) = sink {
|
||||
sink.incomplete(&PartMerger::new());
|
||||
}
|
||||
if let Some(waiting) = waiting {
|
||||
let _ = waiting.send(reply_text(&Failed { error: part_text() }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Once per loop turn: a request the server never answered, or a connection that ended under one.
|
||||
pub(super) fn poll_server_link(&mut self) {
|
||||
if self.profile.incoming.is_some() && self.settle_part().is_err() {
|
||||
self.abandon_part();
|
||||
}
|
||||
let now = Instant::now();
|
||||
let lost = self.remote.is_none() && !self.profile.server_link.is_idle();
|
||||
let silent = self.profile.server_link.timed_out(now);
|
||||
let stalled = self.profile.server_link.stalled(now);
|
||||
if !lost && !silent && !stalled {
|
||||
return;
|
||||
}
|
||||
if lost {
|
||||
self.profile.server_link.reset();
|
||||
}
|
||||
if lost || stalled {
|
||||
self.abandon_part();
|
||||
}
|
||||
self.profile.server_joined = false;
|
||||
if let Some(waiting) = self.profile.server_reply.take() {
|
||||
let error = if lost {
|
||||
"the connection to the server ended".to_owned()
|
||||
} else {
|
||||
"the server did not answer; it may not run a Horizon with server profiles"
|
||||
.to_owned()
|
||||
};
|
||||
let _ = waiting.send(reply_text(&Failed { error }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! A guest's `horizon:perf` requests (docs/perf-platform.md §4.2): who may profile this server, the one capture at a time, and the log lines about it.
|
||||
use super::Game;
|
||||
use super::integrated::capture::{Live, Target};
|
||||
use super::lan::LanHost;
|
||||
use super::profile_capture::{reports_dir, shown};
|
||||
use horizon_client::perf_net::{
|
||||
CHANNEL, Held, OPERATOR_LEVEL, Outgoing, Refusal, Reply, Request, Verdict, Wanted, judge,
|
||||
};
|
||||
use horizon_perf_format::v1::{StopReason, Trigger};
|
||||
use horizon_protocol::packets::{configuration::CustomPayload, play};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
|
||||
/// The guest whose capture runs on this server.
|
||||
pub(in crate::game) struct Requester {
|
||||
conn: u32,
|
||||
name: String,
|
||||
/// Their connection ended: the capture stops and its file stays on the server.
|
||||
left: bool,
|
||||
}
|
||||
|
||||
/// A request that passed the checks and waits for the tick loop that owns the capture state.
|
||||
pub(in crate::game) enum Asked {
|
||||
Start {
|
||||
conn: u32,
|
||||
name: String,
|
||||
wanted: Wanted,
|
||||
},
|
||||
Stop(StopReason),
|
||||
}
|
||||
|
||||
/// What a guest has asked of this server, for the tick loop.
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct Served {
|
||||
requester: Option<Requester>,
|
||||
asked: Vec<Asked>,
|
||||
sending: Vec<Sending>,
|
||||
}
|
||||
|
||||
/// A finished capture's file on its way to the guest that asked for it.
|
||||
struct Sending {
|
||||
conn: u32,
|
||||
file: File,
|
||||
pace: Outgoing,
|
||||
}
|
||||
|
||||
/// How a server capture's file ended.
|
||||
pub(in crate::game) enum Done {
|
||||
Saved {
|
||||
path: String,
|
||||
file: PathBuf,
|
||||
removed: Vec<String>,
|
||||
},
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn payload(reply: &Reply) -> play::Clientbound {
|
||||
play::Clientbound::CustomPayload(CustomPayload {
|
||||
channel: CHANNEL.into(),
|
||||
data: reply.encode(),
|
||||
})
|
||||
}
|
||||
|
||||
fn window(wanted: &Wanted) -> String {
|
||||
wanted
|
||||
.seconds
|
||||
.map_or_else(|| "until stopped".to_owned(), |s| format!("{s} s"))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `horizon:perf` payload from `conn`.
|
||||
pub(super) fn guest_perf(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
|
||||
let Some(guest) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let name = guest.name.clone();
|
||||
let Ok(request) = Request::decode(data) else {
|
||||
log::debug!("Horizon: a bad server profile request from {name}");
|
||||
return;
|
||||
};
|
||||
let served = &mut self.profile.served;
|
||||
let held = match &served.requester {
|
||||
Some(r) if r.conn == conn => Held::Asker,
|
||||
Some(_) => Held::Someone,
|
||||
None if served
|
||||
.asked
|
||||
.iter()
|
||||
.any(|a| matches!(a, Asked::Start { .. })) =>
|
||||
{
|
||||
Held::Someone
|
||||
}
|
||||
None => Held::Nothing,
|
||||
};
|
||||
match judge(&guest.brand, guest.permission, &request, held) {
|
||||
Verdict::Ignore => {}
|
||||
Verdict::Refuse(why) => {
|
||||
if why == Refusal::Denied {
|
||||
log::warn!(
|
||||
"Horizon: {name} asked for a server profile and was refused: operator level {OPERATOR_LEVEL} is required"
|
||||
);
|
||||
}
|
||||
guest.send(&payload(&Reply::Refused(why)));
|
||||
}
|
||||
Verdict::Accept => served.asked.push(match request {
|
||||
Request::Start(wanted) => Asked::Start { conn, name, wanted },
|
||||
Request::Stop => Asked::Stop(StopReason::Normal),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `reply` to the guest on `conn`, if it is still here.
|
||||
pub(in crate::game) fn perf_reply(&self, conn: u32, reply: &Reply) {
|
||||
if let Some(guest) = self.lan.as_ref().and_then(|h| h.hub.guest(conn)) {
|
||||
guest.send(&payload(reply));
|
||||
}
|
||||
}
|
||||
|
||||
/// What guests asked since the last tick, and a stop when the guest whose capture runs has left.
|
||||
pub(in crate::game) fn take_perf_asked(&mut self) -> Vec<Asked> {
|
||||
let served = &mut self.profile.served;
|
||||
let mut asked = std::mem::take(&mut served.asked);
|
||||
if let Some(r) = served.requester.as_mut()
|
||||
&& !r.left
|
||||
&& self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guest(r.conn).is_none())
|
||||
{
|
||||
r.left = true;
|
||||
log::info!("Horizon: {} left; their server profile stops", r.name);
|
||||
asked.push(Asked::Stop(StopReason::Disconnected));
|
||||
}
|
||||
asked
|
||||
}
|
||||
|
||||
/// The capture `name` asked for runs, in the file at `path`.
|
||||
pub(in crate::game) fn perf_started(
|
||||
&mut self,
|
||||
conn: u32,
|
||||
name: String,
|
||||
wanted: &Wanted,
|
||||
path: &str,
|
||||
) {
|
||||
log::info!(
|
||||
"Horizon: {name} started a server profile ({}, samples {})",
|
||||
window(wanted),
|
||||
if wanted.samples { "on" } else { "off" }
|
||||
);
|
||||
self.profile.served.requester = Some(Requester {
|
||||
conn,
|
||||
name,
|
||||
left: false,
|
||||
});
|
||||
self.perf_reply(
|
||||
conn,
|
||||
&Reply::Started {
|
||||
path: path.to_owned(),
|
||||
seconds: wanted.seconds,
|
||||
samples: wanted.samples,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The capture `conn` asked for could not begin.
|
||||
pub(in crate::game) fn perf_refused(&self, conn: u32, why: Refusal) {
|
||||
self.perf_reply(conn, &Reply::Refused(why));
|
||||
}
|
||||
|
||||
/// Server captures whose files are done: the guest that asked is told, and the log names it.
|
||||
pub(in crate::game) fn perf_captures_done(&mut self, done: Vec<Done>) {
|
||||
for done in done {
|
||||
let Some(Requester { conn, name, left }) = self.profile.served.requester.take() else {
|
||||
continue;
|
||||
};
|
||||
if left {
|
||||
if let Done::Saved { path, .. } = &done {
|
||||
log::info!("Horizon: {name}'s server profile saved to {path}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match done {
|
||||
Done::Saved {
|
||||
path,
|
||||
file,
|
||||
removed,
|
||||
} => {
|
||||
log::info!("Horizon: {name}'s server profile saved to {path}");
|
||||
let (bytes, sending) = match File::open(&file).and_then(|f| {
|
||||
let len = f.metadata()?.len();
|
||||
Ok((f, len))
|
||||
}) {
|
||||
Ok((file, len)) => (
|
||||
len,
|
||||
Some(Sending {
|
||||
conn,
|
||||
file,
|
||||
pace: Outgoing::new(len, Instant::now()),
|
||||
}),
|
||||
),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: {name}'s server profile cannot be sent: {e}");
|
||||
(0, None)
|
||||
}
|
||||
};
|
||||
self.perf_reply(
|
||||
conn,
|
||||
&Reply::Finished {
|
||||
path,
|
||||
removed,
|
||||
bytes,
|
||||
},
|
||||
);
|
||||
self.profile.served.sending.extend(sending);
|
||||
}
|
||||
Done::Failed => {
|
||||
log::error!("Horizon: {name}'s server profile could not be written");
|
||||
self.perf_refused(conn, Refusal::Failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the next chunk of each file that is due, at most [`horizon_client::perf_net::RATE`] a second and not while the guest has a backlog.
|
||||
pub(in crate::game) fn pump_perf_files(&mut self) {
|
||||
let now = Instant::now();
|
||||
let mut sending = std::mem::take(&mut self.profile.served.sending);
|
||||
sending.retain_mut(|s| {
|
||||
let Some(guest) = self.lan.as_ref().and_then(|h| h.hub.guest(s.conn)) else {
|
||||
return false;
|
||||
};
|
||||
let queued = guest.sender.pending.load(Ordering::Relaxed);
|
||||
let Some(n) = s.pace.next(now, queued) else {
|
||||
return !s.pace.done();
|
||||
};
|
||||
let mut chunk = vec![0; n];
|
||||
match s.file.read_exact(&mut chunk) {
|
||||
Ok(()) => guest.send(&payload(&Reply::Chunk(chunk))),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: a server profile could not be read for sending: {e}");
|
||||
guest.send(&payload(&Reply::Refused(Refusal::Failed)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
!s.pace.done()
|
||||
});
|
||||
sending.append(&mut self.profile.served.sending);
|
||||
self.profile.served.sending = sending;
|
||||
}
|
||||
|
||||
/// On the server thread, between two ticks: begins and ends the captures guests asked for.
|
||||
pub(super) fn serve_perf_asked(&mut self) {
|
||||
self.pump_perf_files();
|
||||
for asked in self.take_perf_asked() {
|
||||
match asked {
|
||||
Asked::Start { conn, name, wanted } => {
|
||||
if self.server_capture_busy() {
|
||||
self.perf_refused(conn, Refusal::AlreadyRunning);
|
||||
continue;
|
||||
}
|
||||
let target = Target::Own {
|
||||
options: wanted.options(),
|
||||
trigger: Trigger::Remote,
|
||||
dir: reports_dir(),
|
||||
};
|
||||
match self.start_server_capture(target, Live::new()) {
|
||||
Ok(path) => self.perf_started(conn, name, &wanted, &shown(&path)),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: {name}'s server profile did not start: {e}");
|
||||
self.perf_refused(conn, Refusal::Failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Asked::Stop(reason) => self.stop_server_capture(reason, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,519 @@
|
||||
//! What `/profiler` says: a chat line when there is a next step, an action bar notice for progress.
|
||||
use horizon_client::client_perf::request::ProfileSide;
|
||||
use horizon_client::mark_sprite::SPRITE as MARK_SPRITE;
|
||||
use horizon_client::text::{
|
||||
ClickEvent, DARK_GRAY, GOLD, GRAY, InlineObject, RED, Style, Text, WHITE, YELLOW,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Where a message goes: the chat history, or the action bar above the hotbar.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) enum Notice {
|
||||
Chat(Text),
|
||||
Bar(Text),
|
||||
}
|
||||
|
||||
/// Spark's chat shape: a dim tag round Horizon's mark, then the line in `color`.
|
||||
fn tagged(body: Text, color: u32) -> Notice {
|
||||
let mark = Style {
|
||||
color: Some(WHITE),
|
||||
inline_object: Some(InlineObject::Atlas {
|
||||
atlas: "minecraft:gui".into(),
|
||||
sprite: MARK_SPRITE.into(),
|
||||
fallback: "H".into(),
|
||||
}),
|
||||
..Style::default()
|
||||
};
|
||||
Notice::Chat(
|
||||
Text::colored("[", DARK_GRAY)
|
||||
.append(Text::styled("\u{fffc}", mark))
|
||||
.append(Text::colored("] ", DARK_GRAY))
|
||||
.append(body.with_style(&Style::color(color))),
|
||||
)
|
||||
}
|
||||
|
||||
fn say(body: Text) -> Notice {
|
||||
tagged(body, GRAY)
|
||||
}
|
||||
|
||||
fn fail(body: Text) -> Notice {
|
||||
tagged(body, RED)
|
||||
}
|
||||
|
||||
fn bar(key: &str, args: &[Text]) -> Notice {
|
||||
Notice::Bar(Text::translate(key, args))
|
||||
}
|
||||
|
||||
fn tr(key: &str, args: &[Text]) -> Text {
|
||||
Text::translate(key, args)
|
||||
}
|
||||
|
||||
fn button(label: &str, hover: Text, click: ClickEvent) -> Text {
|
||||
tr(label, &[]).with_style(&Style {
|
||||
color: Some(GOLD),
|
||||
click: Some(click),
|
||||
hover: Some(Box::new(hover)),
|
||||
..Style::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn command(label: &str, hover: &str, command: String) -> Text {
|
||||
button(label, tr(hover, &[]), ClickEvent::Profiler(command))
|
||||
}
|
||||
|
||||
/// Buttons side by side, a single space between.
|
||||
fn row(buttons: Vec<Text>) -> Text {
|
||||
let mut out = Text::empty();
|
||||
for (i, one) in buttons.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(" ");
|
||||
}
|
||||
out.push(one);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn stop() -> Text {
|
||||
command(
|
||||
"horizon.perf.stop_button",
|
||||
"horizon.perf.stop_hover",
|
||||
"/profiler stop".into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn start() -> Text {
|
||||
command(
|
||||
"horizon.perf.start_button",
|
||||
"horizon.perf.start_hover",
|
||||
"/profiler start".into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn folder(file: &Path, shown: &str) -> Text {
|
||||
let dir = file.parent().unwrap_or(file).to_string_lossy().into_owned();
|
||||
button(
|
||||
"horizon.perf.folder_button",
|
||||
tr("horizon.perf.folder_hover", &[Text::literal(shown)]),
|
||||
ClickEvent::OpenFile(dir),
|
||||
)
|
||||
}
|
||||
|
||||
fn seconds(secs: f64) -> Text {
|
||||
Text::literal(format!("{secs:.0}"))
|
||||
}
|
||||
|
||||
/// The shared address as a white, underlined link that drops the scheme from view.
|
||||
fn link(url: &str) -> Text {
|
||||
let shown = url
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url.strip_prefix("http://"))
|
||||
.unwrap_or(url);
|
||||
Text::styled(
|
||||
shown,
|
||||
Style {
|
||||
color: Some(WHITE),
|
||||
underlined: Some(true),
|
||||
click: Some(ClickEvent::OpenUrl(url.to_owned())),
|
||||
hover: Some(Box::new(tr("horizon.perf.link_hover", &[]))),
|
||||
..Style::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn started(side: Option<ProfileSide>) -> Notice {
|
||||
let key = match side {
|
||||
None => "horizon.perf.started",
|
||||
Some(ProfileSide::Client) => "horizon.perf.started.client",
|
||||
Some(ProfileSide::Server) => "horizon.perf.started.server",
|
||||
};
|
||||
say(tr(key, &[stop()]))
|
||||
}
|
||||
|
||||
/// The answer to a status, a start while one runs, and (idle) a stop or mark with nothing to stop.
|
||||
pub(super) fn status(sides: &[ProfileSide], elapsed_s: Option<f64>) -> Notice {
|
||||
let Some(elapsed) = elapsed_s.filter(|_| !sides.is_empty()) else {
|
||||
return say(tr("horizon.perf.idle", &[start()]));
|
||||
};
|
||||
let key = match (
|
||||
sides.contains(&ProfileSide::Client),
|
||||
sides.contains(&ProfileSide::Server),
|
||||
) {
|
||||
(true, false) => "horizon.perf.running.client",
|
||||
(false, true) => "horizon.perf.running.server",
|
||||
_ => "horizon.perf.running.both",
|
||||
};
|
||||
say(tr(key, &[seconds(elapsed), stop()]))
|
||||
}
|
||||
|
||||
/// A profile that was asked for both sides and got the client's alone.
|
||||
pub(super) fn client_only(why: &str) -> Notice {
|
||||
tagged(
|
||||
tr("horizon.perf.client_only", &[Text::literal(why)]),
|
||||
YELLOW,
|
||||
)
|
||||
}
|
||||
|
||||
/// `file` is the saved profile, `shown` its path as the player sees it, `can_open_and_share` whether this build has the hooks.
|
||||
pub(super) fn saved(secs: f64, file: &Path, shown: &str, can_open_and_share: bool) -> Notice {
|
||||
let id = file.file_stem().and_then(|s| s.to_str());
|
||||
let mut buttons = Vec::new();
|
||||
if let Some(id) = id.filter(|_| can_open_and_share) {
|
||||
buttons.push(command(
|
||||
"horizon.perf.open_button",
|
||||
"horizon.perf.open_hover",
|
||||
format!("/profiler open {id}"),
|
||||
));
|
||||
buttons.push(share_button(id));
|
||||
}
|
||||
buttons.push(folder(file, shown));
|
||||
say(tr("horizon.perf.saved", &[seconds(secs), row(buttons)]))
|
||||
}
|
||||
|
||||
fn share_button(id: &str) -> Text {
|
||||
command(
|
||||
"horizon.perf.share_button",
|
||||
"horizon.perf.share_hover",
|
||||
format!("/profiler share {id}"),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn shared(url: &str) -> Notice {
|
||||
let copy = button(
|
||||
"horizon.perf.copy_button",
|
||||
tr("horizon.perf.copy_hover", &[]),
|
||||
ClickEvent::CopyToClipboard(url.to_owned()),
|
||||
);
|
||||
say(tr("horizon.perf.shared", &[link(url), copy]))
|
||||
}
|
||||
|
||||
pub(super) fn share_missing() -> Notice {
|
||||
say(tr("horizon.perf.share_missing", &[start()]))
|
||||
}
|
||||
|
||||
pub(super) fn not_found(name: &str) -> Notice {
|
||||
fail(tr("horizon.perf.not_found", &[Text::literal(name)]))
|
||||
}
|
||||
|
||||
pub(super) fn unavailable() -> Notice {
|
||||
fail(tr("horizon.perf.unavailable", &[]))
|
||||
}
|
||||
|
||||
pub(super) fn open_failed(error: &str, file: &Path, shown: &str) -> Notice {
|
||||
fail(tr(
|
||||
"horizon.perf.open_failed",
|
||||
&[Text::literal(error), folder(file, shown)],
|
||||
))
|
||||
}
|
||||
|
||||
/// The retry names the profile, so it shares this one and not whichever is latest.
|
||||
pub(super) fn upload_failed(error: &str, file: &Path) -> Notice {
|
||||
let retry = match file.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(id) => command(
|
||||
"horizon.perf.retry_button",
|
||||
"horizon.perf.retry_hover",
|
||||
format!("/profiler share {id}"),
|
||||
),
|
||||
None => Text::empty(),
|
||||
};
|
||||
fail(tr(
|
||||
"horizon.perf.upload_failed",
|
||||
&[Text::literal(error), retry],
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn error(error: &str) -> Notice {
|
||||
fail(tr("horizon.perf.error", &[Text::literal(error)]))
|
||||
}
|
||||
|
||||
pub(super) fn saving() -> Notice {
|
||||
bar("horizon.perf.saving", &[])
|
||||
}
|
||||
|
||||
pub(super) fn uploading() -> Notice {
|
||||
bar("horizon.perf.uploading", &[])
|
||||
}
|
||||
|
||||
pub(super) fn opened() -> Notice {
|
||||
bar("horizon.perf.opened", &[])
|
||||
}
|
||||
|
||||
pub(super) fn marked(name: &str) -> Notice {
|
||||
bar("horizon.perf.marked", &[Text::literal(name)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::lang::{self, Language};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Builds notices in en_us and fails if any key they use is missing from it.
|
||||
fn english<R>(build: impl FnOnce() -> R) -> R {
|
||||
let (built, missed) = lang::scoped(Language::load(&[], "en_us"), build);
|
||||
assert!(missed.is_empty(), "keys missing from en_us: {missed:?}");
|
||||
built
|
||||
}
|
||||
|
||||
fn chat(notice: Notice) -> Text {
|
||||
match notice {
|
||||
Notice::Chat(text) => text,
|
||||
Notice::Bar(text) => panic!("went to the action bar: {}", text.plain()),
|
||||
}
|
||||
}
|
||||
|
||||
fn bar_text(notice: Notice) -> Text {
|
||||
match notice {
|
||||
Notice::Bar(text) => text,
|
||||
Notice::Chat(text) => panic!("went to chat: {}", text.plain()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The span that reads exactly `text`.
|
||||
fn span<'a>(line: &'a Text, text: &str) -> &'a horizon_client::text::Span {
|
||||
line.spans
|
||||
.iter()
|
||||
.find(|s| s.text == text)
|
||||
.unwrap_or_else(|| panic!("no span {text:?} in {:?}", line.plain()))
|
||||
}
|
||||
|
||||
fn click<'a>(line: &'a Text, text: &str) -> &'a ClickEvent {
|
||||
span(line, text)
|
||||
.style
|
||||
.click
|
||||
.as_ref()
|
||||
.expect("a click event")
|
||||
}
|
||||
|
||||
fn run(command: &str) -> ClickEvent {
|
||||
ClickEvent::Profiler(command.into())
|
||||
}
|
||||
|
||||
fn file() -> PathBuf {
|
||||
PathBuf::from("/home/p/reports/profile-20261110-233142Z.hzperf")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_button_sent_back_by_a_server_is_a_plain_command_not_the_local_profiler() {
|
||||
let line = chat(english(|| saved(12.0, &file(), "reports/x.hzperf", true)));
|
||||
assert_eq!(
|
||||
click(&line, "[Share]"),
|
||||
&run("/profiler share profile-20261110-233142Z")
|
||||
);
|
||||
let echoed = english(|| {
|
||||
horizon_client::component::to_text(&line.component(), &mut |_| Text::empty())
|
||||
});
|
||||
assert_eq!(
|
||||
click(&echoed, "[Share]"),
|
||||
&ClickEvent::RunCommand("/profiler share profile-20261110-233142Z".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_chat_line_starts_with_the_dim_tag_and_the_mark_sprite() {
|
||||
let line = chat(english(|| started(None)));
|
||||
assert_eq!(line.plain(), "[H] Profiling the client and server. [Stop]");
|
||||
assert_eq!(span(&line, "[").style.color, Some(DARK_GRAY));
|
||||
assert_eq!(span(&line, "] ").style.color, Some(DARK_GRAY));
|
||||
let Some(InlineObject::Atlas { sprite, .. }) = &span(&line, "\u{fffc}").style.inline_object
|
||||
else {
|
||||
panic!("the tag has no sprite");
|
||||
};
|
||||
assert_eq!(sprite, MARK_SPRITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_names_the_sides_and_offers_stop() {
|
||||
let (both, client, server) = english(|| {
|
||||
(
|
||||
chat(started(None)),
|
||||
chat(started(Some(ProfileSide::Client))),
|
||||
chat(started(Some(ProfileSide::Server))),
|
||||
)
|
||||
});
|
||||
assert_eq!(both.plain(), "[H] Profiling the client and server. [Stop]");
|
||||
assert_eq!(client.plain(), "[H] Profiling the client. [Stop]");
|
||||
assert_eq!(server.plain(), "[H] Profiling the server. [Stop]");
|
||||
let stop = span(&both, "[Stop]");
|
||||
assert_eq!(stop.style.color, Some(GOLD));
|
||||
assert_eq!(stop.style.underlined, None);
|
||||
assert_eq!(stop.style.click, Some(run("/profiler stop")));
|
||||
assert_eq!(
|
||||
stop.style.hover.as_ref().unwrap().plain(),
|
||||
"Stop profiling and save the profile."
|
||||
);
|
||||
assert_eq!(
|
||||
span(&both, "Profiling the client and server. ").style.color,
|
||||
Some(GRAY)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_is_one_of_three_lines_and_idle_offers_start() {
|
||||
let line = |sides: &[ProfileSide], elapsed| chat(english(|| status(sides, elapsed)));
|
||||
use ProfileSide::{Client, Server};
|
||||
assert_eq!(
|
||||
line(&[Server, Client], Some(41.6)).plain(),
|
||||
"[H] Profiling the client and server for 42 s. [Stop]"
|
||||
);
|
||||
assert_eq!(
|
||||
line(&[Client], Some(3.0)).plain(),
|
||||
"[H] Profiling the client for 3 s. [Stop]"
|
||||
);
|
||||
assert_eq!(
|
||||
line(&[Server], Some(3.0)).plain(),
|
||||
"[H] Profiling the server for 3 s. [Stop]"
|
||||
);
|
||||
let idle = line(&[], None);
|
||||
assert_eq!(idle.plain(), "[H] Not profiling. [Start]");
|
||||
assert_eq!(click(&idle, "[Start]"), &run("/profiler start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_line_carries_the_length_and_the_buttons_for_this_profile() {
|
||||
let line = chat(english(|| {
|
||||
saved(
|
||||
42.2,
|
||||
&file(),
|
||||
"reports/profile-20261110-233142Z.hzperf",
|
||||
true,
|
||||
)
|
||||
}));
|
||||
assert_eq!(
|
||||
line.plain(),
|
||||
"[H] Profile saved (42 s). [Open] [Share] [Folder]"
|
||||
);
|
||||
assert_eq!(
|
||||
click(&line, "[Open]"),
|
||||
&run("/profiler open profile-20261110-233142Z")
|
||||
);
|
||||
assert_eq!(
|
||||
click(&line, "[Share]"),
|
||||
&run("/profiler share profile-20261110-233142Z")
|
||||
);
|
||||
assert_eq!(
|
||||
click(&line, "[Folder]"),
|
||||
&ClickEvent::OpenFile("/home/p/reports".into())
|
||||
);
|
||||
assert_eq!(
|
||||
span(&line, "[Folder]")
|
||||
.style
|
||||
.hover
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.plain(),
|
||||
"Show reports/profile-20261110-233142Z.hzperf in its folder."
|
||||
);
|
||||
assert!(line.spans.iter().all(|s| s.style.underlined.is_none()));
|
||||
let bare = chat(english(|| saved(42.0, &file(), "x", false)));
|
||||
assert_eq!(bare.plain(), "[H] Profile saved (42 s). [Folder]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_profile_is_one_line_with_an_underlined_link_and_copy() {
|
||||
let url = "https://playhorizon.dev/perf/c/55bwef5st6";
|
||||
let line = chat(english(|| shared(url)));
|
||||
assert_eq!(
|
||||
line.plain(),
|
||||
"[H] Shared: playhorizon.dev/perf/c/55bwef5st6 [Copy]"
|
||||
);
|
||||
let link = span(&line, "playhorizon.dev/perf/c/55bwef5st6");
|
||||
assert_eq!(link.style.color, Some(WHITE));
|
||||
assert_eq!(link.style.underlined, Some(true));
|
||||
assert_eq!(link.style.click, Some(ClickEvent::OpenUrl(url.into())));
|
||||
assert_eq!(
|
||||
link.style.hover.as_ref().unwrap().plain(),
|
||||
"Open the shared profile in your browser."
|
||||
);
|
||||
let copy = span(&line, "[Copy]");
|
||||
assert_eq!(copy.style.color, Some(GOLD));
|
||||
assert_eq!(
|
||||
copy.style.click,
|
||||
Some(ClickEvent::CopyToClipboard(url.into()))
|
||||
);
|
||||
assert_eq!(copy.style.underlined, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_share_retries_that_profile_by_name() {
|
||||
let line = chat(english(|| upload_failed("no network", &file())));
|
||||
assert_eq!(
|
||||
line.plain(),
|
||||
"[H] Could not share the profile: no network. [Retry]"
|
||||
);
|
||||
assert_eq!(
|
||||
click(&line, "[Retry]"),
|
||||
&run("/profiler share profile-20261110-233142Z")
|
||||
);
|
||||
assert_eq!(
|
||||
span(&line, "Could not share the profile: ").style.color,
|
||||
Some(RED)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_are_red_and_say_what_failed_in_words() {
|
||||
let reds = english(|| {
|
||||
[
|
||||
(error("boom"), "[H] Profiler: boom"),
|
||||
(not_found("old"), "[H] No saved profile named old."),
|
||||
(
|
||||
unavailable(),
|
||||
"[H] Opening and sharing profiles is not available in this build.",
|
||||
),
|
||||
(
|
||||
open_failed("no browser", &file(), "p"),
|
||||
"[H] Could not open the profile: no browser. [Folder]",
|
||||
),
|
||||
]
|
||||
});
|
||||
for (notice, words) in reds {
|
||||
let line = chat(notice);
|
||||
assert_eq!(line.plain(), words);
|
||||
let body = &line.spans[3];
|
||||
assert_eq!(body.style.color, Some(RED), "{words}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_that_could_not_share_both_sides_is_yellow() {
|
||||
let line = chat(english(|| {
|
||||
client_only("a server profile needs operator level 4")
|
||||
}));
|
||||
assert_eq!(
|
||||
line.plain(),
|
||||
"[H] Profiling the client only: a server profile needs operator level 4"
|
||||
);
|
||||
assert_eq!(line.spans[3].style.color, Some(YELLOW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_saved_yet_is_a_gray_line_with_start() {
|
||||
let line = chat(english(share_missing));
|
||||
assert_eq!(
|
||||
line.plain(),
|
||||
"[H] No profile saved in this session yet. [Start]"
|
||||
);
|
||||
assert_eq!(click(&line, "[Start]"), &run("/profiler start"));
|
||||
assert_eq!(line.spans[3].style.color, Some(GRAY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_goes_to_the_action_bar_without_a_tag() {
|
||||
let (saving, uploading, opened, marked) =
|
||||
english(|| (saving(), uploading(), opened(), marked("boss fight")));
|
||||
for (notice, words) in [
|
||||
(saving, "Saving the profile…"),
|
||||
(uploading, "Uploading the profile…"),
|
||||
(opened, "Opened in your browser"),
|
||||
(marked, "Marked boss fight"),
|
||||
] {
|
||||
let text = bar_text(notice);
|
||||
assert_eq!(text.plain(), words);
|
||||
assert!(
|
||||
text.spans
|
||||
.iter()
|
||||
.all(|s| s.style.inline_object.is_none() && s.style.click.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ impl Game<'_> {
|
||||
forgotten.insert((p.pos[0], p.pos[1]));
|
||||
continue;
|
||||
}
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
horizon_client::spans::time("pr_forget", || self.forget_remote_columns(&mut forgotten));
|
||||
let chunk = matches!(item, Received::Chunk(_));
|
||||
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
|
||||
events.push_front(item);
|
||||
@@ -99,7 +99,9 @@ impl Game<'_> {
|
||||
};
|
||||
self.native_client_packet(&packet);
|
||||
if let C::LevelChunkWithLight(p) = *packet {
|
||||
self.remote_chunk(&p, Some((&format, column)));
|
||||
horizon_client::spans::time("pr_chunk", || {
|
||||
self.remote_chunk(&p, Some((&format, column)))
|
||||
});
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
continue;
|
||||
@@ -264,7 +266,13 @@ impl Game<'_> {
|
||||
self.offer_reconnect(retry);
|
||||
return Ok(());
|
||||
}
|
||||
Event::Packet(packet) => self.handle_packet(*packet)?,
|
||||
Event::Packet(packet) => match *packet {
|
||||
// The server's answer to a `profile` request.
|
||||
C::CustomPayload(p) if p.channel == horizon_client::perf_net::CHANNEL => {
|
||||
self.remote_perf_reply(&p.data);
|
||||
}
|
||||
packet => self.handle_packet(packet)?,
|
||||
},
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
if self.remote.is_none() {
|
||||
@@ -272,7 +280,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
horizon_client::spans::time("pr_forget", || self.forget_remote_columns(&mut forgotten));
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.backlog_since = if events.is_empty() {
|
||||
|
||||
@@ -11,10 +11,10 @@ use horizon_client::{
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
const GUEST: &str = "H274Guest";
|
||||
pub(super) const GUEST: &str = "H274Guest";
|
||||
|
||||
/// A host on a stone floor with one connected, placed guest (conn 2).
|
||||
fn game() -> Game<'static> {
|
||||
pub(super) fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
|
||||
@@ -33,7 +33,7 @@ thread_local! {
|
||||
static COSTS: RefCell<[FxHashMap<(&'static str, usize), Cost>; 2]> = RefCell::default();
|
||||
}
|
||||
|
||||
/// Who wants the tick's spans recorded besides nobody: `HORIZON_SPIKES` (the slow-tick log),
|
||||
/// Who wants the tick's spans recorded besides nobody: `HORIZON_SPIKES` (the slow ring's steps),
|
||||
/// a report's recording, a mod (`native_spans`).
|
||||
pub(super) const SPANS_SPIKES: u8 = 1;
|
||||
pub(super) const SPANS_REPORT: u8 = 2;
|
||||
@@ -50,6 +50,11 @@ pub(super) fn want_spans(user: u8, on: bool) {
|
||||
horizon_client::spans::enable(users != 0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn wanted(user: u8) -> bool {
|
||||
SPAN_USERS.load(Ordering::Relaxed) & user != 0
|
||||
}
|
||||
|
||||
/// Sample (or stop sampling) the mob ticks this thread runs next, for the report.
|
||||
pub(super) fn set_sampling(on: bool) {
|
||||
set(REPORT, on);
|
||||
@@ -76,6 +81,20 @@ pub(super) fn add(dim: &'static str, kind: &MobType, started: Instant) {
|
||||
add_kind(dim, std::ptr::from_ref(kind) as usize, &kind.id, started);
|
||||
}
|
||||
|
||||
/// A mob of type `kind` starts its tick: the CPU sampler's work, and `horizon-report`'s cost by mob type when sampled.
|
||||
pub(super) fn start_mob(kind: &MobType) -> Option<(Instant, MobType)> {
|
||||
horizon_threads::work::set_entity(horizon_threads::work::entity_id(&kind.id));
|
||||
start().map(|t| (t, kind.clone()))
|
||||
}
|
||||
|
||||
/// The mob [`start_mob`] started ended its tick in level `dim`.
|
||||
pub(super) fn end_mob(dim: &'static str, sampled: Option<(Instant, MobType)>) {
|
||||
horizon_threads::work::clear_work();
|
||||
if let Some((started, kind)) = sampled {
|
||||
add(dim, &kind, started);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`add`] for the type at address `key` named `id`.
|
||||
fn add_kind(dim: &'static str, key: usize, id: &str, started: Instant) {
|
||||
let nanos = started.elapsed().as_nanos() as u64;
|
||||
|
||||
@@ -168,6 +168,7 @@ impl Game<'_> {
|
||||
if let Err(e) = self.save_for_exit() {
|
||||
log::warn!("Horizon: failed to save the world on exit: {e}");
|
||||
}
|
||||
self.finish_profile();
|
||||
self.finish_perf();
|
||||
}
|
||||
/// winit's `about_to_wait`: what happens between frames (stop requests, the closing
|
||||
|
||||
@@ -158,7 +158,7 @@ impl Game<'_> {
|
||||
self.clear_lod();
|
||||
self.all_sections_changed();
|
||||
}
|
||||
self.prepare_lod();
|
||||
horizon_client::spans::time("prepare_lod", || self.prepare_lod());
|
||||
// The columns the single-player server changed in bulk without sending them, which far terrain still drew
|
||||
// as this window last saw them: summarized again (taken every tick, far terrain on or off, so none waits).
|
||||
if self.remote.is_some() {
|
||||
@@ -277,7 +277,8 @@ impl Game<'_> {
|
||||
if self.remote.is_none() {
|
||||
horizon_client::spans::time("sync_tick_chunks", || self.sync_tick_chunks());
|
||||
}
|
||||
let (far_distance, far_reduced) = self.far_terrain_choice();
|
||||
let (far_distance, far_reduced) =
|
||||
horizon_client::spans::time("far_terrain_choice", || self.far_terrain_choice());
|
||||
// Terrain still on its way from the server (single player's integrated one
|
||||
// too): the view's outermost columns not all in, or its events not all handled.
|
||||
let radius = match &self.remote {
|
||||
@@ -385,6 +386,7 @@ impl Game<'_> {
|
||||
AUTO_UPLOAD_TIME
|
||||
};
|
||||
let started = Instant::now();
|
||||
let upload_span = horizon_client::spans::guard("upload_queue");
|
||||
while let Some((_, mesh)) = pending.last() {
|
||||
if auto && count > 0 && started.elapsed() >= auto_time {
|
||||
break;
|
||||
@@ -403,6 +405,7 @@ impl Game<'_> {
|
||||
count += 1;
|
||||
bytes += size;
|
||||
}
|
||||
drop(upload_span);
|
||||
self.streaming.free(spent);
|
||||
}
|
||||
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
|
||||
|
||||
@@ -654,6 +654,41 @@ impl ChunkEncoder {
|
||||
)
|
||||
}
|
||||
|
||||
/// The section entries and the column heightmaps cached, each as (entries, bytes).
|
||||
pub fn cache_census(&self) -> [(usize, usize); 2] {
|
||||
use std::mem::size_of;
|
||||
let (entries, sections, wire) = {
|
||||
let cache = self.cache.lock().unwrap();
|
||||
let wire: usize = cache
|
||||
.values()
|
||||
.filter_map(|e| e.wire.as_ref())
|
||||
.map(|(bytes, entities)| bytes.len() + entities.len() * size_of::<(u16, u32)>())
|
||||
.sum();
|
||||
(
|
||||
cache.len(),
|
||||
cache.capacity() * (size_of::<(usize, Encoded)>() + 1),
|
||||
wire,
|
||||
)
|
||||
};
|
||||
let heights = self.heights.lock().unwrap();
|
||||
let maps: usize = heights
|
||||
.values()
|
||||
.map(|h| {
|
||||
h.sections.capacity() * size_of::<std::sync::Weak<Section>>()
|
||||
+ h.maps
|
||||
.iter()
|
||||
.map(|(_, v)| v.capacity() * 8 + 24)
|
||||
.sum::<usize>()
|
||||
})
|
||||
.sum();
|
||||
[
|
||||
(entries, sections + wire),
|
||||
(
|
||||
heights.len(),
|
||||
maps + heights.capacity() * (size_of::<((i32, i32), Heights)>() + 1),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// The cached entry for a section, made with `non_air` when missing.
|
||||
fn cached<T>(&self, section: &Arc<Section>, read: impl FnOnce(&mut Encoded) -> T) -> T {
|
||||
read(self.entry(&mut self.cache.lock().unwrap(), section))
|
||||
|
||||
@@ -446,6 +446,17 @@ impl PlayerSender {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Packets queued for the network thread or held by the tick, and their bytes.
|
||||
pub fn queued(&self) -> (usize, usize) {
|
||||
let waiting = match &self.to {
|
||||
To::Pool(outbox, _) => outbox.queued(),
|
||||
To::Channel(_) => 0,
|
||||
};
|
||||
(
|
||||
waiting + self.held().len(),
|
||||
self.pending.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
/// Marks this sender as a fake player's or not.
|
||||
pub fn set_fake(&self, fake: bool) {
|
||||
self.fake.store(fake, Ordering::Relaxed);
|
||||
@@ -1973,6 +1984,11 @@ impl SharedFrames {
|
||||
fn cache(&self) -> std::sync::MutexGuard<'_, FrameCache> {
|
||||
self.frames.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
/// The frames kept and the bytes of their contents.
|
||||
fn census(&self) -> (usize, usize) {
|
||||
let cache = self.cache();
|
||||
(cache.frames.len(), cache.bytes)
|
||||
}
|
||||
/// `frame::contents(body, Some(threshold))`, compressed only when no
|
||||
/// connection has sent the same body lately or compresses it now.
|
||||
fn contents(&self, body: &[u8], threshold: usize) -> horizon_protocol::Result<Arc<[u8]>> {
|
||||
@@ -2021,6 +2037,11 @@ impl SharedFrames {
|
||||
}
|
||||
}
|
||||
|
||||
/// The compressed frames shared by every connection: how many and their bytes.
|
||||
pub fn shared_frame_census() -> (usize, usize) {
|
||||
SharedFrames::get().census()
|
||||
}
|
||||
|
||||
/// Frame one packet body into `out`: a large one compressed through
|
||||
/// [`SharedFrames`], so a chunk column sent to fifty players is compressed
|
||||
/// once, not fifty times. The bytes on the wire are the same.
|
||||
|
||||
@@ -824,6 +824,13 @@ pub struct Tracker {
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
/// The bytes of its tables, from their capacities.
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
use std::mem::size_of;
|
||||
self.seen.capacity() * (size_of::<(i32, Arc<Tracked>)>() + 1)
|
||||
+ self.events.capacity() * size_of::<TrackEvent>()
|
||||
+ self.hidden.capacity() * (size_of::<i32>() + 1)
|
||||
}
|
||||
pub fn is_tracking(&self, id: i32) -> bool {
|
||||
self.seen.contains_key(&id)
|
||||
}
|
||||
|
||||
@@ -162,6 +162,10 @@ impl Outbox {
|
||||
pub(super) fn take_packets(&self) -> [Vec<(i32, usize)>; 2] {
|
||||
std::mem::take(&mut *self.tally.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
/// The packets queued and not taken by the thread yet.
|
||||
pub(super) fn queued(&self) -> usize {
|
||||
self.queue().bodies.len()
|
||||
}
|
||||
fn queue(&self) -> MutexGuard<'_, Queue> {
|
||||
self.queue.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
//! Horizon: native Rust client for Minecraft Java Edition 26.3 gameplay.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(not(any(feature = "system-allocator", feature = "alloc-census")))]
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
|
||||
#[cfg(all(feature = "alloc-census", not(feature = "system-allocator")))]
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: horizon_threads::CensusAllocator = horizon_threads::CensusAllocator;
|
||||
|
||||
/// The mod API level this game serves, kept in the program for the launcher
|
||||
/// to read without running it (`horizon_mods::api_level::MARK`). `main`
|
||||
/// reads it, so the linker keeps it.
|
||||
static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK;
|
||||
|
||||
fn main() {
|
||||
std::hint::black_box(&MOD_API_LEVEL);
|
||||
horizon_client::entry::main();
|
||||
}
|
||||
@@ -40,7 +40,8 @@ pub fn attempt_counters(publish: bool) {
|
||||
}
|
||||
let counts = ATTEMPT_COUNTS.with(|c| c.replace([0; 2]));
|
||||
if publish {
|
||||
crate::spans::counters(&[("sa_attempts", counts[0]), ("sa_spawns", counts[1])]);
|
||||
horizon_perf_format::counter("natural_spawning.attempts", &[]).add(counts[0]);
|
||||
horizon_perf_format::counter("natural_spawning.spawns", &[]).add(counts[1]);
|
||||
}
|
||||
}
|
||||
fn attempt_count(index: usize) {
|
||||
@@ -1554,6 +1555,26 @@ fn top_non_colliding<L: NaturalLevel>(l: &L, entity: &str, x: i32, z: i32) -> [i
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Attempts and spawns counted while spans are on reach the perf counters once published, and a reset publishes nothing.
|
||||
#[test]
|
||||
fn attempt_counters_publish_what_the_tick_counted() {
|
||||
let attempts = horizon_perf_format::counter("natural_spawning.attempts", &[]);
|
||||
let spawns = horizon_perf_format::counter("natural_spawning.spawns", &[]);
|
||||
let before = (attempts.get(), spawns.get());
|
||||
crate::spans::enable(true);
|
||||
attempt_counters(false);
|
||||
for _ in 0..7 {
|
||||
attempt_count(0);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
attempt_count(1);
|
||||
}
|
||||
attempt_counters(true);
|
||||
attempt_counters(true);
|
||||
crate::spans::enable(false);
|
||||
assert_eq!((attempts.get() - before.0, spawns.get() - before.1), (7, 3));
|
||||
}
|
||||
|
||||
/// The grid finds the players whose chunk is within 8 of a chunk on both
|
||||
/// axes, in their order, as a look at every player does.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
//! `horizon:perf`: a client asks a Horizon server to capture its side (docs/perf-platform.md §4.2).
|
||||
use crate::client_perf::request::{HZ_RANGE, StartOptions, Threads};
|
||||
use crate::server_perf::MAX_WINDOW_S;
|
||||
use horizon_perf_format::MAX_PART_BYTES;
|
||||
use horizon_protocol::{
|
||||
Error, Result,
|
||||
buf::{Reader, Writer},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const CHANNEL: &str = "horizon:perf";
|
||||
/// The server brand of every Horizon server and LAN host.
|
||||
pub const HORIZON_BRAND: &str = "horizon";
|
||||
/// The operator level a server capture needs, as vanilla's `/perf` (`Commands.LEVEL_OWNERS`).
|
||||
pub const OPERATOR_LEVEL: u8 = 4;
|
||||
/// How long a request waits for the server before it counts as unanswered.
|
||||
pub const ANSWER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Bytes of a serverbound payload at most (ServerboundCustomPayloadPacket.MAX_PAYLOAD_SIZE).
|
||||
const MAX_PAYLOAD: usize = 32767;
|
||||
const MAX_PATH: usize = 256;
|
||||
/// Bytes of the server's file in one chunk.
|
||||
pub const CHUNK: usize = 32 * 1024;
|
||||
/// The most bytes per second the server sends of its file.
|
||||
pub const RATE: usize = 256 * 1024;
|
||||
/// Bytes already queued for the guest above which the server holds its next chunk back, so gameplay packets go first.
|
||||
pub const BUSY_QUEUE: usize = 64 * 1024;
|
||||
/// How long a client waits for the file to be finished, or for the next chunk, before it gives up on the server's part.
|
||||
pub const STALL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_REMOVED: usize = 64;
|
||||
|
||||
/// What a capture on the server is asked to record; the switches a server has (no GPU, no file).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Wanted {
|
||||
pub seconds: Option<u32>,
|
||||
pub hz: Option<u32>,
|
||||
pub over_ms: Option<f32>,
|
||||
pub threads: Threads,
|
||||
pub wall: bool,
|
||||
pub samples: bool,
|
||||
pub timeline: bool,
|
||||
}
|
||||
|
||||
impl From<&StartOptions> for Wanted {
|
||||
fn from(o: &StartOptions) -> Self {
|
||||
Self {
|
||||
seconds: o.seconds,
|
||||
hz: o.hz,
|
||||
over_ms: o.over_ms,
|
||||
threads: o.threads,
|
||||
wall: o.wall,
|
||||
samples: o.samples,
|
||||
timeline: o.timeline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Wanted {
|
||||
pub fn options(&self) -> StartOptions {
|
||||
StartOptions {
|
||||
seconds: self.seconds,
|
||||
hz: self.hz,
|
||||
over_ms: self.over_ms,
|
||||
threads: self.threads,
|
||||
wall: self.wall,
|
||||
samples: self.samples,
|
||||
timeline: self.timeline,
|
||||
..StartOptions::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Request {
|
||||
Start(Wanted),
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Why the server did not do what was asked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// The player is below operator level 4.
|
||||
Denied,
|
||||
/// A server capture already runs.
|
||||
AlreadyRunning,
|
||||
/// No capture of this player's runs.
|
||||
NotRunning,
|
||||
/// The capture could not be started or written; the server's log says why.
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Reply {
|
||||
Started {
|
||||
path: String,
|
||||
seconds: Option<u32>,
|
||||
samples: bool,
|
||||
},
|
||||
/// The capture ended and its file is whole on the server; `bytes` of it follow as [`Reply::Chunk`]s, none if 0.
|
||||
Finished {
|
||||
path: String,
|
||||
removed: Vec<String>,
|
||||
bytes: u64,
|
||||
},
|
||||
Refused(Refusal),
|
||||
/// The next bytes of the file, at most [`CHUNK`].
|
||||
Chunk(Vec<u8>),
|
||||
}
|
||||
|
||||
const START: u8 = 0;
|
||||
const STOP: u8 = 1;
|
||||
const WALL: u8 = 1;
|
||||
const SAMPLES: u8 = 2;
|
||||
const TIMELINE: u8 = 4;
|
||||
const THREADS_ALL: u8 = 1;
|
||||
|
||||
impl Request {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
match self {
|
||||
Self::Stop => w.u8(STOP),
|
||||
Self::Start(s) => {
|
||||
w.u8(START);
|
||||
w.varint(s.seconds.unwrap_or(0) as i32);
|
||||
w.varint(s.hz.unwrap_or(0) as i32);
|
||||
w.f32(s.over_ms.unwrap_or(0.0));
|
||||
w.u8(match s.threads {
|
||||
Threads::Main => 0,
|
||||
Threads::All => THREADS_ALL,
|
||||
});
|
||||
let flags = [(s.wall, WALL), (s.samples, SAMPLES), (s.timeline, TIMELINE)]
|
||||
.into_iter()
|
||||
.filter(|(on, _)| *on)
|
||||
.fold(0, |acc, (_, bit)| acc | bit);
|
||||
w.u8(flags);
|
||||
}
|
||||
}
|
||||
w.data
|
||||
}
|
||||
|
||||
/// A payload from a player; every value is checked against the limits `profile start` has.
|
||||
pub fn decode(data: &[u8]) -> Result<Self> {
|
||||
if data.len() > MAX_PAYLOAD {
|
||||
return Err(Error::new("perf: payload too big"));
|
||||
}
|
||||
let mut r = Reader::new(data);
|
||||
let request = match r.u8()? {
|
||||
STOP => Self::Stop,
|
||||
START => {
|
||||
let seconds = r.varint()?;
|
||||
let hz = r.varint()?;
|
||||
let over = r.f32()?;
|
||||
let threads = match r.u8()? {
|
||||
0 => Threads::Main,
|
||||
THREADS_ALL => Threads::All,
|
||||
other => return Err(Error::new(format!("perf: threads {other}"))),
|
||||
};
|
||||
let flags = r.u8()?;
|
||||
if flags & !(WALL | SAMPLES | TIMELINE) != 0 {
|
||||
return Err(Error::new(format!("perf: flags {flags}")));
|
||||
}
|
||||
let seconds = match u32::try_from(seconds) {
|
||||
Ok(0) => None,
|
||||
Ok(s) if f64::from(s) <= MAX_WINDOW_S => Some(s),
|
||||
_ => return Err(Error::new(format!("perf: {seconds} seconds"))),
|
||||
};
|
||||
let hz = match u32::try_from(hz) {
|
||||
Ok(0) => None,
|
||||
Ok(h) if HZ_RANGE.contains(&h) => Some(h),
|
||||
_ => return Err(Error::new(format!("perf: {hz} Hz"))),
|
||||
};
|
||||
let over_ms = if over == 0.0 {
|
||||
None
|
||||
} else if over.is_finite() && over > 0.0 {
|
||||
Some(over)
|
||||
} else {
|
||||
return Err(Error::new("perf: bad slow threshold"));
|
||||
};
|
||||
Self::Start(Wanted {
|
||||
seconds,
|
||||
hz,
|
||||
over_ms,
|
||||
threads,
|
||||
wall: flags & WALL != 0,
|
||||
samples: flags & SAMPLES != 0,
|
||||
timeline: flags & TIMELINE != 0,
|
||||
})
|
||||
}
|
||||
other => return Err(Error::new(format!("perf: request {other}"))),
|
||||
};
|
||||
if r.remaining() > 0 {
|
||||
return Err(Error::new("perf: trailing bytes"));
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
const STARTED: u8 = 0;
|
||||
const FINISHED: u8 = 1;
|
||||
const REFUSED: u8 = 2;
|
||||
const CHUNK_TAG: u8 = 3;
|
||||
|
||||
impl Reply {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
match self {
|
||||
Self::Started {
|
||||
path,
|
||||
seconds,
|
||||
samples,
|
||||
} => {
|
||||
w.u8(STARTED);
|
||||
w.string(path);
|
||||
w.varint(seconds.unwrap_or(0) as i32);
|
||||
w.bool(*samples);
|
||||
}
|
||||
Self::Finished {
|
||||
path,
|
||||
removed,
|
||||
bytes,
|
||||
} => {
|
||||
w.u8(FINISHED);
|
||||
w.string(path);
|
||||
w.varlong(*bytes as i64);
|
||||
w.varint(removed.len().min(MAX_REMOVED) as i32);
|
||||
for name in removed.iter().take(MAX_REMOVED) {
|
||||
w.string(name);
|
||||
}
|
||||
}
|
||||
Self::Chunk(data) => {
|
||||
w.u8(CHUNK_TAG);
|
||||
w.byte_array(data);
|
||||
}
|
||||
Self::Refused(why) => {
|
||||
w.u8(REFUSED);
|
||||
w.u8(match why {
|
||||
Refusal::Denied => 0,
|
||||
Refusal::AlreadyRunning => 1,
|
||||
Refusal::NotRunning => 2,
|
||||
Refusal::Failed => 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
w.data
|
||||
}
|
||||
|
||||
pub fn decode(data: &[u8]) -> Result<Self> {
|
||||
let mut r = Reader::new(data);
|
||||
let reply = match r.u8()? {
|
||||
STARTED => {
|
||||
let path = r.string(MAX_PATH)?;
|
||||
let seconds = r.varint()?;
|
||||
Self::Started {
|
||||
path,
|
||||
seconds: u32::try_from(seconds).ok().filter(|s| *s > 0),
|
||||
samples: r.bool()?,
|
||||
}
|
||||
}
|
||||
FINISHED => {
|
||||
let path = r.string(MAX_PATH)?;
|
||||
let bytes = u64::try_from(r.varlong()?)
|
||||
.map_err(|_| Error::new("perf: negative file size"))?;
|
||||
let removed = (0..r.count(MAX_REMOVED)?)
|
||||
.map(|_| r.string(MAX_PATH))
|
||||
.collect::<Result<_>>()?;
|
||||
Self::Finished {
|
||||
path,
|
||||
removed,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
CHUNK_TAG => Self::Chunk(r.byte_array(CHUNK)?),
|
||||
REFUSED => Self::Refused(match r.u8()? {
|
||||
0 => Refusal::Denied,
|
||||
1 => Refusal::AlreadyRunning,
|
||||
2 => Refusal::NotRunning,
|
||||
3 => Refusal::Failed,
|
||||
other => return Err(Error::new(format!("perf: refusal {other}"))),
|
||||
}),
|
||||
other => return Err(Error::new(format!("perf: reply {other}"))),
|
||||
};
|
||||
if r.remaining() > 0 {
|
||||
return Err(Error::new("perf: trailing bytes"));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
|
||||
/// Who the capture a guest asked of this server belongs to, as the asker sees it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Held {
|
||||
Nothing,
|
||||
/// The asker's own capture runs.
|
||||
Asker,
|
||||
/// Another guest's capture runs or waits to begin.
|
||||
Someone,
|
||||
}
|
||||
|
||||
/// What the server does with a request.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
/// Not from a Horizon client: no answer.
|
||||
Ignore,
|
||||
Refuse(Refusal),
|
||||
Accept,
|
||||
}
|
||||
|
||||
/// The server's rules for a guest's request: Horizon clients only, operator level 4, one capture at a time, and only its owner stops it.
|
||||
pub fn judge(brand: &str, permission: u8, request: &Request, held: Held) -> Verdict {
|
||||
if !is_horizon(brand) {
|
||||
return Verdict::Ignore;
|
||||
}
|
||||
if permission < OPERATOR_LEVEL {
|
||||
return Verdict::Refuse(Refusal::Denied);
|
||||
}
|
||||
match (request, held) {
|
||||
(Request::Start(_), Held::Nothing) | (Request::Stop, Held::Asker) => Verdict::Accept,
|
||||
(Request::Start(_), _) => Verdict::Refuse(Refusal::AlreadyRunning),
|
||||
(Request::Stop, _) => Verdict::Refuse(Refusal::NotRunning),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a server calls itself Horizon.
|
||||
pub fn is_horizon(brand: &str) -> bool {
|
||||
brand == HORIZON_BRAND
|
||||
}
|
||||
|
||||
/// Why a client could not send a request.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Unsent {
|
||||
NotHorizon,
|
||||
/// A request is unanswered, or a server capture of ours runs.
|
||||
Busy,
|
||||
/// No capture of ours runs on the server.
|
||||
NothingToStop,
|
||||
}
|
||||
|
||||
/// Where this client's request to the connected server has got.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub enum Link {
|
||||
#[default]
|
||||
Idle,
|
||||
/// The start request waits for its answer.
|
||||
Asking { since: Instant },
|
||||
Running {
|
||||
path: String,
|
||||
since: Instant,
|
||||
seconds: Option<u32>,
|
||||
},
|
||||
/// The stop request waits for the file to be whole.
|
||||
Stopping { path: String, since: Instant },
|
||||
/// The server's file arrives: `got` of `total` bytes, the last at `since`.
|
||||
Receiving {
|
||||
path: String,
|
||||
since: Instant,
|
||||
got: u64,
|
||||
total: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a chunk of the server's file means for the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Received {
|
||||
/// No file was expected.
|
||||
Stray,
|
||||
More,
|
||||
/// The file is whole.
|
||||
Last,
|
||||
/// More bytes arrived than the server announced.
|
||||
TooMuch,
|
||||
}
|
||||
|
||||
/// The server's pacing of the file it sends: chunks of at most [`CHUNK`], never faster than [`RATE`], and none while the guest's connection has a backlog.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Outgoing {
|
||||
remaining: u64,
|
||||
next_at: Instant,
|
||||
}
|
||||
|
||||
impl Outgoing {
|
||||
pub fn new(total: u64, now: Instant) -> Self {
|
||||
Self {
|
||||
remaining: total,
|
||||
next_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn done(&self) -> bool {
|
||||
self.remaining == 0
|
||||
}
|
||||
|
||||
/// The size of the chunk to send at `now`, if one is due; `queued` is the bytes already waiting to be sent to the guest.
|
||||
pub fn next(&mut self, now: Instant, queued: usize) -> Option<usize> {
|
||||
if self.remaining == 0 || now < self.next_at || queued > BUSY_QUEUE {
|
||||
return None;
|
||||
}
|
||||
let n = self.remaining.min(CHUNK as u64) as usize;
|
||||
self.remaining -= n as u64;
|
||||
self.next_at = now + Duration::from_secs_f64(n as f64 / RATE as f64);
|
||||
Some(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an answer from the server means for the client.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Outcome {
|
||||
Started {
|
||||
path: String,
|
||||
seconds: Option<u32>,
|
||||
samples: bool,
|
||||
},
|
||||
Finished {
|
||||
path: String,
|
||||
removed: Vec<String>,
|
||||
/// Bytes of the server's file to come; 0 when the server sends none.
|
||||
bytes: u64,
|
||||
},
|
||||
Refused(Refusal),
|
||||
/// The server announced a file over [`MAX_PART_BYTES`]; none of it is taken.
|
||||
TooLarge {
|
||||
bytes: u64,
|
||||
},
|
||||
/// The reply fits no request of ours, for example a duplicate.
|
||||
Stray,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
pub fn is_idle(&self) -> bool {
|
||||
matches!(self, Self::Idle)
|
||||
}
|
||||
|
||||
/// Whether the server runs a capture for this client, or is about to.
|
||||
pub fn running(&self) -> bool {
|
||||
matches!(self, Self::Running { .. } | Self::Stopping { .. })
|
||||
}
|
||||
|
||||
/// Whether a start is allowed against a server of `brand`; the link waits for the answer.
|
||||
pub fn ask(&mut self, brand: &str, now: Instant) -> std::result::Result<(), Unsent> {
|
||||
if !is_horizon(brand) {
|
||||
return Err(Unsent::NotHorizon);
|
||||
}
|
||||
if !self.is_idle() {
|
||||
return Err(Unsent::Busy);
|
||||
}
|
||||
*self = Self::Asking { since: now };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a stop can be sent; the link waits for the file.
|
||||
pub fn stop(&mut self, now: Instant) -> std::result::Result<(), Unsent> {
|
||||
match std::mem::take(self) {
|
||||
Self::Running { path, .. } => {
|
||||
*self = Self::Stopping { path, since: now };
|
||||
Ok(())
|
||||
}
|
||||
other => {
|
||||
*self = other;
|
||||
Err(Unsent::NothingToStop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The server file's name and the seconds since the capture began, while one runs.
|
||||
pub fn status(&self, now: Instant) -> Option<(&str, f64)> {
|
||||
match self {
|
||||
Self::Running { path, since, .. } | Self::Stopping { path, since } => {
|
||||
Some((path, now.duration_since(*since).as_secs_f64()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn answer(&mut self, reply: Reply, now: Instant) -> Outcome {
|
||||
match (std::mem::take(self), reply) {
|
||||
(
|
||||
Self::Asking { .. },
|
||||
Reply::Started {
|
||||
path,
|
||||
seconds,
|
||||
samples,
|
||||
},
|
||||
) => {
|
||||
*self = Self::Running {
|
||||
path: path.clone(),
|
||||
since: now,
|
||||
seconds,
|
||||
};
|
||||
Outcome::Started {
|
||||
path,
|
||||
seconds,
|
||||
samples,
|
||||
}
|
||||
}
|
||||
(
|
||||
Self::Running { .. } | Self::Stopping { .. },
|
||||
Reply::Finished {
|
||||
path,
|
||||
removed,
|
||||
bytes,
|
||||
},
|
||||
) => {
|
||||
if bytes > MAX_PART_BYTES {
|
||||
return Outcome::TooLarge { bytes };
|
||||
}
|
||||
if bytes > 0 {
|
||||
*self = Self::Receiving {
|
||||
path: path.clone(),
|
||||
since: now,
|
||||
got: 0,
|
||||
total: bytes,
|
||||
};
|
||||
}
|
||||
Outcome::Finished {
|
||||
path,
|
||||
removed,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
(Self::Receiving { .. }, Reply::Refused(why)) => Outcome::Refused(why),
|
||||
(Self::Asking { .. }, Reply::Refused(why)) => Outcome::Refused(why),
|
||||
(Self::Running { .. } | Self::Stopping { .. }, Reply::Refused(why)) => {
|
||||
Outcome::Refused(why)
|
||||
}
|
||||
(before, _) => {
|
||||
*self = before;
|
||||
Outcome::Stray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts `len` bytes of the server's file that arrived.
|
||||
pub fn chunk(&mut self, len: usize, now: Instant) -> Received {
|
||||
match std::mem::take(self) {
|
||||
Self::Receiving {
|
||||
path, got, total, ..
|
||||
} => {
|
||||
let got = got + len as u64;
|
||||
if got < total {
|
||||
*self = Self::Receiving {
|
||||
path,
|
||||
since: now,
|
||||
got,
|
||||
total,
|
||||
};
|
||||
Received::More
|
||||
} else if got == total {
|
||||
Received::Last
|
||||
} else {
|
||||
Received::TooMuch
|
||||
}
|
||||
}
|
||||
other => {
|
||||
*self = other;
|
||||
Received::Stray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the server's file has been awaited or received too slowly for longer than [`STALL_TIMEOUT`]; the link goes idle.
|
||||
pub fn stalled(&mut self, now: Instant) -> bool {
|
||||
match self {
|
||||
Self::Stopping { since, .. } | Self::Receiving { since, .. }
|
||||
if now.duration_since(*since) > STALL_TIMEOUT =>
|
||||
{
|
||||
*self = Self::Idle;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the link has waited longer than [`ANSWER_TIMEOUT`] for a start's answer; it goes idle.
|
||||
pub fn timed_out(&mut self, now: Instant) -> bool {
|
||||
match self {
|
||||
Self::Asking { since } if now.duration_since(*since) > ANSWER_TIMEOUT => {
|
||||
*self = Self::Idle;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection ended: whatever waited will not be answered.
|
||||
pub fn reset(&mut self) -> Self {
|
||||
std::mem::take(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,480 @@
|
||||
use super::*;
|
||||
|
||||
fn start(seconds: u32) -> Wanted {
|
||||
Wanted {
|
||||
seconds: Some(seconds),
|
||||
hz: None,
|
||||
over_ms: None,
|
||||
threads: Threads::Main,
|
||||
wall: false,
|
||||
samples: true,
|
||||
timeline: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn started(path: &str, seconds: Option<u32>) -> Reply {
|
||||
Reply::Started {
|
||||
path: path.into(),
|
||||
seconds,
|
||||
samples: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn finished(path: &str) -> Reply {
|
||||
finished_with(path, 0)
|
||||
}
|
||||
|
||||
fn finished_with(path: &str, bytes: u64) -> Reply {
|
||||
Reply::Finished {
|
||||
path: path.into(),
|
||||
removed: Vec::new(),
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_request_is_the_bytes_the_wire_says() {
|
||||
let wanted = Wanted {
|
||||
seconds: Some(60),
|
||||
hz: Some(500),
|
||||
over_ms: Some(2.5),
|
||||
threads: Threads::All,
|
||||
wall: true,
|
||||
samples: false,
|
||||
timeline: true,
|
||||
};
|
||||
let mut expected = vec![0, 60];
|
||||
expected.extend([0xf4, 0x03]);
|
||||
expected.extend(2.5f32.to_be_bytes());
|
||||
expected.extend([1, 0b101]);
|
||||
assert_eq!(Request::Start(wanted).encode(), expected);
|
||||
assert_eq!(Request::decode(&expected).unwrap(), Request::Start(wanted));
|
||||
assert_eq!(Request::Stop.encode(), [1]);
|
||||
assert_eq!(Request::decode(&[1]).unwrap(), Request::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_switches_stay_unset_across_the_wire() {
|
||||
let wanted = Wanted {
|
||||
seconds: None,
|
||||
..start(1)
|
||||
};
|
||||
let back = Request::decode(&Request::Start(wanted).encode()).unwrap();
|
||||
assert_eq!(back, Request::Start(wanted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_beyond_the_limits_of_profile_start_is_refused() {
|
||||
let with = |seconds: i32, hz: i32, over: f32, threads: u8, flags: u8| {
|
||||
let mut w = Writer::new();
|
||||
w.u8(0);
|
||||
w.varint(seconds);
|
||||
w.varint(hz);
|
||||
w.f32(over);
|
||||
w.u8(threads);
|
||||
w.u8(flags);
|
||||
w.data
|
||||
};
|
||||
assert!(Request::decode(&with(600, 10, 1.0, 0, 2)).is_ok());
|
||||
for bad in [
|
||||
with(601, 0, 0.0, 0, 2),
|
||||
with(-1, 0, 0.0, 0, 2),
|
||||
with(0, 9, 0.0, 0, 2),
|
||||
with(0, 1001, 0.0, 0, 2),
|
||||
with(0, 0, -1.0, 0, 2),
|
||||
with(0, 0, f32::NAN, 0, 2),
|
||||
with(0, 0, f32::INFINITY, 0, 2),
|
||||
with(0, 0, 0.0, 2, 2),
|
||||
with(0, 0, 0.0, 0, 8),
|
||||
] {
|
||||
assert!(Request::decode(&bad).is_err(), "{bad:?}");
|
||||
}
|
||||
let mut trailing = Request::Stop.encode();
|
||||
trailing.push(0);
|
||||
assert!(Request::decode(&trailing).is_err());
|
||||
assert!(Request::decode(&[]).is_err());
|
||||
assert!(Request::decode(&[9]).is_err());
|
||||
assert!(Request::decode(&vec![1; MAX_PAYLOAD + 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip_and_refusals_are_distinct_bytes() {
|
||||
let replies = [
|
||||
started("reports/profile-20261107-143204Z.hzperf", Some(60)),
|
||||
Reply::Started {
|
||||
path: "reports/a.hzperf".into(),
|
||||
seconds: None,
|
||||
samples: false,
|
||||
},
|
||||
Reply::Finished {
|
||||
path: "reports/a.hzperf".into(),
|
||||
removed: vec!["reports/old.hzperf".into(), "reports/older.hzperf".into()],
|
||||
bytes: 5_000_000_000,
|
||||
},
|
||||
Reply::Chunk(vec![0x1f, 0x8b, 8, 0, 255]),
|
||||
Reply::Chunk(Vec::new()),
|
||||
];
|
||||
for reply in replies {
|
||||
assert_eq!(Reply::decode(&reply.encode()).unwrap(), reply);
|
||||
}
|
||||
let bytes: Vec<Vec<u8>> = [
|
||||
Refusal::Denied,
|
||||
Refusal::AlreadyRunning,
|
||||
Refusal::NotRunning,
|
||||
Refusal::Failed,
|
||||
]
|
||||
.map(|why| Reply::Refused(why).encode())
|
||||
.to_vec();
|
||||
assert_eq!(bytes, [[2, 0], [2, 1], [2, 2], [2, 3]]);
|
||||
assert_eq!(
|
||||
Reply::decode(&[2, 1]).unwrap(),
|
||||
Reply::Refused(Refusal::AlreadyRunning)
|
||||
);
|
||||
assert!(Reply::decode(&[2, 4]).is_err());
|
||||
assert!(Reply::decode(&[7]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_judges_by_brand_then_level_then_what_runs() {
|
||||
let go = Request::Start(start(5));
|
||||
let cases = [
|
||||
("vanilla", 4, &go, Held::Nothing, Verdict::Ignore),
|
||||
("", 4, &Request::Stop, Held::Asker, Verdict::Ignore),
|
||||
(
|
||||
"horizon",
|
||||
3,
|
||||
&go,
|
||||
Held::Nothing,
|
||||
Verdict::Refuse(Refusal::Denied),
|
||||
),
|
||||
(
|
||||
"horizon",
|
||||
2,
|
||||
&Request::Stop,
|
||||
Held::Asker,
|
||||
Verdict::Refuse(Refusal::Denied),
|
||||
),
|
||||
(
|
||||
"horizon",
|
||||
0,
|
||||
&go,
|
||||
Held::Someone,
|
||||
Verdict::Refuse(Refusal::Denied),
|
||||
),
|
||||
("horizon", 4, &go, Held::Nothing, Verdict::Accept),
|
||||
(
|
||||
"horizon",
|
||||
4,
|
||||
&go,
|
||||
Held::Asker,
|
||||
Verdict::Refuse(Refusal::AlreadyRunning),
|
||||
),
|
||||
(
|
||||
"horizon",
|
||||
4,
|
||||
&go,
|
||||
Held::Someone,
|
||||
Verdict::Refuse(Refusal::AlreadyRunning),
|
||||
),
|
||||
("horizon", 4, &Request::Stop, Held::Asker, Verdict::Accept),
|
||||
(
|
||||
"horizon",
|
||||
4,
|
||||
&Request::Stop,
|
||||
Held::Nothing,
|
||||
Verdict::Refuse(Refusal::NotRunning),
|
||||
),
|
||||
(
|
||||
"horizon",
|
||||
4,
|
||||
&Request::Stop,
|
||||
Held::Someone,
|
||||
Verdict::Refuse(Refusal::NotRunning),
|
||||
),
|
||||
];
|
||||
for (brand, level, request, held, expected) in cases {
|
||||
assert_eq!(
|
||||
judge(brand, level, request, held),
|
||||
expected,
|
||||
"{brand:?} level {level} {request:?} {held:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_horizon_brand_may_ask() {
|
||||
let now = Instant::now();
|
||||
for brand in ["vanilla", "", "horizon-studio", "Horizon"] {
|
||||
let mut link = Link::default();
|
||||
assert_eq!(link.ask(brand, now), Err(Unsent::NotHorizon), "{brand:?}");
|
||||
assert!(link.is_idle());
|
||||
}
|
||||
let mut link = Link::default();
|
||||
assert_eq!(link.ask("horizon", now), Ok(()));
|
||||
assert_eq!(link.ask("horizon", now), Err(Unsent::Busy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capture_goes_from_asked_to_running_to_finished() {
|
||||
let t0 = Instant::now();
|
||||
let mut link = Link::default();
|
||||
link.ask("horizon", t0).unwrap();
|
||||
assert!(!link.running());
|
||||
assert_eq!(link.stop(t0), Err(Unsent::NothingToStop));
|
||||
assert_eq!(
|
||||
link.answer(started("reports/p.hzperf", None), t0),
|
||||
Outcome::Started {
|
||||
path: "reports/p.hzperf".into(),
|
||||
seconds: None,
|
||||
samples: true
|
||||
}
|
||||
);
|
||||
assert!(link.running());
|
||||
assert_eq!(link.ask("horizon", t0), Err(Unsent::Busy));
|
||||
assert_eq!(
|
||||
link.answer(started("reports/p.hzperf", None), t0),
|
||||
Outcome::Stray
|
||||
);
|
||||
assert!(link.running(), "a stray reply leaves the link as it was");
|
||||
link.stop(t0).unwrap();
|
||||
assert_eq!(link.stop(t0), Err(Unsent::NothingToStop));
|
||||
assert!(link.running());
|
||||
assert!(matches!(
|
||||
link.answer(finished("reports/p.hzperf"), t0),
|
||||
Outcome::Finished { .. }
|
||||
));
|
||||
assert!(link.is_idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_frees_the_link_and_a_window_end_finishes_a_running_capture() {
|
||||
let t0 = Instant::now();
|
||||
let mut link = Link::default();
|
||||
link.ask("horizon", t0).unwrap();
|
||||
assert_eq!(
|
||||
link.answer(Reply::Refused(Refusal::Denied), t0),
|
||||
Outcome::Refused(Refusal::Denied)
|
||||
);
|
||||
assert!(link.is_idle());
|
||||
link.ask("horizon", t0).unwrap();
|
||||
link.answer(started("p", Some(5)), t0);
|
||||
assert!(matches!(
|
||||
link.answer(finished("p"), t0),
|
||||
Outcome::Finished { .. }
|
||||
));
|
||||
assert!(link.is_idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unanswered_start_times_out_but_a_running_capture_does_not() {
|
||||
let t0 = Instant::now();
|
||||
let mut link = Link::default();
|
||||
link.ask("horizon", t0).unwrap();
|
||||
assert!(!link.timed_out(t0 + ANSWER_TIMEOUT));
|
||||
assert!(link.timed_out(t0 + ANSWER_TIMEOUT + Duration::from_millis(1)));
|
||||
assert!(link.is_idle());
|
||||
link.ask("horizon", t0).unwrap();
|
||||
link.answer(started("p", None), t0);
|
||||
assert!(!link.timed_out(t0 + ANSWER_TIMEOUT * 100));
|
||||
assert!(link.running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wanted_carries_every_switch_a_server_has_and_never_a_file() {
|
||||
let options = StartOptions {
|
||||
seconds: Some(9),
|
||||
hz: Some(250),
|
||||
over_ms: Some(40.0),
|
||||
threads: Threads::All,
|
||||
wall: true,
|
||||
samples: false,
|
||||
timeline: true,
|
||||
file: Some("/etc/passwd".into()),
|
||||
..StartOptions::default()
|
||||
};
|
||||
let back = Wanted::from(&options).options();
|
||||
assert_eq!(
|
||||
back,
|
||||
StartOptions {
|
||||
file: None,
|
||||
..options
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_is_a_tag_and_a_length_prefixed_array_and_no_bigger_than_the_limit() {
|
||||
assert_eq!(Reply::Chunk(vec![9, 8, 7]).encode(), [3, 3, 9, 8, 7]);
|
||||
let mut big = vec![3, 0x81, 0x80, 0x02];
|
||||
big.extend(std::iter::repeat_n(0u8, CHUNK + 1));
|
||||
assert!(Reply::decode(&big).is_err());
|
||||
assert!(Reply::decode(&[3, 4, 1, 2]).is_err());
|
||||
assert!(
|
||||
Reply::decode(&[3, 1, 1, 0]).is_err(),
|
||||
"bytes after the chunk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_reply_announces_the_file_size_as_a_varlong() {
|
||||
let bytes = Reply::Finished {
|
||||
path: "p".into(),
|
||||
removed: Vec::new(),
|
||||
bytes: 300,
|
||||
}
|
||||
.encode();
|
||||
assert_eq!(bytes, [1, 1, b'p', 0xac, 0x02, 0]);
|
||||
}
|
||||
|
||||
/// Everything the server would send, with the poll turns it takes, against a clock.
|
||||
fn drain(total: u64, step: Duration, queued: impl Fn(Duration) -> usize) -> Vec<(Duration, usize)> {
|
||||
let t0 = Instant::now();
|
||||
let mut out = Outgoing::new(total, t0);
|
||||
let mut sent = Vec::new();
|
||||
let mut at = Duration::ZERO;
|
||||
while !out.done() && at < Duration::from_secs(600) {
|
||||
if let Some(n) = out.next(t0 + at, queued(at)) {
|
||||
sent.push((at, n));
|
||||
}
|
||||
at += step;
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transfer_sends_the_whole_file_in_32_kib_chunks() {
|
||||
let total = 5 * CHUNK as u64 + 123;
|
||||
let sent = drain(total, Duration::from_millis(50), |_| 0);
|
||||
let sizes: Vec<usize> = sent.iter().map(|s| s.1).collect();
|
||||
assert_eq!(sizes, [32768, 32768, 32768, 32768, 32768, 123]);
|
||||
assert_eq!(sizes.iter().sum::<usize>() as u64, total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transfer_never_sends_more_than_256_kib_in_any_second() {
|
||||
let polls = [1, 7, 50, 125, 130];
|
||||
for poll_ms in polls {
|
||||
let sent = drain(64 * 1024 * 1024, Duration::from_millis(poll_ms), |_| 0);
|
||||
let last = sent.last().unwrap().0;
|
||||
let mut worst = 0usize;
|
||||
for (start, _) in &sent {
|
||||
let end = *start + Duration::from_secs(1);
|
||||
let in_second: usize = sent
|
||||
.iter()
|
||||
.filter(|(at, _)| at >= start && *at < end)
|
||||
.map(|s| s.1)
|
||||
.sum();
|
||||
worst = worst.max(in_second);
|
||||
}
|
||||
assert!(
|
||||
worst <= 256 * 1024,
|
||||
"polled every {poll_ms} ms: {worst} bytes in a second"
|
||||
);
|
||||
let seconds = last.as_secs_f64();
|
||||
let average = (sent.iter().map(|s| s.1).sum::<usize>() as f64) / seconds;
|
||||
assert!(
|
||||
average > 200.0 * 1024.0,
|
||||
"polled every {poll_ms} ms: only {average} bytes/s"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backlog_holds_the_transfer_back_until_gameplay_packets_are_out() {
|
||||
let busy_until = Duration::from_secs(3);
|
||||
let sent = drain(4 * CHUNK as u64, Duration::from_millis(50), |at| {
|
||||
if at < busy_until {
|
||||
BUSY_QUEUE + 1
|
||||
} else {
|
||||
BUSY_QUEUE
|
||||
}
|
||||
});
|
||||
assert_eq!(sent.len(), 4);
|
||||
assert!(sent.iter().all(|(at, _)| *at >= busy_until));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_arrives_chunk_by_chunk_and_the_last_one_frees_the_link() {
|
||||
let t0 = Instant::now();
|
||||
let mut link = Link::default();
|
||||
link.ask("horizon", t0).unwrap();
|
||||
link.answer(started("p", None), t0);
|
||||
link.stop(t0).unwrap();
|
||||
assert!(matches!(
|
||||
link.answer(finished_with("p", 70_000), t0),
|
||||
Outcome::Finished { bytes: 70_000, .. }
|
||||
));
|
||||
assert!(!link.is_idle(), "the file is still coming");
|
||||
assert!(!link.running());
|
||||
assert_eq!(link.ask("horizon", t0), Err(Unsent::Busy));
|
||||
assert_eq!(link.chunk(32_768, t0), Received::More);
|
||||
assert_eq!(link.chunk(32_768, t0), Received::More);
|
||||
assert_eq!(link.chunk(4_464, t0), Received::Last);
|
||||
assert!(link.is_idle());
|
||||
assert_eq!(link.chunk(1, t0), Received::Stray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_beyond_the_announced_size_ends_the_transfer() {
|
||||
let t0 = Instant::now();
|
||||
let mut link = Link::default();
|
||||
link.ask("horizon", t0).unwrap();
|
||||
link.answer(started("p", None), t0);
|
||||
link.answer(finished_with("p", 10), t0);
|
||||
assert_eq!(link.chunk(11, t0), Received::TooMuch);
|
||||
assert!(link.is_idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_announced_over_the_cap_is_refused_and_its_chunks_are_stray() {
|
||||
let t0 = Instant::now();
|
||||
let mut link = Link::default();
|
||||
link.ask("horizon", t0).unwrap();
|
||||
link.answer(started("p", None), t0);
|
||||
let bytes = horizon_perf_format::MAX_PART_BYTES + 1;
|
||||
assert_eq!(
|
||||
link.answer(finished_with("p", bytes), t0),
|
||||
Outcome::TooLarge { bytes }
|
||||
);
|
||||
assert!(link.is_idle());
|
||||
assert_eq!(link.chunk(32_768, t0), Received::Stray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_fails_mid_file_frees_the_link() {
|
||||
let t0 = Instant::now();
|
||||
let mut link = Link::default();
|
||||
link.ask("horizon", t0).unwrap();
|
||||
link.answer(started("p", None), t0);
|
||||
link.answer(finished_with("p", 100), t0);
|
||||
assert_eq!(link.chunk(10, t0), Received::More);
|
||||
assert_eq!(
|
||||
link.answer(Reply::Refused(Refusal::Failed), t0),
|
||||
Outcome::Refused(Refusal::Failed)
|
||||
);
|
||||
assert!(link.is_idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_server_is_given_up_on_thirty_seconds_after_the_last_sign_of_life() {
|
||||
let t0 = Instant::now();
|
||||
let mut link = Link::default();
|
||||
link.ask("horizon", t0).unwrap();
|
||||
link.answer(started("p", None), t0);
|
||||
assert!(
|
||||
!link.stalled(t0 + STALL_TIMEOUT * 10),
|
||||
"a running capture may run long"
|
||||
);
|
||||
link.stop(t0).unwrap();
|
||||
assert!(!link.stalled(t0 + STALL_TIMEOUT));
|
||||
assert!(link.stalled(t0 + STALL_TIMEOUT + Duration::from_millis(1)));
|
||||
assert!(link.is_idle());
|
||||
link.ask("horizon", t0).unwrap();
|
||||
link.answer(started("p", None), t0);
|
||||
link.stop(t0).unwrap();
|
||||
link.answer(finished_with("p", 100), t0);
|
||||
let t1 = t0 + Duration::from_secs(25);
|
||||
assert_eq!(link.chunk(10, t1), Received::More);
|
||||
assert!(!link.stalled(t1 + STALL_TIMEOUT));
|
||||
assert!(link.stalled(t1 + STALL_TIMEOUT + Duration::from_millis(1)));
|
||||
}
|
||||
@@ -1301,6 +1301,7 @@ impl Projectile {
|
||||
|
||||
/// One tick: returns whether to keep the projectile and what it hit.
|
||||
pub fn tick(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) {
|
||||
horizon_threads::work::set_entity(horizon_threads::work::entity_id(self.kind.id()));
|
||||
self.tick_with(w, true, false)
|
||||
}
|
||||
/// One tick of a server's projectile on its client: [`tick`](Self::tick),
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
//! A side's .hzperf capture (docs/perf-platform.md §3, §4.1), the dedicated server's or the client's, and the always-on health history and slow ring behind it.
|
||||
use crate::gfx::pack_timing::{GpuReadout, TimedFrames};
|
||||
use crate::spans::Span;
|
||||
use horizon_client_render::perf_gpu;
|
||||
use horizon_perf_format::files;
|
||||
use horizon_perf_format::v1::{
|
||||
BenchResult, GpuMode, Kind, Mark, MemorySnapshot, Options, Overhead, Side, Start, Stop,
|
||||
StopReason, ThreadSelection, Trigger, Unit, Warning, WarningCode,
|
||||
};
|
||||
use horizon_perf_format::{
|
||||
CounterReporter, Field, FieldSender, Gauges, HealthRecorder, Interner, SlowRecord, SlowRing,
|
||||
SlowSpan, WriterThread, counter_registry,
|
||||
};
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Tests that sample threads take turns: a sampler session arms every thread of the name it asks for, the other tests' too.
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
pub(crate) static SAMPLED_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
mod samples;
|
||||
pub mod spans;
|
||||
mod system;
|
||||
pub mod timeline;
|
||||
pub mod traffic;
|
||||
pub mod world;
|
||||
pub use samples::MAX_WINDOW_S;
|
||||
use samples::{Sampling, Stopped};
|
||||
use spans::{SpanRecorder, Tick};
|
||||
use system::{BackgroundProbe, SystemProbe};
|
||||
pub use system::{
|
||||
build, game_rules, hardware, hardware_now, horizon_options, machine, settings, warm_hardware,
|
||||
};
|
||||
use timeline::{Drained, TimelineRecorder};
|
||||
use traffic::{TrafficRecorder, Wire};
|
||||
use world::{LevelCounts, MobCosts, WorkerJobs};
|
||||
|
||||
/// What a capturing side is: which side of the file it writes and the unit and sampled thread it counts in.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Role {
|
||||
pub side: Side,
|
||||
pub kind: Kind,
|
||||
pub unit: Unit,
|
||||
pub thread: &'static str,
|
||||
/// The thread whose units the span recorder sums.
|
||||
pub span_thread: &'static str,
|
||||
/// Whether the process and machine probes run off the thread that feeds `tick`.
|
||||
pub background_probe: bool,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub const DEDICATED: Role = Role {
|
||||
side: Side::Server,
|
||||
kind: Kind::Dedicated,
|
||||
unit: Unit::Tick,
|
||||
thread: "tick",
|
||||
span_thread: "Server thread",
|
||||
background_probe: false,
|
||||
};
|
||||
pub const INTEGRATED: Role = Role {
|
||||
side: Side::Server,
|
||||
kind: Kind::Integrated,
|
||||
unit: Unit::Tick,
|
||||
thread: "tick",
|
||||
span_thread: "Server thread",
|
||||
background_probe: true,
|
||||
};
|
||||
pub const CLIENT: Role = Role {
|
||||
side: Side::Client,
|
||||
kind: Kind::Client,
|
||||
unit: Unit::Frame,
|
||||
thread: "render",
|
||||
span_thread: "Render thread",
|
||||
background_probe: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// What a tick spent its time on, gathered by the tick loop only when the tick was slow or a capture runs.
|
||||
pub struct TickDetail {
|
||||
pub cpu_ms: Option<f32>,
|
||||
/// As recorded in the tick, each span after the spans inside it.
|
||||
pub spans: Vec<Span>,
|
||||
}
|
||||
|
||||
/// The counts the health samples carry, read once a second.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct WorldCounts {
|
||||
pub players: u32,
|
||||
pub chunks: u32,
|
||||
pub entities: u32,
|
||||
}
|
||||
|
||||
/// The sampler's switches (`profile start --hz --over --wall --threads`); the default is the sampler's own.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct SamplerOptions {
|
||||
pub hz: Option<u32>,
|
||||
/// Only ticks or frames at least this slow are sampled; the slow ring's threshold when unset.
|
||||
pub over_ms: Option<f32>,
|
||||
pub wall: bool,
|
||||
/// Every thread that opted in, not just the side's main thread.
|
||||
pub all_threads: bool,
|
||||
}
|
||||
|
||||
/// Switches of one capture.
|
||||
pub struct CaptureOptions {
|
||||
/// Unset when the capture runs until stopped, which the file records and the longest window ends.
|
||||
pub window_s: Option<f64>,
|
||||
pub trigger: Trigger,
|
||||
/// Settings, machine and build for `Start`; the capture fills the rest.
|
||||
pub start: Start,
|
||||
/// Whether the side's main thread is sampled (where the platform can).
|
||||
pub samples: bool,
|
||||
pub sampler: SamplerOptions,
|
||||
/// Block names by id, for the samples' contexts.
|
||||
pub block_names: Vec<String>,
|
||||
/// The levels as the capture begins.
|
||||
pub levels: Vec<LevelCounts>,
|
||||
/// The connections as the capture begins.
|
||||
pub wire: Wire,
|
||||
/// Whether the GPU's pass timings are recorded: unspecified on a side without a GPU.
|
||||
pub gpu: GpuMode,
|
||||
/// Records every thread's spans as `timeline` chunks within these caps, unless another capture already does.
|
||||
pub timeline: Option<timeline::Caps>,
|
||||
/// Write the capture at exactly this path, kept by no retention, instead of into the folder given to `start_capture`.
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// What the game gathers as a capture ends.
|
||||
#[derive(Default)]
|
||||
pub struct CaptureEnd {
|
||||
pub levels: Vec<LevelCounts>,
|
||||
pub mob_costs: MobCosts,
|
||||
pub jobs: Vec<WorkerJobs>,
|
||||
pub wire: Wire,
|
||||
/// What timing one span and one mob costs on this machine, in nanoseconds.
|
||||
pub span_ns: f64,
|
||||
pub mob_ns: f64,
|
||||
/// The GPU pass timings, when the capture asked for them.
|
||||
pub gpu: Option<GpuReadout>,
|
||||
/// Benchmark reports to embed verbatim; the capture gives them its side.
|
||||
pub bench: Vec<BenchResult>,
|
||||
}
|
||||
|
||||
/// A finished capture: where it is and the older files retention removed.
|
||||
pub struct Saved {
|
||||
pub path: PathBuf,
|
||||
pub deleted: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Where a running capture is, for `profile status`.
|
||||
pub struct CaptureStatus {
|
||||
pub path: PathBuf,
|
||||
pub elapsed_s: f64,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
side: Side,
|
||||
unit: Unit,
|
||||
/// Unset when the session joined another side's file, which that side finishes.
|
||||
writer: Option<WriterThread<File>>,
|
||||
tx: FieldSender,
|
||||
path: PathBuf,
|
||||
/// The folder retention trims; none for a capture written to a path of its own.
|
||||
dir: Option<PathBuf>,
|
||||
/// The recorder's clock reading when the capture began.
|
||||
origin: f64,
|
||||
cursor: u64,
|
||||
strings: Interner,
|
||||
counters: CounterReporter,
|
||||
sampling: Option<Sampling>,
|
||||
block_names: Vec<String>,
|
||||
spans: SpanRecorder,
|
||||
traffic: TrafficRecorder,
|
||||
gpu: GpuMode,
|
||||
/// The GPU timers' frames and the window's CPU frame times, and when their last block was written.
|
||||
gpu_log: perf_gpu::FrameLog,
|
||||
gpu_block_t: f64,
|
||||
timeline: Option<TimelineRecorder>,
|
||||
}
|
||||
|
||||
enum Ending {
|
||||
Running(JoinHandle<std::io::Result<Saved>>),
|
||||
Done(std::io::Result<Saved>),
|
||||
}
|
||||
|
||||
/// The server's health tiers and slow ring, always on, and the capture running from them, if any.
|
||||
pub struct ServerPerf {
|
||||
role: Role,
|
||||
base: f64,
|
||||
health: HealthRecorder,
|
||||
slow: SlowRing,
|
||||
ticks: u64,
|
||||
gauges_at: f64,
|
||||
system: SystemProbe,
|
||||
background: Option<BackgroundProbe>,
|
||||
frame_medians: (Option<f32>, Option<f32>),
|
||||
session: Option<Session>,
|
||||
ending: Vec<Ending>,
|
||||
}
|
||||
|
||||
impl ServerPerf {
|
||||
/// `start` is the clock reading (seconds) every later time is on; `slow_ms` is the slow ring's threshold.
|
||||
pub fn new(start: f64, slow_ms: f32) -> Self {
|
||||
Self::with_role(Role::DEDICATED, start, slow_ms)
|
||||
}
|
||||
|
||||
pub fn with_role(role: Role, start: f64, slow_ms: f32) -> Self {
|
||||
Self {
|
||||
role,
|
||||
base: start,
|
||||
health: HealthRecorder::new(role.side, start),
|
||||
slow: SlowRing::new(slow_ms),
|
||||
ticks: 0,
|
||||
gauges_at: start,
|
||||
system: SystemProbe::default(),
|
||||
background: None,
|
||||
frame_medians: (None, None),
|
||||
session: None,
|
||||
ending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slow_threshold_ms(&self) -> f32 {
|
||||
self.slow.threshold_ms()
|
||||
}
|
||||
|
||||
/// The slow ring's ticks or frames, oldest first.
|
||||
pub fn slow_records(&self) -> impl Iterator<Item = &SlowRecord> {
|
||||
self.slow.records()
|
||||
}
|
||||
|
||||
pub fn capturing(&self) -> bool {
|
||||
self.session.is_some()
|
||||
}
|
||||
|
||||
pub fn health(&self) -> &HealthRecorder {
|
||||
&self.health
|
||||
}
|
||||
|
||||
/// Whether the next `tick` reads the gauges, so a client can set the frame medians of the second first.
|
||||
pub fn gauges_due(&self, t: f64) -> bool {
|
||||
t - self.gauges_at >= 1.0
|
||||
}
|
||||
|
||||
/// The median CPU and GPU time of the frames since the last second, for the gauges read next.
|
||||
pub fn set_frame_medians(&mut self, cpu_ms: Option<f32>, gpu_ms: Option<f32>) {
|
||||
self.frame_medians = (cpu_ms, gpu_ms);
|
||||
}
|
||||
|
||||
/// One tick or frame that took `wall_ms` and ended at `t`; `world` and `wire` are read when the gauges are due.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
t: f64,
|
||||
wall_ms: f32,
|
||||
detail: Option<TickDetail>,
|
||||
world: impl FnOnce() -> WorldCounts,
|
||||
wire: impl FnOnce() -> Wire,
|
||||
) {
|
||||
let first_background = self.role.background_probe && self.background.is_none();
|
||||
let due = self.gauges_due(t);
|
||||
let counts = (first_background || due).then(world);
|
||||
if let (true, Some(counts)) = (first_background, counts) {
|
||||
let probe = BackgroundProbe::spawn();
|
||||
probe.request(t, counts);
|
||||
self.background = Some(probe);
|
||||
}
|
||||
if let (true, Some(counts)) = (due, counts) {
|
||||
self.gauges_at = t;
|
||||
let mut g = match &self.background {
|
||||
Some(probe) => {
|
||||
probe.request(t, counts);
|
||||
Gauges {
|
||||
players: counts.players,
|
||||
chunks: counts.chunks,
|
||||
entities: counts.entities,
|
||||
..probe.latest()
|
||||
}
|
||||
}
|
||||
None => self.system.read(t, counts),
|
||||
};
|
||||
(g.frame_cpu_ms_p50, g.frame_gpu_ms_p50) = self.frame_medians;
|
||||
self.health.set_gauges(g);
|
||||
if let Some(session) = &mut self.session {
|
||||
session.traffic.sample(t - session.origin, &wire());
|
||||
}
|
||||
}
|
||||
self.ticks += 1;
|
||||
self.health.record(t, wall_ms);
|
||||
if wall_ms > self.slow.threshold_ms() {
|
||||
let record = SlowRecord {
|
||||
t,
|
||||
unit: self.role.unit,
|
||||
counter: self.ticks,
|
||||
wall_ms,
|
||||
cpu_ms: detail.as_ref().and_then(|d| d.cpu_ms).unwrap_or(0.0),
|
||||
top: detail
|
||||
.iter()
|
||||
.flat_map(|d| crate::spans::merge(&d.spans))
|
||||
.map(|s| SlowSpan {
|
||||
name: s.name.to_owned(),
|
||||
wall_ms: s.ms,
|
||||
cpu_ms: s.cpu_ms,
|
||||
depth: u32::from(s.depth),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
if self.slow.offer(record)
|
||||
&& let Some(session) = &mut self.session
|
||||
&& let Some(kept) = self.slow.records().last()
|
||||
{
|
||||
session.slow(kept);
|
||||
}
|
||||
}
|
||||
if let Some(session) = &mut self.session {
|
||||
session.span_tick(t, self.ticks, wall_ms, detail.as_ref());
|
||||
session.pump(&self.health, t);
|
||||
session.timeline_tick(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's frame that ended at `t`: its CPU ms and the GPU frames timed since the last, written about once a second when the capture times the GPU.
|
||||
pub fn gpu_frames(&mut self, t: f64, cpu_ms: f32, frames: &TimedFrames) {
|
||||
let Some(session) = &mut self.session else {
|
||||
return;
|
||||
};
|
||||
if matches!(session.gpu, GpuMode::Unspecified | GpuMode::Off) {
|
||||
return;
|
||||
}
|
||||
let (now, origin) = (Instant::now(), session.origin);
|
||||
session.gpu_log.cpu_frame(cpu_ms);
|
||||
session.gpu_log.add(
|
||||
frames,
|
||||
|at| t - now.saturating_duration_since(at).as_secs_f64() - origin,
|
||||
&mut session.strings,
|
||||
);
|
||||
if t - origin - session.gpu_block_t >= 1.0 {
|
||||
session.gpu_block_t = t - origin;
|
||||
session.gpu_block();
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `dir/profile-<time>Z.hzperf` with `Start`, the health tiers and the slow ring; call it on the tick thread, which it samples.
|
||||
pub fn start_capture(
|
||||
&mut self,
|
||||
t: f64,
|
||||
dir: &Path,
|
||||
options: CaptureOptions,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
if self.session.is_some() {
|
||||
return Err(std::io::Error::other("a capture is already running"));
|
||||
}
|
||||
let now_ms = unix_ms();
|
||||
let (file, path) = match &options.file {
|
||||
Some(path) => (files::create_at(path)?, path.clone()),
|
||||
None => files::create(dir, now_ms.div_euclid(1000))?,
|
||||
};
|
||||
let writer = WriterThread::spawn_with(file, profiler_thread)?;
|
||||
let tx = writer.sender();
|
||||
self.open(t, t, Some(writer), tx, path.clone(), dir, options);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Writes this side into the file another side opened; `origin`, on this side's clock, is the zero of every `t`. Call it on the tick thread.
|
||||
pub fn join_capture(
|
||||
&mut self,
|
||||
t: f64,
|
||||
origin: f64,
|
||||
tx: FieldSender,
|
||||
path: PathBuf,
|
||||
options: CaptureOptions,
|
||||
) -> std::io::Result<()> {
|
||||
if self.session.is_some() {
|
||||
return Err(std::io::Error::other("a capture is already running"));
|
||||
}
|
||||
self.open(t, origin, None, tx, path, Path::new(""), options);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What another side needs to join the running capture: its sender, its file and the clock reading its `t` is zero at.
|
||||
pub fn joinable(&self) -> Option<(FieldSender, PathBuf, f64)> {
|
||||
let s = self.session.as_ref()?;
|
||||
Some((s.tx.clone(), s.path.clone(), s.origin))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open(
|
||||
&mut self,
|
||||
t: f64,
|
||||
origin: f64,
|
||||
writer: Option<WriterThread<File>>,
|
||||
tx: FieldSender,
|
||||
path: PathBuf,
|
||||
dir: &Path,
|
||||
options: CaptureOptions,
|
||||
) {
|
||||
let now_ms = unix_ms();
|
||||
let sampling = options
|
||||
.samples
|
||||
.then(|| {
|
||||
Sampling::start(
|
||||
self.role.thread,
|
||||
options.window_s.unwrap_or(MAX_WINDOW_S),
|
||||
options.sampler.over_ms.unwrap_or(self.slow.threshold_ms()),
|
||||
&options.sampler,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
self.health.advance(t);
|
||||
let (_, pushed) = self.health.samples_since(u64::MAX, t);
|
||||
let partial = (t - self.base).fract() > 1e-9;
|
||||
let timeline = options
|
||||
.timeline
|
||||
.and_then(|caps| TimelineRecorder::start(self.role.side, caps));
|
||||
let mut session = Session {
|
||||
side: self.role.side,
|
||||
unit: self.role.unit,
|
||||
writer,
|
||||
tx,
|
||||
path,
|
||||
dir: options.file.is_none().then(|| dir.to_owned()),
|
||||
origin,
|
||||
cursor: pushed + u64::from(partial),
|
||||
strings: Interner::default(),
|
||||
counters: CounterReporter::new(self.role.side),
|
||||
sampling,
|
||||
block_names: options.block_names,
|
||||
spans: SpanRecorder::new(self.role.span_thread, self.role.side, self.role.unit),
|
||||
traffic: TrafficRecorder::new(options.wire),
|
||||
gpu: options.gpu,
|
||||
gpu_log: perf_gpu::FrameLog::default(),
|
||||
gpu_block_t: 0.0,
|
||||
timeline,
|
||||
};
|
||||
let mut start = options.start;
|
||||
if let Some((used, total)) = system::disk_mb() {
|
||||
start.world_fs_used_mb = used;
|
||||
start.world_fs_total_mb = total;
|
||||
}
|
||||
start.side = self.role.side as i32;
|
||||
start.kind = self.role.kind as i32;
|
||||
start.trigger = options.trigger as i32;
|
||||
start.started_unix_ms = now_ms;
|
||||
start.uptime_s = t - self.base;
|
||||
let sampled = session.sampling.is_some();
|
||||
let threads = match (sampled, options.sampler.all_threads) {
|
||||
(false, _) => ThreadSelection::Unspecified,
|
||||
(true, false) => ThreadSelection::Main,
|
||||
(true, true) => ThreadSelection::All,
|
||||
};
|
||||
start.options = Some(Options {
|
||||
window_s: options.window_s,
|
||||
hz: if sampled {
|
||||
Sampling::hz(&options.sampler)
|
||||
} else {
|
||||
0
|
||||
},
|
||||
threads: threads as i32,
|
||||
ticks_over_ms: options.sampler.over_ms.filter(|_| sampled),
|
||||
wall: sampled && options.sampler.wall,
|
||||
samples: sampled,
|
||||
gpu: options.gpu as i32,
|
||||
timeline: session.timeline.is_some(),
|
||||
});
|
||||
if sampled && let Some(build) = &mut start.build {
|
||||
build.symbols = Sampling::symbols() as i32;
|
||||
}
|
||||
session.tx.send(Field::Start(start));
|
||||
let mut world = world::snapshot("start", 0.0, &options.levels, None, &mut session.strings);
|
||||
world.side = self.role.side as i32;
|
||||
session.entries();
|
||||
session.tx.send(Field::World(world));
|
||||
for event in self
|
||||
.slow
|
||||
.snapshot(self.role.side, origin, &mut session.strings)
|
||||
{
|
||||
session.entries();
|
||||
session.tx.send(Field::SlowHistory(event));
|
||||
}
|
||||
session.entries();
|
||||
for window in self.health.history(origin) {
|
||||
session.tx.send(Field::History(window));
|
||||
}
|
||||
self.session = Some(session);
|
||||
}
|
||||
|
||||
/// Ends the running capture: writes the spans, workers, net and world here and finishes the file off the tick thread; false when none runs.
|
||||
pub fn begin_stop(&mut self, t: f64, reason: StopReason, end: CaptureEnd) -> bool {
|
||||
let Some(mut session) = self.session.take() else {
|
||||
return false;
|
||||
};
|
||||
self.health.advance(t);
|
||||
session.pump(&self.health, t);
|
||||
let stop_t = t - session.origin;
|
||||
let overhead = session.close(stop_t, end);
|
||||
let stopped = session.sampling.take().and_then(Sampling::stop);
|
||||
// The file's owner waits for the sides that joined it, so it never finishes on the caller's thread.
|
||||
let ending = if stopped.is_none() && session.writer.is_none() {
|
||||
Ending::Done(session.finish(None, stop_t, reason, overhead))
|
||||
} else {
|
||||
let worker = thread::Builder::new()
|
||||
.name("horizon-perf-finish".into())
|
||||
.spawn(move || {
|
||||
profiler_thread();
|
||||
session.finish(stopped, stop_t, reason, overhead)
|
||||
});
|
||||
match worker {
|
||||
Ok(handle) => Ending::Running(handle),
|
||||
Err(e) => Ending::Done(Err(e)),
|
||||
}
|
||||
};
|
||||
self.ending.push(ending);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether a stopped capture's file is still being finished or waits in [`ServerPerf::take_saved`].
|
||||
pub fn finishing(&self) -> bool {
|
||||
!self.ending.is_empty()
|
||||
}
|
||||
|
||||
/// The captures whose files are done, oldest first; with `wait`, every one that was begun.
|
||||
pub fn take_saved(&mut self, wait: bool) -> Vec<std::io::Result<Saved>> {
|
||||
let mut saved = Vec::new();
|
||||
for ending in std::mem::take(&mut self.ending) {
|
||||
match ending {
|
||||
Ending::Done(result) => saved.push(result),
|
||||
Ending::Running(handle) if wait || handle.is_finished() => {
|
||||
saved.push(handle.join().unwrap_or_else(|_| {
|
||||
Err(std::io::Error::other(
|
||||
"the capture's finishing thread panicked",
|
||||
))
|
||||
}))
|
||||
}
|
||||
running => self.ending.push(running),
|
||||
}
|
||||
}
|
||||
saved
|
||||
}
|
||||
|
||||
/// Types `name` into the running capture at `t`; false when none runs.
|
||||
pub fn mark(&mut self, t: f64, name: &str) -> bool {
|
||||
let Some(session) = &mut self.session else {
|
||||
return false;
|
||||
};
|
||||
session.tx.send(Field::Mark(Mark {
|
||||
side: session.side as i32,
|
||||
t: t - session.origin,
|
||||
name: name.to_owned(),
|
||||
}));
|
||||
true
|
||||
}
|
||||
|
||||
/// Records a memory snapshot, rows as flattened paths and values, in the running capture; false when none runs.
|
||||
pub fn memory_snapshot(&mut self, t: f64, label: &str, rows: &[(String, f64)]) -> bool {
|
||||
let Some(session) = &mut self.session else {
|
||||
return false;
|
||||
};
|
||||
let label = session.strings.intern(label);
|
||||
let path = rows
|
||||
.iter()
|
||||
.map(|(name, _)| session.strings.intern(name))
|
||||
.collect();
|
||||
session.entries();
|
||||
session.tx.send(Field::Memory(MemorySnapshot {
|
||||
side: session.side as i32,
|
||||
t: t - session.origin,
|
||||
label,
|
||||
path,
|
||||
value_mb: rows.iter().map(|(_, value)| *value).collect(),
|
||||
}));
|
||||
true
|
||||
}
|
||||
|
||||
/// Records `code` at `t` in the running capture; false when none runs.
|
||||
pub fn warn(&mut self, t: f64, code: WarningCode) -> bool {
|
||||
let Some(session) = &mut self.session else {
|
||||
return false;
|
||||
};
|
||||
session.tx.send(Field::Warning(Warning {
|
||||
side: session.side as i32,
|
||||
t: t - session.origin,
|
||||
code: code as i32,
|
||||
args: Vec::new(),
|
||||
}));
|
||||
true
|
||||
}
|
||||
|
||||
/// The running capture's file, age and size so far; None when none runs.
|
||||
pub fn status(&self, t: f64) -> Option<CaptureStatus> {
|
||||
let session = self.session.as_ref()?;
|
||||
Some(CaptureStatus {
|
||||
path: session.path.clone(),
|
||||
elapsed_s: t - session.origin,
|
||||
bytes: std::fs::metadata(&session.path).map_or(0, |m| m.len()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ends the running capture and waits for its file; None when none runs.
|
||||
pub fn stop_capture(
|
||||
&mut self,
|
||||
t: f64,
|
||||
reason: StopReason,
|
||||
end: CaptureEnd,
|
||||
) -> Option<std::io::Result<Saved>> {
|
||||
self.begin_stop(t, reason, end).then(|| {
|
||||
self.take_saved(true)
|
||||
.pop()
|
||||
.unwrap_or_else(|| Err(std::io::Error::other("the capture vanished")))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What recording cost the tick, priced at the end of the capture: every span two clocks and two thread-CPU clocks, every sampled mob two clocks.
|
||||
fn overhead(spans: &SpanRecorder, end: &CaptureEnd) -> Overhead {
|
||||
let ticks = spans.count().max(1) as f64;
|
||||
let mspt = spans.mean_wall_ms().max(1e-9);
|
||||
let spans_ms = spans.spans_recorded() as f64 / ticks * end.span_ns / 1e6;
|
||||
let mob_samples: u64 = end.mob_costs.costs.iter().map(|c| c.mob_ticks).sum();
|
||||
let mob_ms = mob_samples as f64 / ticks * end.mob_ns / 1e6;
|
||||
Overhead {
|
||||
spans_ms_per_tick: spans_ms as f32,
|
||||
spans_share: (spans_ms / mspt) as f32,
|
||||
mob_timing_ms_per_tick: mob_ms as f32,
|
||||
mob_timing_share: (mob_ms / mspt) as f32,
|
||||
..Overhead::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn entries(&mut self) {
|
||||
for entry in self.strings.take_new(self.side) {
|
||||
self.tx.send(Field::String(entry));
|
||||
}
|
||||
}
|
||||
|
||||
fn span_tick(&mut self, t: f64, counter: u64, wall_ms: f32, detail: Option<&TickDetail>) {
|
||||
let tick = Tick {
|
||||
t: t - self.origin,
|
||||
counter,
|
||||
wall_ms,
|
||||
cpu_ms: detail.and_then(|d| d.cpu_ms),
|
||||
spans: detail.map_or(&[], |d| &d.spans),
|
||||
};
|
||||
if let Some(block) = self.spans.unit(tick, &mut self.strings) {
|
||||
self.entries();
|
||||
self.tx.send(Field::SpanBlock(block));
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the spans, workers, net and world as the capture ends; returns what recording cost.
|
||||
fn close(&mut self, rel: f64, end: CaptureEnd) -> Overhead {
|
||||
let mut overhead = overhead(&self.spans, &end);
|
||||
if let Some(timeline) = self.timeline.take() {
|
||||
let (drained, stopped_t) = timeline.finish(rel, &mut self.strings);
|
||||
self.send_timeline(drained);
|
||||
overhead.timeline_stopped_t = stopped_t;
|
||||
}
|
||||
self.traffic.sample(rel, &end.wire);
|
||||
let (last_block, summary) = std::mem::take(&mut self.spans).finish(&mut self.strings);
|
||||
let mut traffic = std::mem::take(&mut self.traffic).finish(rel);
|
||||
traffic.side = self.side as i32;
|
||||
let mut workers = world::workers(&end.jobs, rel, &mut self.strings);
|
||||
workers.side = self.side as i32;
|
||||
let mut world = world::snapshot(
|
||||
"stop",
|
||||
rel,
|
||||
&end.levels,
|
||||
Some(&end.mob_costs),
|
||||
&mut self.strings,
|
||||
);
|
||||
world.side = self.side as i32;
|
||||
self.entries();
|
||||
if let Some(block) = last_block {
|
||||
self.tx.send(Field::SpanBlock(block));
|
||||
}
|
||||
self.tx.send(Field::Spans(summary));
|
||||
self.tx.send(Field::Workers(workers));
|
||||
self.tx.send(Field::Net(traffic));
|
||||
self.gpu(rel, end.gpu.as_ref());
|
||||
self.tx.send(Field::World(world));
|
||||
for mut bench in end.bench {
|
||||
bench.side = self.side as i32;
|
||||
self.tx.send(Field::Bench(bench));
|
||||
}
|
||||
overhead
|
||||
}
|
||||
|
||||
/// Writes the GPU timings the capture asked for, or a warning when there were none.
|
||||
fn gpu(&mut self, rel: f64, readout: Option<&GpuReadout>) {
|
||||
if matches!(self.gpu, GpuMode::Unspecified | GpuMode::Off) {
|
||||
return;
|
||||
}
|
||||
self.gpu_block();
|
||||
let sections = readout.map_or_else(Vec::new, |r| {
|
||||
perf_gpu::gpu_passes(r, &self.gpu_log, self.side, &mut self.strings)
|
||||
});
|
||||
self.entries();
|
||||
if sections.is_empty() {
|
||||
self.tx.send(Field::Warning(Warning {
|
||||
side: self.side as i32,
|
||||
t: rel,
|
||||
code: WarningCode::GpuUnavailable as i32,
|
||||
args: Vec::new(),
|
||||
}));
|
||||
}
|
||||
for gpu in sections {
|
||||
self.tx.send(Field::Gpu(gpu));
|
||||
}
|
||||
}
|
||||
|
||||
fn gpu_block(&mut self) {
|
||||
if let Some(block) = self.gpu_log.take_block(self.side) {
|
||||
self.entries();
|
||||
self.tx.send(Field::GpuFrames(block));
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_tick(&mut self, t: f64) {
|
||||
let Some(timeline) = self.timeline.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(drained) = timeline.tick(t - self.origin, &mut self.strings) {
|
||||
self.send_timeline(drained);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_timeline(&mut self, drained: Drained) {
|
||||
self.entries();
|
||||
for chunk in drained.chunks {
|
||||
self.tx.send(Field::Timeline(chunk));
|
||||
}
|
||||
if let Some(warning) = drained.warning {
|
||||
self.tx.send(Field::Warning(warning));
|
||||
}
|
||||
}
|
||||
|
||||
fn slow(&mut self, record: &SlowRecord) {
|
||||
let event = record.to_proto(self.side, self.origin, &mut self.strings);
|
||||
self.entries();
|
||||
self.tx.send(Field::Slow(event));
|
||||
}
|
||||
|
||||
/// Appends the seconds closed since the last call, and counters with them.
|
||||
fn pump(&mut self, health: &HealthRecorder, t: f64) {
|
||||
let (samples, next) = health.samples_since(self.cursor, self.origin);
|
||||
self.cursor = self.cursor.max(next);
|
||||
if samples.is_empty() {
|
||||
return;
|
||||
}
|
||||
for sample in samples {
|
||||
self.tx.send(Field::Health(sample));
|
||||
}
|
||||
self.counters_now(t);
|
||||
}
|
||||
|
||||
fn counters_now(&mut self, t: f64) {
|
||||
let registry = counter_registry();
|
||||
if registry.is_empty() {
|
||||
return;
|
||||
}
|
||||
let report = self
|
||||
.counters
|
||||
.report(registry, &mut self.strings, t - self.origin);
|
||||
self.entries();
|
||||
for def in report.defs {
|
||||
self.tx.send(Field::CounterDef(def));
|
||||
}
|
||||
self.tx.send(Field::Counters(report.sample));
|
||||
}
|
||||
|
||||
/// Writes the samples (when there are any) and `Stop`, closes the file and applies retention.
|
||||
fn finish(
|
||||
mut self,
|
||||
stopped: Option<Stopped>,
|
||||
stop_t: f64,
|
||||
reason: StopReason,
|
||||
mut overhead: Overhead,
|
||||
) -> std::io::Result<Saved> {
|
||||
if let Some(stopped) = stopped {
|
||||
let done = stopped.finish(self.side, self.unit, &self.block_names, &mut self.strings);
|
||||
self.entries();
|
||||
self.tx.send(Field::Samples(done.samples));
|
||||
overhead = Overhead {
|
||||
spans_ms_per_tick: overhead.spans_ms_per_tick,
|
||||
spans_share: overhead.spans_share,
|
||||
mob_timing_ms_per_tick: overhead.mob_timing_ms_per_tick,
|
||||
mob_timing_share: overhead.mob_timing_share,
|
||||
timeline_stopped_t: overhead.timeline_stopped_t,
|
||||
..done.overhead
|
||||
};
|
||||
}
|
||||
self.tx.send(Field::Stop(Stop {
|
||||
side: self.side as i32,
|
||||
t: stop_t,
|
||||
reason: reason as i32,
|
||||
overhead: Some(overhead),
|
||||
}));
|
||||
let Session {
|
||||
writer,
|
||||
tx,
|
||||
path,
|
||||
dir,
|
||||
..
|
||||
} = self;
|
||||
drop(tx);
|
||||
let Some(writer) = writer else {
|
||||
return Ok(Saved {
|
||||
path,
|
||||
deleted: Vec::new(),
|
||||
});
|
||||
};
|
||||
writer.finish()?;
|
||||
let deleted = match dir {
|
||||
Some(dir) => files::retain(&dir, &path, files::KEEP_FILES, files::KEEP_BYTES)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
Ok(Saved { path, deleted })
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// Keeps the calling thread's allocations out of the heap samples: it is one of the profiler's own.
|
||||
pub(crate) fn profiler_thread() {
|
||||
#[cfg(target_os = "linux")]
|
||||
horizon_threads::heap_sample::ignore_this_thread();
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! The sampler session that runs beside a dedicated server's capture and becomes its `samples` field.
|
||||
use super::SamplerOptions;
|
||||
use horizon_perf_format::Interner;
|
||||
use horizon_perf_format::v1::{Overhead, SampleProfile, Side, Symbols, Unit};
|
||||
|
||||
/// The longest window a capture records, and the one a capture without a window gets.
|
||||
pub const MAX_WINDOW_S: f64 = 600.0;
|
||||
|
||||
/// Seconds per time block: 5 s, longer when the window would need more than 32 of them.
|
||||
fn block_s(window_s: f64) -> f32 {
|
||||
(window_s / 32.0).ceil().max(5.0) as f32
|
||||
}
|
||||
|
||||
pub struct Finished {
|
||||
pub samples: SampleProfile,
|
||||
pub overhead: Overhead,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux::{Sampling, Stopped};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub use other::{Sampling, Stopped};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
use horizon_client_render::perf_capture;
|
||||
use horizon_threads::heap_sample::{self, HeapProfile};
|
||||
use horizon_threads::profile::{self, Profile, SymbolQuality};
|
||||
use horizon_threads::sampler;
|
||||
|
||||
/// The CPU sampler, and the process's heap session when this capture was the first to start one.
|
||||
pub struct Sampling(profile::Session, Option<heap_sample::Session>);
|
||||
|
||||
/// A stopped session, not yet converted: the part that can take seconds.
|
||||
pub struct Stopped(Profile, Option<HeapProfile>);
|
||||
|
||||
impl Sampling {
|
||||
/// Samples the calling thread, which must be the one `thread` names, until [`Sampling::stop`].
|
||||
pub fn start(
|
||||
thread: &'static str,
|
||||
window_s: f64,
|
||||
over_ms: f32,
|
||||
options: &SamplerOptions,
|
||||
) -> Option<Self> {
|
||||
if !sampler::sampled_thread(thread) {
|
||||
log::warn!(
|
||||
"Horizon: this platform cannot sample the {thread} thread; capturing without samples"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let config = profile::Config {
|
||||
sampler: sampler::Config {
|
||||
hz: Sampling::hz(options),
|
||||
wall: options.wall,
|
||||
..sampler::Config::default()
|
||||
},
|
||||
threads: if options.all_threads {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![thread.into()]
|
||||
},
|
||||
ticks_over_ms: f64::from(over_ms),
|
||||
block_s: block_s(window_s),
|
||||
..profile::Config::default()
|
||||
};
|
||||
match profile::start(config) {
|
||||
Ok(session) => Some(Self(
|
||||
session,
|
||||
heap_sample::start(heap_sample::Config::from_env(block_s(window_s))),
|
||||
)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: could not start the sampler ({e}); capturing without samples"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hz(options: &SamplerOptions) -> u32 {
|
||||
options.hz.unwrap_or(sampler::DEFAULT_HZ)
|
||||
}
|
||||
|
||||
pub fn symbols() -> Symbols {
|
||||
match profile::symbol_quality() {
|
||||
SymbolQuality::Dwarf => Symbols::Dwarf,
|
||||
SymbolQuality::Symtab => Symbols::Symtab,
|
||||
SymbolQuality::None => Symbols::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the timers and drains what is left; quick.
|
||||
pub fn stop(self) -> Option<Stopped> {
|
||||
let heap = self.1.and_then(heap_sample::Session::stop);
|
||||
match self.0.stop() {
|
||||
Ok(profile) => Some(Stopped(profile, heap)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the sampler failed ({e}); the capture has no samples");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stopped {
|
||||
/// Symbolizes and converts; reads the running binary's debug info, so run it off the tick thread.
|
||||
pub fn finish(
|
||||
self,
|
||||
side: Side,
|
||||
unit: Unit,
|
||||
block_names: &[String],
|
||||
strings: &mut Interner,
|
||||
) -> Finished {
|
||||
let name = |id: u16| block_names.get(usize::from(id)).cloned();
|
||||
Finished {
|
||||
samples: perf_capture::sample_profile(
|
||||
&self.0,
|
||||
self.1.as_ref(),
|
||||
side,
|
||||
unit,
|
||||
&name,
|
||||
strings,
|
||||
),
|
||||
overhead: perf_capture::overhead(&self.0, self.1.as_ref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod other {
|
||||
use super::*;
|
||||
|
||||
pub enum Sampling {}
|
||||
pub enum Stopped {}
|
||||
|
||||
impl Sampling {
|
||||
pub fn start(_: &'static str, _: f64, _: f32, _: &SamplerOptions) -> Option<Self> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn hz(_: &SamplerOptions) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
pub fn symbols() -> Symbols {
|
||||
Symbols::Unspecified
|
||||
}
|
||||
|
||||
pub fn stop(self) -> Option<Stopped> {
|
||||
match self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stopped {
|
||||
pub fn finish(self, _: Side, _: Unit, _: &[String], _: &mut Interner) -> Finished {
|
||||
match self {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! The tick thread's spans as the capture's `span_blocks` (live) and `spans` (at the end).
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::spans::Span;
|
||||
use horizon_perf_format::Interner;
|
||||
use horizon_perf_format::v1::{
|
||||
Histogram, Side, SpanBlock, SpanCost, SpanSummary, SpanTotal, Unit, WorstUnit,
|
||||
};
|
||||
|
||||
/// Seconds one `SpanBlock` covers.
|
||||
pub const BLOCK_S: f32 = 5.0;
|
||||
/// Ticks kept with their whole span tree.
|
||||
pub const WORST_KEPT: usize = 16;
|
||||
/// Upper edges of the tick-time histogram, in ms.
|
||||
pub const HISTOGRAM_EDGES_MS: [f32; 14] = [
|
||||
5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 75.0, 100.0, 150.0, 250.0, 500.0, 1000.0,
|
||||
];
|
||||
|
||||
type Key = (&'static str, &'static str);
|
||||
|
||||
/// Wall and CPU milliseconds of one span under one parent, and the units it ran in.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Sum {
|
||||
wall_ms: f64,
|
||||
cpu_ms: f64,
|
||||
seen: u64,
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
struct Worst {
|
||||
t: f64,
|
||||
counter: u64,
|
||||
wall_ms: f32,
|
||||
cpu_ms: f32,
|
||||
spans: Vec<Span>,
|
||||
}
|
||||
|
||||
/// The index in `spans` of each span's enclosing span; spans are recorded after the spans inside them, one level deeper.
|
||||
fn parents(spans: &[Span]) -> Vec<Option<usize>> {
|
||||
let mut parent = vec![None; spans.len()];
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
for (i, span) in spans.iter().enumerate() {
|
||||
while let Some(&j) = open.last()
|
||||
&& spans[j].depth > span.depth
|
||||
{
|
||||
open.pop();
|
||||
parent[j] = Some(i);
|
||||
}
|
||||
open.push(i);
|
||||
}
|
||||
parent
|
||||
}
|
||||
|
||||
/// One tick's spans merged by (name, parent name), with the tree depth of the first of each.
|
||||
fn merge_by_parent(spans: &[Span]) -> Vec<(Key, Sum)> {
|
||||
let parent = parents(spans);
|
||||
let mut depth = vec![0u32; spans.len()];
|
||||
for i in (0..spans.len()).rev() {
|
||||
depth[i] = parent[i].map_or(0, |p| depth[p] + 1);
|
||||
}
|
||||
let mut merged: Vec<(Key, Sum)> = Vec::new();
|
||||
let mut index: FxHashMap<Key, usize> = FxHashMap::default();
|
||||
for (i, span) in spans.iter().enumerate() {
|
||||
let key = (span.name, parent[i].map_or("", |p| spans[p].name));
|
||||
let at = *index.entry(key).or_insert_with(|| {
|
||||
merged.push((
|
||||
key,
|
||||
Sum {
|
||||
depth: depth[i],
|
||||
..Sum::default()
|
||||
},
|
||||
));
|
||||
merged.len() - 1
|
||||
});
|
||||
merged[at].1.wall_ms += f64::from(span.ms);
|
||||
merged[at].1.cpu_ms += f64::from(span.cpu_ms);
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// A tick's whole span tree, parents before children, siblings costliest first; same-named spans under one parent are one node.
|
||||
fn tree(spans: &[Span], strings: &mut Interner) -> Vec<SpanCost> {
|
||||
let parent = parents(spans);
|
||||
struct Node {
|
||||
name: &'static str,
|
||||
wall_ms: f32,
|
||||
cpu_ms: f32,
|
||||
children: Vec<usize>,
|
||||
}
|
||||
let mut nodes: Vec<Node> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
let mut node_of: Vec<usize> = vec![usize::MAX; spans.len()];
|
||||
let mut by_parent: FxHashMap<(Option<usize>, &'static str), usize> = FxHashMap::default();
|
||||
for i in (0..spans.len()).rev() {
|
||||
let up = parent[i].map(|p| node_of[p]);
|
||||
let at = *by_parent.entry((up, spans[i].name)).or_insert_with(|| {
|
||||
nodes.push(Node {
|
||||
name: spans[i].name,
|
||||
wall_ms: 0.0,
|
||||
cpu_ms: 0.0,
|
||||
children: Vec::new(),
|
||||
});
|
||||
let id = nodes.len() - 1;
|
||||
match up {
|
||||
Some(p) => nodes[p].children.push(id),
|
||||
None => roots.push(id),
|
||||
}
|
||||
id
|
||||
});
|
||||
nodes[at].wall_ms += spans[i].ms;
|
||||
nodes[at].cpu_ms += spans[i].cpu_ms;
|
||||
node_of[i] = at;
|
||||
}
|
||||
let mut out = Vec::with_capacity(nodes.len());
|
||||
let mut stack: Vec<(usize, u32)> = Vec::new();
|
||||
let costliest_last = |ids: &mut Vec<usize>, nodes: &[Node]| {
|
||||
ids.sort_by(|&a, &b| {
|
||||
nodes[a]
|
||||
.wall_ms
|
||||
.total_cmp(&nodes[b].wall_ms)
|
||||
.then_with(|| nodes[b].name.cmp(nodes[a].name))
|
||||
});
|
||||
};
|
||||
costliest_last(&mut roots, &nodes);
|
||||
stack.extend(roots.iter().map(|&id| (id, 0)));
|
||||
while let Some((id, depth)) = stack.pop() {
|
||||
let node = &nodes[id];
|
||||
out.push(SpanCost {
|
||||
name: strings.intern(node.name),
|
||||
wall_ms: node.wall_ms,
|
||||
cpu_ms: node.cpu_ms,
|
||||
depth,
|
||||
});
|
||||
let mut children = node.children.clone();
|
||||
costliest_last(&mut children, &nodes);
|
||||
stack.extend(children.iter().map(|&c| (c, depth + 1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One server tick as the recorder takes it.
|
||||
pub struct Tick<'a> {
|
||||
/// Seconds into the capture when the tick ended.
|
||||
pub t: f64,
|
||||
/// The server's tick counter.
|
||||
pub counter: u64,
|
||||
pub wall_ms: f32,
|
||||
/// The thread's CPU time; None where the platform does not tell it.
|
||||
pub cpu_ms: Option<f32>,
|
||||
/// As recorded: each span after the spans inside it.
|
||||
pub spans: &'a [Span],
|
||||
}
|
||||
|
||||
/// Sums the server thread's ticks into blocks of [`BLOCK_S`] seconds and, at the end, into the capture's totals.
|
||||
pub struct SpanRecorder {
|
||||
thread: &'static str,
|
||||
side: Side,
|
||||
unit: Unit,
|
||||
block: u32,
|
||||
units: u32,
|
||||
rows: FxHashMap<Key, Sum>,
|
||||
totals: FxHashMap<Key, Sum>,
|
||||
count: u64,
|
||||
wall_total: f64,
|
||||
cpu_total: f64,
|
||||
timing: horizon_perf_format::TimingRecorder,
|
||||
histogram: [u64; HISTOGRAM_EDGES_MS.len() + 1],
|
||||
worst: Vec<Worst>,
|
||||
recorded: u64,
|
||||
}
|
||||
|
||||
impl Default for SpanRecorder {
|
||||
fn default() -> Self {
|
||||
Self::new("", Side::Server, Unit::Tick)
|
||||
}
|
||||
}
|
||||
|
||||
impl SpanRecorder {
|
||||
pub fn new(thread: &'static str, side: Side, unit: Unit) -> Self {
|
||||
Self {
|
||||
thread,
|
||||
side,
|
||||
unit,
|
||||
block: 0,
|
||||
units: 0,
|
||||
rows: FxHashMap::default(),
|
||||
totals: FxHashMap::default(),
|
||||
count: 0,
|
||||
wall_total: 0.0,
|
||||
cpu_total: 0.0,
|
||||
timing: horizon_perf_format::TimingRecorder::default(),
|
||||
histogram: [0; HISTOGRAM_EDGES_MS.len() + 1],
|
||||
worst: Vec::new(),
|
||||
recorded: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks recorded so far.
|
||||
pub fn count(&self) -> u64 {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Spans the ticks recorded themselves, before they were merged: what timing them cost.
|
||||
pub fn spans_recorded(&self) -> u64 {
|
||||
self.recorded
|
||||
}
|
||||
|
||||
/// The mean tick time in ms.
|
||||
pub fn mean_wall_ms(&self) -> f64 {
|
||||
self.wall_total / self.count.max(1) as f64
|
||||
}
|
||||
|
||||
/// Adds one tick; returns the block it closed, if it began a new one.
|
||||
pub fn unit(&mut self, tick: Tick, strings: &mut Interner) -> Option<SpanBlock> {
|
||||
let Tick {
|
||||
t,
|
||||
counter,
|
||||
wall_ms,
|
||||
cpu_ms,
|
||||
spans,
|
||||
} = tick;
|
||||
let block = (t.max(0.0) / f64::from(BLOCK_S)) as u32;
|
||||
let closed = if block != self.block {
|
||||
let closed = self.close_block(strings);
|
||||
self.block = block;
|
||||
closed
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cpu_ms = cpu_ms.unwrap_or(wall_ms);
|
||||
self.units += 1;
|
||||
self.count += 1;
|
||||
self.timing.record(wall_ms);
|
||||
self.recorded += spans.len() as u64;
|
||||
self.wall_total += f64::from(wall_ms);
|
||||
self.cpu_total += f64::from(cpu_ms);
|
||||
self.histogram[HISTOGRAM_EDGES_MS.partition_point(|&edge| edge <= wall_ms)] += 1;
|
||||
for (key, sum) in merge_by_parent(spans) {
|
||||
for rows in [&mut self.rows, &mut self.totals] {
|
||||
let row = rows.entry(key).or_insert(Sum {
|
||||
depth: sum.depth,
|
||||
..Sum::default()
|
||||
});
|
||||
row.wall_ms += sum.wall_ms;
|
||||
row.cpu_ms += sum.cpu_ms;
|
||||
row.seen += 1;
|
||||
}
|
||||
}
|
||||
if self.worst.len() < WORST_KEPT || self.worst.iter().any(|w| w.wall_ms < wall_ms) {
|
||||
if self.worst.len() >= WORST_KEPT {
|
||||
let cheapest = (0..self.worst.len())
|
||||
.min_by(|&a, &b| self.worst[a].wall_ms.total_cmp(&self.worst[b].wall_ms))
|
||||
.unwrap_or(0);
|
||||
self.worst.swap_remove(cheapest);
|
||||
}
|
||||
self.worst.push(Worst {
|
||||
t,
|
||||
counter,
|
||||
wall_ms,
|
||||
cpu_ms,
|
||||
spans: spans.to_vec(),
|
||||
});
|
||||
}
|
||||
closed
|
||||
}
|
||||
|
||||
fn close_block(&mut self, strings: &mut Interner) -> Option<SpanBlock> {
|
||||
let units = std::mem::take(&mut self.units);
|
||||
let mut rows: Vec<_> = std::mem::take(&mut self.rows).into_iter().collect();
|
||||
if units == 0 {
|
||||
return None;
|
||||
}
|
||||
rows.sort_by(|a, b| b.1.wall_ms.total_cmp(&a.1.wall_ms).then(a.0.cmp(&b.0)));
|
||||
let mut block = SpanBlock {
|
||||
side: self.side as i32,
|
||||
thread: strings.intern(self.thread),
|
||||
block: self.block,
|
||||
t0: f64::from(self.block) * f64::from(BLOCK_S),
|
||||
block_s: BLOCK_S,
|
||||
units,
|
||||
..SpanBlock::default()
|
||||
};
|
||||
for ((name, parent), sum) in rows {
|
||||
block.span.push(strings.intern(name));
|
||||
block.parent.push(if parent.is_empty() {
|
||||
0
|
||||
} else {
|
||||
strings.intern(parent)
|
||||
});
|
||||
block.wall_ms.push(sum.wall_ms as f32);
|
||||
block.cpu_ms.push(sum.cpu_ms as f32);
|
||||
block.seen.push(sum.seen as u32);
|
||||
}
|
||||
Some(block)
|
||||
}
|
||||
|
||||
/// The block still open and the capture's totals.
|
||||
pub fn finish(mut self, strings: &mut Interner) -> (Option<SpanBlock>, SpanSummary) {
|
||||
let last = self.close_block(strings);
|
||||
let mut totals: Vec<_> = std::mem::take(&mut self.totals).into_iter().collect();
|
||||
totals.sort_by(|a, b| b.1.wall_ms.total_cmp(&a.1.wall_ms).then(a.0.cmp(&b.0)));
|
||||
let mut worst = std::mem::take(&mut self.worst);
|
||||
worst.sort_by(|a, b| {
|
||||
b.wall_ms
|
||||
.total_cmp(&a.wall_ms)
|
||||
.then(a.counter.cmp(&b.counter))
|
||||
});
|
||||
let thread = strings.intern(self.thread);
|
||||
let summary = SpanSummary {
|
||||
side: self.side as i32,
|
||||
thread,
|
||||
unit: self.unit as i32,
|
||||
count: self.count,
|
||||
spans: totals
|
||||
.into_iter()
|
||||
.map(|((name, parent), sum)| SpanTotal {
|
||||
name: strings.intern(name),
|
||||
parent: if parent.is_empty() {
|
||||
0
|
||||
} else {
|
||||
strings.intern(parent)
|
||||
},
|
||||
depth: sum.depth,
|
||||
wall_ms: sum.wall_ms,
|
||||
cpu_ms: sum.cpu_ms,
|
||||
seen: sum.seen,
|
||||
})
|
||||
.collect(),
|
||||
histogram: Some(Histogram {
|
||||
edges_ms: HISTOGRAM_EDGES_MS.to_vec(),
|
||||
counts: self.histogram.to_vec(),
|
||||
}),
|
||||
worst: worst
|
||||
.into_iter()
|
||||
.map(|w| WorstUnit {
|
||||
t: w.t,
|
||||
counter: w.counter,
|
||||
wall_ms: w.wall_ms,
|
||||
cpu_ms: w.cpu_ms,
|
||||
spans: tree(&w.spans, strings),
|
||||
})
|
||||
.collect(),
|
||||
wall_ms_total: self.wall_total,
|
||||
cpu_ms_total: self.cpu_total,
|
||||
timing: self.timing.finish(),
|
||||
};
|
||||
(last, summary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use crate::gamerules::GameRules as Rules;
|
||||
use horizon_perf_format::Gauges;
|
||||
use horizon_perf_format::v1::{Build, GameRules, HorizonOptions, Machine, Settings};
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::WorldCounts;
|
||||
|
||||
/// The disk the game runs from, where its worlds are saved: used and total MiB.
|
||||
pub(super) fn disk_mb() -> Option<(u64, u64)> {
|
||||
const MIB: u64 = 1 << 20;
|
||||
let dir = std::env::current_dir().ok()?;
|
||||
let total = fs4::total_space(&dir).ok()?;
|
||||
let free = fs4::available_space(&dir).ok()?;
|
||||
Some((total.saturating_sub(free) / MIB, total / MIB))
|
||||
}
|
||||
|
||||
/// Reads the process and the machine for the health gauges; the CPU figures are rates since the last read.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SystemProbe {
|
||||
last: Option<(f64, f64, Option<(u64, u64)>)>,
|
||||
}
|
||||
|
||||
impl SystemProbe {
|
||||
pub(super) fn read(&mut self, t: f64, counts: WorldCounts) -> Gauges {
|
||||
let pid = std::process::id();
|
||||
let cpu_s = horizon_bench::cpu_seconds(pid);
|
||||
let stat = std::fs::read_to_string("/proc/stat")
|
||||
.ok()
|
||||
.and_then(|s| parse_cpu_stat(&s));
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()) as f64;
|
||||
let mut g = Gauges {
|
||||
rss_mb: horizon_bench::rss_bytes(pid).map_or(0.0, |b| (b >> 20) as f32),
|
||||
players: counts.players,
|
||||
chunks: counts.chunks,
|
||||
entities: counts.entities,
|
||||
..Gauges::default()
|
||||
};
|
||||
if let Some((used, swap)) = std::fs::read_to_string("/proc/meminfo")
|
||||
.ok()
|
||||
.and_then(|s| parse_meminfo(&s))
|
||||
{
|
||||
g.physical_used_mb = used as f32;
|
||||
g.swap_used_mb = swap as f32;
|
||||
}
|
||||
if let Some((last_t, last_cpu, last_stat)) = self.last
|
||||
&& t > last_t
|
||||
{
|
||||
if let Some(cpu) = cpu_s {
|
||||
g.cpu_process_cores = ((cpu - last_cpu) / (t - last_t)) as f32;
|
||||
}
|
||||
if let (Some((busy, total)), Some((last_busy, last_total))) = (stat, last_stat)
|
||||
&& total > last_total
|
||||
{
|
||||
g.cpu_system_cores =
|
||||
((busy - last_busy) as f64 / (total - last_total) as f64 * threads) as f32;
|
||||
}
|
||||
}
|
||||
self.last = Some((t, cpu_s.unwrap_or(0.0), stat));
|
||||
g
|
||||
}
|
||||
}
|
||||
|
||||
/// A `SystemProbe` on a thread of its own, for the sides whose frame must not wait on the probes (macOS runs `ps`).
|
||||
pub(super) struct BackgroundProbe {
|
||||
requests: std::sync::mpsc::Sender<(f64, WorldCounts)>,
|
||||
latest: std::sync::Arc<std::sync::Mutex<Gauges>>,
|
||||
}
|
||||
|
||||
impl BackgroundProbe {
|
||||
pub(super) fn spawn() -> Self {
|
||||
let (requests, wanted) = std::sync::mpsc::channel::<(f64, WorldCounts)>();
|
||||
let latest = std::sync::Arc::new(std::sync::Mutex::new(Gauges::default()));
|
||||
let shared = std::sync::Arc::clone(&latest);
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("horizon-perf-gauges".into())
|
||||
.spawn(move || {
|
||||
super::profiler_thread();
|
||||
let mut probe = SystemProbe::default();
|
||||
for (t, counts) in wanted {
|
||||
let gauges = probe.read(t, counts);
|
||||
*shared.lock().unwrap_or_else(|e| e.into_inner()) = gauges;
|
||||
}
|
||||
});
|
||||
if let Err(e) = worker {
|
||||
log::warn!("Horizon: no thread for the health gauges ({e})");
|
||||
}
|
||||
Self { requests, latest }
|
||||
}
|
||||
|
||||
/// Asks for a reading at `t`; the answer shows in [`BackgroundProbe::latest`] a moment later.
|
||||
pub(super) fn request(&self, t: f64, counts: WorldCounts) {
|
||||
let _ = self.requests.send((t, counts));
|
||||
}
|
||||
|
||||
pub(super) fn latest(&self) -> Gauges {
|
||||
*self.latest.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
/// `/proc/stat`'s first line as (busy, total) jiffies: everything but idle and iowait is busy.
|
||||
pub(super) fn parse_cpu_stat(text: &str) -> Option<(u64, u64)> {
|
||||
let fields: Vec<u64> = text
|
||||
.lines()
|
||||
.next()?
|
||||
.strip_prefix("cpu ")?
|
||||
.split_whitespace()
|
||||
.filter_map(|f| f.parse().ok())
|
||||
.collect();
|
||||
if fields.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let total: u64 = fields.iter().take(8).sum();
|
||||
Some((total - fields[3] - fields[4], total))
|
||||
}
|
||||
|
||||
/// `/proc/meminfo` as (physical MB in use, swap MB in use); in use is total minus available.
|
||||
pub(super) fn parse_meminfo(text: &str) -> Option<(u64, u64)> {
|
||||
let kb = |key: &str| -> Option<u64> {
|
||||
text.lines()
|
||||
.find_map(|l| l.strip_prefix(key)?.strip_prefix(':'))?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
};
|
||||
let used = kb("MemTotal")?.checked_sub(kb("MemAvailable")?)?;
|
||||
let swap = kb("SwapTotal")?.saturating_sub(kb("SwapFree")?);
|
||||
Some((used >> 10, swap >> 10))
|
||||
}
|
||||
|
||||
/// The cgroup v2 `cpu.max` as cores: "200000 100000" is 2; "max 100000" is no limit.
|
||||
pub(super) fn parse_cgroup_cpu(text: &str) -> Option<f32> {
|
||||
let mut words = text.split_whitespace();
|
||||
let quota: f64 = words.next()?.parse().ok()?;
|
||||
let period: f64 = words.next()?.parse().ok()?;
|
||||
(period > 0.0).then(|| (quota / period) as f32)
|
||||
}
|
||||
|
||||
/// The cgroup v2 `memory.max` in MB; "max" is no limit.
|
||||
pub(super) fn parse_cgroup_memory(text: &str) -> Option<u64> {
|
||||
text.trim().parse::<u64>().ok().map(|b| b >> 20)
|
||||
}
|
||||
|
||||
static HARDWARE: OnceLock<horizon_bench::Hardware> = OnceLock::new();
|
||||
|
||||
/// Describes the hardware on a thread of its own: the probes run commands, which the tick must not wait for.
|
||||
pub fn warm_hardware() {
|
||||
std::thread::spawn(|| {
|
||||
HARDWARE.get_or_init(horizon_bench::Hardware::detect);
|
||||
});
|
||||
}
|
||||
|
||||
/// The hardware, described now if nothing has yet: the probes run commands, so not on a tick.
|
||||
pub fn hardware_now() -> &'static horizon_bench::Hardware {
|
||||
HARDWARE.get_or_init(horizon_bench::Hardware::detect)
|
||||
}
|
||||
|
||||
/// The hardware, once `warm_hardware` has described it.
|
||||
pub fn hardware() -> Option<&'static horizon_bench::Hardware> {
|
||||
HARDWARE.get()
|
||||
}
|
||||
|
||||
/// What the machine and its container give the process, for `Start.machine`.
|
||||
pub fn machine(hardware: Option<&horizon_bench::Hardware>) -> Machine {
|
||||
let cgroup = |name: &str| std::fs::read_to_string(format!("/sys/fs/cgroup/{name}")).ok();
|
||||
let mut m = Machine {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu_threads: std::thread::available_parallelism().map_or(0, |n| n.get() as u32),
|
||||
cgroup_cpu_cores: cgroup("cpu.max").and_then(|t| parse_cgroup_cpu(&t)),
|
||||
cgroup_memory_mb: cgroup("memory.max").and_then(|t| parse_cgroup_memory(&t)),
|
||||
..Machine::default()
|
||||
};
|
||||
if let Some(h) = hardware {
|
||||
m.os_version = h.os_version.clone().unwrap_or_default();
|
||||
m.cpu = h.cpu.clone().unwrap_or_default();
|
||||
m.cpu_cores = h.cpu_cores.map_or(0, u32::from);
|
||||
m.ram_mb = h.ram_mb.unwrap_or(0);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The build this server runs, for `Start.build`.
|
||||
pub fn build() -> Build {
|
||||
Build {
|
||||
version: crate::crash::version(),
|
||||
profile: if cfg!(debug_assertions) {
|
||||
"debug"
|
||||
} else {
|
||||
"release"
|
||||
}
|
||||
.to_owned(),
|
||||
target: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
|
||||
..Build::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The server settings a capture records: the typed allowlist, nothing else from `server.properties`.
|
||||
pub fn settings(view: u32, simulation: u32, max_players: u32, compression: i32) -> Settings {
|
||||
Settings {
|
||||
view_distance: Some(view),
|
||||
simulation_distance: Some(simulation),
|
||||
max_players: Some(max_players),
|
||||
network_compression_threshold: Some(compression),
|
||||
}
|
||||
}
|
||||
|
||||
/// The game rules a capture records: the typed allowlist, nothing else from the world's rule set.
|
||||
pub fn game_rules(rules: &Rules) -> GameRules {
|
||||
GameRules {
|
||||
random_tick_speed: Some(rules.int("random_tick_speed")),
|
||||
max_entity_cramming: Some(rules.int("max_entity_cramming")),
|
||||
fire_spread_radius_around_player: Some(rules.int("fire_spread_radius_around_player")),
|
||||
advance_time: Some(rules.bool("advance_time")),
|
||||
mob_griefing: Some(rules.bool("mob_griefing")),
|
||||
spawn_mobs: Some(rules.bool("spawn_mobs")),
|
||||
spawn_monsters: Some(rules.bool("spawn_monsters")),
|
||||
spawner_blocks_work: Some(rules.bool("spawner_blocks_work")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Horizon switches a capture records: the typed allowlist, never the environment or `server.toml`.
|
||||
pub fn horizon_options(
|
||||
max_view_distance: u32,
|
||||
lod_distance: u32,
|
||||
anticheat: bool,
|
||||
) -> HorizonOptions {
|
||||
HorizonOptions {
|
||||
max_view_distance: Some(max_view_distance),
|
||||
lod_distance: Some(lod_distance),
|
||||
anticheat: Some(anticheat),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cpu_stat_busy_excludes_idle_and_iowait() {
|
||||
// user nice system idle iowait irq softirq steal
|
||||
let text = "cpu 100 20 30 700 50 5 5 90 0 0\ncpu0 1 1 1 1 1 1 1 1 0 0\n";
|
||||
assert_eq!(parse_cpu_stat(text), Some((250, 1000)));
|
||||
assert_eq!(parse_cpu_stat("intr 1 2 3"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meminfo_used_is_total_minus_available() {
|
||||
let text = "MemTotal: 16384000 kB\nMemFree: 1 kB\nMemAvailable: 8192000 kB\nSwapTotal: 4096000 kB\nSwapFree: 3072000 kB\n";
|
||||
assert_eq!(parse_meminfo(text), Some((8000, 1000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cgroup_limits() {
|
||||
assert_eq!(parse_cgroup_cpu("250000 100000\n"), Some(2.5));
|
||||
assert_eq!(parse_cgroup_cpu("max 100000\n"), None);
|
||||
assert_eq!(parse_cgroup_memory("2147483648\n"), Some(2048));
|
||||
assert_eq!(parse_cgroup_memory("max\n"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 456 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user