mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Merge main into mods/equipment-every-namespace
Through mods/port-item-stats with main merged. Conflicts: main's equipment layers became every layer of a kind (leather's overlay), so each layer's texture now resolves its namespace there; hud.rs keeps the namespaced sprite names with main's decoded image. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
97f2c57e5d
930 files changed
+76803
-26686
No files matched your search
@@ -1,7 +1,21 @@
|
||||
<!-- Opened by an AI agent? First line: "> 🤖 Opened by <your GitHub login>'s AI agent (<tool, model>), not by <your GitHub login> personally." Use your own login, not someone else's. -->
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
|
||||
## Kind
|
||||
|
||||
<!-- Tick one: it sets the pull request's label (CONTRIBUTING.md "Pull requests"). -->
|
||||
|
||||
- [ ] parity: Horizon now does what vanilla 26.3 does
|
||||
- [ ] bug: a crash or a breakage
|
||||
- [ ] perf: faster, or less memory
|
||||
- [ ] feature: something new for players (listed under New in the release notes)
|
||||
- [ ] extra: Horizon-only, off by default
|
||||
- [ ] tooling: tools, CI, tests, harnesses
|
||||
- [ ] docs
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -62,8 +62,7 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
@@ -71,11 +70,11 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: claim command
|
||||
# `/claim` and `/unclaim` comments: anyone takes an issue before working on it, and a daily run releases the claims
|
||||
# older than 5 days that no open pull request links (CONTRIBUTING.md "Claim before you start",
|
||||
# tools/ci/claim_command.py).
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` and `schedule` always run this file from main, whoever comments and wherever.
|
||||
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
|
||||
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step
|
||||
# and can only write issues (assignees, comments, reactions).
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
schedule:
|
||||
- cron: "23 5 * * *"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts. Pull requests are answered by the script.
|
||||
if: >-
|
||||
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/claim') ||
|
||||
startsWith(github.event.comment.body, '/unclaim') ||
|
||||
startsWith(github.event.comment.body, '🤖 Agent: /claim') ||
|
||||
startsWith(github.event.comment.body, '🤖 Agent: /unclaim'))
|
||||
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/trusted-reviewers.txt
|
||||
tools/ci/nightly_set.py
|
||||
tools/ci/claim_command.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Run the command
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
AUTHOR: ${{ github.event.comment.user.login }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/claim_command.py command
|
||||
release:
|
||||
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/nightly_set.py
|
||||
tools/ci/claim_command.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Release stale claims
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/claim_command.py release
|
||||
@@ -138,8 +138,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
@@ -267,9 +266,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
|
||||
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
|
||||
@@ -233,9 +233,9 @@ jobs:
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: overlap note
|
||||
# One comment on a pull request naming the other open pull requests that close the same issue or change the same
|
||||
# functions, edited on every push (tools/ci/overlap_note.py, issue #3859).
|
||||
#
|
||||
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write:
|
||||
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
|
||||
# - The checkout is main's (`ref: main`), blobless, with the script alone on disk. The pull requests' heads are fetched
|
||||
# as git objects and only diffed, never checked out; git runs with hooks, fsmonitor, external diff and textconv off.
|
||||
# - The rest comes from the API. The token writes the comment only.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
note:
|
||||
if: github.event.pull_request.base.ref == 'main'
|
||||
# A newer push replaces the note being computed.
|
||||
concurrency:
|
||||
group: overlap-note-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/overlap_note.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Note
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/overlap_note.py event
|
||||
@@ -0,0 +1,36 @@
|
||||
name: pr labels
|
||||
# A pull request's kind label (the box ticked in the template) and area labels (the paths it changes):
|
||||
# tools/ci/pr_labels.py, CONTRIBUTING.md "Pull requests".
|
||||
#
|
||||
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write to fork PRs:
|
||||
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
|
||||
# - It checks out main's script only (`ref: main`), never the pull request's head or merge commit.
|
||||
# - The script reads the event file (the description is data in JSON, never in a shell line) and the API's list of
|
||||
# changed file names; it runs, builds and imports nothing of theirs. The token writes labels only.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, edited, ready_for_review]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
label:
|
||||
# Title-only edits change nothing: skip them before a runner starts.
|
||||
if: github.event.action != 'edited' || github.event.changes.body != null
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/pr_labels.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/pr_labels.py event
|
||||
@@ -13,13 +13,15 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
|
||||
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
|
||||
the tool: `> 🤖 Opened by <login>'s AI agent (<tool, model>), not by <login> personally.`, where `<login>`
|
||||
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
|
||||
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
|
||||
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Ludovic's agents use
|
||||
`Co-Authored-By: Claude <model> <[email protected]>` and
|
||||
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
`Co-Authored-By: Claude <model> <[email protected]>`, last line
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
@@ -28,6 +30,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
|
||||
## Crates
|
||||
|
||||
@@ -49,7 +53,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `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 |
|
||||
@@ -69,12 +73,12 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 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
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
@@ -89,9 +93,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
@@ -167,17 +168,18 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
|
||||
+20
-3
@@ -74,6 +74,19 @@ including examples. The dependency scope includes optional, platform, build and
|
||||
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
|
||||
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
|
||||
|
||||
## Claim before you start
|
||||
|
||||
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
|
||||
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
|
||||
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
|
||||
enough), then claim it. `/unclaim` gives it back.
|
||||
|
||||
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
|
||||
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. Agents claim the same way.
|
||||
|
||||
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
|
||||
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
|
||||
@@ -82,8 +95,12 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
can see.
|
||||
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
|
||||
ourselves instead of sending it back to you; only design questions come back.
|
||||
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
|
||||
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
|
||||
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
|
||||
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
|
||||
`label:parity` is the list of real progress toward vanilla.
|
||||
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
|
||||
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
|
||||
|
||||
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
|
||||
@@ -102,4 +119,4 @@ new pushes, we add it again.
|
||||
## Talk to us
|
||||
|
||||
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
Generated
+31
-811
File diff suppressed because it is too large.
Load diff
-13
@@ -23,26 +23,13 @@ members = [
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mod-sdk",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mods/tests/guest",
|
||||
"crates/horizon-mods/tests/ui_guest",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/far-view",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
|
||||
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
|
||||
</p>
|
||||
@@ -46,9 +47,9 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -127,6 +128,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
|
||||
### Java parity: 100%
|
||||
@@ -163,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
|
||||
|
||||
### Known issues
|
||||
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+408
-553
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:13:01Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 3158.208,
|
||||
"stdev": 215.77,
|
||||
"min": 3023.046,
|
||||
"max": 3407.049,
|
||||
"ci95": 536.046
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 7170.164,
|
||||
"stdev": 557.958,
|
||||
"min": 6703.242,
|
||||
"max": 7788.08,
|
||||
"ci95": 1386.157
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1694.647,
|
||||
"stdev": 41.285,
|
||||
"min": 1653.122,
|
||||
"max": 1735.687,
|
||||
"ci95": 102.566
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 23544.0,
|
||||
"stdev": 47.127,
|
||||
"min": 23495.0,
|
||||
"max": 23589.0,
|
||||
"ci95": 117.081
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1711.652,
|
||||
"stdev": 77.412,
|
||||
"min": 1623.403,
|
||||
"max": 1768.093,
|
||||
"ci95": 192.317
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 593.407,
|
||||
"stdev": 32.279,
|
||||
"min": 571.485,
|
||||
"max": 630.473,
|
||||
"ci95": 80.191
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 358.473,
|
||||
"stdev": 27.832,
|
||||
"min": 326.35,
|
||||
"max": 375.353,
|
||||
"ci95": 69.143
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 631.813,
|
||||
"stdev": 8.381,
|
||||
"min": 626.803,
|
||||
"max": 641.489,
|
||||
"ci95": 20.822
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2521.363,
|
||||
"stdev": 291.624,
|
||||
"min": 2336.881,
|
||||
"max": 2857.57,
|
||||
"ci95": 724.493
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 27346.667,
|
||||
"stdev": 57.934,
|
||||
"min": 27306.0,
|
||||
"max": 27413.0,
|
||||
"ci95": 143.927
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2423.783,
|
||||
"stdev": 55.746,
|
||||
"min": 2386.353,
|
||||
"max": 2487.851,
|
||||
"ci95": 138.493
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 709.165,
|
||||
"stdev": 66.309,
|
||||
"min": 638.666,
|
||||
"max": 770.286,
|
||||
"ci95": 164.734
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 401.039,
|
||||
"stdev": 40.413,
|
||||
"min": 354.472,
|
||||
"max": 426.929,
|
||||
"ci95": 100.399
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 705.008,
|
||||
"stdev": 6.062,
|
||||
"min": 698.466,
|
||||
"max": 710.435,
|
||||
"ci95": 15.06
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2094.909,
|
||||
"stdev": 521.225,
|
||||
"min": 1758.024,
|
||||
"max": 2695.274,
|
||||
"ci95": 1294.9
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 24312.667,
|
||||
"stdev": 261.934,
|
||||
"min": 24116.0,
|
||||
"max": 24610.0,
|
||||
"ci95": 650.732
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2201.125,
|
||||
"stdev": 73.595,
|
||||
"min": 2153.6,
|
||||
"max": 2285.897,
|
||||
"ci95": 182.834
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 553.968,
|
||||
"stdev": 100.955,
|
||||
"min": 445.599,
|
||||
"max": 645.355,
|
||||
"ci95": 250.806
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 325.078,
|
||||
"stdev": 131.154,
|
||||
"min": 177.4,
|
||||
"max": 427.981,
|
||||
"ci95": 325.831
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 652.944,
|
||||
"stdev": 5.177,
|
||||
"min": 647.51,
|
||||
"max": 657.818,
|
||||
"ci95": 12.862
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3304.58,
|
||||
"stdev": 134.041,
|
||||
"min": 3155.996,
|
||||
"max": 3416.409,
|
||||
"ci95": 333.003
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 26591.0,
|
||||
"stdev": 57.472,
|
||||
"min": 26525.0,
|
||||
"max": 26630.0,
|
||||
"ci95": 142.779
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1961.086,
|
||||
"stdev": 39.725,
|
||||
"min": 1921.241,
|
||||
"max": 2000.69,
|
||||
"ci95": 98.69
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 536.973,
|
||||
"stdev": 38.828,
|
||||
"min": 495.61,
|
||||
"max": 572.635,
|
||||
"ci95": 96.461
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 296.586,
|
||||
"stdev": 30.236,
|
||||
"min": 275.859,
|
||||
"max": 331.281,
|
||||
"ci95": 75.118
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 896.941,
|
||||
"stdev": 5.177,
|
||||
"min": 891.506,
|
||||
"max": 901.814,
|
||||
"ci95": 12.862
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1458.578,
|
||||
"stdev": 3.229,
|
||||
"min": 1455.32,
|
||||
"max": 1461.777,
|
||||
"ci95": 8.022
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1236.28,
|
||||
"stdev": 4.869,
|
||||
"min": 1230.701,
|
||||
"max": 1239.672,
|
||||
"ci95": 12.096
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 21218,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1768.093,
|
||||
"low_1pct_fps": 630.473,
|
||||
"low_01pct_fps": 375.353,
|
||||
"p50_ms": 0.537,
|
||||
"p95_ms": 0.846,
|
||||
"p99_ms": 1.222,
|
||||
"p999_ms": 2.081,
|
||||
"max_ms": 6.256,
|
||||
"cpu_p50_ms": 0.38,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
20695,
|
||||
499,
|
||||
22,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 19482,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1623.403,
|
||||
"low_1pct_fps": 578.262,
|
||||
"low_01pct_fps": 373.717,
|
||||
"p50_ms": 0.586,
|
||||
"p95_ms": 0.95,
|
||||
"p99_ms": 1.357,
|
||||
"p999_ms": 2.158,
|
||||
"max_ms": 7.35,
|
||||
"cpu_p50_ms": 0.445,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
18729,
|
||||
713,
|
||||
38,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 20922,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1743.46,
|
||||
"low_1pct_fps": 571.485,
|
||||
"low_01pct_fps": 326.35,
|
||||
"p50_ms": 0.526,
|
||||
"p95_ms": 0.883,
|
||||
"p99_ms": 1.313,
|
||||
"p999_ms": 2.14,
|
||||
"max_ms": 12.027,
|
||||
"cpu_p50_ms": 0.379,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
20308,
|
||||
582,
|
||||
28,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 29855,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2487.851,
|
||||
"low_1pct_fps": 770.286,
|
||||
"low_01pct_fps": 421.717,
|
||||
"p50_ms": 0.365,
|
||||
"p95_ms": 0.619,
|
||||
"p99_ms": 0.948,
|
||||
"p999_ms": 1.717,
|
||||
"max_ms": 9.446,
|
||||
"cpu_p50_ms": 0.232,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
29607,
|
||||
233,
|
||||
13,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28766,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2397.145,
|
||||
"low_1pct_fps": 718.542,
|
||||
"low_01pct_fps": 426.929,
|
||||
"p50_ms": 0.374,
|
||||
"p95_ms": 0.662,
|
||||
"p99_ms": 1.016,
|
||||
"p999_ms": 1.858,
|
||||
"max_ms": 6.379,
|
||||
"cpu_p50_ms": 0.239,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
28457,
|
||||
293,
|
||||
14,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 28637,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2386.353,
|
||||
"low_1pct_fps": 638.666,
|
||||
"low_01pct_fps": 354.472,
|
||||
"p50_ms": 0.373,
|
||||
"p95_ms": 0.704,
|
||||
"p99_ms": 1.159,
|
||||
"p999_ms": 1.952,
|
||||
"max_ms": 14.388,
|
||||
"cpu_p50_ms": 0.233,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
28198,
|
||||
415,
|
||||
18,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 25968,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 2163.877,
|
||||
"low_1pct_fps": 570.949,
|
||||
"low_01pct_fps": 369.854,
|
||||
"p50_ms": 0.408,
|
||||
"p95_ms": 0.767,
|
||||
"p99_ms": 1.339,
|
||||
"p999_ms": 2.38,
|
||||
"max_ms": 3.329,
|
||||
"cpu_p50_ms": 0.271,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
25383,
|
||||
534,
|
||||
47,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 25843,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2153.6,
|
||||
"low_1pct_fps": 445.599,
|
||||
"low_01pct_fps": 177.4,
|
||||
"p50_ms": 0.413,
|
||||
"p95_ms": 0.725,
|
||||
"p99_ms": 1.309,
|
||||
"p999_ms": 3.451,
|
||||
"max_ms": 12.071,
|
||||
"cpu_p50_ms": 0.275,
|
||||
"gpu_p50_ms": 0.142,
|
||||
"histogram": [
|
||||
25375,
|
||||
373,
|
||||
56,
|
||||
22,
|
||||
1,
|
||||
7,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 27431,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2285.897,
|
||||
"low_1pct_fps": 645.355,
|
||||
"low_01pct_fps": 427.981,
|
||||
"p50_ms": 0.388,
|
||||
"p95_ms": 0.693,
|
||||
"p99_ms": 1.163,
|
||||
"p999_ms": 2.041,
|
||||
"max_ms": 3.068,
|
||||
"cpu_p50_ms": 0.254,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
27036,
|
||||
363,
|
||||
31,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 23536,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1961.326,
|
||||
"low_1pct_fps": 542.675,
|
||||
"low_01pct_fps": 331.281,
|
||||
"p50_ms": 0.436,
|
||||
"p95_ms": 0.856,
|
||||
"p99_ms": 1.425,
|
||||
"p999_ms": 2.22,
|
||||
"max_ms": 9.458,
|
||||
"cpu_p50_ms": 0.296,
|
||||
"gpu_p50_ms": 0.14,
|
||||
"histogram": [
|
||||
22819,
|
||||
671,
|
||||
40,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 23056,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1921.241,
|
||||
"low_1pct_fps": 495.61,
|
||||
"low_01pct_fps": 282.617,
|
||||
"p50_ms": 0.44,
|
||||
"p95_ms": 0.911,
|
||||
"p99_ms": 1.516,
|
||||
"p999_ms": 2.543,
|
||||
"max_ms": 14.342,
|
||||
"cpu_p50_ms": 0.299,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
22198,
|
||||
783,
|
||||
65,
|
||||
7,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 24009,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2000.69,
|
||||
"low_1pct_fps": 572.635,
|
||||
"low_01pct_fps": 275.859,
|
||||
"p50_ms": 0.436,
|
||||
"p95_ms": 0.819,
|
||||
"p99_ms": 1.274,
|
||||
"p999_ms": 2.085,
|
||||
"max_ms": 14.003,
|
||||
"cpu_p50_ms": 0.297,
|
||||
"gpu_p50_ms": 0.14,
|
||||
"histogram": [
|
||||
23421,
|
||||
560,
|
||||
22,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:55:59Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 2897.812,
|
||||
"stdev": 82.455,
|
||||
"min": 2844.677,
|
||||
"max": 2992.8,
|
||||
"ci95": 204.846
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 10503.454,
|
||||
"stdev": 493.592,
|
||||
"min": 9934.491,
|
||||
"max": 10816.992,
|
||||
"ci95": 1226.251
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3717.47,
|
||||
"stdev": 1330.649,
|
||||
"min": 2942.872,
|
||||
"max": 5253.954,
|
||||
"ci95": 3305.783
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 47525.333,
|
||||
"stdev": 1369.122,
|
||||
"min": 46133.0,
|
||||
"max": 48870.0,
|
||||
"ci95": 3401.363
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1163.541,
|
||||
"stdev": 3.485,
|
||||
"min": 1159.52,
|
||||
"max": 1165.693,
|
||||
"ci95": 8.658
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 415.992,
|
||||
"stdev": 15.742,
|
||||
"min": 399.062,
|
||||
"max": 430.188,
|
||||
"ci95": 39.108
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 215.549,
|
||||
"stdev": 34.678,
|
||||
"min": 177.857,
|
||||
"max": 246.101,
|
||||
"ci95": 86.151
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1833.432,
|
||||
"stdev": 4.415,
|
||||
"min": 1828.334,
|
||||
"max": 1835.997,
|
||||
"ci95": 10.969
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5455.049,
|
||||
"stdev": 593.897,
|
||||
"min": 4973.659,
|
||||
"max": 6118.723,
|
||||
"ci95": 1475.44
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 82758.0,
|
||||
"stdev": 413.547,
|
||||
"min": 82487.0,
|
||||
"max": 83234.0,
|
||||
"ci95": 1027.39
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1389.46,
|
||||
"stdev": 28.128,
|
||||
"min": 1371.096,
|
||||
"max": 1421.843,
|
||||
"ci95": 69.88
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 504.377,
|
||||
"stdev": 13.251,
|
||||
"min": 492.444,
|
||||
"max": 518.637,
|
||||
"ci95": 32.919
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 329.682,
|
||||
"stdev": 47.451,
|
||||
"min": 294.872,
|
||||
"max": 383.732,
|
||||
"ci95": 117.885
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1985.175,
|
||||
"stdev": 6.354,
|
||||
"min": 1978.907,
|
||||
"max": 1991.612,
|
||||
"ci95": 15.786
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4504.145,
|
||||
"stdev": 745.393,
|
||||
"min": 3650.589,
|
||||
"max": 5026.8,
|
||||
"ci95": 1851.809
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 74927.333,
|
||||
"stdev": 399.375,
|
||||
"min": 74476.0,
|
||||
"max": 75235.0,
|
||||
"ci95": 992.182
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1188.639,
|
||||
"stdev": 10.807,
|
||||
"min": 1177.005,
|
||||
"max": 1198.365,
|
||||
"ci95": 26.849
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 154.773,
|
||||
"stdev": 3.712,
|
||||
"min": 151.38,
|
||||
"max": 158.738,
|
||||
"ci95": 9.223
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 67.809,
|
||||
"stdev": 3.032,
|
||||
"min": 65.903,
|
||||
"max": 71.305,
|
||||
"ci95": 7.532
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2084.845,
|
||||
"stdev": 9.674,
|
||||
"min": 2074.009,
|
||||
"max": 2092.615,
|
||||
"ci95": 24.034
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 9048.431,
|
||||
"stdev": 344.33,
|
||||
"min": 8821.41,
|
||||
"max": 9444.624,
|
||||
"ci95": 855.432
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 89659.667,
|
||||
"stdev": 87.123,
|
||||
"min": 89561.0,
|
||||
"max": 89726.0,
|
||||
"ci95": 216.442
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1085.847,
|
||||
"stdev": 13.819,
|
||||
"min": 1070.378,
|
||||
"max": 1096.974,
|
||||
"ci95": 34.332
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 257.7,
|
||||
"stdev": 31.437,
|
||||
"min": 224.366,
|
||||
"max": 286.814,
|
||||
"ci95": 78.101
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 107.783,
|
||||
"stdev": 14.883,
|
||||
"min": 91.541,
|
||||
"max": 120.768,
|
||||
"ci95": 36.975
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2708.833,
|
||||
"stdev": 9.674,
|
||||
"min": 2697.997,
|
||||
"max": 2716.603,
|
||||
"ci95": 24.034
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2044.895,
|
||||
"stdev": 10.562,
|
||||
"min": 2032.875,
|
||||
"max": 2052.691,
|
||||
"ci95": 26.238
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1730.107,
|
||||
"stdev": 14.764,
|
||||
"min": 1717.52,
|
||||
"max": 1746.357,
|
||||
"ci95": 36.678
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 13986,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1165.41,
|
||||
"low_1pct_fps": 399.062,
|
||||
"low_01pct_fps": 177.857,
|
||||
"p50_ms": 0.822,
|
||||
"p95_ms": 1.21,
|
||||
"p99_ms": 1.691,
|
||||
"p999_ms": 3.499,
|
||||
"max_ms": 13.089,
|
||||
"cpu_p50_ms": 0.665,
|
||||
"gpu_p50_ms": 0.193,
|
||||
"histogram": [
|
||||
12380,
|
||||
1527,
|
||||
58,
|
||||
13,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 13915,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1159.52,
|
||||
"low_1pct_fps": 430.188,
|
||||
"low_01pct_fps": 246.101,
|
||||
"p50_ms": 0.809,
|
||||
"p95_ms": 1.212,
|
||||
"p99_ms": 1.702,
|
||||
"p999_ms": 3.291,
|
||||
"max_ms": 6.894,
|
||||
"cpu_p50_ms": 0.648,
|
||||
"gpu_p50_ms": 0.192,
|
||||
"histogram": [
|
||||
11996,
|
||||
1839,
|
||||
59,
|
||||
17,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 13989,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1165.693,
|
||||
"low_1pct_fps": 418.725,
|
||||
"low_01pct_fps": 222.688,
|
||||
"p50_ms": 0.816,
|
||||
"p95_ms": 1.22,
|
||||
"p99_ms": 1.692,
|
||||
"p999_ms": 3.264,
|
||||
"max_ms": 7.781,
|
||||
"cpu_p50_ms": 0.669,
|
||||
"gpu_p50_ms": 0.193,
|
||||
"histogram": [
|
||||
12321,
|
||||
1578,
|
||||
72,
|
||||
11,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 17063,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1421.843,
|
||||
"low_1pct_fps": 502.049,
|
||||
"low_01pct_fps": 294.872,
|
||||
"p50_ms": 0.666,
|
||||
"p95_ms": 0.935,
|
||||
"p99_ms": 1.549,
|
||||
"p999_ms": 2.692,
|
||||
"max_ms": 6.831,
|
||||
"cpu_p50_ms": 0.533,
|
||||
"gpu_p50_ms": 0.362,
|
||||
"histogram": [
|
||||
16417,
|
||||
596,
|
||||
40,
|
||||
7,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 16454,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1371.096,
|
||||
"low_1pct_fps": 492.444,
|
||||
"low_01pct_fps": 310.443,
|
||||
"p50_ms": 0.688,
|
||||
"p95_ms": 0.979,
|
||||
"p99_ms": 1.556,
|
||||
"p999_ms": 2.693,
|
||||
"max_ms": 4.596,
|
||||
"cpu_p50_ms": 0.553,
|
||||
"gpu_p50_ms": 0.36,
|
||||
"histogram": [
|
||||
15692,
|
||||
697,
|
||||
57,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 16506,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1375.442,
|
||||
"low_1pct_fps": 518.637,
|
||||
"low_01pct_fps": 383.732,
|
||||
"p50_ms": 0.675,
|
||||
"p95_ms": 1.017,
|
||||
"p99_ms": 1.605,
|
||||
"p999_ms": 2.369,
|
||||
"max_ms": 3.201,
|
||||
"cpu_p50_ms": 0.54,
|
||||
"gpu_p50_ms": 0.364,
|
||||
"histogram": [
|
||||
15628,
|
||||
833,
|
||||
44,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 14381,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1198.365,
|
||||
"low_1pct_fps": 154.2,
|
||||
"low_01pct_fps": 66.219,
|
||||
"p50_ms": 0.731,
|
||||
"p95_ms": 1.023,
|
||||
"p99_ms": 3.003,
|
||||
"p999_ms": 12.478,
|
||||
"max_ms": 18.863,
|
||||
"cpu_p50_ms": 0.596,
|
||||
"gpu_p50_ms": 0.315,
|
||||
"histogram": [
|
||||
13586,
|
||||
546,
|
||||
105,
|
||||
60,
|
||||
13,
|
||||
13,
|
||||
8,
|
||||
16,
|
||||
8,
|
||||
11,
|
||||
5,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 14287,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1190.548,
|
||||
"low_1pct_fps": 158.738,
|
||||
"low_01pct_fps": 71.305,
|
||||
"p50_ms": 0.741,
|
||||
"p95_ms": 1.041,
|
||||
"p99_ms": 2.9,
|
||||
"p999_ms": 12.0,
|
||||
"max_ms": 17.23,
|
||||
"cpu_p50_ms": 0.602,
|
||||
"gpu_p50_ms": 0.315,
|
||||
"histogram": [
|
||||
13450,
|
||||
591,
|
||||
109,
|
||||
57,
|
||||
13,
|
||||
8,
|
||||
10,
|
||||
11,
|
||||
14,
|
||||
10,
|
||||
7,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 14124,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1177.005,
|
||||
"low_1pct_fps": 151.38,
|
||||
"low_01pct_fps": 65.903,
|
||||
"p50_ms": 0.739,
|
||||
"p95_ms": 1.096,
|
||||
"p99_ms": 2.928,
|
||||
"p999_ms": 11.884,
|
||||
"max_ms": 19.375,
|
||||
"cpu_p50_ms": 0.604,
|
||||
"gpu_p50_ms": 0.314,
|
||||
"histogram": [
|
||||
13052,
|
||||
822,
|
||||
114,
|
||||
54,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
14,
|
||||
11,
|
||||
15,
|
||||
3,
|
||||
8,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 12845,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1070.378,
|
||||
"low_1pct_fps": 224.366,
|
||||
"low_01pct_fps": 91.541,
|
||||
"p50_ms": 0.825,
|
||||
"p95_ms": 1.362,
|
||||
"p99_ms": 2.325,
|
||||
"p999_ms": 9.621,
|
||||
"max_ms": 13.309,
|
||||
"cpu_p50_ms": 0.689,
|
||||
"gpu_p50_ms": 0.307,
|
||||
"histogram": [
|
||||
10937,
|
||||
1699,
|
||||
143,
|
||||
25,
|
||||
5,
|
||||
6,
|
||||
4,
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 13083,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1090.188,
|
||||
"low_1pct_fps": 261.921,
|
||||
"low_01pct_fps": 111.039,
|
||||
"p50_ms": 0.816,
|
||||
"p95_ms": 1.335,
|
||||
"p99_ms": 2.14,
|
||||
"p999_ms": 8.222,
|
||||
"max_ms": 11.95,
|
||||
"cpu_p50_ms": 0.681,
|
||||
"gpu_p50_ms": 0.306,
|
||||
"histogram": [
|
||||
11105,
|
||||
1812,
|
||||
112,
|
||||
21,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
11,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 13164,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1096.974,
|
||||
"low_1pct_fps": 286.814,
|
||||
"low_01pct_fps": 120.768,
|
||||
"p50_ms": 0.822,
|
||||
"p95_ms": 1.307,
|
||||
"p99_ms": 1.989,
|
||||
"p999_ms": 7.701,
|
||||
"max_ms": 9.429,
|
||||
"cpu_p50_ms": 0.687,
|
||||
"gpu_p50_ms": 0.307,
|
||||
"histogram": [
|
||||
11280,
|
||||
1758,
|
||||
82,
|
||||
13,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:37:03Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 2950.343,
|
||||
"stdev": 162.732,
|
||||
"min": 2822.925,
|
||||
"max": 3133.657,
|
||||
"ci95": 404.282
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 6135.1,
|
||||
"stdev": 863.831,
|
||||
"min": 5547.861,
|
||||
"max": 7126.98,
|
||||
"ci95": 2146.05
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 985.519,
|
||||
"stdev": 28.184,
|
||||
"min": 956.244,
|
||||
"max": 1012.469,
|
||||
"ci95": 70.019
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 6908.0,
|
||||
"stdev": 9.849,
|
||||
"min": 6897.0,
|
||||
"max": 6916.0,
|
||||
"ci95": 24.468
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2941.608,
|
||||
"stdev": 79.283,
|
||||
"min": 2850.416,
|
||||
"max": 2994.188,
|
||||
"ci95": 196.965
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1271.318,
|
||||
"stdev": 58.797,
|
||||
"min": 1203.758,
|
||||
"max": 1310.917,
|
||||
"ci95": 146.072
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 839.261,
|
||||
"stdev": 37.982,
|
||||
"min": 795.415,
|
||||
"max": 862.067,
|
||||
"ci95": 94.36
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 363.984,
|
||||
"stdev": 0.926,
|
||||
"min": 363.449,
|
||||
"max": 365.053,
|
||||
"ci95": 2.299
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1350.752,
|
||||
"stdev": 293.128,
|
||||
"min": 1106.764,
|
||||
"max": 1675.913,
|
||||
"ci95": 728.23
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 8066.0,
|
||||
"stdev": 108.885,
|
||||
"min": 7986.0,
|
||||
"max": 8190.0,
|
||||
"ci95": 270.508
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3494.693,
|
||||
"stdev": 110.997,
|
||||
"min": 3389.894,
|
||||
"max": 3610.993,
|
||||
"ci95": 275.755
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1295.14,
|
||||
"stdev": 159.638,
|
||||
"min": 1148.468,
|
||||
"max": 1465.171,
|
||||
"ci95": 396.596
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 859.326,
|
||||
"stdev": 119.845,
|
||||
"min": 721.994,
|
||||
"max": 942.743,
|
||||
"ci95": 297.734
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 381.189,
|
||||
"stdev": 5.222,
|
||||
"min": 376.423,
|
||||
"max": 386.77,
|
||||
"ci95": 12.972
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1123.386,
|
||||
"stdev": 604.175,
|
||||
"min": 734.302,
|
||||
"max": 1819.414,
|
||||
"ci95": 1500.976
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 6613.0,
|
||||
"stdev": 135.632,
|
||||
"min": 6523.0,
|
||||
"max": 6769.0,
|
||||
"ci95": 336.955
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3346.003,
|
||||
"stdev": 59.312,
|
||||
"min": 3303.535,
|
||||
"max": 3413.77,
|
||||
"ci95": 147.352
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1357.991,
|
||||
"stdev": 95.364,
|
||||
"min": 1248.887,
|
||||
"max": 1425.451,
|
||||
"ci95": 236.917
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 911.822,
|
||||
"stdev": 94.717,
|
||||
"min": 802.528,
|
||||
"max": 969.989,
|
||||
"ci95": 235.308
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 391.636,
|
||||
"stdev": 5.435,
|
||||
"min": 387.424,
|
||||
"max": 397.771,
|
||||
"ci95": 13.502
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1588.954,
|
||||
"stdev": 458.924,
|
||||
"min": 1309.548,
|
||||
"max": 2118.606,
|
||||
"ci95": 1140.123
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7887.667,
|
||||
"stdev": 41.041,
|
||||
"min": 7862.0,
|
||||
"max": 7935.0,
|
||||
"ci95": 101.959
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3375.623,
|
||||
"stdev": 26.82,
|
||||
"min": 3352.309,
|
||||
"max": 3404.933,
|
||||
"ci95": 66.629
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1383.745,
|
||||
"stdev": 67.628,
|
||||
"min": 1324.071,
|
||||
"max": 1457.203,
|
||||
"ci95": 168.01
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 850.213,
|
||||
"stdev": 103.149,
|
||||
"min": 790.305,
|
||||
"max": 969.318,
|
||||
"ci95": 256.256
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 448.64,
|
||||
"stdev": 5.435,
|
||||
"min": 444.428,
|
||||
"max": 454.775,
|
||||
"ci95": 13.502
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1171.629,
|
||||
"stdev": 0.337,
|
||||
"min": 1171.398,
|
||||
"max": 1172.016,
|
||||
"ci95": 0.837
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1082.078,
|
||||
"stdev": 12.494,
|
||||
"min": 1069.117,
|
||||
"max": 1094.047,
|
||||
"ci95": 31.04
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 35764,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2980.22,
|
||||
"low_1pct_fps": 1310.917,
|
||||
"low_01pct_fps": 860.301,
|
||||
"p50_ms": 0.317,
|
||||
"p95_ms": 0.433,
|
||||
"p99_ms": 0.633,
|
||||
"p999_ms": 0.937,
|
||||
"max_ms": 2.807,
|
||||
"cpu_p50_ms": 0.192,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
35741,
|
||||
22,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 35931,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2994.188,
|
||||
"low_1pct_fps": 1299.278,
|
||||
"low_01pct_fps": 795.415,
|
||||
"p50_ms": 0.318,
|
||||
"p95_ms": 0.428,
|
||||
"p99_ms": 0.62,
|
||||
"p999_ms": 0.943,
|
||||
"max_ms": 5.797,
|
||||
"cpu_p50_ms": 0.191,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
35906,
|
||||
23,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 34206,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2850.416,
|
||||
"low_1pct_fps": 1203.758,
|
||||
"low_01pct_fps": 862.067,
|
||||
"p50_ms": 0.326,
|
||||
"p95_ms": 0.508,
|
||||
"p99_ms": 0.705,
|
||||
"p999_ms": 1.025,
|
||||
"max_ms": 1.398,
|
||||
"cpu_p50_ms": 0.196,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
34167,
|
||||
39,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 41800,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3483.193,
|
||||
"low_1pct_fps": 1271.782,
|
||||
"low_01pct_fps": 913.242,
|
||||
"p50_ms": 0.264,
|
||||
"p95_ms": 0.415,
|
||||
"p99_ms": 0.657,
|
||||
"p999_ms": 0.935,
|
||||
"max_ms": 1.482,
|
||||
"cpu_p50_ms": 0.144,
|
||||
"gpu_p50_ms": 0.075,
|
||||
"histogram": [
|
||||
41768,
|
||||
32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 43333,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3610.993,
|
||||
"low_1pct_fps": 1465.171,
|
||||
"low_01pct_fps": 942.743,
|
||||
"p50_ms": 0.263,
|
||||
"p95_ms": 0.355,
|
||||
"p99_ms": 0.561,
|
||||
"p999_ms": 0.859,
|
||||
"max_ms": 1.694,
|
||||
"cpu_p50_ms": 0.143,
|
||||
"gpu_p50_ms": 0.074,
|
||||
"histogram": [
|
||||
43310,
|
||||
23,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 40679,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3389.894,
|
||||
"low_1pct_fps": 1148.468,
|
||||
"low_01pct_fps": 721.994,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.446,
|
||||
"p99_ms": 0.67,
|
||||
"p999_ms": 1.183,
|
||||
"max_ms": 2.434,
|
||||
"cpu_p50_ms": 0.146,
|
||||
"gpu_p50_ms": 0.076,
|
||||
"histogram": [
|
||||
40590,
|
||||
87,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 39849,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3320.704,
|
||||
"low_1pct_fps": 1399.634,
|
||||
"low_01pct_fps": 969.989,
|
||||
"p50_ms": 0.281,
|
||||
"p95_ms": 0.404,
|
||||
"p99_ms": 0.601,
|
||||
"p999_ms": 0.875,
|
||||
"max_ms": 1.494,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
39831,
|
||||
18,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 40966,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3413.77,
|
||||
"low_1pct_fps": 1425.451,
|
||||
"low_01pct_fps": 962.948,
|
||||
"p50_ms": 0.278,
|
||||
"p95_ms": 0.372,
|
||||
"p99_ms": 0.581,
|
||||
"p999_ms": 0.857,
|
||||
"max_ms": 1.576,
|
||||
"cpu_p50_ms": 0.157,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40947,
|
||||
19,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 39643,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3303.535,
|
||||
"low_1pct_fps": 1248.887,
|
||||
"low_01pct_fps": 802.528,
|
||||
"p50_ms": 0.28,
|
||||
"p95_ms": 0.413,
|
||||
"p99_ms": 0.642,
|
||||
"p999_ms": 1.087,
|
||||
"max_ms": 2.215,
|
||||
"cpu_p50_ms": 0.158,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
39582,
|
||||
60,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 40436,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3369.626,
|
||||
"low_1pct_fps": 1369.962,
|
||||
"low_01pct_fps": 790.305,
|
||||
"p50_ms": 0.279,
|
||||
"p95_ms": 0.403,
|
||||
"p99_ms": 0.604,
|
||||
"p999_ms": 0.812,
|
||||
"max_ms": 10.313,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40425,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 40860,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3404.933,
|
||||
"low_1pct_fps": 1457.203,
|
||||
"low_01pct_fps": 969.318,
|
||||
"p50_ms": 0.278,
|
||||
"p95_ms": 0.389,
|
||||
"p99_ms": 0.574,
|
||||
"p999_ms": 0.814,
|
||||
"max_ms": 2.714,
|
||||
"cpu_p50_ms": 0.158,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40847,
|
||||
11,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 40229,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3352.309,
|
||||
"low_1pct_fps": 1324.071,
|
||||
"low_01pct_fps": 791.016,
|
||||
"p50_ms": 0.28,
|
||||
"p95_ms": 0.402,
|
||||
"p99_ms": 0.617,
|
||||
"p999_ms": 0.968,
|
||||
"max_ms": 3.746,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40192,
|
||||
35,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:25:53Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 10777.756,
|
||||
"stdev": 129.67,
|
||||
"min": 10636.92,
|
||||
"max": 10892.2,
|
||||
"ci95": 322.144
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 23477.423,
|
||||
"stdev": 346.614,
|
||||
"min": 23130.92,
|
||||
"max": 23824.148,
|
||||
"ci95": 861.106
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6007.033,
|
||||
"stdev": 104.227,
|
||||
"min": 5891.431,
|
||||
"max": 6093.825,
|
||||
"ci95": 258.936
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1282.11,
|
||||
"stdev": 65.207,
|
||||
"min": 1228.13,
|
||||
"max": 1354.56,
|
||||
"ci95": 161.997
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 343.465,
|
||||
"stdev": 9.96,
|
||||
"min": 335.223,
|
||||
"max": 354.532,
|
||||
"ci95": 24.744
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 131.669,
|
||||
"stdev": 4.692,
|
||||
"min": 126.427,
|
||||
"max": 135.476,
|
||||
"ci95": 11.656
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8669.311,
|
||||
"stdev": 434.39,
|
||||
"min": 8280.854,
|
||||
"max": 9138.35,
|
||||
"ci95": 1079.173
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1745.757,
|
||||
"stdev": 13.014,
|
||||
"min": 1734.41,
|
||||
"max": 1759.963,
|
||||
"ci95": 32.332
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 460.958,
|
||||
"stdev": 10.859,
|
||||
"min": 449.051,
|
||||
"max": 470.316,
|
||||
"ci95": 26.978
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 178.955,
|
||||
"stdev": 3.777,
|
||||
"min": 175.213,
|
||||
"max": 182.766,
|
||||
"ci95": 9.383
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 7056.368,
|
||||
"stdev": 117.329,
|
||||
"min": 6986.912,
|
||||
"max": 7191.833,
|
||||
"ci95": 291.485
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1815.056,
|
||||
"stdev": 47.875,
|
||||
"min": 1759.788,
|
||||
"max": 1843.745,
|
||||
"ci95": 118.937
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 514.215,
|
||||
"stdev": 57.205,
|
||||
"min": 452.445,
|
||||
"max": 565.368,
|
||||
"ci95": 142.117
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 191.842,
|
||||
"stdev": 23.217,
|
||||
"min": 175.546,
|
||||
"max": 218.426,
|
||||
"ci95": 57.68
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14094.582,
|
||||
"stdev": 399.624,
|
||||
"min": 13641.534,
|
||||
"max": 14397.0,
|
||||
"ci95": 992.801
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1499.768,
|
||||
"stdev": 22.197,
|
||||
"min": 1477.269,
|
||||
"max": 1521.651,
|
||||
"ci95": 55.146
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 408.466,
|
||||
"stdev": 16.114,
|
||||
"min": 395.097,
|
||||
"max": 426.358,
|
||||
"ci95": 40.032
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 139.743,
|
||||
"stdev": 3.969,
|
||||
"min": 136.465,
|
||||
"max": 144.156,
|
||||
"ci95": 9.86
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2495.607,
|
||||
"stdev": 75.339,
|
||||
"min": 2412.738,
|
||||
"max": 2559.965,
|
||||
"ci95": 187.166
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2326.611,
|
||||
"stdev": 15.702,
|
||||
"min": 2314.98,
|
||||
"max": 2344.473,
|
||||
"ci95": 39.01
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 14738,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1228.13,
|
||||
"low_1pct_fps": 335.223,
|
||||
"low_01pct_fps": 133.103,
|
||||
"p50_ms": 0.722,
|
||||
"p95_ms": 1.384,
|
||||
"p99_ms": 2.043,
|
||||
"p999_ms": 5.224,
|
||||
"max_ms": 11.459,
|
||||
"histogram": [
|
||||
12159,
|
||||
2421,
|
||||
128,
|
||||
14,
|
||||
1,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 16255,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1354.56,
|
||||
"low_1pct_fps": 354.532,
|
||||
"low_01pct_fps": 135.476,
|
||||
"p50_ms": 0.672,
|
||||
"p95_ms": 1.136,
|
||||
"p99_ms": 1.841,
|
||||
"p999_ms": 4.913,
|
||||
"max_ms": 16.94,
|
||||
"histogram": [
|
||||
14841,
|
||||
1298,
|
||||
86,
|
||||
13,
|
||||
2,
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 15164,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1263.64,
|
||||
"low_1pct_fps": 340.639,
|
||||
"low_01pct_fps": 126.427,
|
||||
"p50_ms": 0.708,
|
||||
"p95_ms": 1.299,
|
||||
"p99_ms": 1.895,
|
||||
"p999_ms": 5.173,
|
||||
"max_ms": 16.748,
|
||||
"histogram": [
|
||||
13026,
|
||||
2014,
|
||||
94,
|
||||
13,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 20915,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1742.897,
|
||||
"low_1pct_fps": 470.316,
|
||||
"low_01pct_fps": 182.766,
|
||||
"p50_ms": 0.5,
|
||||
"p95_ms": 0.936,
|
||||
"p99_ms": 1.437,
|
||||
"p999_ms": 2.77,
|
||||
"max_ms": 13.414,
|
||||
"histogram": [
|
||||
20067,
|
||||
788,
|
||||
44,
|
||||
4,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 21120,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1759.963,
|
||||
"low_1pct_fps": 449.051,
|
||||
"low_01pct_fps": 178.887,
|
||||
"p50_ms": 0.49,
|
||||
"p95_ms": 0.901,
|
||||
"p99_ms": 1.531,
|
||||
"p999_ms": 3.005,
|
||||
"max_ms": 12.907,
|
||||
"histogram": [
|
||||
20414,
|
||||
644,
|
||||
40,
|
||||
10,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 20813,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1734.41,
|
||||
"low_1pct_fps": 463.506,
|
||||
"low_01pct_fps": 175.213,
|
||||
"p50_ms": 0.496,
|
||||
"p95_ms": 0.924,
|
||||
"p99_ms": 1.414,
|
||||
"p999_ms": 2.733,
|
||||
"max_ms": 16.832,
|
||||
"histogram": [
|
||||
20049,
|
||||
707,
|
||||
41,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 22100,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1841.634,
|
||||
"low_1pct_fps": 565.368,
|
||||
"low_01pct_fps": 218.426,
|
||||
"p50_ms": 0.483,
|
||||
"p95_ms": 0.835,
|
||||
"p99_ms": 1.209,
|
||||
"p999_ms": 2.082,
|
||||
"max_ms": 14.627,
|
||||
"histogram": [
|
||||
21540,
|
||||
532,
|
||||
18,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 21118,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1759.788,
|
||||
"low_1pct_fps": 452.445,
|
||||
"low_01pct_fps": 175.546,
|
||||
"p50_ms": 0.489,
|
||||
"p95_ms": 0.915,
|
||||
"p99_ms": 1.532,
|
||||
"p999_ms": 2.811,
|
||||
"max_ms": 11.846,
|
||||
"histogram": [
|
||||
20308,
|
||||
755,
|
||||
36,
|
||||
9,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 22125,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1843.745,
|
||||
"low_1pct_fps": 524.832,
|
||||
"low_01pct_fps": 181.555,
|
||||
"p50_ms": 0.477,
|
||||
"p95_ms": 0.855,
|
||||
"p99_ms": 1.241,
|
||||
"p999_ms": 2.236,
|
||||
"max_ms": 12.478,
|
||||
"histogram": [
|
||||
21487,
|
||||
608,
|
||||
18,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 18005,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1500.383,
|
||||
"low_1pct_fps": 403.944,
|
||||
"low_01pct_fps": 136.465,
|
||||
"p50_ms": 0.591,
|
||||
"p95_ms": 1.058,
|
||||
"p99_ms": 1.592,
|
||||
"p999_ms": 3.186,
|
||||
"max_ms": 12.608,
|
||||
"histogram": [
|
||||
16864,
|
||||
1084,
|
||||
32,
|
||||
8,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 17728,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1477.269,
|
||||
"low_1pct_fps": 395.097,
|
||||
"low_01pct_fps": 138.609,
|
||||
"p50_ms": 0.598,
|
||||
"p95_ms": 1.042,
|
||||
"p99_ms": 1.561,
|
||||
"p999_ms": 5.033,
|
||||
"max_ms": 11.462,
|
||||
"histogram": [
|
||||
16612,
|
||||
1051,
|
||||
39,
|
||||
7,
|
||||
1,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 18260,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1521.651,
|
||||
"low_1pct_fps": 426.358,
|
||||
"low_01pct_fps": 144.156,
|
||||
"p50_ms": 0.585,
|
||||
"p95_ms": 1.015,
|
||||
"p99_ms": 1.458,
|
||||
"p999_ms": 3.609,
|
||||
"max_ms": 11.945,
|
||||
"histogram": [
|
||||
17267,
|
||||
943,
|
||||
26,
|
||||
6,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T19:27:56Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 11038.922,
|
||||
"stdev": 118.058,
|
||||
"min": 10935.424,
|
||||
"max": 11167.506,
|
||||
"ci95": 293.295
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 39248.588,
|
||||
"stdev": 479.755,
|
||||
"min": 38964.834,
|
||||
"max": 39802.506,
|
||||
"ci95": 1191.875
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11176.953,
|
||||
"stdev": 356.144,
|
||||
"min": 10836.65,
|
||||
"max": 11547.064,
|
||||
"ci95": 884.781
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 925.977,
|
||||
"stdev": 6.727,
|
||||
"min": 918.832,
|
||||
"max": 932.189,
|
||||
"ci95": 16.713
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 250.092,
|
||||
"stdev": 1.868,
|
||||
"min": 248.431,
|
||||
"max": 252.114,
|
||||
"ci95": 4.64
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.692,
|
||||
"stdev": 4.466,
|
||||
"min": 88.423,
|
||||
"max": 97.332,
|
||||
"ci95": 11.095
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17335.638,
|
||||
"stdev": 687.588,
|
||||
"min": 16544.168,
|
||||
"max": 17785.772,
|
||||
"ci95": 1708.201
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 929.034,
|
||||
"stdev": 3.11,
|
||||
"min": 926.32,
|
||||
"max": 932.428,
|
||||
"ci95": 7.727
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 312.69,
|
||||
"stdev": 16.386,
|
||||
"min": 297.667,
|
||||
"max": 330.163,
|
||||
"ci95": 40.708
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 129.331,
|
||||
"stdev": 12.786,
|
||||
"min": 115.486,
|
||||
"max": 140.694,
|
||||
"ci95": 31.765
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12601.063,
|
||||
"stdev": 148.042,
|
||||
"min": 12430.182,
|
||||
"max": 12690.506,
|
||||
"ci95": 367.785
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1016.598,
|
||||
"stdev": 5.481,
|
||||
"min": 1010.482,
|
||||
"max": 1021.068,
|
||||
"ci95": 13.618
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 325.361,
|
||||
"stdev": 15.112,
|
||||
"min": 315.682,
|
||||
"max": 342.774,
|
||||
"ci95": 37.543
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 117.042,
|
||||
"stdev": 11.095,
|
||||
"min": 107.807,
|
||||
"max": 129.349,
|
||||
"ci95": 27.563
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 38513.008,
|
||||
"stdev": 958.119,
|
||||
"min": 37447.333,
|
||||
"max": 39303.226,
|
||||
"ci95": 2380.292
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 644.782,
|
||||
"stdev": 3.94,
|
||||
"min": 640.866,
|
||||
"max": 648.745,
|
||||
"ci95": 9.788
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 166.029,
|
||||
"stdev": 4.758,
|
||||
"min": 162.326,
|
||||
"max": 171.395,
|
||||
"ci95": 11.819
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 86.231,
|
||||
"stdev": 8.587,
|
||||
"min": 76.632,
|
||||
"max": 93.184,
|
||||
"ci95": 21.334
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2964.911,
|
||||
"stdev": 8.27,
|
||||
"min": 2959.813,
|
||||
"max": 2974.453,
|
||||
"ci95": 20.545
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2894.541,
|
||||
"stdev": 12.847,
|
||||
"min": 2879.928,
|
||||
"max": 2904.059,
|
||||
"ci95": 31.917
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 11123,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 926.911,
|
||||
"low_1pct_fps": 249.732,
|
||||
"low_01pct_fps": 92.322,
|
||||
"p50_ms": 0.989,
|
||||
"p95_ms": 1.492,
|
||||
"p99_ms": 2.33,
|
||||
"p999_ms": 7.409,
|
||||
"max_ms": 15.258,
|
||||
"histogram": [
|
||||
6224,
|
||||
4701,
|
||||
147,
|
||||
28,
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 11187,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 932.189,
|
||||
"low_1pct_fps": 252.114,
|
||||
"low_01pct_fps": 88.423,
|
||||
"p50_ms": 0.984,
|
||||
"p95_ms": 1.496,
|
||||
"p99_ms": 2.328,
|
||||
"p999_ms": 7.858,
|
||||
"max_ms": 16.843,
|
||||
"histogram": [
|
||||
6518,
|
||||
4486,
|
||||
138,
|
||||
20,
|
||||
9,
|
||||
1,
|
||||
1,
|
||||
6,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 11026,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 918.832,
|
||||
"low_1pct_fps": 248.431,
|
||||
"low_01pct_fps": 97.332,
|
||||
"p50_ms": 0.989,
|
||||
"p95_ms": 1.524,
|
||||
"p99_ms": 2.37,
|
||||
"p999_ms": 7.446,
|
||||
"max_ms": 16.573,
|
||||
"histogram": [
|
||||
6075,
|
||||
4740,
|
||||
162,
|
||||
23,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 11117,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 926.32,
|
||||
"low_1pct_fps": 310.239,
|
||||
"low_01pct_fps": 131.814,
|
||||
"p50_ms": 1.011,
|
||||
"p95_ms": 1.34,
|
||||
"p99_ms": 2.045,
|
||||
"p999_ms": 4.711,
|
||||
"max_ms": 14.077,
|
||||
"histogram": [
|
||||
3849,
|
||||
7150,
|
||||
78,
|
||||
26,
|
||||
5,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 11190,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 932.428,
|
||||
"low_1pct_fps": 330.163,
|
||||
"low_01pct_fps": 140.694,
|
||||
"p50_ms": 1.008,
|
||||
"p95_ms": 1.307,
|
||||
"p99_ms": 1.869,
|
||||
"p999_ms": 4.341,
|
||||
"max_ms": 13.489,
|
||||
"histogram": [
|
||||
4273,
|
||||
6822,
|
||||
60,
|
||||
20,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 11141,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 928.354,
|
||||
"low_1pct_fps": 297.667,
|
||||
"low_01pct_fps": 115.486,
|
||||
"p50_ms": 1.005,
|
||||
"p95_ms": 1.352,
|
||||
"p99_ms": 2.054,
|
||||
"p999_ms": 5.292,
|
||||
"max_ms": 14.339,
|
||||
"histogram": [
|
||||
4901,
|
||||
6118,
|
||||
83,
|
||||
23,
|
||||
3,
|
||||
2,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 12126,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1010.482,
|
||||
"low_1pct_fps": 317.626,
|
||||
"low_01pct_fps": 113.971,
|
||||
"p50_ms": 0.933,
|
||||
"p95_ms": 1.23,
|
||||
"p99_ms": 1.935,
|
||||
"p999_ms": 4.393,
|
||||
"max_ms": 14.824,
|
||||
"histogram": [
|
||||
9183,
|
||||
2830,
|
||||
82,
|
||||
17,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 12219,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1018.243,
|
||||
"low_1pct_fps": 342.774,
|
||||
"low_01pct_fps": 129.349,
|
||||
"p50_ms": 0.929,
|
||||
"p95_ms": 1.222,
|
||||
"p99_ms": 1.867,
|
||||
"p999_ms": 4.329,
|
||||
"max_ms": 10.33,
|
||||
"histogram": [
|
||||
9421,
|
||||
2710,
|
||||
60,
|
||||
15,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 12253,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1021.068,
|
||||
"low_1pct_fps": 315.682,
|
||||
"low_01pct_fps": 107.807,
|
||||
"p50_ms": 0.923,
|
||||
"p95_ms": 1.225,
|
||||
"p99_ms": 1.969,
|
||||
"p999_ms": 5.235,
|
||||
"max_ms": 19.524,
|
||||
"histogram": [
|
||||
9231,
|
||||
2904,
|
||||
92,
|
||||
10,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 7691,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 640.866,
|
||||
"low_1pct_fps": 164.367,
|
||||
"low_01pct_fps": 93.184,
|
||||
"p50_ms": 1.454,
|
||||
"p95_ms": 1.95,
|
||||
"p99_ms": 3.4,
|
||||
"p999_ms": 9.754,
|
||||
"max_ms": 14.995,
|
||||
"histogram": [
|
||||
36,
|
||||
7331,
|
||||
233,
|
||||
26,
|
||||
17,
|
||||
13,
|
||||
20,
|
||||
3,
|
||||
6,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 7737,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 644.736,
|
||||
"low_1pct_fps": 162.326,
|
||||
"low_01pct_fps": 76.632,
|
||||
"p50_ms": 1.454,
|
||||
"p95_ms": 1.905,
|
||||
"p99_ms": 3.676,
|
||||
"p999_ms": 9.926,
|
||||
"max_ms": 18.459,
|
||||
"histogram": [
|
||||
46,
|
||||
7399,
|
||||
199,
|
||||
24,
|
||||
21,
|
||||
24,
|
||||
10,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 7785,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 648.745,
|
||||
"low_1pct_fps": 171.395,
|
||||
"low_01pct_fps": 88.876,
|
||||
"p50_ms": 1.439,
|
||||
"p95_ms": 1.886,
|
||||
"p99_ms": 3.562,
|
||||
"p999_ms": 10.342,
|
||||
"max_ms": 12.927,
|
||||
"histogram": [
|
||||
57,
|
||||
7432,
|
||||
186,
|
||||
44,
|
||||
23,
|
||||
20,
|
||||
9,
|
||||
3,
|
||||
3,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:49:40Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 11089.831,
|
||||
"stdev": 46.336,
|
||||
"min": 11036.36,
|
||||
"max": 11118.192,
|
||||
"ci95": 115.114
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 17643.831,
|
||||
"stdev": 47.672,
|
||||
"min": 17589.192,
|
||||
"max": 17676.942,
|
||||
"ci95": 118.433
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3603.773,
|
||||
"stdev": 161.342,
|
||||
"min": 3488.106,
|
||||
"max": 3788.086,
|
||||
"ci95": 400.828
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2103.54,
|
||||
"stdev": 40.465,
|
||||
"min": 2078.464,
|
||||
"max": 2150.222,
|
||||
"ci95": 100.529
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 577.326,
|
||||
"stdev": 33.693,
|
||||
"min": 557.613,
|
||||
"max": 616.23,
|
||||
"ci95": 83.704
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 195.53,
|
||||
"stdev": 18.899,
|
||||
"min": 181.495,
|
||||
"max": 217.019,
|
||||
"ci95": 46.951
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3282.612,
|
||||
"stdev": 48.569,
|
||||
"min": 3233.16,
|
||||
"max": 3330.246,
|
||||
"ci95": 120.661
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2424.739,
|
||||
"stdev": 26.074,
|
||||
"min": 2394.789,
|
||||
"max": 2442.383,
|
||||
"ci95": 64.777
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 732.987,
|
||||
"stdev": 11.926,
|
||||
"min": 724.144,
|
||||
"max": 746.55,
|
||||
"ci95": 29.627
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 249.926,
|
||||
"stdev": 8.826,
|
||||
"min": 241.293,
|
||||
"max": 258.934,
|
||||
"ci95": 21.928
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2705.954,
|
||||
"stdev": 173.487,
|
||||
"min": 2538.486,
|
||||
"max": 2884.891,
|
||||
"ci95": 431.0
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2431.99,
|
||||
"stdev": 33.071,
|
||||
"min": 2394.02,
|
||||
"max": 2454.493,
|
||||
"ci95": 82.159
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 764.065,
|
||||
"stdev": 14.782,
|
||||
"min": 747.287,
|
||||
"max": 775.168,
|
||||
"ci95": 36.723
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 261.954,
|
||||
"stdev": 14.724,
|
||||
"min": 252.457,
|
||||
"max": 278.916,
|
||||
"ci95": 36.58
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4982.865,
|
||||
"stdev": 115.523,
|
||||
"min": 4849.837,
|
||||
"max": 5057.932,
|
||||
"ci95": 286.997
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2538.05,
|
||||
"stdev": 67.405,
|
||||
"min": 2462.815,
|
||||
"max": 2592.938,
|
||||
"ci95": 167.458
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 760.16,
|
||||
"stdev": 13.503,
|
||||
"min": 746.001,
|
||||
"max": 772.895,
|
||||
"ci95": 33.547
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 240.897,
|
||||
"stdev": 17.872,
|
||||
"min": 224.391,
|
||||
"max": 259.878,
|
||||
"ci95": 44.401
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2052.982,
|
||||
"stdev": 57.756,
|
||||
"min": 1992.574,
|
||||
"max": 2107.66,
|
||||
"ci95": 143.487
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1903.921,
|
||||
"stdev": 62.254,
|
||||
"min": 1838.307,
|
||||
"max": 1962.156,
|
||||
"ci95": 154.659
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 25803,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2150.222,
|
||||
"low_1pct_fps": 616.23,
|
||||
"low_01pct_fps": 217.019,
|
||||
"p50_ms": 0.433,
|
||||
"p95_ms": 0.642,
|
||||
"p99_ms": 1.04,
|
||||
"p999_ms": 2.307,
|
||||
"max_ms": 7.466,
|
||||
"histogram": [
|
||||
25503,
|
||||
269,
|
||||
8,
|
||||
6,
|
||||
7,
|
||||
6,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 24942,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2078.464,
|
||||
"low_1pct_fps": 557.613,
|
||||
"low_01pct_fps": 181.495,
|
||||
"p50_ms": 0.45,
|
||||
"p95_ms": 0.665,
|
||||
"p99_ms": 1.1,
|
||||
"p999_ms": 2.305,
|
||||
"max_ms": 9.819,
|
||||
"histogram": [
|
||||
24615,
|
||||
288,
|
||||
17,
|
||||
2,
|
||||
4,
|
||||
9,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 24984,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2081.934,
|
||||
"low_1pct_fps": 558.135,
|
||||
"low_01pct_fps": 188.076,
|
||||
"p50_ms": 0.444,
|
||||
"p95_ms": 0.682,
|
||||
"p99_ms": 1.101,
|
||||
"p999_ms": 2.412,
|
||||
"max_ms": 11.567,
|
||||
"histogram": [
|
||||
24645,
|
||||
308,
|
||||
9,
|
||||
3,
|
||||
5,
|
||||
5,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 29245,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2437.044,
|
||||
"low_1pct_fps": 728.266,
|
||||
"low_01pct_fps": 249.55,
|
||||
"p50_ms": 0.38,
|
||||
"p95_ms": 0.555,
|
||||
"p99_ms": 0.892,
|
||||
"p999_ms": 1.851,
|
||||
"max_ms": 6.973,
|
||||
"histogram": [
|
||||
29066,
|
||||
154,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28738,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2394.789,
|
||||
"low_1pct_fps": 724.144,
|
||||
"low_01pct_fps": 241.293,
|
||||
"p50_ms": 0.384,
|
||||
"p95_ms": 0.571,
|
||||
"p99_ms": 0.907,
|
||||
"p999_ms": 1.833,
|
||||
"max_ms": 8.35,
|
||||
"histogram": [
|
||||
28573,
|
||||
140,
|
||||
6,
|
||||
3,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 29309,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2442.383,
|
||||
"low_1pct_fps": 746.55,
|
||||
"low_01pct_fps": 258.934,
|
||||
"p50_ms": 0.378,
|
||||
"p95_ms": 0.566,
|
||||
"p99_ms": 0.876,
|
||||
"p999_ms": 1.744,
|
||||
"max_ms": 10.291,
|
||||
"histogram": [
|
||||
29138,
|
||||
148,
|
||||
5,
|
||||
6,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 29454,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2454.493,
|
||||
"low_1pct_fps": 769.741,
|
||||
"low_01pct_fps": 252.457,
|
||||
"p50_ms": 0.378,
|
||||
"p95_ms": 0.55,
|
||||
"p99_ms": 0.853,
|
||||
"p999_ms": 1.471,
|
||||
"max_ms": 6.629,
|
||||
"histogram": [
|
||||
29323,
|
||||
110,
|
||||
2,
|
||||
2,
|
||||
7,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 28729,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2394.02,
|
||||
"low_1pct_fps": 747.287,
|
||||
"low_01pct_fps": 254.49,
|
||||
"p50_ms": 0.383,
|
||||
"p95_ms": 0.579,
|
||||
"p99_ms": 0.896,
|
||||
"p999_ms": 1.56,
|
||||
"max_ms": 7.367,
|
||||
"histogram": [
|
||||
28567,
|
||||
141,
|
||||
3,
|
||||
5,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 29370,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2447.458,
|
||||
"low_1pct_fps": 775.168,
|
||||
"low_01pct_fps": 278.916,
|
||||
"p50_ms": 0.376,
|
||||
"p95_ms": 0.559,
|
||||
"p99_ms": 0.887,
|
||||
"p999_ms": 1.567,
|
||||
"max_ms": 9.65,
|
||||
"histogram": [
|
||||
29209,
|
||||
141,
|
||||
3,
|
||||
9,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 31116,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2592.938,
|
||||
"low_1pct_fps": 761.584,
|
||||
"low_01pct_fps": 224.391,
|
||||
"p50_ms": 0.356,
|
||||
"p95_ms": 0.511,
|
||||
"p99_ms": 0.767,
|
||||
"p999_ms": 1.843,
|
||||
"max_ms": 8.478,
|
||||
"histogram": [
|
||||
31011,
|
||||
76,
|
||||
4,
|
||||
3,
|
||||
13,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 29554,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2462.815,
|
||||
"low_1pct_fps": 746.001,
|
||||
"low_01pct_fps": 238.423,
|
||||
"p50_ms": 0.366,
|
||||
"p95_ms": 0.579,
|
||||
"p99_ms": 0.849,
|
||||
"p999_ms": 1.75,
|
||||
"max_ms": 7.607,
|
||||
"histogram": [
|
||||
29430,
|
||||
101,
|
||||
2,
|
||||
3,
|
||||
9,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 30701,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2558.396,
|
||||
"low_1pct_fps": 772.895,
|
||||
"low_01pct_fps": 259.878,
|
||||
"p50_ms": 0.357,
|
||||
"p95_ms": 0.537,
|
||||
"p99_ms": 0.81,
|
||||
"p999_ms": 1.702,
|
||||
"max_ms": 6.682,
|
||||
"histogram": [
|
||||
30568,
|
||||
112,
|
||||
1,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:19:23Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7681.834,
|
||||
"stdev": 189.48,
|
||||
"min": 7538.777,
|
||||
"max": 7896.728,
|
||||
"ci95": 470.732
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 19898.834,
|
||||
"stdev": 192.981,
|
||||
"min": 19695.728,
|
||||
"max": 20079.777,
|
||||
"ci95": 479.431
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6127.947,
|
||||
"stdev": 26.756,
|
||||
"min": 6097.19,
|
||||
"max": 6145.854,
|
||||
"ci95": 66.471
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1218.486,
|
||||
"stdev": 26.337,
|
||||
"min": 1188.13,
|
||||
"max": 1235.248,
|
||||
"ci95": 65.43
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 318.468,
|
||||
"stdev": 6.292,
|
||||
"min": 313.2,
|
||||
"max": 325.435,
|
||||
"ci95": 15.632
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 126.59,
|
||||
"stdev": 1.844,
|
||||
"min": 124.744,
|
||||
"max": 128.431,
|
||||
"ci95": 4.58
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8548.814,
|
||||
"stdev": 152.302,
|
||||
"min": 8380.912,
|
||||
"max": 8678.071,
|
||||
"ci95": 378.37
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1912.359,
|
||||
"stdev": 130.238,
|
||||
"min": 1791.386,
|
||||
"max": 2050.215,
|
||||
"ci95": 323.555
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 401.907,
|
||||
"stdev": 35.911,
|
||||
"min": 379.849,
|
||||
"max": 443.345,
|
||||
"ci95": 89.216
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 149.26,
|
||||
"stdev": 9.983,
|
||||
"min": 139.809,
|
||||
"max": 159.701,
|
||||
"ci95": 24.801
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6953.478,
|
||||
"stdev": 276.636,
|
||||
"min": 6635.649,
|
||||
"max": 7140.072,
|
||||
"ci95": 687.258
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1860.512,
|
||||
"stdev": 116.512,
|
||||
"min": 1726.149,
|
||||
"max": 1933.597,
|
||||
"ci95": 289.454
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 446.336,
|
||||
"stdev": 56.993,
|
||||
"min": 382.26,
|
||||
"max": 491.369,
|
||||
"ci95": 141.589
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.934,
|
||||
"stdev": 11.839,
|
||||
"min": 152.493,
|
||||
"max": 176.062,
|
||||
"ci95": 29.413
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 15211.386,
|
||||
"stdev": 256.348,
|
||||
"min": 14938.008,
|
||||
"max": 15446.375,
|
||||
"ci95": 636.856
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1653.223,
|
||||
"stdev": 61.705,
|
||||
"min": 1584.652,
|
||||
"max": 1704.273,
|
||||
"ci95": 153.297
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 331.929,
|
||||
"stdev": 10.858,
|
||||
"min": 319.394,
|
||||
"max": 338.396,
|
||||
"ci95": 26.974
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 113.436,
|
||||
"stdev": 3.255,
|
||||
"min": 110.869,
|
||||
"max": 117.097,
|
||||
"ci95": 8.086
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2540.129,
|
||||
"stdev": 152.057,
|
||||
"min": 2373.152,
|
||||
"max": 2670.633,
|
||||
"ci95": 377.761
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2299.514,
|
||||
"stdev": 81.451,
|
||||
"min": 2207.129,
|
||||
"max": 2360.973,
|
||||
"ci95": 202.352
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 14785,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1232.081,
|
||||
"low_1pct_fps": 325.435,
|
||||
"low_01pct_fps": 128.431,
|
||||
"p50_ms": 0.742,
|
||||
"p95_ms": 1.265,
|
||||
"p99_ms": 2.032,
|
||||
"p999_ms": 5.653,
|
||||
"max_ms": 12.791,
|
||||
"histogram": [
|
||||
12845,
|
||||
1784,
|
||||
127,
|
||||
7,
|
||||
1,
|
||||
8,
|
||||
4,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 14258,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1188.13,
|
||||
"low_1pct_fps": 313.2,
|
||||
"low_01pct_fps": 126.595,
|
||||
"p50_ms": 0.757,
|
||||
"p95_ms": 1.349,
|
||||
"p99_ms": 2.12,
|
||||
"p999_ms": 5.541,
|
||||
"max_ms": 13.998,
|
||||
"histogram": [
|
||||
11994,
|
||||
2076,
|
||||
157,
|
||||
9,
|
||||
2,
|
||||
8,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 14823,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1235.248,
|
||||
"low_1pct_fps": 316.768,
|
||||
"low_01pct_fps": 124.744,
|
||||
"p50_ms": 0.744,
|
||||
"p95_ms": 1.199,
|
||||
"p99_ms": 2.047,
|
||||
"p999_ms": 5.628,
|
||||
"max_ms": 15.519,
|
||||
"histogram": [
|
||||
13214,
|
||||
1453,
|
||||
118,
|
||||
16,
|
||||
2,
|
||||
8,
|
||||
6,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 21497,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1791.386,
|
||||
"low_1pct_fps": 382.527,
|
||||
"low_01pct_fps": 139.809,
|
||||
"p50_ms": 0.474,
|
||||
"p95_ms": 0.931,
|
||||
"p99_ms": 1.646,
|
||||
"p999_ms": 5.274,
|
||||
"max_ms": 13.459,
|
||||
"histogram": [
|
||||
20598,
|
||||
799,
|
||||
66,
|
||||
9,
|
||||
3,
|
||||
8,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 24603,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2050.215,
|
||||
"low_1pct_fps": 443.345,
|
||||
"low_01pct_fps": 159.701,
|
||||
"p50_ms": 0.426,
|
||||
"p95_ms": 0.752,
|
||||
"p99_ms": 1.442,
|
||||
"p999_ms": 3.134,
|
||||
"max_ms": 13.282,
|
||||
"histogram": [
|
||||
24041,
|
||||
487,
|
||||
48,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 22746,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1895.476,
|
||||
"low_1pct_fps": 379.849,
|
||||
"low_01pct_fps": 148.269,
|
||||
"p50_ms": 0.456,
|
||||
"p95_ms": 0.829,
|
||||
"p99_ms": 1.561,
|
||||
"p999_ms": 4.176,
|
||||
"max_ms": 10.671,
|
||||
"histogram": [
|
||||
22092,
|
||||
523,
|
||||
91,
|
||||
17,
|
||||
4,
|
||||
7,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 23062,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1921.791,
|
||||
"low_1pct_fps": 465.379,
|
||||
"low_01pct_fps": 166.247,
|
||||
"p50_ms": 0.463,
|
||||
"p95_ms": 0.809,
|
||||
"p99_ms": 1.45,
|
||||
"p999_ms": 2.398,
|
||||
"max_ms": 11.808,
|
||||
"histogram": [
|
||||
22454,
|
||||
561,
|
||||
27,
|
||||
3,
|
||||
2,
|
||||
6,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 23204,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1933.597,
|
||||
"low_1pct_fps": 491.369,
|
||||
"low_01pct_fps": 176.062,
|
||||
"p50_ms": 0.466,
|
||||
"p95_ms": 0.766,
|
||||
"p99_ms": 1.406,
|
||||
"p999_ms": 2.229,
|
||||
"max_ms": 15.102,
|
||||
"histogram": [
|
||||
22699,
|
||||
469,
|
||||
19,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 20714,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1726.149,
|
||||
"low_1pct_fps": 382.26,
|
||||
"low_01pct_fps": 152.493,
|
||||
"p50_ms": 0.5,
|
||||
"p95_ms": 0.956,
|
||||
"p99_ms": 1.742,
|
||||
"p999_ms": 3.852,
|
||||
"max_ms": 12.888,
|
||||
"histogram": [
|
||||
19814,
|
||||
787,
|
||||
83,
|
||||
11,
|
||||
2,
|
||||
7,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 20049,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1670.745,
|
||||
"low_1pct_fps": 338.396,
|
||||
"low_01pct_fps": 117.097,
|
||||
"p50_ms": 0.502,
|
||||
"p95_ms": 0.949,
|
||||
"p99_ms": 1.673,
|
||||
"p999_ms": 6.456,
|
||||
"max_ms": 16.791,
|
||||
"histogram": [
|
||||
19187,
|
||||
753,
|
||||
70,
|
||||
8,
|
||||
1,
|
||||
6,
|
||||
12,
|
||||
4,
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 19016,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1584.652,
|
||||
"low_1pct_fps": 337.998,
|
||||
"low_01pct_fps": 112.343,
|
||||
"p50_ms": 0.531,
|
||||
"p95_ms": 1.082,
|
||||
"p99_ms": 1.717,
|
||||
"p999_ms": 5.939,
|
||||
"max_ms": 12.958,
|
||||
"histogram": [
|
||||
17849,
|
||||
1069,
|
||||
63,
|
||||
7,
|
||||
3,
|
||||
6,
|
||||
2,
|
||||
7,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 20452,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1704.273,
|
||||
"low_1pct_fps": 319.394,
|
||||
"low_01pct_fps": 110.869,
|
||||
"p50_ms": 0.496,
|
||||
"p95_ms": 0.911,
|
||||
"p99_ms": 1.706,
|
||||
"p999_ms": 5.938,
|
||||
"max_ms": 24.863,
|
||||
"histogram": [
|
||||
19694,
|
||||
626,
|
||||
82,
|
||||
14,
|
||||
3,
|
||||
14,
|
||||
5,
|
||||
4,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T19:06:49Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7518.874,
|
||||
"stdev": 42.968,
|
||||
"min": 7469.532,
|
||||
"max": 7548.049,
|
||||
"ci95": 106.748
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 35424.207,
|
||||
"stdev": 144.711,
|
||||
"min": 35318.049,
|
||||
"max": 35589.041,
|
||||
"ci95": 359.511
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11146.828,
|
||||
"stdev": 49.683,
|
||||
"min": 11094.798,
|
||||
"max": 11193.773,
|
||||
"ci95": 123.43
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 882.053,
|
||||
"stdev": 19.433,
|
||||
"min": 866.126,
|
||||
"max": 903.706,
|
||||
"ci95": 48.279
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 183.995,
|
||||
"stdev": 4.924,
|
||||
"min": 178.512,
|
||||
"max": 188.041,
|
||||
"ci95": 12.234
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 83.193,
|
||||
"stdev": 6.797,
|
||||
"min": 77.734,
|
||||
"max": 90.806,
|
||||
"ci95": 16.886
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18184.002,
|
||||
"stdev": 266.879,
|
||||
"min": 17880.667,
|
||||
"max": 18382.735,
|
||||
"ci95": 663.017
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1202.907,
|
||||
"stdev": 20.426,
|
||||
"min": 1179.9,
|
||||
"max": 1218.906,
|
||||
"ci95": 50.744
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 228.139,
|
||||
"stdev": 5.166,
|
||||
"min": 222.394,
|
||||
"max": 232.401,
|
||||
"ci95": 12.833
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 107.956,
|
||||
"stdev": 4.675,
|
||||
"min": 105.185,
|
||||
"max": 113.354,
|
||||
"ci95": 11.614
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13146.632,
|
||||
"stdev": 507.943,
|
||||
"min": 12572.118,
|
||||
"max": 13536.145,
|
||||
"ci95": 1261.902
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1223.169,
|
||||
"stdev": 13.11,
|
||||
"min": 1208.837,
|
||||
"max": 1234.556,
|
||||
"ci95": 32.569
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 236.27,
|
||||
"stdev": 6.77,
|
||||
"min": 228.686,
|
||||
"max": 241.702,
|
||||
"ci95": 16.818
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 99.089,
|
||||
"stdev": 8.354,
|
||||
"min": 91.897,
|
||||
"max": 108.253,
|
||||
"ci95": 20.755
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 41846.641,
|
||||
"stdev": 661.649,
|
||||
"min": 41435.666,
|
||||
"max": 42609.895,
|
||||
"ci95": 1643.76
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 815.008,
|
||||
"stdev": 24.644,
|
||||
"min": 797.565,
|
||||
"max": 843.201,
|
||||
"ci95": 61.224
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 55.371,
|
||||
"stdev": 5.128,
|
||||
"min": 50.389,
|
||||
"max": 60.633,
|
||||
"ci95": 12.739
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.966,
|
||||
"stdev": 1.103,
|
||||
"min": 6.848,
|
||||
"max": 9.053,
|
||||
"ci95": 2.74
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2892.492,
|
||||
"stdev": 29.54,
|
||||
"min": 2859.574,
|
||||
"max": 2916.691,
|
||||
"ci95": 73.387
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2799.159,
|
||||
"stdev": 12.256,
|
||||
"min": 2786.234,
|
||||
"max": 2810.613,
|
||||
"ci95": 30.447
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 10845,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 903.706,
|
||||
"low_1pct_fps": 185.432,
|
||||
"low_01pct_fps": 81.039,
|
||||
"p50_ms": 0.98,
|
||||
"p95_ms": 1.708,
|
||||
"p99_ms": 3.082,
|
||||
"p999_ms": 9.304,
|
||||
"max_ms": 15.487,
|
||||
"histogram": [
|
||||
6030,
|
||||
4403,
|
||||
297,
|
||||
49,
|
||||
25,
|
||||
16,
|
||||
8,
|
||||
2,
|
||||
6,
|
||||
2,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 10516,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 876.328,
|
||||
"low_1pct_fps": 188.041,
|
||||
"low_01pct_fps": 90.806,
|
||||
"p50_ms": 1.012,
|
||||
"p95_ms": 1.806,
|
||||
"p99_ms": 3.354,
|
||||
"p999_ms": 8.714,
|
||||
"max_ms": 14.39,
|
||||
"histogram": [
|
||||
4927,
|
||||
5158,
|
||||
292,
|
||||
70,
|
||||
33,
|
||||
13,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 10394,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 866.126,
|
||||
"low_1pct_fps": 178.512,
|
||||
"low_01pct_fps": 77.734,
|
||||
"p50_ms": 1.026,
|
||||
"p95_ms": 1.789,
|
||||
"p99_ms": 3.399,
|
||||
"p999_ms": 10.6,
|
||||
"max_ms": 15.389,
|
||||
"histogram": [
|
||||
4437,
|
||||
5552,
|
||||
273,
|
||||
65,
|
||||
18,
|
||||
28,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 14519,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1209.916,
|
||||
"low_1pct_fps": 232.401,
|
||||
"low_01pct_fps": 105.185,
|
||||
"p50_ms": 0.726,
|
||||
"p95_ms": 1.306,
|
||||
"p99_ms": 2.25,
|
||||
"p999_ms": 7.033,
|
||||
"max_ms": 13.303,
|
||||
"histogram": [
|
||||
13121,
|
||||
1197,
|
||||
103,
|
||||
36,
|
||||
24,
|
||||
19,
|
||||
4,
|
||||
3,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 14159,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1179.9,
|
||||
"low_1pct_fps": 222.394,
|
||||
"low_01pct_fps": 105.33,
|
||||
"p50_ms": 0.732,
|
||||
"p95_ms": 1.366,
|
||||
"p99_ms": 2.499,
|
||||
"p999_ms": 8.061,
|
||||
"max_ms": 11.797,
|
||||
"histogram": [
|
||||
12501,
|
||||
1356,
|
||||
195,
|
||||
45,
|
||||
25,
|
||||
14,
|
||||
6,
|
||||
3,
|
||||
9,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 14627,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1218.906,
|
||||
"low_1pct_fps": 229.622,
|
||||
"low_01pct_fps": 113.354,
|
||||
"p50_ms": 0.713,
|
||||
"p95_ms": 1.292,
|
||||
"p99_ms": 2.317,
|
||||
"p999_ms": 7.136,
|
||||
"max_ms": 12.041,
|
||||
"histogram": [
|
||||
13194,
|
||||
1224,
|
||||
97,
|
||||
47,
|
||||
27,
|
||||
21,
|
||||
2,
|
||||
6,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 14714,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1226.113,
|
||||
"low_1pct_fps": 238.423,
|
||||
"low_01pct_fps": 97.117,
|
||||
"p50_ms": 0.722,
|
||||
"p95_ms": 1.223,
|
||||
"p99_ms": 2.243,
|
||||
"p999_ms": 7.032,
|
||||
"max_ms": 13.21,
|
||||
"histogram": [
|
||||
13490,
|
||||
1027,
|
||||
115,
|
||||
29,
|
||||
18,
|
||||
13,
|
||||
7,
|
||||
3,
|
||||
3,
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 14507,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1208.837,
|
||||
"low_1pct_fps": 228.686,
|
||||
"low_01pct_fps": 91.897,
|
||||
"p50_ms": 0.719,
|
||||
"p95_ms": 1.256,
|
||||
"p99_ms": 2.354,
|
||||
"p999_ms": 7.473,
|
||||
"max_ms": 15.432,
|
||||
"histogram": [
|
||||
13124,
|
||||
1112,
|
||||
180,
|
||||
35,
|
||||
23,
|
||||
9,
|
||||
8,
|
||||
4,
|
||||
2,
|
||||
6,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 14815,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1234.556,
|
||||
"low_1pct_fps": 241.702,
|
||||
"low_01pct_fps": 108.253,
|
||||
"p50_ms": 0.721,
|
||||
"p95_ms": 1.2,
|
||||
"p99_ms": 2.197,
|
||||
"p999_ms": 6.689,
|
||||
"max_ms": 12.791,
|
||||
"histogram": [
|
||||
13565,
|
||||
1055,
|
||||
107,
|
||||
29,
|
||||
23,
|
||||
15,
|
||||
8,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 10119,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 843.201,
|
||||
"low_1pct_fps": 60.633,
|
||||
"low_01pct_fps": 9.053,
|
||||
"p50_ms": 0.891,
|
||||
"p95_ms": 1.845,
|
||||
"p99_ms": 3.91,
|
||||
"p999_ms": 8.209,
|
||||
"max_ms": 316.409,
|
||||
"histogram": [
|
||||
6956,
|
||||
2747,
|
||||
240,
|
||||
83,
|
||||
45,
|
||||
22,
|
||||
8,
|
||||
8,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 9693,
|
||||
"seconds": 12.052,
|
||||
"avg_fps": 804.258,
|
||||
"low_1pct_fps": 50.389,
|
||||
"low_01pct_fps": 6.848,
|
||||
"p50_ms": 0.906,
|
||||
"p95_ms": 1.924,
|
||||
"p99_ms": 4.157,
|
||||
"p999_ms": 8.578,
|
||||
"max_ms": 297.695,
|
||||
"histogram": [
|
||||
6578,
|
||||
2678,
|
||||
248,
|
||||
94,
|
||||
45,
|
||||
18,
|
||||
16,
|
||||
5,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 9571,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 797.565,
|
||||
"low_1pct_fps": 55.09,
|
||||
"low_01pct_fps": 7.997,
|
||||
"p50_ms": 0.929,
|
||||
"p95_ms": 1.944,
|
||||
"p99_ms": 4.495,
|
||||
"p999_ms": 7.764,
|
||||
"max_ms": 321.994,
|
||||
"histogram": [
|
||||
5954,
|
||||
3172,
|
||||
230,
|
||||
103,
|
||||
39,
|
||||
34,
|
||||
19,
|
||||
11,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:43:17Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7676.526,
|
||||
"stdev": 305.763,
|
||||
"min": 7426.024,
|
||||
"max": 8017.247,
|
||||
"ci95": 759.617
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 13862.526,
|
||||
"stdev": 284.577,
|
||||
"min": 13569.024,
|
||||
"max": 14137.247,
|
||||
"ci95": 706.985
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3808.014,
|
||||
"stdev": 61.275,
|
||||
"min": 3737.989,
|
||||
"max": 3851.799,
|
||||
"ci95": 152.227
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2216.576,
|
||||
"stdev": 45.247,
|
||||
"min": 2166.285,
|
||||
"max": 2253.985,
|
||||
"ci95": 112.409
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 545.307,
|
||||
"stdev": 14.876,
|
||||
"min": 528.15,
|
||||
"max": 554.607,
|
||||
"ci95": 36.957
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 215.412,
|
||||
"stdev": 5.692,
|
||||
"min": 209.522,
|
||||
"max": 220.882,
|
||||
"ci95": 14.14
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3212.958,
|
||||
"stdev": 151.722,
|
||||
"min": 3076.655,
|
||||
"max": 3376.428,
|
||||
"ci95": 376.928
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2456.542,
|
||||
"stdev": 336.369,
|
||||
"min": 2068.162,
|
||||
"max": 2654.549,
|
||||
"ci95": 835.654
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 624.759,
|
||||
"stdev": 90.0,
|
||||
"min": 521.303,
|
||||
"max": 685.008,
|
||||
"ci95": 223.59
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 252.469,
|
||||
"stdev": 21.044,
|
||||
"min": 229.228,
|
||||
"max": 270.233,
|
||||
"ci95": 52.28
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2983.236,
|
||||
"stdev": 168.416,
|
||||
"min": 2788.777,
|
||||
"max": 3082.249,
|
||||
"ci95": 418.403
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2469.831,
|
||||
"stdev": 333.258,
|
||||
"min": 2085.121,
|
||||
"max": 2669.9,
|
||||
"ci95": 827.925
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 688.684,
|
||||
"stdev": 96.943,
|
||||
"min": 576.803,
|
||||
"max": 747.759,
|
||||
"ci95": 240.839
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 286.444,
|
||||
"stdev": 21.36,
|
||||
"min": 262.183,
|
||||
"max": 302.422,
|
||||
"ci95": 53.066
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6079.869,
|
||||
"stdev": 719.864,
|
||||
"min": 5542.196,
|
||||
"max": 6897.69,
|
||||
"ci95": 1788.385
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2605.12,
|
||||
"stdev": 326.424,
|
||||
"min": 2228.824,
|
||||
"max": 2812.075,
|
||||
"ci95": 810.948
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 699.63,
|
||||
"stdev": 87.846,
|
||||
"min": 599.44,
|
||||
"max": 763.45,
|
||||
"ci95": 218.239
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 264.461,
|
||||
"stdev": 28.459,
|
||||
"min": 239.602,
|
||||
"max": 295.503,
|
||||
"ci95": 70.701
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2120.426,
|
||||
"stdev": 116.406,
|
||||
"min": 2046.93,
|
||||
"max": 2254.637,
|
||||
"ci95": 289.191
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1912.642,
|
||||
"stdev": 38.925,
|
||||
"min": 1889.766,
|
||||
"max": 1957.586,
|
||||
"ci95": 96.702
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 27048,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2253.985,
|
||||
"low_1pct_fps": 553.164,
|
||||
"low_01pct_fps": 215.833,
|
||||
"p50_ms": 0.412,
|
||||
"p95_ms": 0.617,
|
||||
"p99_ms": 1.187,
|
||||
"p999_ms": 2.238,
|
||||
"max_ms": 9.792,
|
||||
"histogram": [
|
||||
26716,
|
||||
296,
|
||||
15,
|
||||
4,
|
||||
7,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 26754,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2229.458,
|
||||
"low_1pct_fps": 554.607,
|
||||
"low_01pct_fps": 220.882,
|
||||
"p50_ms": 0.417,
|
||||
"p95_ms": 0.626,
|
||||
"p99_ms": 1.212,
|
||||
"p999_ms": 2.448,
|
||||
"max_ms": 8.388,
|
||||
"histogram": [
|
||||
26423,
|
||||
293,
|
||||
16,
|
||||
9,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 25996,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2166.285,
|
||||
"low_1pct_fps": 528.15,
|
||||
"low_01pct_fps": 209.522,
|
||||
"p50_ms": 0.43,
|
||||
"p95_ms": 0.662,
|
||||
"p99_ms": 1.308,
|
||||
"p999_ms": 2.477,
|
||||
"max_ms": 11.033,
|
||||
"histogram": [
|
||||
25615,
|
||||
338,
|
||||
23,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 31855,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2654.549,
|
||||
"low_1pct_fps": 667.966,
|
||||
"low_01pct_fps": 257.945,
|
||||
"p50_ms": 0.341,
|
||||
"p95_ms": 0.519,
|
||||
"p99_ms": 0.932,
|
||||
"p999_ms": 1.761,
|
||||
"max_ms": 10.944,
|
||||
"histogram": [
|
||||
31558,
|
||||
275,
|
||||
6,
|
||||
2,
|
||||
7,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 31763,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2646.916,
|
||||
"low_1pct_fps": 685.008,
|
||||
"low_01pct_fps": 270.233,
|
||||
"p50_ms": 0.344,
|
||||
"p95_ms": 0.52,
|
||||
"p99_ms": 0.94,
|
||||
"p999_ms": 1.685,
|
||||
"max_ms": 11.406,
|
||||
"histogram": [
|
||||
31474,
|
||||
272,
|
||||
3,
|
||||
4,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 24818,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2068.162,
|
||||
"low_1pct_fps": 521.303,
|
||||
"low_01pct_fps": 229.228,
|
||||
"p50_ms": 0.439,
|
||||
"p95_ms": 0.767,
|
||||
"p99_ms": 1.284,
|
||||
"p999_ms": 2.351,
|
||||
"max_ms": 11.468,
|
||||
"histogram": [
|
||||
24356,
|
||||
410,
|
||||
36,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 32039,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2669.9,
|
||||
"low_1pct_fps": 741.491,
|
||||
"low_01pct_fps": 294.727,
|
||||
"p50_ms": 0.345,
|
||||
"p95_ms": 0.501,
|
||||
"p99_ms": 0.86,
|
||||
"p999_ms": 1.431,
|
||||
"max_ms": 9.257,
|
||||
"histogram": [
|
||||
31771,
|
||||
250,
|
||||
3,
|
||||
2,
|
||||
4,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 31854,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2654.471,
|
||||
"low_1pct_fps": 747.759,
|
||||
"low_01pct_fps": 302.422,
|
||||
"p50_ms": 0.347,
|
||||
"p95_ms": 0.503,
|
||||
"p99_ms": 0.853,
|
||||
"p999_ms": 1.404,
|
||||
"max_ms": 13.925,
|
||||
"histogram": [
|
||||
31580,
|
||||
261,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 25022,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2085.121,
|
||||
"low_1pct_fps": 576.803,
|
||||
"low_01pct_fps": 262.183,
|
||||
"p50_ms": 0.428,
|
||||
"p95_ms": 0.758,
|
||||
"p99_ms": 1.214,
|
||||
"p999_ms": 2.109,
|
||||
"max_ms": 9.206,
|
||||
"histogram": [
|
||||
24600,
|
||||
390,
|
||||
20,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 33745,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2812.075,
|
||||
"low_1pct_fps": 736.0,
|
||||
"low_01pct_fps": 258.279,
|
||||
"p50_ms": 0.327,
|
||||
"p95_ms": 0.471,
|
||||
"p99_ms": 0.808,
|
||||
"p999_ms": 1.689,
|
||||
"max_ms": 10.07,
|
||||
"histogram": [
|
||||
33516,
|
||||
205,
|
||||
7,
|
||||
0,
|
||||
7,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 33294,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2774.461,
|
||||
"low_1pct_fps": 763.45,
|
||||
"low_01pct_fps": 295.503,
|
||||
"p50_ms": 0.334,
|
||||
"p95_ms": 0.471,
|
||||
"p99_ms": 0.813,
|
||||
"p999_ms": 1.63,
|
||||
"max_ms": 8.512,
|
||||
"histogram": [
|
||||
33056,
|
||||
221,
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 26747,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2228.824,
|
||||
"low_1pct_fps": 599.44,
|
||||
"low_01pct_fps": 239.602,
|
||||
"p50_ms": 0.403,
|
||||
"p95_ms": 0.678,
|
||||
"p99_ms": 1.107,
|
||||
"p999_ms": 2.054,
|
||||
"max_ms": 9.178,
|
||||
"histogram": [
|
||||
26418,
|
||||
298,
|
||||
13,
|
||||
6,
|
||||
2,
|
||||
8,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Horizon v0.2.97 vs vanilla 26.3 on Windows 11: Ryzen 7 9800X3D + RTX 5080 (2026-10-29, RD 8 and 32 rerun 2026-10-30)
|
||||
|
||||
The first `tools/perf_bench.py` run on Windows ([docs/benchmark.md](../../docs/benchmark.md)): Horizon, vanilla 26.3 and
|
||||
vanilla with performance mods, render distances 8, 16 and 32, 1280x720, three games each, every game from a fresh
|
||||
world. The reports are the `*-win-9800x3d-5080-rd*.json` files next to this page.
|
||||
|
||||
```sh
|
||||
python tools/perf_bench.py --table horizon-win-9800x3d-5080 vanilla-win-9800x3d-5080 vanilla-mods-win-9800x3d-5080
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
- **Machine:** AMD Ryzen 7 9800X3D (8 cores, 16 threads), 94 GB RAM (DDR5, EXPO off), NVIDIA GeForce RTX 5080
|
||||
(driver 617.42), Windows 11 Pro 26200, Balanced power plan, desktop. One 1920x1080 120 Hz display. The game windows
|
||||
were on the desktop of an unlocked session.
|
||||
- **Horizon:** release v0.2.97 as the launcher installs it (`--build %APPDATA%\horizon-launcher\versions\v0.2.97`,
|
||||
`--resources` its `.local\horizon-client\26.3`), Vulkan.
|
||||
- **Vanilla:** the 26.3 client jar with Fabric Loader 0.19.5 and the driver mod, Java 25.0.5 (Temurin), `-Xmx2G`
|
||||
with the launcher's G1 flags and the manifest's `-XX:StackShadowPages=32`, OpenGL 3.3.
|
||||
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0 and
|
||||
ImmediatelyFast 1.17.1 (`tools/benchmark-deps/mods.lock.json`).
|
||||
- **Commands:** `python tools/perf_bench.py --game <game> --label <game>-win-9800x3d-5080 --distances <d>
|
||||
--sizes 1280x720 --repeats 3` (plus `--build` and `--resources` for Horizon), with the games taking turns at
|
||||
each distance.
|
||||
- **Memory** is the working set on Windows (what Task Manager shows), sampled from outside the process. Horizon's
|
||||
GPU figure is its own Vulkan allocator total. `nvidia-smi` gives no per-process figure under WDDM, so vanilla has
|
||||
none.
|
||||
|
||||
## Load
|
||||
|
||||
Windows has no load average, so the reports have no `load_avg_1m`. Every game ran on an otherwise idle machine:
|
||||
|
||||
- **RD 8 and RD 32** (rerun on 2026-10-30, after the first pass had builds running alongside): a script started each
|
||||
game only once no `rustc`, `cargo`, `link` (or other compiler) process and no other game (`horizon-client`, `java`,
|
||||
`javaw`, Minecraft) was running, and the machine's CPU load had stayed under 15 % in every 5-second sample for
|
||||
30 seconds. During each game it sampled every 2 seconds the machine's CPU load and the CPU of everything outside the
|
||||
benchmark's own processes. A game during which a compiler or another game ran, or other processes used over 15 % of
|
||||
the machine for 10 seconds, was discarded and run again: two of the 20 games were (vanilla + mods at RD 32, a
|
||||
build started during one, background processes during the other).
|
||||
- **RD 16** (2026-10-29): no `rustc`, `cargo` or `link` process at any point, the load logged every 5 seconds.
|
||||
|
||||
| Game | RD | Mean / max CPU load (machine, the game included) | Other processes' CPU, mean / max | Compiler processes |
|
||||
|---|---|---|---|---|
|
||||
| Horizon | 8 | 18 % / 49 % | 6 % / 12 % | 0 |
|
||||
| Vanilla | 8 | 28 % / 75 % | 7 % / 20 % | 0 |
|
||||
| Vanilla + mods | 8 | 30 % / 81 % | 7 % / 20 % | 0 |
|
||||
| Horizon | 16 | 29 % / 80 % | – | 0 |
|
||||
| Vanilla | 16 | 41 % / 91 % | – | 0 |
|
||||
| Vanilla + mods | 16 | 51 % / 90 % | – | 0 |
|
||||
| Horizon | 32 | 36 % / 100 % | 5 % / 15 % | 0 |
|
||||
| Vanilla | 32 | 39 % / 73 % | 6 % / 18 % | 0 |
|
||||
| Vanilla + mods | 32 | 42 % / 88 % | 6 % / 21 % | 0 |
|
||||
|
||||
"Other processes" is the machine's load minus the benchmark's own processes (the game and `perf_bench.py`), in
|
||||
2-second samples. Most of it is the system's share of the game's own drawing (the graphics driver and the
|
||||
compositor), which no process of the benchmark is charged for; the desktop apps left open (a browser, chat apps)
|
||||
briefly reached 5 % at most. RD 16's load was logged from outside every 5 seconds, so it has no such split, and its mean includes the time between
|
||||
games. Compared with the first pass, the idle RD 8 runs give Horizon about 10 % higher averages and about 50 % higher
|
||||
1% lows, and vanilla up to 10 % higher averages.
|
||||
|
||||
## Results
|
||||
|
||||
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight at 16 blocks/s (average / 1% low /
|
||||
0.1% low):
|
||||
|
||||
| RD | scene | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 8 | forest | 3495 ±276 / 1295 ±397 / 859 ±298 | 2457 ±836 / 625 ±224 / 252 ±52 | 2425 ±65 / 733 ±30 / 250 ±22 |
|
||||
| 8 | mountain | 3376 ±67 / 1384 ±168 / 850 ±256 | 2605 ±811 / 700 ±218 / 264 ±71 | 2538 ±167 / 760 ±34 / 241 ±44 |
|
||||
| 8 | ocean | 3346 ±147 / 1358 ±237 / 912 ±235 | 2470 ±828 / 689 ±241 / 286 ±53 | 2432 ±82 / 764 ±37 / 262 ±37 |
|
||||
| 8 | village | 2942 ±197 / 1271 ±146 / 839 ±94 | 2217 ±112 / 545 ±37 / 215 ±14 | 2104 ±101 / 577 ±84 / 196 ±47 |
|
||||
| 16 | forest | 2424 ±138 / 709 ±165 / 401 ±100 | 1912 ±324 / 402 ±89 / 149 ±25 | 1746 ±32 / 461 ±27 / 179 ±9 |
|
||||
| 16 | mountain | 1961 ±99 / 537 ±96 / 297 ±75 | 1653 ±153 / 332 ±27 / 113 ±8 | 1500 ±55 / 408 ±40 / 140 ±10 |
|
||||
| 16 | ocean | 2201 ±183 / 554 ±251 / 325 ±326 | 1861 ±289 / 446 ±142 / 165 ±29 | 1815 ±119 / 514 ±142 / 192 ±58 |
|
||||
| 16 | village | 1712 ±192 / 593 ±80 / 358 ±69 | 1218 ±65 / 318 ±16 / 127 ±5 | 1282 ±162 / 343 ±25 / 132 ±12 |
|
||||
| 32 | forest | 1389 ±70 / 504 ±33 / 330 ±118 | 1203 ±51 / 228 ±13 / 108 ±12 | 929 ±8 / 313 ±41 / 129 ±32 |
|
||||
| 32 | mountain | 1086 ±34 / 258 ±78 / 108 ±37 | 815 ±61 / 55 ±13 / 8 ±3 | 645 ±10 / 166 ±12 / 86 ±21 |
|
||||
| 32 | ocean | 1189 ±27 / 155 ±9 / 68 ±8 | 1223 ±33 / 236 ±17 / 99 ±21 | 1017 ±14 / 325 ±38 / 117 ±28 |
|
||||
| 32 | village | 1164 ±9 / 416 ±39 / 216 ±86 | 882 ±48 / 184 ±12 / 83 ±17 | 926 ±17 / 250 ±5 / 93 ±11 |
|
||||
|
||||
Startup, loading and memory (means of 3 games):
|
||||
|
||||
| RD | | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 8 | start to title / in-world | 3.0 s / 6.1 s | 7.7 s / 13.9 s | 11.1 s / 17.6 s |
|
||||
| 8 | mean scene load | 1.3 s | 4.0 s | 3.6 s |
|
||||
| 8 | working set, steady / peak | 1082 / 1172 MB | 1913 / 2120 MB | 1904 / 2053 MB |
|
||||
| 16 | start to title / in-world | 3.2 s / 7.2 s | 7.7 s / 19.9 s | 10.8 s / 23.5 s |
|
||||
| 16 | mean scene load | 2.4 s | 9.2 s | 9.0 s |
|
||||
| 16 | working set, steady / peak | 1236 / 1459 MB | 2300 / 2540 MB | 2327 / 2496 MB |
|
||||
| 32 | start to title / in-world | 2.9 s / 10.5 s | 7.5 s / 35.4 s | 11.0 s / 39.2 s |
|
||||
| 32 | mean scene load | 5.7 s | 21.1 s | 19.9 s |
|
||||
| 32 | working set, steady / peak | 1730 / 2045 MB | 2799 / 2892 MB | 2895 / 2965 MB |
|
||||
|
||||
## What it shows
|
||||
|
||||
- At RD 8 and 16, Horizon's average FPS is 18–42 % above vanilla's in every scene. Its 1% lows are 1.2–2.3 times
|
||||
vanilla's and its 0.1% lows 2.0–3.9 times. On this CPU and GPU the performance mods leave vanilla's averages
|
||||
level or slightly lower, and raise its 1% lows by 6–23 %.
|
||||
- At RD 32, Horizon's average is 15–33 % above vanilla's over the forest, the mountain and the village, with 1% lows
|
||||
2.2–4.7 times vanilla's. Over the ocean its average is level with vanilla's, but its lows are lower than vanilla's
|
||||
and the modded client's (155 vs 236 and 325 FPS 1% low, 68 vs 99 and 117 FPS 0.1% low), on an idle machine too.
|
||||
That scene is worth a closer look.
|
||||
- Horizon reaches the title screen 2.4–2.6 times faster than vanilla (3.4–3.8 times faster than the modded client)
|
||||
and loads a scene's chunks 3–4 times faster. Its steady working set is 38–46 % smaller.
|
||||
- Each game uses its own graphics API: Horizon Vulkan, vanilla OpenGL. That difference is part of what's compared.
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands (a server mod:
|
||||
//! a player joining, chatting, typing them and leaving), and import no more
|
||||
//! than its capabilities.
|
||||
//! when given a key, and for a `.hmod` that it is content only.
|
||||
use horizon_mods::{
|
||||
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
|
||||
Package,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod has code to run (a content mod may have none).
|
||||
pub code: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
@@ -53,10 +37,6 @@ impl Report {
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"code": self.code,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,18 +80,8 @@ impl Report {
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
if self.kind == Kind::Mod {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -153,145 +123,26 @@ pub fn check(
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
if kind == Kind::Mod {
|
||||
content_only(path)?;
|
||||
}
|
||||
Ok(Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
code: false,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards. A content mod has no code to
|
||||
/// run: `inspect` already read its content as the game does.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let Some(wasm) = package.wasm.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
report.code = true;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
name: "Steve".into(),
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let snapshot = |t: u64| Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
players: vec![steve.clone()],
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snapshot(0));
|
||||
mods.player_joined(&steve);
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(snapshot(t));
|
||||
mods.tick(t);
|
||||
mods.server_tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
mods.player_chat(&steve, "hello");
|
||||
for name in &client {
|
||||
mods.command(name);
|
||||
}
|
||||
for name in &server {
|
||||
mods.server_command(&steve, name);
|
||||
}
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
mods.player_left(&steve);
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Tell { text, .. } => format!("tell: {text}"),
|
||||
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
|
||||
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::QuietCommands { lines, .. } => {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::SetBlocks {
|
||||
dimension, blocks, ..
|
||||
} => {
|
||||
format!("set {} block(s) in {dimension}", blocks.len())
|
||||
}
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
|
||||
}
|
||||
Output::Give { item, count, .. } => format!("gave {count} {item}"),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
});
|
||||
}
|
||||
let shapes = mods.hud_shapes().count();
|
||||
if shapes > 0 {
|
||||
report.output.push(format!("drew {shapes} HUD shape(s)"));
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
/// A mod is content only: WebAssembly code mods don't run any more, code mods are native
|
||||
/// (`.hnmod`, docs/native-mods.md). `inspect` already read its content as the game does.
|
||||
fn content_only(path: &Path) -> Result<(), String> {
|
||||
match Package::read(path)?.wasm {
|
||||
Some(_) => Err(
|
||||
"it has WebAssembly code, which Horizon doesn't run any more: make it a native mod (docs/native-mods.md)"
|
||||
.into(),
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -316,7 +167,7 @@ mod tests {
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
// A mod with WebAssembly code is refused: code mods are native.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
@@ -324,7 +175,7 @@ mod tests {
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
assert!(e.contains("native mod"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
|
||||
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
|
||||
//!
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `new <path> [--name "Name"]`: a content mod from the template.
|
||||
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
|
||||
//! and `data/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
|
||||
//! `tests/*.toml` scripts in the sandbox, with no game.
|
||||
//! the mod.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! (`.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`, `.hshader`).
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
use horizon_mods::{Capability, Content, Manifest, Package, manifest::MANIFEST};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
process::{Command, ExitCode},
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -35,14 +26,13 @@ fn main() -> ExitCode {
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => new(&args[1..]),
|
||||
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
|
||||
Some("build") => build().map(|p| {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -83,31 +73,9 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
(
|
||||
"tests/hello.toml",
|
||||
include_str!("../template/tests/hello.toml"),
|
||||
),
|
||||
];
|
||||
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
let files = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
@@ -146,12 +114,12 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
if flag(args, "--template").is_some_and(|t| t != "content") {
|
||||
return Err(
|
||||
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
|
||||
);
|
||||
}
|
||||
for (file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
@@ -169,29 +137,24 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
|
||||
/// the manifest, the crate's component (none without a `Cargo.toml`: a
|
||||
/// content mod) and the files in `assets/` and `data/`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."), release)
|
||||
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
|
||||
/// manifest and the files in `assets/` and `data/`.
|
||||
fn build() -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."))
|
||||
}
|
||||
|
||||
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
fn build_in(root: &Path) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
|
||||
let (wasm, target) = compile(root, release)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
(Some(wasm), target)
|
||||
} else if manifest.capabilities.contains(&Capability::Registries) {
|
||||
(None, root.join("target"))
|
||||
} else {
|
||||
if root.join("Cargo.toml").exists() {
|
||||
return Err("a Cargo.toml: code mods are native now (docs/native-mods.md)".into());
|
||||
}
|
||||
if !manifest.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
|
||||
"a content mod has capability registries in {MANIFEST}"
|
||||
));
|
||||
};
|
||||
}
|
||||
// The same checks as the game's, and the files in the order it lists them.
|
||||
let content = Content::read(root, &manifest)?;
|
||||
let mut reader = content.reader()?;
|
||||
@@ -200,53 +163,17 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
let dir = root.join("target/horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
|
||||
let bytes = Package::write_files(&manifest_text, None, &files)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
let out = cmd
|
||||
.stderr(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"cargo build failed (is the target installed? rustup target add {TARGET})"
|
||||
));
|
||||
}
|
||||
let core = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
|
||||
.filter(|m| m["reason"] == "compiler-artifact")
|
||||
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|f| f.as_str().map(PathBuf::from))
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
Ok((wasm, target.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
@@ -278,74 +205,14 @@ fn check(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
|
||||
/// against the built mod, or the `.hmod` given with `--hmod`.
|
||||
fn test(args: &[String]) -> Result<(), String> {
|
||||
let hmod = match flag(args, "--hmod") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => build(!args.iter().any(|a| a == "--debug"))?,
|
||||
};
|
||||
let filter = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
|
||||
.map(|(_, a)| a.as_str());
|
||||
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| Some(e.ok()?.path()))
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
|
||||
.collect();
|
||||
scripts.sort();
|
||||
if scripts.is_empty() {
|
||||
return Err("no tests/*.toml to run".into());
|
||||
}
|
||||
println!(
|
||||
"running {} scripts against {}",
|
||||
scripts.len(),
|
||||
hmod.display()
|
||||
);
|
||||
let mut failed = 0;
|
||||
for path in &scripts {
|
||||
let result = std::fs::read_to_string(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| Script::parse(&text))
|
||||
.and_then(|script| {
|
||||
let mut game = TestGame::load(&hmod)?;
|
||||
script.run(&mut game)?;
|
||||
Ok(script.steps.len())
|
||||
});
|
||||
match result {
|
||||
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
println!("test {} ... FAILED\n {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
let passed = scripts.len() - failed;
|
||||
if failed > 0 {
|
||||
return Err(format!("{failed} failed, {passed} passed"));
|
||||
}
|
||||
println!("test result: ok. {passed} passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let path = build()?;
|
||||
let game = flag(args, "--game")
|
||||
.map(str::to_string)
|
||||
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
|
||||
.unwrap_or_else(|| "horizon-client".into());
|
||||
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
|
||||
println!(
|
||||
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
|
||||
path.display()
|
||||
);
|
||||
println!("Starting {game} with {}", path.display());
|
||||
let status = Command::new(&game)
|
||||
.arg("--dev-mod")
|
||||
.arg(&path)
|
||||
@@ -364,28 +231,6 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
|
||||
let path = dir.join("My-Mod");
|
||||
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(manifest.id.as_str(), manifest.name.as_str()),
|
||||
("my_mod", "My Mod")
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
|
||||
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
@@ -397,7 +242,7 @@ mod tests {
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path, true).unwrap();
|
||||
let hmod = build_in(&path).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
@@ -409,17 +254,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
assert!(!report.code);
|
||||
assert!(
|
||||
report.text().contains("no code: content only"),
|
||||
"{}",
|
||||
report.text()
|
||||
);
|
||||
// Without registries, a mod needs code.
|
||||
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
|
||||
let manifest = manifest.replace("[\"registries\"]", "[]");
|
||||
std::fs::write(path.join(MANIFEST), manifest).unwrap();
|
||||
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
|
||||
// Code is native now.
|
||||
std::fs::write(path.join("Cargo.toml"), "").unwrap();
|
||||
assert!(build_in(&path).unwrap_err().contains("native"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = {sdk}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -1,12 +0,0 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the SDK targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -1,19 +0,0 @@
|
||||
use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
|
||||
struct MyMod {
|
||||
greeted: u32,
|
||||
}
|
||||
|
||||
impl Mod for MyMod {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.greeted += 1;
|
||||
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(MyMod);
|
||||
@@ -1,14 +0,0 @@
|
||||
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
|
||||
# steps happen in order, and `expect` checks what the mod showed during the step.
|
||||
# Every key: docs/mods.md, "Testing a mod".
|
||||
|
||||
[[step]]
|
||||
expect.commands = ["hello"]
|
||||
|
||||
[[step]]
|
||||
command = "hello world"
|
||||
expect.chat = ["Hello world! (1 so far)"]
|
||||
|
||||
[[step]]
|
||||
command = "hello again"
|
||||
expect.chat = ["Hello again! (2 so far)"]
|
||||
@@ -21,18 +21,19 @@ use horizon_protocol::{
|
||||
connection::{ConnWriter, Connection},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
item::Stack,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
handshake::{self, Intention},
|
||||
login::{self, ServerHello},
|
||||
play,
|
||||
play, recipes,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
Arc, LazyLock, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -44,7 +45,22 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
|
||||
flood-packets (--flood rotations a tick),
|
||||
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
|
||||
moved-too-quickly check allows, never waiting for chunks),
|
||||
and --flood a tick of packets that cost the server work:
|
||||
flood-use (place and break stone around itself), flood-click (inventory
|
||||
clicks with a stale state), flood-suggest (command suggestions, every
|
||||
other one 30 000 characters long), flood-book (100 pages of 1024 characters
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
--seconds N how long to run after the first bot joins; default 60
|
||||
--view-distance N what the bots ask for; default 10
|
||||
@@ -59,6 +75,16 @@ enum Behaviour {
|
||||
Idle,
|
||||
Walk,
|
||||
Explore,
|
||||
FloodMove,
|
||||
FloodAcks,
|
||||
FloodPackets,
|
||||
FloodChunks,
|
||||
FloodUse,
|
||||
FloodClick,
|
||||
FloodSuggest,
|
||||
FloodBook,
|
||||
FloodSign,
|
||||
FloodRecipe,
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
@@ -67,6 +93,16 @@ impl Behaviour {
|
||||
Self::Idle => "idle",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::FloodMove => "flood-move",
|
||||
Self::FloodAcks => "flood-acks",
|
||||
Self::FloodPackets => "flood-packets",
|
||||
Self::FloodChunks => "flood-chunks",
|
||||
Self::FloodUse => "flood-use",
|
||||
Self::FloodClick => "flood-click",
|
||||
Self::FloodSuggest => "flood-suggest",
|
||||
Self::FloodBook => "flood-book",
|
||||
Self::FloodSign => "flood-sign",
|
||||
Self::FloodRecipe => "flood-recipe",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +117,8 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
name: String,
|
||||
report: Option<String>,
|
||||
}
|
||||
@@ -96,6 +134,8 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
};
|
||||
@@ -105,6 +145,10 @@ fn options() -> Result<Options, String> {
|
||||
print!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
if arg == "--no-read" {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
let value = args.next().ok_or(format!("{arg} needs a value"))?;
|
||||
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
|
||||
match arg.as_str() {
|
||||
@@ -122,14 +166,31 @@ fn options() -> Result<Options, String> {
|
||||
"--explore-speed" => {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
|
||||
const BEHAVIOURS: [&str; 14] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"mixed",
|
||||
"flood-move",
|
||||
"flood-acks",
|
||||
"flood-packets",
|
||||
"flood-chunks",
|
||||
"flood-use",
|
||||
"flood-click",
|
||||
"flood-suggest",
|
||||
"flood-book",
|
||||
"flood-sign",
|
||||
"flood-recipe",
|
||||
];
|
||||
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore or mixed",
|
||||
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
@@ -156,6 +217,8 @@ struct Bot {
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -185,6 +248,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
w.data
|
||||
}
|
||||
|
||||
/// Item ids of the pinned version (26.3's item registry).
|
||||
const STONE: i32 = 1;
|
||||
const WRITABLE_BOOK: i32 = 1371;
|
||||
|
||||
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
|
||||
fn costly(
|
||||
behaviour: Behaviour,
|
||||
i: usize,
|
||||
sequence: i32,
|
||||
pos: [f64; 3],
|
||||
random: &mut Random,
|
||||
) -> Option<Vec<u8>> {
|
||||
use ids::play::serverbound as sb;
|
||||
static LONG_COMMAND: LazyLock<String> =
|
||||
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
|
||||
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||
play::Serverbound::EditBook(play::EditBook {
|
||||
slot: 0,
|
||||
pages: vec!["a".repeat(1024); 100],
|
||||
title: None,
|
||||
})
|
||||
.encode()
|
||||
});
|
||||
let block = pos.map(|v| v.floor() as i32);
|
||||
let packet = match behaviour {
|
||||
Behaviour::FloodUse => {
|
||||
// A block within reach: place stone there, or break what is there.
|
||||
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
|
||||
if i % 2 == 0 {
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
} else {
|
||||
play::Serverbound::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: at,
|
||||
direction: 1,
|
||||
sequence,
|
||||
})
|
||||
}
|
||||
}
|
||||
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
|
||||
container: 0,
|
||||
state: 0,
|
||||
slot: 36 + (i % 9) as i16,
|
||||
button: 0,
|
||||
input: 0,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
}),
|
||||
Behaviour::FloodSuggest => {
|
||||
let text = if i % 2 == 0 {
|
||||
"/"
|
||||
} else {
|
||||
LONG_COMMAND.as_str()
|
||||
};
|
||||
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
|
||||
w.varint(sequence);
|
||||
w.string(text);
|
||||
}));
|
||||
}
|
||||
Behaviour::FloodBook => return Some(BOOK.clone()),
|
||||
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
|
||||
pos: [block[0], block[1] - 1, block[2]],
|
||||
lines: std::array::from_fn(|_| "a".repeat(384)),
|
||||
front: true,
|
||||
}),
|
||||
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
|
||||
container: 0,
|
||||
recipe: (i % 2042) as i32,
|
||||
use_max: true,
|
||||
}),
|
||||
_ => return None,
|
||||
};
|
||||
Some(packet.encode())
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
@@ -339,6 +485,12 @@ fn read(
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
loop {
|
||||
if bot.deaf.load(Ordering::Relaxed) {
|
||||
while bot.online.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (id, body) = match reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
@@ -489,6 +641,24 @@ fn run_bot(
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
bot.deaf.store(o.no_read, Ordering::Relaxed);
|
||||
// What the costly floods need in hand (creative: the server takes the bot's word for it).
|
||||
let tool = match behaviour {
|
||||
Behaviour::FloodUse => Some(Stack::new(STONE, 64)),
|
||||
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
|
||||
_ => None,
|
||||
};
|
||||
if tool.is_some() {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot: 36,
|
||||
item: tool,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
let cruise = spawn[1] + 24.0;
|
||||
@@ -504,7 +674,38 @@ fn run_bot(
|
||||
pos = at;
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !lock(&bot.held).contains(&here) {
|
||||
// The abusive client doesn't wait for its chunk, as a real client must.
|
||||
if behaviour == Behaviour::FloodMove {
|
||||
// The last of these moves goes out below, with the tick's end.
|
||||
pos[1] = cruise;
|
||||
for _ in 1..o.flood.max(1) {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 64.0 + random.unit() * 192.0;
|
||||
pos[0] += angle.cos() * reach;
|
||||
pos[2] += angle.sin() * reach;
|
||||
send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
pos[0] += angle.cos() * 128.0;
|
||||
pos[2] += angle.sin() * 128.0;
|
||||
} else if behaviour == Behaviour::FloodChunks {
|
||||
// Up to cruising height first, then just under the server's 10 blocks a tick:
|
||||
// every tick asks for new chunks.
|
||||
if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
pos[0] += heading.cos() * 9.9;
|
||||
pos[2] += heading.sin() * 9.9;
|
||||
}
|
||||
} else if !lock(&bot.held).contains(&here) {
|
||||
bot.waited.fetch_add(1, Ordering::Relaxed);
|
||||
} else if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
@@ -529,7 +730,37 @@ fn run_bot(
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::Explore => {
|
||||
Behaviour::FloodMove
|
||||
| Behaviour::FloodChunks
|
||||
| Behaviour::FloodUse
|
||||
| Behaviour::FloodClick
|
||||
| Behaviour::FloodSuggest
|
||||
| Behaviour::FloodBook
|
||||
| Behaviour::FloodSign
|
||||
| Behaviour::FloodRecipe => {}
|
||||
Behaviour::FloodPackets => {
|
||||
for i in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
|
||||
yaw: (i % 360) as f32,
|
||||
pitch: 0.0,
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::FloodAcks | Behaviour::Explore => {
|
||||
if behaviour == Behaviour::FloodAcks {
|
||||
for _ in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: 64.0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Flying in creative, by default about 22 blocks a second, which keeps new
|
||||
// chunks coming as fast as the server sends them.
|
||||
let step = o.explore_speed / 20.0;
|
||||
@@ -538,6 +769,13 @@ fn run_bot(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
@@ -569,6 +807,16 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
"idle" => Behaviour::Idle,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
"flood-acks" => Behaviour::FloodAcks,
|
||||
"flood-packets" => Behaviour::FloodPackets,
|
||||
"flood-chunks" => Behaviour::FloodChunks,
|
||||
"flood-use" => Behaviour::FloodUse,
|
||||
"flood-click" => Behaviour::FloodClick,
|
||||
"flood-suggest" => Behaviour::FloodSuggest,
|
||||
"flood-book" => Behaviour::FloodBook,
|
||||
"flood-sign" => Behaviour::FloodSign,
|
||||
"flood-recipe" => Behaviour::FloodRecipe,
|
||||
// mixed: of every five bots, three walk, one explores, one stands.
|
||||
_ => match index % 5 {
|
||||
0 | 2 | 4 => Behaviour::Walk,
|
||||
|
||||
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
_ => string("minecraft:air"),
|
||||
},
|
||||
);
|
||||
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
f.put(
|
||||
"facing",
|
||||
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
|
||||
);
|
||||
f.float("progress", 0.0);
|
||||
f.flag("extending", false);
|
||||
f.flag("source", false);
|
||||
@@ -873,6 +877,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
|
||||
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
|
||||
#[test]
|
||||
fn moving_piston_fields_match_vanilla() {
|
||||
let out = canon(
|
||||
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
|
||||
facing:5,progress:0.5f,extending:1b,source:0b}",
|
||||
);
|
||||
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
|
||||
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
|
||||
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
|
||||
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
|
||||
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
|
||||
let out = canon(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
|
||||
//! value, `ComponentSerialization` turns it into styled text.
|
||||
use crate::text::{Style, Text, named_color};
|
||||
use crate::text::{InlineObject, Style, Text, named_color};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -195,6 +195,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
@@ -236,7 +237,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// HoverEvent.ShowText and ShowItem.
|
||||
// HoverEvent.ShowText, ShowItem and ShowEntity.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
@@ -247,6 +248,9 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_item = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
Some("show_entity") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_entity = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -256,6 +260,28 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
.map(str::to_owned);
|
||||
style
|
||||
}
|
||||
|
||||
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
|
||||
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
|
||||
if let Some(packed) = value.as_i64() {
|
||||
return i32::try_from(packed).ok().map(|n| n as u32);
|
||||
}
|
||||
let rgba = value.as_array()?;
|
||||
if rgba.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let channels = rgba
|
||||
.iter()
|
||||
.map(|channel| {
|
||||
// ExtraCodecs.ARGB_COLOR_CODEC reads floats; ARGB.as8BitChannel
|
||||
// floors each normalized channel multiplied by 255.
|
||||
let value = channel.as_f64()? as f32;
|
||||
(value.is_finite() && (0.0..=1.0).contains(&value))
|
||||
.then_some((value * 255.0_f32).floor() as u32)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(channels[3] << 24 | channels[0] << 16 | channels[1] << 8 | channels[2])
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
|
||||
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
@@ -296,6 +322,10 @@ pub fn to_text_with(
|
||||
let style = object_style(o);
|
||||
let content = if let Some(t) = contents(o) {
|
||||
t
|
||||
} else if let Some(object) = inline_object(o, resolve, contents) {
|
||||
let mut text = Text::literal("\u{fffc}");
|
||||
text.spans[0].style.inline_object = Some(object);
|
||||
text
|
||||
} else if let Some(t) = o.get("text") {
|
||||
match t {
|
||||
Value::String(s) => Text::literal(s.clone()),
|
||||
@@ -332,12 +362,74 @@ pub fn to_text_with(
|
||||
{
|
||||
out.push(to_text_with(extra, resolve, contents).with_style(&style));
|
||||
}
|
||||
out
|
||||
if o.contains_key("object") || o.contains_key("player") || o.contains_key("sprite") {
|
||||
out.with_component(Value::Object(o.clone()))
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
Value::Null => Text::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_object(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
resolve: &mut dyn FnMut(&str) -> Text,
|
||||
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
|
||||
) -> Option<InlineObject> {
|
||||
let kind = object.get("object").and_then(Value::as_str);
|
||||
let fallback = object
|
||||
.get("fallback")
|
||||
.map(|value| to_text_with(value, resolve, contents).plain());
|
||||
match kind {
|
||||
Some("player") | None if object.contains_key("player") => {
|
||||
let profile = object.get("player")?.clone();
|
||||
// PlayerSprite.defaultFallback.
|
||||
let fallback = fallback.unwrap_or_else(|| {
|
||||
match profile
|
||||
.as_str()
|
||||
.or_else(|| profile.get("name").and_then(Value::as_str))
|
||||
{
|
||||
Some(name) => format!("[{name} head]"),
|
||||
None => "[unknown player head]".to_owned(),
|
||||
}
|
||||
});
|
||||
Some(InlineObject::Player {
|
||||
profile,
|
||||
hat: object.get("hat").and_then(Value::as_bool).unwrap_or(true),
|
||||
fallback,
|
||||
})
|
||||
}
|
||||
Some("atlas") | None if object.contains_key("sprite") => {
|
||||
let atlas = object
|
||||
.get("atlas")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("minecraft:blocks")
|
||||
.to_owned();
|
||||
let sprite = object.get("sprite")?.as_str()?.to_owned();
|
||||
// AtlasSprite.defaultFallback: short names, the atlas unless blocks.
|
||||
let fallback = fallback.unwrap_or_else(|| {
|
||||
let short = |id: &str| match id.split_once(':') {
|
||||
Some(("minecraft", path)) => path.to_owned(),
|
||||
Some(_) => id.to_owned(),
|
||||
None => id.to_owned(),
|
||||
};
|
||||
if short(&atlas) == "blocks" {
|
||||
format!("[{}]", short(&sprite))
|
||||
} else {
|
||||
format!("[{}@{}]", short(&sprite), short(&atlas))
|
||||
}
|
||||
});
|
||||
Some(InlineObject::Atlas {
|
||||
atlas,
|
||||
sprite,
|
||||
fallback,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
@@ -351,6 +443,61 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shadow_color_accepts_packed_and_rgba_styles() {
|
||||
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
|
||||
let text = to_text(&packed, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0xff00ff00));
|
||||
assert_eq!(text.component()["shadow_color"], -16711936);
|
||||
let rgba = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 1]});
|
||||
let text = to_text(&rgba, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0xffff007f));
|
||||
let translucent = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 0.5]});
|
||||
let text = to_text(&translucent, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0x7fff007f));
|
||||
let no_shadow = serde_json::json!({"text": "A", "shadow_color": 0});
|
||||
let text = to_text(&no_shadow, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
|
||||
let component = serde_json::json!({
|
||||
"text": "<",
|
||||
"extra": [
|
||||
{"object": "player", "player": {"name": "elyapluggin"}, "hat": false,
|
||||
"fallback": "[head]"},
|
||||
{"text": " >", "color": "gold"}
|
||||
]
|
||||
});
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
assert_eq!(text.plain(), "<[head] >");
|
||||
assert!(matches!(
|
||||
&text.spans[1].style.inline_object,
|
||||
Some(InlineObject::Player { hat: false, .. })
|
||||
));
|
||||
assert!(text.spans[2].style.inline_object.is_none());
|
||||
assert_eq!(text.component(), component);
|
||||
let sprite = serde_json::json!({"object": "atlas", "sprite": "minecraft:item/porkchop"});
|
||||
let text = to_text(&sprite, &mut |_| Text::empty());
|
||||
assert!(matches!(
|
||||
&text.spans[0].style.inline_object,
|
||||
Some(InlineObject::Atlas { sprite, .. }) if sprite == "minecraft:item/porkchop"
|
||||
));
|
||||
// ObjectContents.visit without a fallback: the sprite's defaultFallback.
|
||||
assert_eq!(text.plain(), "[item/porkchop]");
|
||||
let head = serde_json::json!({"object": "player", "player": {"name": "Steve"}});
|
||||
assert_eq!(
|
||||
to_text(&head, &mut |_| Text::empty()).plain(),
|
||||
"[Steve head]"
|
||||
);
|
||||
let gui = serde_json::json!({"atlas": "gui", "sprite": "hud/heart/full"});
|
||||
assert_eq!(
|
||||
to_text(&gui, &mut |_| Text::empty()).plain(),
|
||||
"[hud/heart/full@gui]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snbt_and_json_components() {
|
||||
let input = "{text:'Hi ',color:red,extra:[{text:\"there\",bold:1b}]} rest";
|
||||
|
||||
@@ -60,6 +60,12 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
"assets/horizon/lang/pl_pl.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/pl_pl.json")
|
||||
}
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -266,6 +272,21 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
// Every bundled string carries both languages: a key added to one file
|
||||
// and forgotten in the other shows as a key name on screen.
|
||||
for key in en.map().keys() {
|
||||
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
let pl = Language::load(&[&empty], "pl_pl");
|
||||
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
|
||||
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
|
||||
let uk = Language::load(&[&empty], "uk_ua");
|
||||
assert_eq!(
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -55,6 +55,12 @@ particle_types! {
|
||||
Rain = "rain",
|
||||
Bubble = "bubble",
|
||||
SulfurBubbles = "sulfur_bubbles",
|
||||
NoxiousGas = "noxious_gas",
|
||||
NoxiousGasCloud = "noxious_gas_cloud",
|
||||
Geyser = "geyser",
|
||||
GeyserBase = "geyser_base",
|
||||
GeyserPoof = "geyser_poof",
|
||||
GeyserPlume = "geyser_plume",
|
||||
BubblePop = "bubble_pop",
|
||||
BubbleColumnUp = "bubble_column_up",
|
||||
CurrentDown = "current_down",
|
||||
@@ -149,6 +155,10 @@ impl ParticleType {
|
||||
matches!(
|
||||
self,
|
||||
Self::BlockMarker
|
||||
| Self::Geyser
|
||||
| Self::GeyserBase
|
||||
| Self::GeyserPoof
|
||||
| Self::GeyserPlume
|
||||
| Self::DamageIndicator
|
||||
| Self::ExplosionEmitter
|
||||
| Self::Explosion
|
||||
@@ -191,6 +201,8 @@ impl ParticleType {
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// NoxiousGasParticle.
|
||||
| Self::NoxiousGas
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let key = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
@@ -102,7 +103,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct Registries {
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
|
||||
/// each once.
|
||||
pub custom_completions: std::collections::BTreeSet<String>,
|
||||
/// Scoreboard objective names, team names and tracked score holders.
|
||||
pub objectives: Vec<String>,
|
||||
pub teams: Vec<String>,
|
||||
@@ -54,6 +58,9 @@ pub struct Registries {
|
||||
pub function_tags: Vec<String>,
|
||||
/// The block the source is looking at (relevant coordinates).
|
||||
pub target_block: Option<[i32; 3]>,
|
||||
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
|
||||
/// entity under the crosshair (`Entity.getStringUUID`), if any.
|
||||
pub selected_entities: Vec<String>,
|
||||
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
|
||||
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
|
||||
pub target_point: Option<[String; 3]>,
|
||||
@@ -84,7 +91,55 @@ pub struct RegistryIds {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
const EMPTY: &[String] = &[];
|
||||
/// ClientboundCustomChatCompletionsPacket.Action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CustomCompletionAction {
|
||||
Add,
|
||||
Remove,
|
||||
Set,
|
||||
}
|
||||
impl CustomCompletionAction {
|
||||
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
|
||||
/// so an unknown id is `ADD`.
|
||||
pub fn from_id(id: i32) -> Self {
|
||||
match id {
|
||||
1 => Self::Remove,
|
||||
2 => Self::Set,
|
||||
_ => Self::Add,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
|
||||
/// removes or replaces the extra chat completions.
|
||||
pub fn modify_custom_completions(
|
||||
set: &mut std::collections::BTreeSet<String>,
|
||||
action: CustomCompletionAction,
|
||||
entries: &[String],
|
||||
) {
|
||||
match action {
|
||||
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
|
||||
CustomCompletionAction::Remove => {
|
||||
for e in entries {
|
||||
set.remove(e);
|
||||
}
|
||||
}
|
||||
CustomCompletionAction::Set => {
|
||||
set.clear();
|
||||
set.extend(entries.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Registries {
|
||||
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
|
||||
/// command) completes, the online players' names with the server's custom
|
||||
/// completions, each once.
|
||||
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
|
||||
self.players
|
||||
.iter()
|
||||
.chain(&self.custom_completions)
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
/// Fill what the game did not build itself from vanilla's exported
|
||||
/// registries (`command-registries.json`, written by
|
||||
/// `VanillaCommandParseOracle registries`): built-in registries such as
|
||||
|
||||
@@ -112,6 +112,7 @@ pub fn style_from_json(v: &Value) -> Style {
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
s.bold = flag("bold");
|
||||
s.italic = flag("italic");
|
||||
@@ -1563,3 +1564,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
|
||||
/// name gets the `<id>.item` message, with the item's name as a third
|
||||
/// argument.
|
||||
pub fn death_message_args(
|
||||
key: &str,
|
||||
victim: &Text,
|
||||
cause: &Text,
|
||||
item: Option<&Text>,
|
||||
) -> (String, Vec<Text>) {
|
||||
match item {
|
||||
Some(item) => (
|
||||
format!("{key}.item"),
|
||||
vec![victim.clone(), cause.clone(), item.clone()],
|
||||
),
|
||||
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod death_message_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
|
||||
let victim = Text::literal("Steve");
|
||||
let cause = Text::literal("Zombie");
|
||||
// DamageSource.getLocalizedDeathMessage: the item variant, with the
|
||||
// item's name as the third argument.
|
||||
let (key, args) = death_message_args(
|
||||
"death.attack.arrow",
|
||||
&victim,
|
||||
&cause,
|
||||
Some(&Text::literal("My Sword")),
|
||||
);
|
||||
assert_eq!(key, "death.attack.arrow.item");
|
||||
assert_eq!(args.len(), 3);
|
||||
assert_eq!(args[0].plain(), "Steve");
|
||||
assert_eq!(args[1].plain(), "Zombie");
|
||||
assert_eq!(args[2].plain(), "My Sword");
|
||||
// Without a custom name the message keeps its two arguments.
|
||||
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
|
||||
assert_eq!(key, "death.attack.arrow");
|
||||
assert_eq!(args.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,34 @@ pub enum ClickEvent {
|
||||
OpenFile(String),
|
||||
}
|
||||
|
||||
/// A non-character `minecraft:object` embedded in a text component.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum InlineObject {
|
||||
Player {
|
||||
profile: Value,
|
||||
hat: bool,
|
||||
fallback: String,
|
||||
},
|
||||
Atlas {
|
||||
atlas: String,
|
||||
sprite: String,
|
||||
fallback: String,
|
||||
},
|
||||
}
|
||||
impl InlineObject {
|
||||
pub fn fallback(&self) -> &str {
|
||||
match self {
|
||||
Self::Player { fallback, .. } | Self::Atlas { fallback, .. } => fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text style; `None` fields inherit from the enclosing style.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Style {
|
||||
pub color: Option<u32>,
|
||||
/// Text component `shadow_color` as packed ARGB. Zero suppresses shadows.
|
||||
pub shadow_color: Option<u32>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub underlined: Option<bool>,
|
||||
@@ -72,11 +96,15 @@ pub struct Style {
|
||||
pub hover: Option<Box<Text>>,
|
||||
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
|
||||
pub hover_item: Option<Box<Value>>,
|
||||
/// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object.
|
||||
pub hover_entity: Option<Box<Value>>,
|
||||
/// `Style.insertion`: the text a Shift-click puts in the chat box.
|
||||
pub insertion: Option<String>,
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
/// `minecraft:alt`; the default font when unset.
|
||||
pub font: Option<String>,
|
||||
/// Content metadata for a single U+FFFC object span; never inherited.
|
||||
pub inline_object: Option<InlineObject>,
|
||||
}
|
||||
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
|
||||
pub fn font_id(v: &Value) -> Option<String> {
|
||||
@@ -92,6 +120,7 @@ impl Style {
|
||||
/// Fill unset fields from `base` (Style.applyTo).
|
||||
pub fn inherit(&mut self, base: &Style) {
|
||||
self.color = self.color.or(base.color);
|
||||
self.shadow_color = self.shadow_color.or(base.shadow_color);
|
||||
self.bold = self.bold.or(base.bold);
|
||||
self.italic = self.italic.or(base.italic);
|
||||
self.underlined = self.underlined.or(base.underlined);
|
||||
@@ -100,11 +129,12 @@ impl Style {
|
||||
if self.click.is_none() {
|
||||
self.click.clone_from(&base.click);
|
||||
}
|
||||
if self.hover.is_none() {
|
||||
// One HoverEvent: its three kinds are inherited together, so a style
|
||||
// with its own event never picks up another kind from `base`.
|
||||
if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() {
|
||||
self.hover.clone_from(&base.hover);
|
||||
}
|
||||
if self.hover_item.is_none() {
|
||||
self.hover_item.clone_from(&base.hover_item);
|
||||
self.hover_entity.clone_from(&base.hover_entity);
|
||||
}
|
||||
if self.insertion.is_none() {
|
||||
self.insertion.clone_from(&base.insertion);
|
||||
@@ -337,7 +367,13 @@ impl Text {
|
||||
}
|
||||
/// Component.getString: the plain characters.
|
||||
pub fn plain(&self) -> String {
|
||||
self.spans.iter().map(|s| s.text.as_str()).collect()
|
||||
self.spans
|
||||
.iter()
|
||||
.map(|s| match &s.style.inline_object {
|
||||
Some(object) if s.text == "\u{fffc}" => object.fallback(),
|
||||
_ => s.text.as_str(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.spans.iter().all(|s| s.text.is_empty())
|
||||
@@ -490,8 +526,9 @@ impl Text {
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
const STYLE_KEYS: [&str; 10] = [
|
||||
const STYLE_KEYS: [&str; 11] = [
|
||||
"color",
|
||||
"shadow_color",
|
||||
"bold",
|
||||
"italic",
|
||||
"underlined",
|
||||
@@ -512,6 +549,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
.map_or_else(|| format!("#{c:06X}"), |n| n.1.to_owned());
|
||||
out.push(("color".to_owned(), name.into()));
|
||||
}
|
||||
if let Some(shadow) = style.shadow_color {
|
||||
out.push(("shadow_color".to_owned(), Value::from(shadow as i32)));
|
||||
}
|
||||
for (key, flag) in [
|
||||
("bold", style.bold),
|
||||
("italic", style.italic),
|
||||
@@ -554,6 +594,10 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
let mut event = item.clone();
|
||||
event.insert("action".into(), "show_item".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
} else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() {
|
||||
let mut event = entity.clone();
|
||||
event.insert("action".into(), "show_entity".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
}
|
||||
if let Some(insertion) = &style.insertion {
|
||||
out.push(("insertion".to_owned(), insertion.clone().into()));
|
||||
|
||||
@@ -60,6 +60,19 @@ pub fn edit_shortcut(
|
||||
})
|
||||
}
|
||||
|
||||
/// Windows reports AltGr as Ctrl plus Alt: the system presses a left Control
|
||||
/// in front of the right Alt.
|
||||
pub const ALTGR_IS_CTRL_ALT: bool = cfg!(windows);
|
||||
|
||||
/// Whether a key's text reaches the screen (`KeyboardHandler.textInput`, then
|
||||
/// `charTyped` per code point). The edit modifier's shortcuts type nothing,
|
||||
/// but `textInput` takes no modifiers: with AltGr held (Ctrl plus Alt where
|
||||
/// `altgr_is_ctrl_alt`) the layout's third-level characters are text, as `/`
|
||||
/// on ABNT2, `@`, `{` and `[` on German or the Polish letters.
|
||||
pub fn types_text(edit_modifier: bool, alt: bool, altgr_is_ctrl_alt: bool) -> bool {
|
||||
!edit_modifier || (altgr_is_ctrl_alt && alt)
|
||||
}
|
||||
|
||||
/// `StringUtil.isAllowedChatCharacter`.
|
||||
pub fn is_allowed_chat_character(c: char) -> bool {
|
||||
c != '§' && c >= ' ' && c != '\u{7f}'
|
||||
@@ -303,6 +316,22 @@ pub fn append_limited(value: &mut String, text: &str, max_chars: usize) -> bool
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn altgr_types_text_where_it_is_ctrl_alt() {
|
||||
// Plain keys and Shift type.
|
||||
assert!(types_text(false, false, true));
|
||||
assert!(types_text(false, false, false));
|
||||
// Ctrl (Cmd) shortcuts do not.
|
||||
assert!(!types_text(true, false, true));
|
||||
assert!(!types_text(true, false, false));
|
||||
// AltGr on Windows is Ctrl+Alt: ABNT2 `/`, German `@` type.
|
||||
assert!(types_text(true, true, true));
|
||||
// Where AltGr is its own modifier, Ctrl+Alt is a shortcut.
|
||||
assert!(!types_text(true, true, false));
|
||||
// Alt alone (macOS Option, Linux AltGr) types.
|
||||
assert!(types_text(false, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_need_the_edit_modifier_alone() {
|
||||
let c = Some('c');
|
||||
|
||||
@@ -13,6 +13,12 @@ pub enum Button {
|
||||
}
|
||||
|
||||
pub trait InteractionWorld {
|
||||
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
|
||||
/// every click of the tick reads, even after one of them changed the
|
||||
/// block it names (a creative break, then a use that replaces it).
|
||||
fn pick_hit_result(&mut self);
|
||||
/// The tick's clicks are done: later picks follow the crosshair again.
|
||||
fn release_hit_result(&mut self);
|
||||
fn using_item(&self) -> bool;
|
||||
fn release_using(&mut self);
|
||||
fn start_attack(&mut self) -> bool;
|
||||
@@ -48,6 +54,7 @@ impl InteractionInput {
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
world.pick_hit_result();
|
||||
let mut instant = false;
|
||||
if world.using_item() {
|
||||
if !use_down {
|
||||
@@ -70,6 +77,7 @@ impl InteractionInput {
|
||||
world.start_use();
|
||||
}
|
||||
world.continue_attack(!instant && attack_down);
|
||||
world.release_hit_result();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +94,8 @@ mod tests {
|
||||
events: Vec<&'static str>,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
@@ -183,4 +193,67 @@ mod tests {
|
||||
input.tick(&mut world, false, false);
|
||||
assert_eq!(world.events, ["release", "continue"]);
|
||||
}
|
||||
|
||||
/// A floating block in front of the crosshair, nothing behind it, and a
|
||||
/// block item in the hand of a creative player.
|
||||
#[derive(Default)]
|
||||
struct Column {
|
||||
block: bool,
|
||||
hit: Option<Option<()>>,
|
||||
placed: bool,
|
||||
}
|
||||
impl Column {
|
||||
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
|
||||
fn target(&self) -> Option<()> {
|
||||
self.hit.unwrap_or(self.block.then_some(()))
|
||||
}
|
||||
}
|
||||
impl InteractionWorld for Column {
|
||||
fn pick_hit_result(&mut self) {
|
||||
self.hit = Some(self.target());
|
||||
}
|
||||
fn release_hit_result(&mut self) {
|
||||
self.hit = None;
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn release_using(&mut self) {}
|
||||
fn start_attack(&mut self) -> bool {
|
||||
// MultiPlayerGameMode.startDestroyBlock in creative: the
|
||||
// predicted break leaves air.
|
||||
if self.target().is_some() {
|
||||
self.block = false;
|
||||
}
|
||||
!self.block
|
||||
}
|
||||
fn start_use(&mut self) {
|
||||
// BlockItem.place: the clicked block, now air, can be replaced.
|
||||
if self.target().is_some() && !self.block {
|
||||
self.block = true;
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
fn pick(&mut self) {}
|
||||
fn use_delay(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
fn continue_attack(&mut self, _: bool) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_and_use_in_one_tick_share_the_hit_result() {
|
||||
// #3006: both buttons at once replace the block the crosshair is on.
|
||||
let mut input = InteractionInput::default();
|
||||
let mut world = Column {
|
||||
block: true,
|
||||
..Default::default()
|
||||
};
|
||||
input.press(Button::Attack);
|
||||
input.press(Button::Use);
|
||||
input.tick(&mut world, false, false);
|
||||
assert!(world.placed, "the use acts on the block the attack broke");
|
||||
assert!(world.block);
|
||||
assert!(world.hit.is_none(), "the next pick follows the crosshair");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Two offscreen frames presented in one window, without game resources or player data.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
|
||||
use horizon_client_render::gfx::compositor::Compositor;
|
||||
use std::sync::Arc;
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
struct Frames {
|
||||
window: Arc<Window>,
|
||||
compositor: Compositor,
|
||||
textures: [wgpu::Texture; 2],
|
||||
}
|
||||
|
||||
impl Frames {
|
||||
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
|
||||
let window = Arc::new(
|
||||
event_loop.create_window(
|
||||
Window::default_attributes()
|
||||
.with_title("Compositor: red and blue views")
|
||||
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
|
||||
)?,
|
||||
);
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
|
||||
Box::new(window.clone()),
|
||||
));
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
..Default::default()
|
||||
}))?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
|
||||
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
|
||||
let size = wgpu::Extent3d {
|
||||
width: 320,
|
||||
height: 360,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Offscreen view"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&pixel.repeat(320 * 360),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(320 * 4),
|
||||
rows_per_image: Some(360),
|
||||
},
|
||||
size,
|
||||
);
|
||||
texture
|
||||
});
|
||||
let compositor = Compositor::new(
|
||||
surface,
|
||||
&adapter,
|
||||
&device,
|
||||
&queue,
|
||||
window.inner_size().into(),
|
||||
)?;
|
||||
Ok(Self {
|
||||
window,
|
||||
compositor,
|
||||
textures,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Demo {
|
||||
frames: Option<Frames>,
|
||||
error: Option<String>,
|
||||
presented: usize,
|
||||
smoke: bool,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Demo {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.frames.is_none() {
|
||||
match Frames::new(event_loop) {
|
||||
Ok(frames) => self.frames = Some(frames),
|
||||
Err(error) => {
|
||||
self.error = Some(error.to_string());
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
|
||||
let Some(f) = &mut self.frames else { return };
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
|
||||
// Uncovered pixels are black; resize smaller to exercise clipping.
|
||||
WindowEvent::RedrawRequested
|
||||
if f.compositor
|
||||
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
|
||||
{
|
||||
self.presented += 1;
|
||||
if self.smoke && self.presented == 10 {
|
||||
f.compositor.set_vsync(false);
|
||||
f.compositor.resize(0, 0);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(u32::MAX, u32::MAX);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(400, 240);
|
||||
let _ = f
|
||||
.window
|
||||
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
|
||||
}
|
||||
if self.smoke && self.presented == 20 {
|
||||
println!("compositor: 20 frames, resize, suspension and clipping passed");
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
|
||||
if let Some(f) = &self.frames {
|
||||
f.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client_render::Result<()> {
|
||||
let mut demo = Demo {
|
||||
smoke: std::env::args().any(|a| a == "--smoke"),
|
||||
..Default::default()
|
||||
};
|
||||
EventLoop::new()?.run_app(&mut demo)?;
|
||||
if let Some(error) = demo.error {
|
||||
return Err(error.into());
|
||||
}
|
||||
if demo.smoke && demo.presented < 20 {
|
||||
return Err("smoke test closed early".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -192,16 +192,30 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the item models
|
||||
/// and the sprites' layers, without the block models or the textures'
|
||||
/// pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
}
|
||||
impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
Self::load_with(root, assets, true)
|
||||
}
|
||||
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
|
||||
// A server draws no block: its models (about 24 MB baked) stay empty.
|
||||
let models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
crate::models::PreparedModels::default()
|
||||
};
|
||||
let model_library = parsed.into_item_library();
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
@@ -218,7 +232,11 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
if draws {
|
||||
sprites.finish();
|
||||
} else {
|
||||
sprites.finish_without_pixels();
|
||||
}
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
@@ -241,6 +259,7 @@ impl RenderAssets {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::items::ConsumeEffect;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
@@ -251,7 +270,8 @@ mod tests {
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let items =
|
||||
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
|
||||
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
|
||||
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
|
||||
type Stream = std::convert::Infallible;
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
@@ -164,6 +165,8 @@ pub struct Audio {
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -543,15 +546,48 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
/// Available audio output device names.
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
if output_disabled(|name| std::env::var_os(name)).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let mut names = Vec::new();
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for device in devices {
|
||||
if let Ok(name) = device.name()
|
||||
&& !names.contains(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
Self::with_device(root, "")
|
||||
}
|
||||
pub fn with_device(root: &Path, device_name: &str) -> Self {
|
||||
Self::with_output(root, true, device_name)
|
||||
}
|
||||
/// No output device: the dedicated server.
|
||||
pub fn silent(root: &Path) -> Self {
|
||||
Self::with_output(root, false)
|
||||
Self::with_output(root, false, "")
|
||||
}
|
||||
fn with_output(root: &Path, output: bool) -> Self {
|
||||
/// The output device asked for (`soundDevice`), empty for the system's default; the
|
||||
/// default plays when that one can't be opened, as in vanilla.
|
||||
pub fn device(&self) -> &str {
|
||||
&self.device_name
|
||||
}
|
||||
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
if output && registry.is_empty() {
|
||||
// The game then plays nothing on a working output (#1035, #1076).
|
||||
@@ -564,7 +600,7 @@ impl Audio {
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let opened = if output {
|
||||
Self::open(mixer.clone())
|
||||
Self::open_named(mixer.clone(), target_device)
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
@@ -581,6 +617,7 @@ impl Audio {
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
device_name: target_device.to_owned(),
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
@@ -599,13 +636,65 @@ impl Audio {
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
|
||||
Err("this build has no sound output (built without the `audio` feature)".into())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
if !target.is_empty()
|
||||
&& let Ok(devices) = host.output_devices()
|
||||
{
|
||||
for device in devices {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if name == target {
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: could not open audio device '{target}': {e}; falling back to default"
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::open(mixer)
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
|
||||
self.device_name = name.to_owned();
|
||||
return Ok(());
|
||||
}
|
||||
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
|
||||
self.device_name = name.to_owned();
|
||||
self.backend.live = true;
|
||||
self.engine.loaded = true;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
self.device_name = name.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
@@ -731,6 +820,13 @@ impl Audio {
|
||||
false,
|
||||
);
|
||||
}
|
||||
/// `SoundPreviewHandler.preview`: a category volume slider moved to
|
||||
/// `volume` on the title screen's options.
|
||||
pub fn preview(&mut self, source: usize, volume: f32) {
|
||||
let mut preview = std::mem::take(&mut self.preview);
|
||||
preview.preview(self, source, volume);
|
||||
self.preview = preview;
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
|
||||
@@ -52,20 +52,20 @@ impl AuthSession {
|
||||
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
|
||||
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
|
||||
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
|
||||
let name = field("name").ok_or("the session file has no name")?;
|
||||
let name = field("name").ok_or("the session has no name")?;
|
||||
if name.is_empty() || name.chars().count() > 16 {
|
||||
return Err("the session file's name is not a valid player name".into());
|
||||
return Err("the session's name is not a valid player name".into());
|
||||
}
|
||||
let uuid = field("uuid")
|
||||
.and_then(parse_uuid)
|
||||
.ok_or("the session file has no valid uuid")?;
|
||||
.ok_or("the session has no valid uuid")?;
|
||||
let access_token = field("access_token")
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or("the session file has no access token")?;
|
||||
.ok_or("the session has no access token")?;
|
||||
if field("user_type").is_some_and(|t| t != "msa") {
|
||||
return Err("the session file is not a Microsoft account session".into());
|
||||
return Err("the session is not a Microsoft account session".into());
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
use crate::Result;
|
||||
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GrassModifier {
|
||||
@@ -102,6 +107,9 @@ pub struct BiomeInfo {
|
||||
/// The biome's own fog colour attribute; otherwise the dimension's applies.
|
||||
pub fog: Option<u32>,
|
||||
pub water_fog: u32,
|
||||
/// `BiomeGenerationSettings.features`: the placed feature ids of each
|
||||
/// `GenerationStep.Decoration` step, in order.
|
||||
pub features: Vec<Vec<String>>,
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
|
||||
@@ -130,10 +138,27 @@ impl BiomeInfo {
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
|
||||
/// file order, and keep their ids whatever a world's data packs do; a pack's
|
||||
/// new biomes follow them (`with_packs`). Those are the client's own ids:
|
||||
/// the network registry's are `registry_order`'s.
|
||||
#[derive(Clone)]
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
pub index: HashMap<String, u16>,
|
||||
info_noise: SimplexNoise,
|
||||
colormaps: Arc<Colormaps>,
|
||||
/// The built-in biomes, the first of `list` unless packs replaced them.
|
||||
builtin: Arc<Vec<BiomeInfo>>,
|
||||
/// The data packs' biome files (new biomes and replaced built-in ones),
|
||||
/// for the tables that read more of a biome than `BiomeInfo` keeps.
|
||||
packs: BTreeMap<String, Arc<Value>>,
|
||||
}
|
||||
/// The grass, foliage and dry foliage colormaps.
|
||||
struct Colormaps {
|
||||
grass: Vec<u32>,
|
||||
foliage: Vec<u32>,
|
||||
dry: Vec<u32>,
|
||||
}
|
||||
fn color(v: &Value) -> Option<u32> {
|
||||
match v {
|
||||
@@ -166,11 +191,90 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
|
||||
let index = (y << 8 | x) as usize;
|
||||
map.get(index).copied().unwrap_or(fallback)
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
|
||||
/// leaves out sampled from the colormaps.
|
||||
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
|
||||
let effects = &v["effects"];
|
||||
let attributes = &v["attributes"];
|
||||
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
|
||||
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
|
||||
let modifier = match effects["grass_color_modifier"].as_str() {
|
||||
Some("dark_forest") => GrassModifier::DarkForest,
|
||||
Some("swamp") => GrassModifier::Swamp,
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
|
||||
modifier,
|
||||
foliage: color(&effects["foliage_color"])
|
||||
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
|
||||
dry_foliage: color(&effects["dry_foliage_color"])
|
||||
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
|
||||
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
|
||||
features: v["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|step| {
|
||||
step.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Identifier.compareTo`: the path first, then the namespace.
|
||||
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
fn split(id: &str) -> (&str, &str) {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
(path, ns)
|
||||
}
|
||||
split(a).cmp(&split(b))
|
||||
}
|
||||
impl Biomes {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let grass = colormap(root, "grass")?;
|
||||
let foliage = colormap(root, "foliage")?;
|
||||
let dry = colormap(root, "dry_foliage")?;
|
||||
let maps = Colormaps {
|
||||
grass: colormap(root, "grass")?,
|
||||
foliage: colormap(root, "foliage")?,
|
||||
dry: colormap(root, "dry_foliage")?,
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
|
||||
.filter_map(|e| e.ok())
|
||||
@@ -186,57 +290,7 @@ impl Biomes {
|
||||
.ok_or("bad biome")?
|
||||
);
|
||||
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
let effects = &v["effects"];
|
||||
let attributes = &v["attributes"];
|
||||
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
|
||||
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
|
||||
let modifier = match effects["grass_color_modifier"].as_str() {
|
||||
Some("dark_forest") => GrassModifier::DarkForest,
|
||||
Some("swamp") => GrassModifier::Swamp,
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
.unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)),
|
||||
modifier,
|
||||
foliage: color(&effects["foliage_color"])
|
||||
.unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)),
|
||||
dry_foliage: color(&effects["dry_foliage_color"])
|
||||
.unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)),
|
||||
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"])
|
||||
.unwrap_or(0x050533),
|
||||
name,
|
||||
});
|
||||
list.push(BiomeInfo::from_json(name, &v, &maps));
|
||||
}
|
||||
let index = list
|
||||
.iter()
|
||||
@@ -244,11 +298,65 @@ impl Biomes {
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
builtin: Arc::new(list.clone()),
|
||||
list,
|
||||
index,
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
})
|
||||
}
|
||||
/// The built-in biomes with a world's data packs' biome files (id →
|
||||
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
|
||||
/// for a built-in biome replaces it, keeping its id, and new biomes join
|
||||
/// after the built-in ones, in registry order. Starts from the built-in
|
||||
/// table whatever packs `self` has.
|
||||
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
|
||||
let mut out = self.builtin_only();
|
||||
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
|
||||
files.sort_by(|a, b| identifier_order(a.0, b.0));
|
||||
for (name, json) in files {
|
||||
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
|
||||
match out.index.get(name) {
|
||||
Some(&id) => out.list[usize::from(id)] = info,
|
||||
None if out.list.len() < usize::from(u16::MAX) => {
|
||||
out.index.insert(name.to_owned(), out.list.len() as u16);
|
||||
out.list.push(info);
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The built-in biomes alone: this table without what packs gave it.
|
||||
pub fn builtin_only(&self) -> Self {
|
||||
let mut out = self.clone();
|
||||
if self.packs.is_empty() {
|
||||
return out;
|
||||
}
|
||||
out.list = self.builtin.to_vec();
|
||||
let builtin = out.list.len();
|
||||
out.index.retain(|_, id| usize::from(*id) < builtin);
|
||||
out.packs.clear();
|
||||
out
|
||||
}
|
||||
/// The data pack file a world's biome `name` came from, if a pack added
|
||||
/// or replaced it.
|
||||
pub fn pack_file(&self, name: &str) -> Option<&Value> {
|
||||
self.packs.get(name).map(|v| &**v)
|
||||
}
|
||||
/// Whether data packs added or replaced any biome.
|
||||
pub fn has_packs(&self) -> bool {
|
||||
!self.packs.is_empty()
|
||||
}
|
||||
/// The biome names in the network registry's order: `RegistryDataLoader`
|
||||
/// sorts a registry's files by `Identifier` (path, then namespace).
|
||||
pub fn registry_order(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
|
||||
names.sort_by(|a, b| identifier_order(a, b));
|
||||
names
|
||||
}
|
||||
pub fn id(&self, name: &str) -> u16 {
|
||||
self.index
|
||||
.get(name)
|
||||
@@ -256,6 +364,10 @@ impl Biomes {
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// Whether the game has the biome `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
@@ -340,6 +452,88 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3])
|
||||
result.unwrap_or([0.0; 3])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A built-in table of `names` (no colormaps: sampled colours fall back).
|
||||
fn builtin(names: &[&str]) -> Biomes {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let list: Vec<BiomeInfo> = names
|
||||
.iter()
|
||||
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
|
||||
.collect();
|
||||
Biomes {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_order_is_path_then_namespace() {
|
||||
use std::cmp::Ordering::*;
|
||||
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
|
||||
assert_eq!(identifier_order("z:a", "a:b"), Less);
|
||||
assert_eq!(identifier_order("b:same", "a:same"), Greater);
|
||||
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
|
||||
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
|
||||
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
|
||||
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
|
||||
let hot = json!({"effects": {"grass_color": 0x123456}});
|
||||
let files = [
|
||||
("pack:zeta", &hot),
|
||||
("minecraft:molten_peaks", &hot),
|
||||
("minecraft:river", &red),
|
||||
];
|
||||
let world = base.with_packs(files);
|
||||
// The built-in ids stay; the river takes the pack's values.
|
||||
assert_eq!(world.id("minecraft:plains"), 1);
|
||||
assert_eq!(world.id("minecraft:river"), 2);
|
||||
assert_eq!(world.get(2).water, 0xFF0000);
|
||||
assert_eq!(world.get(2).temperature, 2.0);
|
||||
// New biomes follow, in registry order.
|
||||
assert_eq!(world.id("minecraft:molten_peaks"), 3);
|
||||
assert_eq!(world.id("pack:zeta"), 4);
|
||||
assert_eq!(world.get(3).grass, 0x123456);
|
||||
assert!(world.pack_file("minecraft:river").is_some());
|
||||
assert!(world.pack_file("minecraft:plains").is_none());
|
||||
// The network registry sorts every name by path, then namespace.
|
||||
assert_eq!(
|
||||
world.registry_order(),
|
||||
[
|
||||
"minecraft:meadow",
|
||||
"minecraft:molten_peaks",
|
||||
"minecraft:plains",
|
||||
"minecraft:river",
|
||||
"pack:zeta"
|
||||
]
|
||||
);
|
||||
// Another world starts again from the built-in biomes.
|
||||
let other = world.with_packs([]);
|
||||
assert_eq!(other.list.len(), 3);
|
||||
assert!(!other.contains("minecraft:molten_peaks"));
|
||||
assert_eq!(other.get(2).water, 0x3F76E4);
|
||||
assert!(!other.has_packs());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blend_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -64,6 +64,10 @@ pub struct BlockEntities {
|
||||
/// Positions whose block entity `take` took and `put` has not brought
|
||||
/// back.
|
||||
out: FxHashSet<Pos>,
|
||||
/// Positions a loaded chunk gave a new block entity over an old one: at
|
||||
/// the next tick their slot stays only if their block state ticks
|
||||
/// (`updateBlockEntityTicker`: rebind, or remove without a ticker).
|
||||
replaced: FxHashSet<Pos>,
|
||||
/// `Ticking` by block state id; empty until the game builds it.
|
||||
states: Vec<Ticking>,
|
||||
}
|
||||
@@ -239,15 +243,20 @@ impl BlockEntities {
|
||||
self.added.clear();
|
||||
self.pending.clear();
|
||||
self.out.clear();
|
||||
self.replaced.clear();
|
||||
self.freed = false;
|
||||
}
|
||||
/// A loaded or generated chunk's block entities, in the order the chunk
|
||||
/// lists them; they tick in its block entity map's order
|
||||
/// (`registerAllBlockEntitiesAfterLevelLoad`).
|
||||
/// (`registerAllBlockEntitiesAfterLevelLoad`). One replacing an entity
|
||||
/// already at its position (`LevelChunk.setBlockEntity`) drops that one;
|
||||
/// its slot is decided by the block state at the next tick.
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
self.map.insert(p, e);
|
||||
if self.map.insert(p, e).is_some() {
|
||||
self.replaced.insert(p);
|
||||
}
|
||||
}
|
||||
for p in order {
|
||||
self.add(p);
|
||||
@@ -291,6 +300,11 @@ impl BlockEntities {
|
||||
self.live.insert(pos, slot);
|
||||
Arc::make_mut(&mut self.tickers).push((pos, slot));
|
||||
}
|
||||
for pos in std::mem::take(&mut self.replaced) {
|
||||
if self.live.contains_key(&pos) && self.ticking(state(pos)) != Ticking::Yes {
|
||||
self.cancel(&pos);
|
||||
}
|
||||
}
|
||||
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
|
||||
return Arc::clone(&self.tickers);
|
||||
}
|
||||
@@ -390,4 +404,33 @@ mod tests {
|
||||
let order = chunk_map_order(many.iter().copied().chain(many.iter().copied()));
|
||||
assert_eq!(order.len(), 100);
|
||||
}
|
||||
|
||||
/// A chunk loaded over a block entity already there: the new one replaces
|
||||
/// it, and keeps the old slot only while its block state ticks.
|
||||
#[test]
|
||||
fn loaded_chunk_replaces_block_entity_by_state() {
|
||||
let mut b = level();
|
||||
let mut s = HashMap::new();
|
||||
let (p, q) = ((0, 0, 0), (1, 0, 0));
|
||||
for pos in [p, q] {
|
||||
s.insert(pos, 1);
|
||||
}
|
||||
// Two chunks, so the order is the order they load in.
|
||||
b.load_chunk(vec![(p, hopper())]);
|
||||
b.load_chunk(vec![(q, hopper())]);
|
||||
assert_eq!(order(&mut b, &s), [p, q]);
|
||||
// Replaced where the state ticks: the slot stays in its place.
|
||||
b.load_chunk(vec![(p, hopper())]);
|
||||
assert_eq!(order(&mut b, &s), [p, q]);
|
||||
// Replaced where the state has no ticker: the slot is freed, and the
|
||||
// new block entity is the one in the map.
|
||||
s.insert(p, 2);
|
||||
b.load_chunk(vec![(p, BlockEntity::Chest(vec![None; 27]))]);
|
||||
assert_eq!(order(&mut b, &s), [q]);
|
||||
assert!(matches!(b.get(&p), Some(BlockEntity::Chest(items)) if items.len() == 27));
|
||||
// Ticking again later appends it, as any new ticker does.
|
||||
s.insert(p, 1);
|
||||
b.state_changed(p, 2, 1);
|
||||
assert_eq!(order(&mut b, &s), [q, p]);
|
||||
}
|
||||
}
|
||||
@@ -865,13 +865,23 @@ const VIEW_DISTANCE: f64 = 64.0;
|
||||
|
||||
impl BlockEntityRenderer {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry) -> crate::Result<Self> {
|
||||
let visibility = crate::models::load_visibility(root, registry)?;
|
||||
Self::load_with_visibility(root, registry, visibility)
|
||||
}
|
||||
/// [`BlockEntityRenderer::load`] over the face visibility the meshes use
|
||||
/// (`MeshTables::visibility`): a clone shares its shapes and their faces.
|
||||
pub fn load_with_visibility(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
) -> crate::Result<Self> {
|
||||
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
|
||||
let models = MODELS
|
||||
.iter()
|
||||
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
|
||||
.collect();
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(crate::models::load_visibility(root, registry)?));
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
@@ -1104,7 +1114,11 @@ impl BlockEntityRenderer {
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
!matches!(self.kind(state), Kind::None)
|
||||
}
|
||||
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
|
||||
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
|
||||
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
|
||||
/// BellRenderer.submit, LecternRenderer.submit (the book) and
|
||||
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
|
||||
/// destroy stage.
|
||||
pub fn render_breaking(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
@@ -1113,7 +1127,16 @@ impl BlockEntityRenderer {
|
||||
stage: [f32; 2],
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
|
||||
if !matches!(
|
||||
self.kind(state),
|
||||
Kind::Banner { .. }
|
||||
| Kind::Chest { .. }
|
||||
| Kind::Skull { .. }
|
||||
| Kind::Shulker { .. }
|
||||
| Kind::Bell
|
||||
| Kind::Lectern { .. }
|
||||
| Kind::CopperGolem { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
@@ -2363,6 +2386,7 @@ mod tests {
|
||||
lines: [line.into(), "two".into(), String::new(), "four".into()],
|
||||
color: 3,
|
||||
glowing,
|
||||
..Default::default()
|
||||
};
|
||||
let first_text = crate::signs::SignData {
|
||||
front: text("one", false),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
@@ -35,6 +36,22 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
|
||||
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
|
||||
format!("item.minecraft.{prefix}.effect.{suffix}")
|
||||
}
|
||||
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
|
||||
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
|
||||
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
|
||||
let potion = stack.potion().and_then(self::potion);
|
||||
let custom = stack.custom_potion_effects();
|
||||
potion
|
||||
.map_or(&[][..], |d| d.effects)
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(
|
||||
custom
|
||||
.iter()
|
||||
.filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
|
||||
pub fn tooltip(
|
||||
potion: Option<&str>,
|
||||
@@ -50,20 +67,30 @@ pub fn tooltip(
|
||||
Category::Neutral,
|
||||
)];
|
||||
}
|
||||
def.effects
|
||||
.iter()
|
||||
effect_lines(def.effects.iter().copied(), scale, translate)
|
||||
}
|
||||
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
|
||||
/// scaled duration, for (effect id, duration, amplifier) entries.
|
||||
pub fn effect_lines<'a>(
|
||||
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
|
||||
scale: f32,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, Category)> {
|
||||
effects
|
||||
.into_iter()
|
||||
.map(|(e, duration, amp)| {
|
||||
let e = e.trim_start_matches("minecraft:");
|
||||
let mut line =
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
|
||||
if *amp > 0 {
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_str(
|
||||
&translate(&format!("potion.potency.{amp}"))
|
||||
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
|
||||
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
|
||||
);
|
||||
}
|
||||
let instant = effect(e).is_some_and(|d| d.instant);
|
||||
let scaled = (*duration as f32 * scale) as i32;
|
||||
let scaled = (duration as f32 * scale) as i32;
|
||||
if !instant && scaled > 20 {
|
||||
line = format!("{line} ({})", format_duration(scaled));
|
||||
}
|
||||
@@ -132,20 +159,41 @@ pub struct Brewing {
|
||||
pub inputs: HashSet<String>,
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items) -> Self {
|
||||
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut recipes = Vec::new();
|
||||
for p in files {
|
||||
let Ok(bytes) = fs::read(&p) else { continue };
|
||||
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let bytes = fs::read(e.path()).ok()?;
|
||||
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
|
||||
Some((
|
||||
format!(
|
||||
"minecraft:brewing/{}",
|
||||
e.file_name().to_string_lossy().strip_suffix(".json")?
|
||||
),
|
||||
v,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// The world's packs' brewing recipes over the game's own: the top
|
||||
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
if !id.starts_with("minecraft:brewing/") {
|
||||
continue;
|
||||
}
|
||||
let Ok(v) = serde_json::from_slice::<Value>(PackOverlay::top(files)) else {
|
||||
continue;
|
||||
};
|
||||
match sources.iter_mut().find(|(n, _)| n == id) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => sources.push((id.to_owned(), v)),
|
||||
}
|
||||
}
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
if v["type"] != "minecraft:brewing" {
|
||||
continue;
|
||||
}
|
||||
@@ -159,10 +207,7 @@ impl Brewing {
|
||||
output = output.with_potion(potion);
|
||||
}
|
||||
recipes.push(BrewingRecipe {
|
||||
id: format!(
|
||||
"minecraft:brewing/{}",
|
||||
p.file_stem().and_then(|s| s.to_str()).unwrap_or("")
|
||||
),
|
||||
id,
|
||||
input: PotionIngredient::parse(&v["input"], items),
|
||||
reagent: PotionIngredient::parse(&v["reagent"], items),
|
||||
output,
|
||||
|
||||
@@ -21,6 +21,12 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
/// A player message's signature (MessageSignature), which names it to a
|
||||
/// server's deletion.
|
||||
pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
/// How long a message shows before a deletion replaces it
|
||||
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
|
||||
const DELETION_DELAY: u64 = 60;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -165,6 +171,8 @@ struct Message {
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
/// `GuiMessage.signature`: a signed player message's, for deletion.
|
||||
signature: Option<Box<Signature>>,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -205,6 +213,9 @@ pub struct Chat {
|
||||
pub received: u64,
|
||||
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
|
||||
abilities: ChatAbilities,
|
||||
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
|
||||
/// too new to replace, with the tick from which they may be.
|
||||
deletions: Vec<(Box<Signature>, u64)>,
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
@@ -227,6 +238,38 @@ impl Chat {
|
||||
) {
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
}
|
||||
/// `add` of a player message with its signature, which a server's
|
||||
/// deletion names it by (`GuiMessage.signature`).
|
||||
pub fn add_signed(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
|
||||
}
|
||||
fn add_signed_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator: None,
|
||||
source: ChatSource::Player,
|
||||
signature: signature.map(Box::new),
|
||||
},
|
||||
focused,
|
||||
);
|
||||
}
|
||||
fn add_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
@@ -236,20 +279,28 @@ impl Chat {
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
if !self.abilities.shows(source) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
signature: None,
|
||||
},
|
||||
focused,
|
||||
);
|
||||
}
|
||||
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
|
||||
if !self.abilities.shows(message.source) {
|
||||
return;
|
||||
}
|
||||
// ChatComponent.logChatMessage: every message shown is in the log.
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
|
||||
let plain = message.content.plain();
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {plain}"));
|
||||
if self.log {
|
||||
println!("[CHAT] {}", content.plain());
|
||||
println!("[CHAT] {plain}");
|
||||
}
|
||||
let message = Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
};
|
||||
self.add_lines(font, &message, focused);
|
||||
self.messages.insert(0, message);
|
||||
self.messages.truncate(100);
|
||||
@@ -319,8 +370,66 @@ impl Chat {
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.lines.clear();
|
||||
self.deletions.clear();
|
||||
self.scroll = 0;
|
||||
}
|
||||
/// `ChatComponent.deleteMessage`: the server deleted a message. One
|
||||
/// shown for 60 ticks becomes the grey "deleted" marker now; a newer
|
||||
/// one waits until then (`messageDeletionQueue`).
|
||||
pub fn delete_message(&mut self, atlas: &HudAtlas, signature: &Signature, ticks: u64) {
|
||||
self.delete_message_with(&atlas.font, signature, ticks);
|
||||
}
|
||||
fn delete_message_with(&mut self, font: &crate::font::Font, signature: &Signature, ticks: u64) {
|
||||
if let Some(delayed) = self.delete_or_delay(font, signature, ticks) {
|
||||
self.deletions.push((Box::new(*signature), delayed));
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.tick` (`processMessageDeletionQueue`): the delayed
|
||||
/// deletions whose message has shown long enough.
|
||||
pub fn tick(&mut self, atlas: &HudAtlas, ticks: u64) {
|
||||
self.process_deletions(&atlas.font, ticks);
|
||||
}
|
||||
fn process_deletions(&mut self, font: &crate::font::Font, ticks: u64) {
|
||||
let mut deletions = std::mem::take(&mut self.deletions);
|
||||
deletions.retain(|(signature, after)| {
|
||||
ticks < *after || self.delete_or_delay(font, signature, ticks).is_some()
|
||||
});
|
||||
self.deletions = deletions;
|
||||
}
|
||||
/// `ChatComponent.deleteMessageOrDelay`: the newest message with this
|
||||
/// signature, replaced by `createDeletedMarker` once 60 ticks have
|
||||
/// passed since it was added; otherwise the tick to retry from.
|
||||
fn delete_or_delay(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
signature: &Signature,
|
||||
ticks: u64,
|
||||
) -> Option<u64> {
|
||||
let message = self
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|m| m.signature.as_deref() == Some(signature))?;
|
||||
let deletable = message.added + DELETION_DELAY;
|
||||
if ticks < deletable {
|
||||
return Some(deletable);
|
||||
}
|
||||
*message = Message {
|
||||
added: message.added,
|
||||
content: Self::deleted_marker(),
|
||||
indicator: Some(SYSTEM_INDICATOR),
|
||||
source: ChatSource::Server,
|
||||
signature: None,
|
||||
};
|
||||
// refreshTrimmedMessages.
|
||||
self.rewrap(font);
|
||||
None
|
||||
}
|
||||
/// `ChatComponent.DELETED_CHAT_MESSAGE`: grey, italic.
|
||||
fn deleted_marker() -> Text {
|
||||
Text::translate("chat.deleted_marker", &[])
|
||||
.with_color(text::GRAY)
|
||||
.italic()
|
||||
}
|
||||
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
|
||||
/// start the chat's history (`ChatComponent`'s constructor).
|
||||
pub fn load_history(&mut self, game_folder: &std::path::Path) {
|
||||
@@ -653,6 +762,42 @@ pub struct Commands<'a> {
|
||||
pub registries: &'a Registries,
|
||||
}
|
||||
|
||||
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
|
||||
/// parse and where they are drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SuggestLayout {
|
||||
/// `commandsOnly`: the input is a command without a leading `/`.
|
||||
pub commands_only: bool,
|
||||
/// `onlyShowIfCursorPastError`: completions from the command's start,
|
||||
/// not only past the `/`.
|
||||
pub only_past_error: bool,
|
||||
/// `suggestionLineLimit`.
|
||||
pub line_limit: usize,
|
||||
/// `anchorToBottom`: the list and usage over a box at the bottom, else
|
||||
/// under one at the top (y 72).
|
||||
pub anchor_to_bottom: bool,
|
||||
/// `fillColor`: behind the list and the usage lines.
|
||||
pub fill: i32,
|
||||
}
|
||||
impl SuggestLayout {
|
||||
/// ChatScreen.init's `CommandSuggestions`.
|
||||
pub const CHAT: Self = Self {
|
||||
commands_only: false,
|
||||
only_past_error: false,
|
||||
line_limit: 10,
|
||||
anchor_to_bottom: true,
|
||||
fill: FILL,
|
||||
};
|
||||
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
|
||||
pub const COMMAND_BLOCK: Self = Self {
|
||||
commands_only: true,
|
||||
only_past_error: true,
|
||||
line_limit: 7,
|
||||
anchor_to_bottom: false,
|
||||
fill: i32::MIN,
|
||||
};
|
||||
}
|
||||
|
||||
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
|
||||
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
|
||||
/// the suggestion list, Tab completion, history).
|
||||
@@ -680,6 +825,7 @@ pub struct ChatScreen {
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
pub layout: SuggestLayout,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -750,6 +896,43 @@ impl ChatScreen {
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
layout: SuggestLayout::CHAT,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
|
||||
/// focused, holding `command`, with suggestions from the start.
|
||||
pub fn command_block(
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
command: &str,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
|
||||
input.max_length = 32500;
|
||||
input.focused = true;
|
||||
input.set_value(m, command);
|
||||
let mut s = Self {
|
||||
input,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
history_pos: 0,
|
||||
parse: None,
|
||||
pending: None,
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
allow_suggestions: true,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities: ChatAbilities::new(ChatVisibility::Full),
|
||||
layout: SuggestLayout::COMMAND_BLOCK,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -768,7 +951,11 @@ impl ChatScreen {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -814,11 +1001,18 @@ impl ChatScreen {
|
||||
self.usage.clear();
|
||||
let cursor = self.input.cursor;
|
||||
let cursor_b = byte_at(&command, cursor);
|
||||
if command.starts_with('/') {
|
||||
let slash = command.starts_with('/');
|
||||
if self.layout.commands_only || slash {
|
||||
let start = usize::from(slash);
|
||||
if self.parse.is_none() {
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
|
||||
}
|
||||
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let from = if self.layout.only_past_error {
|
||||
start
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let parse = self.parse.as_ref().expect("parsed above");
|
||||
let (range, list, ask) =
|
||||
c.dispatcher
|
||||
@@ -844,7 +1038,9 @@ impl ChatScreen {
|
||||
} else if !command.chars().all(char::is_whitespace) {
|
||||
let partial = &command[..cursor_b];
|
||||
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
|
||||
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the players
|
||||
// and the server's custom chat completions.
|
||||
builder.suggest_matching(c.registries.custom_tab_suggestions());
|
||||
let (range, list) = merge_suggestions(partial, builder.out);
|
||||
self.awaiting = None;
|
||||
self.pending = Some((range.0, list));
|
||||
@@ -1002,7 +1198,11 @@ impl ChatScreen {
|
||||
0,
|
||||
self.input.screen_x(m, 0) + self.input.inner_width() - width,
|
||||
);
|
||||
let y = self.height - 12;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
self.height - 12
|
||||
} else {
|
||||
72
|
||||
};
|
||||
// sortSuggestions: those starting with the word at the cursor first.
|
||||
let partial = &value[..byte_at(&value, self.input.cursor)];
|
||||
let word = partial[last_word_index(partial)..].to_lowercase();
|
||||
@@ -1011,9 +1211,16 @@ impl ChatScreen {
|
||||
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
|
||||
});
|
||||
first.extend(rest);
|
||||
let n = first.len().min(10) as i32;
|
||||
// SuggestionsList: beside a bordered box, or over an unbordered one.
|
||||
let n = first.len().min(self.layout.line_limit) as i32;
|
||||
let bordered = i32::from(self.input.bordered);
|
||||
let top = if self.layout.anchor_to_bottom {
|
||||
y - 3 - n * 12
|
||||
} else {
|
||||
y - bordered
|
||||
};
|
||||
let mut l = SuggestionList {
|
||||
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
|
||||
rect: [x - (1 - bordered), top, width + 1, n * 12],
|
||||
original: value,
|
||||
list: first,
|
||||
offset: 0,
|
||||
@@ -1042,11 +1249,12 @@ impl ChatScreen {
|
||||
}
|
||||
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
|
||||
self.select(l, l.current as isize + dir);
|
||||
let max = l.list.len().saturating_sub(10);
|
||||
let limit = self.layout.line_limit;
|
||||
let max = l.list.len().saturating_sub(limit);
|
||||
if l.current < l.offset {
|
||||
l.offset = l.current.min(max);
|
||||
} else if l.current > l.offset + 9 {
|
||||
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
|
||||
} else if l.current >= l.offset + limit {
|
||||
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
|
||||
}
|
||||
}
|
||||
/// `SuggestionsList.useSuggestion`.
|
||||
@@ -1074,7 +1282,57 @@ impl ChatScreen {
|
||||
ctrl: bool,
|
||||
) -> ChatAction {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
// CommandSuggestions.keyPressed.
|
||||
if self.suggestions_key(m, c, key, shift) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
self.field_key(m, c, key, shift, ctrl);
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
|
||||
/// following an edit: whether the box took the key.
|
||||
pub fn field_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
ctrl: bool,
|
||||
) -> bool {
|
||||
match self.input.key(m, key, shift, ctrl) {
|
||||
Some(changed) => {
|
||||
if changed {
|
||||
self.edited(m, c);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
|
||||
/// and Tab to show it. True when the suggestions took the key.
|
||||
pub fn suggestions_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
let handled = match key {
|
||||
K::ArrowUp | K::ArrowDown => {
|
||||
@@ -1091,37 +1349,18 @@ impl ChatScreen {
|
||||
}
|
||||
K::Escape => {
|
||||
self.input.suggestion = None;
|
||||
return ChatAction::None;
|
||||
return true;
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.suggestions = Some(l);
|
||||
if handled {
|
||||
return ChatAction::None;
|
||||
}
|
||||
handled
|
||||
} else if key == K::Tab {
|
||||
self.show_suggestions(m);
|
||||
return ChatAction::None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
if self.input.key(m, key, shift, ctrl) == Some(true) {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// `ChatScreen.moveInHistory`.
|
||||
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
|
||||
@@ -1175,7 +1414,7 @@ impl ChatScreen {
|
||||
if !l.contains(mouse.0, mouse.1) {
|
||||
return false;
|
||||
}
|
||||
let max = l.list.len().saturating_sub(10) as i32;
|
||||
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
@@ -1246,7 +1485,9 @@ impl ChatScreen {
|
||||
) -> Vec<Draw> {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let mut out = Vec::new();
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
if self.layout.anchor_to_bottom {
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
}
|
||||
let format = |s: &str, o: usize| self.format(s, o);
|
||||
self.input.draw(&mut out, m, blink_ms, Some(&format));
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
@@ -1254,14 +1495,18 @@ impl ChatScreen {
|
||||
self.suggestions = Some(l);
|
||||
} else {
|
||||
for (i, line) in self.usage.iter().enumerate() {
|
||||
let y = h - 27 - 12 * i as i32;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
h - 27 - 12 * i as i32
|
||||
} else {
|
||||
72 + 12 * i as i32
|
||||
};
|
||||
d::fill(
|
||||
&mut out,
|
||||
self.usage_position - 1,
|
||||
y,
|
||||
self.usage_position + self.usage_width + 1,
|
||||
y + 12,
|
||||
FILL,
|
||||
self.layout.fill,
|
||||
);
|
||||
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
|
||||
}
|
||||
@@ -1275,7 +1520,7 @@ impl ChatScreen {
|
||||
l: &mut SuggestionList,
|
||||
mouse: (i32, i32),
|
||||
) {
|
||||
let limit = l.list.len().min(10);
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
@@ -1284,8 +1529,8 @@ impl ChatScreen {
|
||||
l.last_mouse = mouse;
|
||||
}
|
||||
if has_prev || has_next {
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
|
||||
if has_prev {
|
||||
for x in (0..rw).step_by(2) {
|
||||
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
|
||||
@@ -1300,7 +1545,7 @@ impl ChatScreen {
|
||||
let mut hovered = false;
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, FILL);
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
|
||||
if moved {
|
||||
self.select(l, (i + l.offset) as isize);
|
||||
|
||||
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
assert_eq!(screen.value(), "/coords");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completes_the_servers_custom_completions_with_the_players() {
|
||||
// CommandSuggestions.updateCommandInfo completes chat from
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
|
||||
// and what ClientboundCustomChatCompletionsPacket added.
|
||||
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let mut registries = Registries {
|
||||
players: vec!["Steve".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
|
||||
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
|
||||
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
|
||||
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
|
||||
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
|
||||
assert_eq!(
|
||||
registries
|
||||
.custom_tab_suggestions()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
["#trade", "Notch", "Steve", "Zuri"]
|
||||
);
|
||||
let completed = |registries: &Registries, line: &str| {
|
||||
let dispatcher = crate::command::vanilla::game_dispatcher();
|
||||
let c = Commands {
|
||||
dispatcher,
|
||||
registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
// Chat shows no list by itself: Tab shows it, Tab again uses it.
|
||||
for _ in 0..2 {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
}
|
||||
screen.value().to_owned()
|
||||
};
|
||||
assert_eq!(completed(®istries, "hi no"), "hi Notch");
|
||||
assert_eq!(completed(®istries, "sell #tr"), "sell #trade");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi al");
|
||||
// SET replaces them all; the players stay.
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
|
||||
assert_eq!(completed(®istries, "hi no"), "hi no");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi Alex");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
|
||||
@@ -467,3 +519,62 @@ fn a_guests_line_follows_the_player_message_permission() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60
|
||||
/// ticks from when it was added, then becomes the grey italic
|
||||
/// `chat.deleted_marker` line with the system bar; the other lines stay.
|
||||
#[test]
|
||||
fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
let secret = [7; 256];
|
||||
chat.add_signed_message(
|
||||
&font,
|
||||
Text::literal("<Steve> secret"),
|
||||
Some(secret),
|
||||
100,
|
||||
false,
|
||||
);
|
||||
chat.add_signed_message(
|
||||
&font,
|
||||
Text::literal("<Alex> hi"),
|
||||
Some([8; 256]),
|
||||
110,
|
||||
false,
|
||||
);
|
||||
let lines =
|
||||
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
|
||||
let marker = Chat::deleted_marker().plain();
|
||||
chat.delete_message_with(&font, &secret, 130);
|
||||
assert_eq!(
|
||||
lines(&chat),
|
||||
["<Alex> hi", "<Steve> secret"],
|
||||
"too new: delayed"
|
||||
);
|
||||
chat.process_deletions(&font, 159);
|
||||
assert_eq!(lines(&chat), ["<Alex> hi", "<Steve> secret"]);
|
||||
chat.process_deletions(&font, 160);
|
||||
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
|
||||
assert!(chat.deletions.is_empty());
|
||||
let line = &chat.lines[1];
|
||||
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
|
||||
assert_eq!(line.added, 100, "the marker keeps the message's time");
|
||||
assert!(
|
||||
line.content
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
|
||||
);
|
||||
assert_eq!(
|
||||
chat.messages[1].content.translation_key(),
|
||||
Some("chat.deleted_marker")
|
||||
);
|
||||
// A message shown 60 ticks already is replaced at once; an unknown or
|
||||
// already deleted signature changes nothing.
|
||||
chat.delete_message_with(&font, &[8; 256], 170);
|
||||
assert_eq!(lines(&chat), [marker.clone(), marker.clone()]);
|
||||
chat.delete_message_with(&font, &secret, 200);
|
||||
chat.delete_message_with(&font, &[9; 256], 200);
|
||||
assert_eq!(lines(&chat), [marker.clone(), marker]);
|
||||
assert!(chat.deletions.is_empty());
|
||||
}
|
||||
@@ -8,10 +8,208 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
|
||||
/// The vanilla Overworld's column, which every dimension's chunks hold for
|
||||
/// now (`LevelShape::column`).
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
|
||||
/// A level's build range and skylight, from its dimension type (`min_y`,
|
||||
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
|
||||
/// `SerializableChunkData.write` read them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShape {
|
||||
pub sky_light: bool,
|
||||
pub min_y: i32,
|
||||
pub height: i32,
|
||||
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
|
||||
pub logical_height: i32,
|
||||
}
|
||||
impl LevelShape {
|
||||
pub const OVERWORLD: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: MIN_Y,
|
||||
height: HEIGHT,
|
||||
logical_height: HEIGHT,
|
||||
};
|
||||
pub const NETHER: Self = Self {
|
||||
sky_light: false,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
logical_height: 128,
|
||||
};
|
||||
pub const END: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
logical_height: 256,
|
||||
};
|
||||
/// A dimension type's shape, checked as its codec checks it.
|
||||
pub fn new(
|
||||
min_y: i32,
|
||||
height: i32,
|
||||
logical_height: i32,
|
||||
sky_light: bool,
|
||||
) -> Result<Self, String> {
|
||||
ColumnLayout::new(min_y, height)?;
|
||||
if !(0..=height).contains(&logical_height) {
|
||||
return Err(format!(
|
||||
"logical_height {logical_height} is outside 0..={height}"
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
sky_light,
|
||||
min_y,
|
||||
height,
|
||||
logical_height,
|
||||
})
|
||||
}
|
||||
/// One above the highest block (`getMaxY() + 1`).
|
||||
pub fn max_y(self) -> i32 {
|
||||
self.min_y + self.height
|
||||
}
|
||||
pub fn min_section(self) -> i32 {
|
||||
self.min_y >> 4
|
||||
}
|
||||
pub fn section_count(self) -> usize {
|
||||
(self.height >> 4) as usize
|
||||
}
|
||||
/// Whether section `y` (a section coordinate) is inside the level.
|
||||
pub fn has_section(self, y: i32) -> bool {
|
||||
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
|
||||
}
|
||||
/// The sections a chunk of this level holds: the level's, widened to the
|
||||
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
|
||||
/// the 24 sections they have always had.
|
||||
pub fn column(self) -> ColumnLayout {
|
||||
ColumnLayout::of_level(self.min_y, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes of a world's three levels, from its dimension types.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShapes {
|
||||
pub overworld: LevelShape,
|
||||
pub nether: LevelShape,
|
||||
pub end: LevelShape,
|
||||
}
|
||||
impl LevelShapes {
|
||||
pub const VANILLA: Self = Self {
|
||||
overworld: LevelShape::OVERWORLD,
|
||||
nether: LevelShape::NETHER,
|
||||
end: LevelShape::END,
|
||||
};
|
||||
/// The shape of the level `id` (`minecraft:overworld`,
|
||||
/// `minecraft:the_nether`, `minecraft:the_end`); others get the
|
||||
/// Overworld's.
|
||||
pub fn get(&self, id: &str) -> LevelShape {
|
||||
match id {
|
||||
"minecraft:the_nether" => self.nether,
|
||||
"minecraft:the_end" => self.end,
|
||||
_ => self.overworld,
|
||||
}
|
||||
}
|
||||
pub fn get_mut(&mut self, id: &str) -> &mut LevelShape {
|
||||
match id {
|
||||
"minecraft:the_nether" => &mut self.nether,
|
||||
"minecraft:the_end" => &mut self.end,
|
||||
_ => &mut self.overworld,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for LevelShapes {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
}
|
||||
}
|
||||
|
||||
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
|
||||
/// (`LevelShape::column`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColumnLayout {
|
||||
min_y: i32,
|
||||
sections: usize,
|
||||
}
|
||||
impl ColumnLayout {
|
||||
pub const VANILLA: Self = Self {
|
||||
min_y: MIN_Y,
|
||||
sections: SECTIONS,
|
||||
};
|
||||
/// The column of a level from `min_y`, `height` high
|
||||
/// (`LevelShape::column`).
|
||||
pub fn of_level(min_y: i32, height: i32) -> Self {
|
||||
let low = min_y.min(MIN_Y);
|
||||
let high = (min_y + height).max(MIN_Y + HEIGHT);
|
||||
Self {
|
||||
min_y: low,
|
||||
sections: ((high - low) / 16) as usize,
|
||||
}
|
||||
}
|
||||
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
|
||||
pub const LOWEST_Y: i32 = -2032;
|
||||
pub const HIGHEST_Y: i32 = 2031;
|
||||
/// A dimension type's `min_y` and `height`, as its codec accepts them:
|
||||
/// multiples of 16, at least 16 high, within `LOWEST_Y..=HIGHEST_Y`.
|
||||
pub fn new(min_y: i32, height: i32) -> Result<Self, String> {
|
||||
if min_y % 16 != 0 || height % 16 != 0 {
|
||||
return Err(format!(
|
||||
"min_y {min_y} and height {height} must be multiples of 16"
|
||||
));
|
||||
}
|
||||
if height < 16 {
|
||||
return Err(format!("height {height} is below 16"));
|
||||
}
|
||||
if min_y < Self::LOWEST_Y
|
||||
|| i64::from(min_y) + i64::from(height) > i64::from(Self::HIGHEST_Y) + 1
|
||||
{
|
||||
return Err(format!(
|
||||
"min_y {min_y} with height {height} leaves {}..={}",
|
||||
Self::LOWEST_Y,
|
||||
Self::HIGHEST_Y
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
min_y,
|
||||
sections: (height / 16) as usize,
|
||||
})
|
||||
}
|
||||
pub fn min_y(self) -> i32 {
|
||||
self.min_y
|
||||
}
|
||||
/// One above the highest block.
|
||||
pub fn max_y(self) -> i32 {
|
||||
self.min_y + self.height()
|
||||
}
|
||||
pub fn height(self) -> i32 {
|
||||
self.sections as i32 * 16
|
||||
}
|
||||
pub fn sections(self) -> usize {
|
||||
self.sections
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
pub fn section_index(self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections)
|
||||
}
|
||||
/// The minimum block Y of section `index`.
|
||||
pub fn section_y(self, index: usize) -> i32 {
|
||||
self.min_y + index as i32 * 16
|
||||
}
|
||||
}
|
||||
/// `true` for each section of `layout`: what callers that keep no
|
||||
/// per-section record (of fluids, say) pass as "every section may have it".
|
||||
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
|
||||
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
|
||||
[true; crate::section_mask::SectionMask::CAPACITY];
|
||||
&ALL[..layout.sections()]
|
||||
}
|
||||
impl Default for ColumnLayout {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
}
|
||||
}
|
||||
|
||||
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
|
||||
/// global palette).
|
||||
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
|
||||
@@ -45,6 +243,47 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
|
||||
pub fn set_block_states(states: usize) {
|
||||
let bits = horizon_core::paletted::ceil_log2(states).max(16);
|
||||
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
|
||||
forget_shared();
|
||||
}
|
||||
|
||||
/// One allocation per kind of uniform section (a single block, a single
|
||||
/// biome and one light value), shared by every column that has it, keyed by
|
||||
/// (block, biome, light). A tall column is mostly such sections: air with
|
||||
/// full sky light above the terrain. Edits copy them (`Arc::make_mut`).
|
||||
type SharedSections = std::collections::HashMap<(u32, u32, u8), Arc<Section>>;
|
||||
static SHARED: std::sync::Mutex<Option<SharedSections>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Replaces the uniform sections of `sections` with the shared ones.
|
||||
pub fn share_uniform(sections: &mut [Arc<Section>]) {
|
||||
let key = |s: &Section| -> Option<(u32, u32, u8)> {
|
||||
let Light::Uniform(light) = s.light else {
|
||||
return None;
|
||||
};
|
||||
if s.blocks.bits() != 0 || s.biomes.bits() != 0 {
|
||||
return None;
|
||||
}
|
||||
Some((s.blocks.uniform()?, s.biomes.uniform()?, light))
|
||||
};
|
||||
let Ok(mut shared) = SHARED.lock() else {
|
||||
return;
|
||||
};
|
||||
let shared = shared.get_or_insert_with(Default::default);
|
||||
for section in sections {
|
||||
let Some(k) = key(section) else {
|
||||
continue;
|
||||
};
|
||||
let one = shared.entry(k).or_insert_with(|| section.clone());
|
||||
if !Arc::ptr_eq(one, section) {
|
||||
*section = one.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the shared sections, when the palette changes.
|
||||
fn forget_shared() {
|
||||
if let Ok(mut shared) = SHARED.lock() {
|
||||
*shared = None;
|
||||
}
|
||||
}
|
||||
/// Packed light per block: sky light in the high nibble, block light in the low one.
|
||||
#[derive(Clone)]
|
||||
@@ -138,9 +377,11 @@ impl Section {
|
||||
pub struct Chunk {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
/// The minimum block Y of `sections[0]` (the layout's when it was made).
|
||||
min_y: i32,
|
||||
pub sections: Vec<Arc<Section>>,
|
||||
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
|
||||
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
|
||||
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
|
||||
/// `set` that can move a column's surface drops it.
|
||||
surface: OnceLock<Box<[i16; 256]>>,
|
||||
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
|
||||
@@ -169,13 +410,19 @@ pub struct Chunk {
|
||||
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
|
||||
}
|
||||
impl Chunk {
|
||||
/// An all-air chunk in the current `layout`.
|
||||
pub fn empty(x: i32, z: i32) -> Self {
|
||||
Self::with_layout(x, z, ColumnLayout::VANILLA)
|
||||
}
|
||||
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
|
||||
let mut one = [Arc::new(Section::default())];
|
||||
share_uniform(&mut one);
|
||||
let [empty] = one;
|
||||
Self {
|
||||
x,
|
||||
z,
|
||||
sections: (0..SECTIONS)
|
||||
.map(|_| Arc::new(Section::default()))
|
||||
.collect(),
|
||||
min_y: layout.min_y(),
|
||||
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
|
||||
surface: OnceLock::new(),
|
||||
motion_blocking: OnceLock::new(),
|
||||
lit: false,
|
||||
@@ -190,13 +437,36 @@ impl Chunk {
|
||||
entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The minimum block Y of the column.
|
||||
#[inline]
|
||||
pub fn min_y(&self) -> i32 {
|
||||
self.min_y
|
||||
}
|
||||
/// The column the chunk holds.
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
ColumnLayout {
|
||||
min_y: self.min_y,
|
||||
sections: self.sections.len(),
|
||||
}
|
||||
}
|
||||
/// One above the column's highest block.
|
||||
#[inline]
|
||||
pub fn max_y(&self) -> i32 {
|
||||
self.min_y + self.sections.len() as i32 * 16
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
#[inline]
|
||||
pub fn section_index(&self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections.len())
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, x: i32, y: i32, z: i32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
return 0;
|
||||
match self.section_index(y) {
|
||||
Some(s) => self.sections[s].get(index(x, y, z)),
|
||||
None => 0,
|
||||
}
|
||||
self.sections[s as usize].get(index(x, y, z))
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
@@ -211,16 +481,15 @@ impl Chunk {
|
||||
.rev()
|
||||
.filter(|(_, section)| !section.is_uniform(skip))
|
||||
.find_map(|(s, _)| {
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
(base..base + 16).rev().find(|&y| matches(y))
|
||||
})
|
||||
}
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
let Some(s) = self.section_index(y) else {
|
||||
return v;
|
||||
}
|
||||
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
|
||||
};
|
||||
let previous = Arc::make_mut(&mut self.sections[s]).set(index(x, y, z), v);
|
||||
// `Heightmap.update`: a write two or more below a map's height can't
|
||||
// move it. Any other change rebuilds that map on the next read.
|
||||
if previous != v {
|
||||
@@ -236,7 +505,8 @@ impl Chunk {
|
||||
previous
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
/// Sections must change only through `set` once this has been read.
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
|
||||
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
|
||||
@@ -244,7 +514,8 @@ impl Chunk {
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
|
||||
/// the chunk): one above the highest state where `blocks` (a motion
|
||||
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
|
||||
/// blocking block or a fluid), or the column's minimum Y. Kept as
|
||||
/// `world_surface` is.
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
|
||||
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
|
||||
i32::from(heights[column(x, z)])
|
||||
@@ -252,7 +523,7 @@ impl Chunk {
|
||||
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
|
||||
/// those of one state that doesn't count, until every column is found.
|
||||
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
|
||||
let mut surface = Box::new([MIN_Y as i16; 256]);
|
||||
let mut surface = Box::new([self.min_y as i16; 256]);
|
||||
let mut found = [false; 256];
|
||||
let mut left = 256;
|
||||
let mut values = [0u32; 4096];
|
||||
@@ -264,7 +535,7 @@ impl Chunk {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut values);
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
for i in 0..256 {
|
||||
if found[i] {
|
||||
continue;
|
||||
@@ -280,11 +551,11 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
let s = (y - self.min_y) >> 4;
|
||||
if s < 0 {
|
||||
return 0;
|
||||
}
|
||||
if s >= SECTIONS as i32 {
|
||||
if s >= self.sections.len() as i32 {
|
||||
return 0xF0;
|
||||
}
|
||||
self.sections[s as usize].light(index(x, y, z))
|
||||
@@ -297,7 +568,7 @@ impl Chunk {
|
||||
/// the quart y clamped to the column).
|
||||
#[inline]
|
||||
pub fn biome(&self, x: i32, y: i32, z: i32) -> u16 {
|
||||
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
|
||||
let qy = ((y - self.min_y) >> 2).clamp(0, self.sections.len() as i32 * 4 - 1);
|
||||
self.sections[(qy >> 2) as usize]
|
||||
.biomes
|
||||
.get(((qy & 3) << 4 | ((z & 15) >> 2) << 2 | (x & 15) >> 2) as usize) as u16
|
||||
@@ -504,4 +775,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_follow_the_dimension_type_codec() {
|
||||
assert_eq!(ColumnLayout::new(-64, 384), Ok(ColumnLayout::VANILLA));
|
||||
assert_eq!(ColumnLayout::default(), ColumnLayout::VANILLA);
|
||||
// To The Max's Overworld: the highest top vanilla allows.
|
||||
let tall = ColumnLayout::new(-64, 2096).unwrap();
|
||||
assert_eq!((tall.sections(), tall.max_y()), (131, 2032));
|
||||
assert!(ColumnLayout::new(-2032, 4064).is_ok());
|
||||
for (min_y, height) in [(-64, 2112), (-2048, 64), (-60, 384), (0, 250), (0, 0)] {
|
||||
assert!(
|
||||
ColumnLayout::new(min_y, height).is_err(),
|
||||
"{min_y} {height}"
|
||||
);
|
||||
}
|
||||
assert_eq!(tall.section_index(-65), None);
|
||||
assert_eq!(tall.section_index(-64), Some(0));
|
||||
assert_eq!(tall.section_index(2031), Some(130));
|
||||
assert_eq!(tall.section_index(2032), None);
|
||||
assert_eq!(tall.section_y(130), 2016);
|
||||
assert_eq!(
|
||||
LevelShape::new(-64, 384, 384, true),
|
||||
Ok(LevelShape::OVERWORLD)
|
||||
);
|
||||
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
|
||||
assert!(LevelShape::new(0, 256, 257, true).is_err());
|
||||
assert!(LevelShape::new(-60, 384, 384, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_levels_keep_the_overworld_column() {
|
||||
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
|
||||
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
|
||||
}
|
||||
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
|
||||
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
|
||||
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
|
||||
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
|
||||
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
|
||||
assert!(!LevelShape::NETHER.has_section(-1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tall_column_stores_its_whole_height() {
|
||||
let layout = ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
assert_eq!(
|
||||
(chunk.min_y(), chunk.max_y(), chunk.sections.len()),
|
||||
(-64, 2032, 131)
|
||||
);
|
||||
let stone = 1;
|
||||
assert_eq!(chunk.set(5, 2031, 6, stone), 0);
|
||||
assert_eq!(chunk.set(5, 2032, 6, stone), stone, "above the column");
|
||||
assert_eq!(chunk.get(5, 2031, 6), stone);
|
||||
assert_eq!(chunk.get(5, 2032, 6), 0);
|
||||
assert_eq!(chunk.world_surface(5, 6, |v| v == 0), 2032);
|
||||
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), -64);
|
||||
assert_eq!(chunk.highest(0, |y| y < 1000), None);
|
||||
chunk.set(5, 999, 6, stone);
|
||||
assert_eq!(chunk.highest(0, |y| y < 1000), Some(999));
|
||||
assert_eq!(chunk.light(0, 2100, 0), 0xF0);
|
||||
assert_eq!(chunk.light(0, -65, 0), 0);
|
||||
chunk.set_biome_cell(0, 130 * 4 + 3, 0, 9);
|
||||
assert_eq!(chunk.biome(0, 5000, 0), 9);
|
||||
}
|
||||
}
|
||||
@@ -74,8 +74,45 @@ pub enum BlockEntity {
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
|
||||
PotentSulfur(PotentSulfur),
|
||||
}
|
||||
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
|
||||
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
|
||||
/// last eruption block event, which the client's plume counts from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PotentSulfur {
|
||||
pub waiting_countdown: i32,
|
||||
pub eruption_tick: i64,
|
||||
}
|
||||
impl Default for PotentSulfur {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
waiting_countdown: -1,
|
||||
eruption_tick: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Brushable {
|
||||
pub loot_table: Option<horizon_world::nbt::Tag>,
|
||||
pub loot_seed: Option<horizon_world::nbt::Tag>,
|
||||
pub item: Option<ItemStack>,
|
||||
/// Direction's legacy network id; not saved to disk by vanilla.
|
||||
pub hit_direction: Option<i32>,
|
||||
}
|
||||
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Shelf {
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub align_to_bottom: bool,
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -209,6 +246,8 @@ pub struct SignText {
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
/// The `click_event` in each line's component style, as saved.
|
||||
pub click: [Option<Tag>; 4],
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -217,6 +256,8 @@ pub struct SignData {
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
/// `allow_op_features`: the sign's click events may run commands.
|
||||
pub allow_op_features: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -82,6 +82,9 @@ impl crate::light::LightWorld for ChunkMap {
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
|
||||
self.columns.set_section_light(cx, s, cz, light);
|
||||
}
|
||||
fn layout(&self) -> crate::chunk::ColumnLayout {
|
||||
self.columns.layout()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -230,6 +230,14 @@ impl BlockEntity {
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:suspicious_sand"
|
||||
| "minecraft:suspicious_gravel"
|
||||
| "minecraft:brushable_block"
|
||||
) {
|
||||
return Some(Self::Brushable(Box::default()));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
return Some(Self::CreakingHeart(Box::default()));
|
||||
}
|
||||
@@ -292,9 +300,15 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:shelf" || name.ends_with("_shelf") {
|
||||
return Some(Self::Shelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:potent_sulfur" {
|
||||
return Some(Self::PotentSulfur(PotentSulfur::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -334,8 +348,11 @@ impl BlockEntity {
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -354,13 +371,16 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -376,9 +396,12 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::Shelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -402,9 +425,12 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::Shelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -486,6 +512,24 @@ pub fn create_experience(
|
||||
xp
|
||||
}
|
||||
|
||||
/// The `MenuType` the block entity of a one-block storage container opens
|
||||
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
|
||||
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
|
||||
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
|
||||
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
|
||||
pub fn block_menu_type(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
|
||||
"minecraft:generic_9x3"
|
||||
}
|
||||
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Written to `crash-reports/crash-<yyyy-MM-dd_HH.mm.ss>-client.txt` in the game folder when
|
||||
//! the main thread panics or `main` gets an error back. It leaves the machine only scrubbed, and
|
||||
//! only if the player allows crash reports (`telemetry`).
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
@@ -14,6 +15,51 @@ use chrono::{DateTime, Local};
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
static REPORTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
thread_local! {
|
||||
/// How many `contain` calls this thread is inside.
|
||||
static CONTAINED: Cell<u32> = const { Cell::new(0) };
|
||||
/// The message of the last panic `contain` caught on this thread, from the hook.
|
||||
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
|
||||
/// it end the game. For work that is not the game's own and whose failure the caller recovers
|
||||
/// from, such as handing a shader pack's programs to the GPU driver's compiler: the panic goes
|
||||
/// to the log with its stack, and no crash report is made for it.
|
||||
pub fn contain<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
CONTAINED.with(|c| c.set(c.get() + 1));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
CONTAINED.with(|c| c.set(c.get() - 1));
|
||||
result.map_err(|payload| {
|
||||
CAUGHT
|
||||
.with(|c| c.borrow_mut().take())
|
||||
.unwrap_or_else(|| payload_text(&*payload))
|
||||
})
|
||||
}
|
||||
|
||||
/// For the hook: a panic inside `contain` is logged with its stack and kept for `contain` to
|
||||
/// return. False for any other panic.
|
||||
fn contained_panic(info: &std::panic::PanicHookInfo) -> bool {
|
||||
if CONTAINED.with(Cell::get) == 0 {
|
||||
return false;
|
||||
}
|
||||
let message = panic_message(info);
|
||||
let stack = stack_lines(
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
);
|
||||
log::error!(
|
||||
"Recovered from an error: {message}\n{}",
|
||||
stack
|
||||
.lines()
|
||||
.map(|l| format!("\tat {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
CAUGHT.with(|c| *c.borrow_mut() = Some(message));
|
||||
true
|
||||
}
|
||||
|
||||
/// Sets one line of the report's details (replacing the last value under `key`).
|
||||
pub fn set_detail(key: &'static str, value: impl Into<String>) {
|
||||
let mut details = DETAILS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -250,8 +296,9 @@ fn memory() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
|
||||
pub fn system_details() -> Vec<(String, String)> {
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran. A dedicated
|
||||
/// server renders nothing: it leaves out the render settings it read from options.txt.
|
||||
pub fn system_details(side: &str) -> Vec<(String, String)> {
|
||||
let mut out = vec![
|
||||
("Horizon Version".to_owned(), version()),
|
||||
(
|
||||
@@ -262,18 +309,30 @@ pub fn system_details() -> Vec<(String, String)> {
|
||||
("CPU".to_owned(), cpu()),
|
||||
("Memory".to_owned(), memory()),
|
||||
];
|
||||
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
|
||||
out.extend(
|
||||
details()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| side != "server" || *k != "Render Settings")
|
||||
.map(|(k, v)| (k.to_owned(), v)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn thread_name() -> String {
|
||||
/// The thread's name as vanilla's reports say it: the main thread is the client's "Render thread"
|
||||
/// and the dedicated server's "Server thread".
|
||||
fn thread_name(side: &str) -> String {
|
||||
let current = std::thread::current();
|
||||
match current.name() {
|
||||
Some("main") | None if side == "server" => "Server thread".into(),
|
||||
Some("main") | None => "Render thread".into(),
|
||||
Some(name) => name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
|
||||
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
|
||||
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
@@ -297,7 +356,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
match report.save(home) {
|
||||
Ok(path) => {
|
||||
eprintln!("Horizon: crash report saved to {}", path.display());
|
||||
eprintln!("{SAVED_LINE}{}", path.display());
|
||||
Some(path)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -308,13 +367,35 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let payload = info.payload();
|
||||
let text = payload
|
||||
/// The report for a hung server tick, written by the watchdog thread
|
||||
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
|
||||
/// the report carries none.
|
||||
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into());
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into())
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let text = payload_text(info.payload());
|
||||
match info.location() {
|
||||
Some(at) => format!("panicked at {at}:\n{text}"),
|
||||
None => format!("panicked:\n{text}"),
|
||||
@@ -408,19 +489,22 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic.
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if contained_panic(info) {
|
||||
return;
|
||||
}
|
||||
default(info);
|
||||
let main = std::thread::current().name() == Some("main");
|
||||
if main {
|
||||
@@ -428,7 +512,7 @@ pub fn install(home: PathBuf, side: &'static str) {
|
||||
} else {
|
||||
log::error!(
|
||||
"Caught previously unhandled exception in thread {}: {}",
|
||||
thread_name(),
|
||||
thread_name(side),
|
||||
panic_message(info)
|
||||
);
|
||||
}
|
||||
@@ -477,9 +561,9 @@ pub fn report_error(
|
||||
description: DebugCrash::MESSAGE.into(),
|
||||
error: DebugCrash::MESSAGE.into(),
|
||||
stack: debug.stack.clone(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
);
|
||||
@@ -497,9 +581,9 @@ pub fn report_error(
|
||||
description: "Fatal error".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
@@ -597,6 +681,26 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dedicated_servers_report_names_its_server_thread_and_no_render_settings() {
|
||||
let unnamed = |side: &'static str| {
|
||||
std::thread::spawn(move || thread_name(side))
|
||||
.join()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(unnamed("server"), "Server thread");
|
||||
assert_eq!(unnamed("client"), "Render thread");
|
||||
set_detail("Render Settings", "render distance 12");
|
||||
let has = |side| {
|
||||
system_details(side)
|
||||
.iter()
|
||||
.any(|(k, _)| k == "Render Settings")
|
||||
};
|
||||
assert!(has("client"));
|
||||
assert!(!has("server"));
|
||||
clear_detail("Render Settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_start_at_the_panicking_frame() {
|
||||
let bt = format!(
|
||||
@@ -609,6 +713,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contained_panics_come_back_as_errors() {
|
||||
assert_eq!(contain(|| 7), Ok(7));
|
||||
let error = contain(|| -> u8 { panic!("a pack's program") }).unwrap_err();
|
||||
assert!(error.contains("a pack's program"), "{error}");
|
||||
// Nested, and the thread is no longer inside one after.
|
||||
let outer = contain(|| contain(|| -> u8 { panic!("inner") }));
|
||||
assert!(outer.unwrap().unwrap_err().contains("inner"));
|
||||
assert_eq!(CONTAINED.with(Cell::get), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_debug_crash_is_reported_as_vanillas() {
|
||||
let error: Box<dyn std::error::Error> = DebugCrash::new().into();
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! the parts of them commands use: `.mcfunction` functions (with macros and
|
||||
//! line continuation), function tags and predicates.
|
||||
use crate::zip::Zip;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::overrides::{self, PackFile, WorldgenOverrides},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -125,6 +128,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
packs
|
||||
}
|
||||
|
||||
// ---- Create World ------------------------------------------------------------
|
||||
|
||||
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
|
||||
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
|
||||
/// built-in one (`resources` holds its `pack.png`), which can be unselected
|
||||
/// and stays at the bottom. Horizon has no experiments, so their feature
|
||||
/// packs are not offered.
|
||||
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
|
||||
use crate::{
|
||||
pack_screen::{Compat, PackInfo, Source, load_icon},
|
||||
packs::Position,
|
||||
text::{Style, Text},
|
||||
};
|
||||
let format = horizon_core::version::DATA_PACK_FORMAT;
|
||||
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
|
||||
.into_iter()
|
||||
.map(|p| PackInfo {
|
||||
icon: p.icon_png().as_deref().and_then(load_icon),
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.raw_description,
|
||||
description_style: p.description_style,
|
||||
source: Source::World,
|
||||
compat: p.compatibility,
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
})
|
||||
.collect();
|
||||
infos.push(PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("dataPack.vanilla.name", &[]),
|
||||
description: Text::translate("dataPack.vanilla.description", &[]),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: std::fs::read(resources.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(load_icon),
|
||||
required: false,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
});
|
||||
infos
|
||||
}
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
/// What's in a folder of packs, to notice packs added or removed while the
|
||||
/// screen shows it (`PackSelectionScreen.Watcher`).
|
||||
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
|
||||
let mut stamp: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let modified = e.metadata().and_then(|m| m.modified()).ok();
|
||||
(e.file_name().to_string_lossy().into_owned(), modified)
|
||||
})
|
||||
.collect();
|
||||
stamp.sort();
|
||||
stamp
|
||||
}
|
||||
/// Create World's packs into the new world before it opens
|
||||
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
|
||||
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
|
||||
/// (lowest priority first) and disables the rest.
|
||||
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
|
||||
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
|
||||
let target = world.join("datapacks");
|
||||
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
|
||||
let mut disabled = Vec::new();
|
||||
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
|
||||
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
|
||||
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
|
||||
if !selected.contains(&pack.id) {
|
||||
disabled.push(pack.id);
|
||||
}
|
||||
}
|
||||
if !selected.iter().any(|id| id == "vanilla") {
|
||||
disabled.push("vanilla".into());
|
||||
}
|
||||
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// A file, or a folder and everything in it.
|
||||
fn copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
|
||||
if !from.is_dir() {
|
||||
return std::fs::copy(from, to).map(drop).map_err(err);
|
||||
}
|
||||
std::fs::create_dir_all(to).map_err(err)?;
|
||||
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
|
||||
copy(&e.path(), &to.join(e.file_name()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- functions ---------------------------------------------------------------
|
||||
|
||||
/// A `.mcfunction`: plain command lines and macro lines.
|
||||
@@ -312,9 +423,31 @@ pub struct DataStore {
|
||||
pub predicates: BTreeMap<String, serde_json::Value>,
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
/// Functions that failed to parse (id, message), for logging.
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
|
||||
/// keeps the sub path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
let (ns, rest) = rest.split_once('/')?;
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "loot_table" | "advancement" => kind,
|
||||
"tags" => {
|
||||
let (sub, name) = rest.rsplit_once('/')?;
|
||||
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("{kind}/{ns}:{}", rest.strip_suffix(".json")?))
|
||||
}
|
||||
/// `ns:path` for a file `data/<ns>/<kind>/<path>.<ext>`.
|
||||
fn resource_id(file: &str, kind: &str, ext: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -329,7 +462,22 @@ impl DataStore {
|
||||
pub fn load(packs: &[&DataPack]) -> Self {
|
||||
let mut s = Self::default();
|
||||
for pack in packs {
|
||||
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
|
||||
// `tags` walks the whole tags tree, so a `tags/function` file is
|
||||
// listed by both kinds: handle every file once.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
"predicate",
|
||||
"recipe",
|
||||
"tags",
|
||||
]) {
|
||||
if !seen.insert(f.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = resource_id(&f, "function", ".mcfunction")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
@@ -359,6 +507,14 @@ impl DataStore {
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.item_modifiers.insert(id, v);
|
||||
} else if !builtin
|
||||
&& let Some(id) = content_id(&f)
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
// The built-in pack's content files are the resource
|
||||
// root's own, which the tables read from disk; only the
|
||||
// world's packs come through here.
|
||||
s.files.entry(id).or_default().push(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,11 +524,21 @@ impl DataStore {
|
||||
/// included, each function once).
|
||||
pub fn tag_functions(&self, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
self.expand(id, &mut out, 0);
|
||||
let mut seen_fns = std::collections::HashSet::new();
|
||||
let mut seen_tags = std::collections::HashSet::new();
|
||||
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
|
||||
out
|
||||
}
|
||||
fn expand(&self, id: &str, out: &mut Vec<String>, depth: usize) {
|
||||
if depth > 16 {
|
||||
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
|
||||
/// once, so reference cycles end and nesting depth is not capped.
|
||||
fn expand<'a>(
|
||||
&'a self,
|
||||
id: &'a str,
|
||||
out: &mut Vec<String>,
|
||||
seen_fns: &mut std::collections::HashSet<&'a str>,
|
||||
seen_tags: &mut std::collections::HashSet<&'a str>,
|
||||
) {
|
||||
if !seen_tags.insert(id) {
|
||||
return;
|
||||
}
|
||||
let Some(entries) = self.function_tags.get(id) else {
|
||||
@@ -380,9 +546,9 @@ impl DataStore {
|
||||
};
|
||||
for (e, required) in entries {
|
||||
match e.strip_prefix('#') {
|
||||
Some(inner) => self.expand(inner, out, depth + 1),
|
||||
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
|
||||
None => {
|
||||
if (self.functions.contains_key(e) || *required) && !out.contains(e) {
|
||||
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
|
||||
out.push(e.clone());
|
||||
}
|
||||
}
|
||||
@@ -421,9 +587,136 @@ fn hash_normalize(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
|
||||
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
|
||||
/// order, then every pack it lists neither way, in name order.
|
||||
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
|
||||
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let list = |key: &str| -> Vec<String> {
|
||||
saved
|
||||
.as_ref()
|
||||
.and_then(|v| v[key].as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|s| s.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let disabled = list("disabled");
|
||||
let mut enabled: Vec<String> = list("enabled")
|
||||
.into_iter()
|
||||
.filter(|id| packs.iter().any(|p| &p.id == id))
|
||||
.collect();
|
||||
for p in packs {
|
||||
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
|
||||
enabled.push(p.id.clone());
|
||||
}
|
||||
}
|
||||
enabled
|
||||
}
|
||||
|
||||
// ---- worldgen ----------------------------------------------------------------
|
||||
|
||||
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
|
||||
/// left out: the prepared bundles are it) for the chunk generator, and what
|
||||
/// it leaves out, for the log. `biomes` says which biomes the game has: a
|
||||
/// pack's new biome is left out (the game has no colours, sounds or id for it).
|
||||
pub fn worldgen_overrides(
|
||||
packs: &[&DataPack],
|
||||
biomes: impl Fn(&str) -> bool,
|
||||
) -> (WorldgenOverrides, Vec<String>) {
|
||||
let mut out = WorldgenOverrides::default();
|
||||
let mut not_read: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&overrides::PACK_KINDS) {
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
PackFile::NotRead(kind) => {
|
||||
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
|
||||
}
|
||||
PackFile::Unreadable => warnings.push(format!("{}: {file}: unreadable", pack.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
warnings.extend(out.finish(biomes));
|
||||
for (kind, files) in not_read {
|
||||
warnings.push(format!("{kind}: {files} files the generator does not read"));
|
||||
}
|
||||
(out, warnings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join("datapacks").join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(
|
||||
pack,
|
||||
"data/minecraft/tags/worldgen/biome/is_hill.json",
|
||||
"{\"values\":[]}",
|
||||
);
|
||||
}
|
||||
write("a", "data/minecraft/worldgen/biome/new_peaks.json", "{}");
|
||||
write("a", "data/minecraft/worldgen/biome/plains.json", "{}");
|
||||
write(
|
||||
"a",
|
||||
"data/minecraft/worldgen/world_preset/normal.json",
|
||||
"{}",
|
||||
);
|
||||
write(
|
||||
"b",
|
||||
"data/minecraft/dimension_type/overworld.json",
|
||||
"{\"min_y\":-64,\"height\":2096}",
|
||||
);
|
||||
// `b` is enabled first, so `a` (enabled after it) wins.
|
||||
std::fs::write(
|
||||
dir.join("datapacks.json"),
|
||||
"{\"enabled\":[\"file/b\",\"file/a\"],\"disabled\":[\"file/off\"]}",
|
||||
)
|
||||
.unwrap();
|
||||
let packs = discover(&dir);
|
||||
let enabled = enabled_ids(&dir, &packs);
|
||||
assert_eq!(enabled, ["file/b", "file/a"]);
|
||||
let enabled: Vec<&DataPack> = enabled
|
||||
.iter()
|
||||
.map(|id| packs.iter().find(|p| &p.id == id).unwrap())
|
||||
.collect();
|
||||
let (o, warnings) = worldgen_overrides(&enabled, |b| b == "minecraft:plains");
|
||||
assert_eq!(
|
||||
o.entries("noises").collect::<Vec<_>>(),
|
||||
[("minecraft:cave", "a")]
|
||||
);
|
||||
assert_eq!(
|
||||
o.entries("biomes").collect::<Vec<_>>(),
|
||||
[("minecraft:plains", "{}")]
|
||||
);
|
||||
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
|
||||
assert_eq!(tag_packs, ["file/b", "file/a"]);
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings[0].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[1].contains("worldgen/world_preset"));
|
||||
assert_eq!(
|
||||
o.level_height("minecraft:overworld").map(|h| h.height),
|
||||
Some(2096)
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
@@ -527,6 +820,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
|
||||
let mut store = DataStore::default();
|
||||
let big: Vec<(String, bool)> = (0..50_000)
|
||||
.map(|i| (format!("t:f{}", i % 25_000), true))
|
||||
.collect();
|
||||
store.function_tags.insert("t:big".into(), big);
|
||||
let start = std::time::Instant::now();
|
||||
let got = store.tag_functions("t:big");
|
||||
assert_eq!(got.len(), 25_000);
|
||||
assert_eq!(got[0], "t:f0");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5));
|
||||
// A cycle terminates and keeps first-seen order.
|
||||
store.function_tags.insert(
|
||||
"t:a".into(),
|
||||
vec![("#t:b".into(), true), ("t:x".into(), true)],
|
||||
);
|
||||
store.function_tags.insert(
|
||||
"t:b".into(),
|
||||
vec![("#t:a".into(), true), ("t:y".into(), true)],
|
||||
);
|
||||
assert_eq!(store.tag_functions("t:a"), ["t:y", "t:x"]);
|
||||
// Nesting deeper than the old cap of 16 still resolves.
|
||||
for i in 0..40 {
|
||||
store
|
||||
.function_tags
|
||||
.insert(format!("d:{i}"), vec![(format!("#d:{}", i + 1), true)]);
|
||||
}
|
||||
store
|
||||
.function_tags
|
||||
.insert("d:40".into(), vec![("t:deep".into(), true)]);
|
||||
assert_eq!(store.tag_functions("d:0"), ["t:deep"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_packs_list_the_needed_kinds_in_walk_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-kinds-{}", std::process::id()));
|
||||
@@ -566,4 +893,51 @@ mod tests {
|
||||
assert_eq!(p.list_kinds(&kinds), wanted);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_world_installs_its_packs() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (dir, world) = (root.join("mcworld"), root.join("world"));
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let infos = screen_packs(&dir, &root);
|
||||
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
|
||||
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
|
||||
use crate::pack_screen::Compat;
|
||||
assert_eq!(
|
||||
compat,
|
||||
[
|
||||
Compat::Compatible,
|
||||
Compat::Compatible,
|
||||
Compat::TooOld,
|
||||
Compat::Compatible
|
||||
]
|
||||
);
|
||||
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
|
||||
install(&dir, &world, &selected).unwrap();
|
||||
let packs = discover(&world);
|
||||
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
saved,
|
||||
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,15 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving
|
||||
/// an infinite (-1) or zero duration alone.
|
||||
pub fn scaled_duration(duration: i32, scale: f32) -> i32 {
|
||||
if duration == -1 || duration == 0 {
|
||||
duration
|
||||
} else {
|
||||
((duration as f32 * scale).floor() as i32).max(1)
|
||||
}
|
||||
}
|
||||
/// What an omen's `applyEffectTick` decided.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum OmenTick {
|
||||
@@ -308,19 +317,29 @@ impl PlayerEffects {
|
||||
pub fn potion_effects(
|
||||
potion: Option<&str>,
|
||||
scale: f32,
|
||||
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
|
||||
let effects = potion
|
||||
.and_then(potions::potion)
|
||||
.map_or(&[][..], |d| d.effects);
|
||||
Self::contents_effects(effects, scale)
|
||||
}
|
||||
/// `PotionContents.forEachEffect`'s effects, as `potion_effects`.
|
||||
pub fn contents_effects(
|
||||
effects: &[(&'static str, i32, i32)],
|
||||
scale: f32,
|
||||
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
|
||||
let mut lasting = Vec::new();
|
||||
let mut now = Vec::new();
|
||||
if let Some(def) = potion.and_then(potions::potion) {
|
||||
for (id, duration, amp) in def.effects {
|
||||
let id = potions::effect(id).map_or("speed", |e| e.id);
|
||||
if potions::effect(id).is_some_and(|e| e.instant) {
|
||||
now.extend(instant(id, *amp, 1.0));
|
||||
} else {
|
||||
// withScaledDuration: max(floor(d * scale), 1).
|
||||
let d = ((*duration as f32) * scale).floor() as i32;
|
||||
lasting.push(EffectInstance::new(id, d.max(1), *amp));
|
||||
}
|
||||
for (id, duration, amp) in effects {
|
||||
let id = potions::effect(id).map_or("speed", |e| e.id);
|
||||
if potions::effect(id).is_some_and(|e| e.instant) {
|
||||
now.extend(instant(id, *amp, 1.0));
|
||||
} else {
|
||||
lasting.push(EffectInstance::new(
|
||||
id,
|
||||
scaled_duration(*duration, scale),
|
||||
*amp,
|
||||
));
|
||||
}
|
||||
}
|
||||
(lasting, now)
|
||||
|
||||
@@ -53,10 +53,13 @@ struct Out {
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
|
||||
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
|
||||
@location(13) @interpolate(flat) tier: u32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -126,8 +129,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay, color, light, normal);
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = overlay >> 16u;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
@@ -250,8 +254,8 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
} else if in.overlay == 36u {
|
||||
// Enchanted armour (mobs::render::ARMOR_GLINT): enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
@@ -279,6 +283,14 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
}
|
||||
|
||||
// HDR output: a light-giving particle's pixels marked in the frame's alpha the
|
||||
// way terrain.wgsl marks a light source's, drawn again after the entities with
|
||||
// only alpha written, so its colour stays as blended.
|
||||
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
|
||||
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
|
||||
@@ -389,3 +389,36 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
|
||||
let mut sky = environment.sample(day_time, None, None);
|
||||
apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
(15.0 - 15.0 * sky.sky_light_level).round() as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn overworld() -> Option<Environment> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
root.join("data/minecraft/timeline/day.json")
|
||||
.exists()
|
||||
.then(|| Environment::load(&root).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_outside_follows_time_and_weather_levels() {
|
||||
let Some(env) = overworld() else {
|
||||
return;
|
||||
};
|
||||
let dark = |time, rain, thunder| sky_darken(&env, time, rain, thunder) >= 4;
|
||||
assert!(!dark(6000, 0.0, 0.0), "clear noon");
|
||||
assert!(!dark(6000, 1.0, 0.0), "rainy noon");
|
||||
assert!(dark(6000, 1.0, 1.0), "thunderstorm at noon");
|
||||
assert!(dark(18000, 0.0, 0.0), "clear midnight");
|
||||
assert!(!dark(1000, 0.0, 0.0), "clear morning");
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,8 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now
|
||||
/// (`moved_extras`): their old settings stay here until the game has
|
||||
/// installed the mod for a player who had one on.
|
||||
/// The FPS counter and keystrokes extras are official mods now: their
|
||||
/// old settings stay here, kept as they were.
|
||||
pub fps: bool,
|
||||
pub fps_corner: Corner,
|
||||
/// Also show the minimum and 1% low.
|
||||
@@ -83,8 +82,8 @@ pub struct Extras {
|
||||
pub keystrokes_corner: Corner,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub keystrokes_cps: bool,
|
||||
/// The official mods installed for the extras they replace, by mod id
|
||||
/// (`movedToMods`): each is done once.
|
||||
/// The official mods once installed for the extras they replace, by mod
|
||||
/// id (`movedToMods`).
|
||||
pub moved: Vec<String>,
|
||||
pub minimap: bool,
|
||||
pub minimap_corner: Corner,
|
||||
@@ -97,6 +96,8 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// The player's own name tag over its model in third person.
|
||||
pub own_name_tag: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
@@ -137,6 +138,7 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
own_name_tag: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
@@ -211,6 +213,7 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
@@ -225,7 +228,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -251,6 +254,7 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.own_name_tag),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
@@ -282,7 +286,7 @@ impl Extras {
|
||||
}
|
||||
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`native_zoom`). A
|
||||
/// zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
@@ -378,6 +382,7 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -396,6 +401,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -410,6 +417,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::OwnNameTag => "Own name tag",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
@@ -426,7 +434,7 @@ impl Item {
|
||||
Some(match self {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
@@ -439,6 +447,9 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::OwnNameTag => {
|
||||
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
|
||||
}
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
@@ -471,7 +482,9 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -480,6 +493,7 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
@@ -506,6 +520,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
@@ -534,7 +549,7 @@ mod tests {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
@@ -558,6 +573,8 @@ mod tests {
|
||||
e.minimap_rotate = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.auto_reconnect = true;
|
||||
Item::OwnNameTag.cycle(&mut e, false);
|
||||
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
|
||||
@@ -311,10 +311,14 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
fog.environmental_end = 2.0;
|
||||
fog.sky_end = 2.0;
|
||||
fog.cloud_end = 2.0;
|
||||
// PowderedSnowFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else {
|
||||
(0.0, 2.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
_ if input.blindness.is_some() => {
|
||||
let (duration, infinite) = input.blindness.unwrap_or((0, true));
|
||||
|
||||
@@ -896,6 +896,9 @@ impl Font {
|
||||
std::mem::take(&mut self.cache.borrow_mut().region.pending)
|
||||
}
|
||||
pub fn advance(&self, c: char, style: &Style) -> f32 {
|
||||
if c == '\u{fffc}' && style.inline_object.is_some() {
|
||||
return 8.0;
|
||||
}
|
||||
let g = self.glyph_styled(c, style);
|
||||
g.advance + if style.is_bold() { g.offset } else { 0.0 }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -147,7 +147,7 @@ impl SpriteAnimator {
|
||||
group,
|
||||
});
|
||||
}
|
||||
let Some(pipeline) = pipeline else {
|
||||
let Some((pipeline, layout)) = pipeline else {
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
@@ -193,7 +193,6 @@ impl SpriteAnimator {
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
@@ -364,11 +363,15 @@ impl SpriteAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
|
||||
/// driver can list compute shaders among its extensions while wgpu writes
|
||||
/// the GLSL version of its core version, which has none (Mesa's
|
||||
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
|
||||
/// unless the device fails to make the pipeline: an OpenGL driver can list
|
||||
/// compute shaders among its extensions while wgpu writes the GLSL version
|
||||
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
|
||||
/// older drivers). A device lost between the scopes' pushes answers pipeline
|
||||
/// creation with a silent placeholder and reports its invalidity only when
|
||||
/// the layout is asked for — inside the scopes that error is caught, outside
|
||||
/// them it is uncaptured and panics (#3866).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => Some(pipeline),
|
||||
None => Some((pipeline, layout)),
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
|
||||
None
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Splitscreen: several players' renderers draw offscreen on one device, and the
|
||||
//! compositor copies each frame into its place on a window's surface. A window holds views but
|
||||
//! never owns them, so a view can later move to another window (another monitor, detached).
|
||||
use super::Renderer;
|
||||
use crate::Result;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
pub struct Compositor {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
present_modes: Vec<wgpu::PresentMode>,
|
||||
suspended: bool,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// A compositor for `window` on this renderer's device, presenting on the surface it gave up.
|
||||
pub fn compositor(&self, window: Arc<Window>) -> Result<Compositor> {
|
||||
// The surface this renderer set aside for its successor (`release_surface`), else a new
|
||||
// one: a window can't keep two.
|
||||
let spare = self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
let surface = match spare {
|
||||
Some(surface) => surface,
|
||||
None => self.instance.create_surface(window.clone())?,
|
||||
};
|
||||
Compositor::new(
|
||||
surface,
|
||||
&self.adapter,
|
||||
&self.device,
|
||||
&self.queue,
|
||||
window.inner_size().into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The last offscreen frame (`--offscreen`), for the compositor.
|
||||
pub fn offscreen_frame(&self) -> Option<&wgpu::Texture> {
|
||||
self.offscreen.as_ref()
|
||||
}
|
||||
|
||||
/// Splitscreen turned off: a compositor's surface, set aside for the renderer that draws on
|
||||
/// the window again (it takes it, as after `release_surface`).
|
||||
pub fn take_back(&self, compositor: Compositor) {
|
||||
*self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(compositor.surface);
|
||||
}
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Present offscreen BGRA frames on a compatible surface using their existing device.
|
||||
/// The surface must support COPY_DST and Bgra8Unorm; unsupported surfaces return an error.
|
||||
pub fn new(
|
||||
surface: wgpu::Surface<'static>,
|
||||
adapter: &wgpu::Adapter,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
) -> Result<Self> {
|
||||
let caps = surface.get_capabilities(adapter);
|
||||
// ponytail: a plain texture copy needs the offscreen frames' format; a blit pass would
|
||||
// take any surface format (Rgba8Unorm on some Linux drivers).
|
||||
if !caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
|
||||
return Err("surface does not support copying offscreen frames".into());
|
||||
}
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
if !caps.formats.contains(&format) {
|
||||
return Err(format!(
|
||||
"splitscreen needs a {format:?} surface; this one offers {:?}",
|
||||
caps.formats
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
|
||||
format,
|
||||
width: size[0].clamp(1, max),
|
||||
height: size[1].clamp(1, max),
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
surface.configure(device, &config);
|
||||
Ok(Compositor {
|
||||
surface,
|
||||
device: device.clone(),
|
||||
queue: queue.clone(),
|
||||
config,
|
||||
present_modes: caps.present_modes,
|
||||
suspended: !super::drawable_size(size[0], size[1], max),
|
||||
})
|
||||
}
|
||||
|
||||
/// Vsync on: presents wait for the display. Off: they don't (Mailbox, else Immediate).
|
||||
pub fn set_vsync(&mut self, vsync: bool) {
|
||||
let mode = [wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate]
|
||||
.into_iter()
|
||||
.find(|m| !vsync && self.present_modes.contains(m))
|
||||
.unwrap_or(wgpu::PresentMode::Fifo);
|
||||
if mode != self.config.present_mode {
|
||||
self.config.present_mode = mode;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.suspended =
|
||||
!super::drawable_size(width, height, self.device.limits().max_texture_dimension_2d);
|
||||
if !self.suspended {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy each view's frame to its top-left corner on the window and present. Frames that
|
||||
/// don't fit are clipped; parts of the window no view covers (a view before its first frame,
|
||||
/// one still at its old size after a resize) are black. Nothing when the window has no frame
|
||||
/// to give (occluded, resized, timed out). Returns true when a frame was submitted.
|
||||
pub fn present(&mut self, views: &[(&wgpu::Texture, [u32; 2])]) -> bool {
|
||||
if self.suspended {
|
||||
return false;
|
||||
}
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(f)
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
|
||||
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return false;
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Splitscreen compositor"),
|
||||
});
|
||||
let target = frame.texture.create_view(&Default::default());
|
||||
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Splitscreen compositor clear"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
}));
|
||||
for &(texture, [x, y]) in views {
|
||||
let [width, height] = clipped_size(
|
||||
[texture.width(), texture.height()],
|
||||
[x, y],
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
if width == 0 || height == 0 {
|
||||
continue;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
origin: wgpu::Origin3d { x, y, z: 0 },
|
||||
..frame.texture.as_image_copy()
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.queue.submit([encoder.finish()]);
|
||||
self.queue.present(frame);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies never reach past the surface, including while a resized view still has its old frame.
|
||||
fn clipped_size(view: [u32; 2], at: [u32; 2], surface: [u32; 2]) -> [u32; 2] {
|
||||
[
|
||||
view[0].min(surface[0].saturating_sub(at[0])),
|
||||
view[1].min(surface[1].saturating_sub(at[1])),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frames_are_clipped_to_the_window_during_resize() {
|
||||
assert_eq!(clipped_size([640, 480], [0, 0], [1280, 720]), [640, 480]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [1000, 600], [1280, 720]),
|
||||
[280, 120]
|
||||
);
|
||||
assert_eq!(clipped_size([640, 480], [1280, 720], [1280, 720]), [0, 0]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [u32::MAX, 0], [1280, 720]),
|
||||
[0, 480]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ pub struct Flat {
|
||||
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
|
||||
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
|
||||
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
|
||||
const MIN_SECTIONS: usize = 1600;
|
||||
pub const MIN_SECTIONS: usize = 1600;
|
||||
|
||||
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
|
||||
/// shaders can read storage buffers large enough for an index page.
|
||||
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
Ok("1") => return supported(adapter, device),
|
||||
_ => {}
|
||||
}
|
||||
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
|
||||
let info = adapter.get_info();
|
||||
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
|
||||
&& supported(adapter, device)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
|
||||
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
|
||||
/// and flicker.
|
||||
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Vulkan
|
||||
&& info.vendor == 0x8086
|
||||
&& (info.name.contains("ICL")
|
||||
|| info.name.contains("Ice Lake")
|
||||
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
|
||||
}
|
||||
|
||||
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
@@ -254,6 +267,7 @@ impl Flat {
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
page_vertices: u64,
|
||||
min_sections: usize,
|
||||
pipeline: impl Fn(
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
@@ -357,11 +371,7 @@ impl Flat {
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
shadow_calls: [0; 2],
|
||||
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
} else {
|
||||
MIN_SECTIONS
|
||||
},
|
||||
min_sections,
|
||||
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
|
||||
.max(1),
|
||||
}
|
||||
|
||||
@@ -60,19 +60,66 @@ struct Targets {
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub share: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
share: 0.2,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
share: 0.5,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 0.6,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 1.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
|
||||
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
|
||||
clear_alpha: wgpu::RenderPipeline,
|
||||
/// Scales the frame's alpha down to 1/255 where the hand drew (`mark_hand`), so HDR
|
||||
/// output keeps the hand at the world's brightness.
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
|
||||
/// so it can differ from the swapchain's in one frame.
|
||||
capture_pipeline: wgpu::RenderPipeline,
|
||||
capture_params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
|
||||
pub black: f32,
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
@@ -113,47 +160,100 @@ impl Hdr {
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: output.format(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
let pipeline_for = |format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&device.create_pipeline_layout(&Default::default())),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_clear_alpha"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let clear_alpha = alpha_pass("Horizon HDR UI mask", None);
|
||||
// Alpha times the blend constant (1/255): the hand's 1 becomes 1/255, the world's 0
|
||||
// stays 0.
|
||||
let scale_alpha = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Constant,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let hand_mark = alpha_pass(
|
||||
"Horizon HDR hand mark",
|
||||
Some(wgpu::BlendState {
|
||||
color: scale_alpha,
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 32,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
params,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params: params(),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
capture_params: params(),
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +310,9 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it.
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
@@ -220,6 +322,56 @@ impl Hdr {
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR UI mask"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.clear_alpha);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR hand mark"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.hand_mark);
|
||||
let a = 1.0 / 255.0;
|
||||
pass.set_blend_constant(wgpu::Color {
|
||||
r: a,
|
||||
g: a,
|
||||
b: a,
|
||||
a,
|
||||
});
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Writes the finished frame to the swapchain image `target`.
|
||||
@@ -233,18 +385,89 @@ impl Hdr {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
queue.write_buffer(
|
||||
&self.params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
/// the swapchain is, in a texture of its own for a screenshot (`Rgb10a2Unorm`).
|
||||
pub fn capture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
let t = self.targets.as_ref()?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon HDR screenshot"),
|
||||
size: t.frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Output::Pq.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR screenshot bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
queue.write_buffer(
|
||||
&self.capture_params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let params = [
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn draw(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
@@ -260,8 +483,8 @@ impl Hdr {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &t.group, &[]);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -271,6 +494,24 @@ mod tests {
|
||||
use super::{Output, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
for source in [
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
|
||||
wgpu::SurfaceCapabilities {
|
||||
format_capabilities: offered
|
||||
|
||||
@@ -15,10 +15,19 @@ pub struct Bolt {
|
||||
#[derive(Default)]
|
||||
pub struct Input<'a> {
|
||||
pub bolts: &'a [Bolt],
|
||||
/// Current atlas white texel; a changed rect invalidates retained vertices.
|
||||
/// Current atlas white texel ([`white_uv`]); a changed rect invalidates
|
||||
/// retained vertices.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
/// The atlas point an untextured quad (`RenderTypes.lightning` has no
|
||||
/// texture) samples: the middle of the white sprite. Its corner (`u0`, `v0`)
|
||||
/// is on the sprite's border, where the atlas's nearest sampling picks the
|
||||
/// neighbouring, transparent texels for most fragments.
|
||||
pub fn white_uv(white: &crate::hud::Rect) -> [f32; 2] {
|
||||
[(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0]
|
||||
}
|
||||
|
||||
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
|
||||
pub(super) const QUADS: u32 = 4 * 14 * 4;
|
||||
|
||||
|
||||
@@ -496,3 +496,253 @@ fn retained_gpu_draw_matches_transient_additive_draw() {
|
||||
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon/white` (4x4 texels) at (40, 12) of a 64x32 atlas.
|
||||
const ATLAS: (u32, u32) = (64, 32);
|
||||
fn white_sprite() -> crate::hud::Rect {
|
||||
let (w, h) = (ATLAS.0 as f32, ATLAS.1 as f32);
|
||||
crate::hud::Rect {
|
||||
u0: 40.0 / w,
|
||||
v0: 12.0 / h,
|
||||
u1: 44.0 / w,
|
||||
v1: 16.0 / h,
|
||||
w: 4,
|
||||
h: 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The white sprite's corner is on its border with the next atlas texels,
|
||||
/// which nearest sampling picks for most of a bolt's fragments (#3171): the
|
||||
/// point sampled is at least a texel inside the sprite.
|
||||
#[test]
|
||||
fn white_uv_is_inside_the_white_sprite() {
|
||||
let [u, v] = white_uv(&white_sprite());
|
||||
let (x, y) = (u * ATLAS.0 as f32, v * ATLAS.1 as f32);
|
||||
assert!((41.0..=43.0).contains(&x), "u at texel {x}");
|
||||
assert!((13.0..=15.0).contains(&y), "v at texel {y}");
|
||||
}
|
||||
|
||||
/// A bolt seen from 14 blocks through the atlas as the game binds it (nearest
|
||||
/// sampling, only the white sprite opaque) draws the same pixels as through a
|
||||
/// texture that is white everywhere (#3171: the sprite's corner left a few
|
||||
/// scattered pixels). Run explicitly, like the test above.
|
||||
#[test]
|
||||
#[ignore = "requires GPU; run separately with --ignored --exact"]
|
||||
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let offsets = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &offsets,
|
||||
}),
|
||||
];
|
||||
// The lightning pipeline (gfx.rs): additive, into RGBA8.
|
||||
let additive = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_lightning"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: additive,
|
||||
alpha: additive,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// The game's camera: 70 degrees, reversed depth, the bolt 14 blocks ahead.
|
||||
let globals = crate::gfx::Globals {
|
||||
view_proj: (glam::Mat4::perspective_rh(70f32.to_radians(), 1.0, 400.0, 0.05)
|
||||
* glam::Mat4::look_at_rh(glam::Vec3::ZERO, glam::Vec3::Z, glam::Vec3::Y))
|
||||
.to_cols_array_2d(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::bytes_of(&globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let texture = |(width, height): (u32, u32), data: &[u8]| {
|
||||
device
|
||||
.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
data,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let white = texture((1, 1), &[255; 4]);
|
||||
// Transparent but for the white sprite.
|
||||
let mut texels = vec![0u8; (ATLAS.0 * ATLAS.1 * 4) as usize];
|
||||
for y in 12..16 {
|
||||
for x in 40..44 {
|
||||
let i = ((y * ATLAS.0 + x) * 4) as usize;
|
||||
texels[i..i + 4].copy_from_slice(&[255; 4]);
|
||||
}
|
||||
}
|
||||
let atlas = texture(ATLAS, &texels);
|
||||
// The atlas sampler (gfx.rs): wgpu's default, nearest.
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS);
|
||||
let size = 256;
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let draw = |view: &wgpu::TextureView, uv: [f32; 2]| -> Vec<u8> {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&white),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let bolts = [Bolt {
|
||||
seed: 123,
|
||||
at: [0.0, -1.62, 14.0],
|
||||
}];
|
||||
let input = Input { bolts: &bolts, uv };
|
||||
let mut renderer = Renderer::new();
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
renderer.prepare(&device, &mut uploads, &mut encoder, &input, DVec3::ZERO);
|
||||
let color = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
{
|
||||
let target = color.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
renderer.draw(&mut pass, &input);
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(size * size * 4),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
read.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
};
|
||||
let lit = |pixels: &[u8]| pixels.chunks(4).filter(|p| p[3] != 0).count();
|
||||
let expected = draw(&white, [0.5; 2]);
|
||||
let drawn = draw(&atlas, white_uv(&white_sprite()));
|
||||
assert!(lit(&expected) > 1000, "{} pixels", lit(&expected));
|
||||
assert_eq!(
|
||||
lit(&drawn),
|
||||
lit(&expected),
|
||||
"pixels drawn through the atlas"
|
||||
);
|
||||
assert!(drawn == expected, "the bolt's colours through the atlas");
|
||||
}
|
||||
@@ -476,10 +476,12 @@ fn sampler(
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `create` and returns its validation error, if any.
|
||||
/// Runs `create` and returns its validation error, if any. A panic while it runs is an error
|
||||
/// too: naga's backends turn a module they cannot write into a panic as the driver gets the
|
||||
/// pipeline (#3525), which a pack's program must not turn into a crash.
|
||||
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let value = create();
|
||||
let value = crate::crash::contain(create);
|
||||
match pollster::block_on(scope.pop()) {
|
||||
Some(e) => {
|
||||
// The details are in the error's description or its sources.
|
||||
@@ -494,7 +496,7 @@ fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, St
|
||||
}
|
||||
Err(message)
|
||||
}
|
||||
None => Ok(value),
|
||||
None => value.map_err(|panic| format!("the shader compiler failed: {panic}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2629,6 +2631,38 @@ mod tests {
|
||||
assert_eq!(used, [1, 2, 3]);
|
||||
}
|
||||
|
||||
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
|
||||
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
|
||||
#[test]
|
||||
fn a_shader_compiler_panic_skips_the_pass() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let failed = checked(&device, || -> wgpu::Buffer {
|
||||
panic!("internal error: entered unreachable code: Expression [89] is not cached!")
|
||||
});
|
||||
let error = failed.expect_err("the panic comes back as an error");
|
||||
assert!(error.contains("Expression [89] is not cached!"), "{error}");
|
||||
let buffer = |usage| {
|
||||
checked(&device, || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 16,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
};
|
||||
// The device still makes things, and its errors still come back.
|
||||
assert!(buffer(wgpu::BufferUsages::UNIFORM).is_ok());
|
||||
assert!(buffer(wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::MAP_WRITE).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
|
||||
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
|
||||
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
|
||||
//! then drawn onto the swapchain image as a fullscreen triangle.
|
||||
|
||||
/// The texture a frame to read back is drawn into, and the pass that puts it
|
||||
/// on the swapchain afterwards.
|
||||
#[derive(Default)]
|
||||
pub struct Readback {
|
||||
texture: Option<wgpu::Texture>,
|
||||
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
|
||||
/// A frame drew into `texture` and still has to reach the swapchain.
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl Readback {
|
||||
/// The texture this frame draws into instead of the swapchain image
|
||||
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
|
||||
/// image cannot be copied from.
|
||||
pub fn target(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Texture,
|
||||
screenshot: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return None;
|
||||
}
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self
|
||||
.texture
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != surface.size() || t.format() != format)
|
||||
{
|
||||
self.texture = Some(device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon screenshot frame"),
|
||||
size: surface.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
}));
|
||||
}
|
||||
self.drawn = true;
|
||||
self.texture.clone()
|
||||
}
|
||||
|
||||
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
|
||||
/// when this frame drew straight into it.
|
||||
pub fn present(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
if !std::mem::take(&mut self.drawn) {
|
||||
return;
|
||||
}
|
||||
let Some(texture) = &self.texture else {
|
||||
return;
|
||||
};
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
|
||||
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
|
||||
}
|
||||
let target = surface.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
let source = texture.create_view(&Default::default());
|
||||
if let Some((_, blitter)) = &self.blitter {
|
||||
blitter.copy(device, encoder, &source, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn texture(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 70,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: wgpu::Color) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(color),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// The last pixel of the last row of a 70x3 frame read back by `copy_frame`.
|
||||
fn last_pixel(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
let pixels = read(device, buffer);
|
||||
let row = 70usize * 4;
|
||||
let last = row.next_multiple_of(256) * 2 + row - 4;
|
||||
pixels[last..last + 4].to_vec()
|
||||
}
|
||||
|
||||
/// The renderer's screenshot path on a surface like OpenGL's (#3510): the
|
||||
/// frame is drawn into `target`, read back with `copy_frame` and drawn on
|
||||
/// the swapchain, with no validation error (the copy used to need the
|
||||
/// swapchain's missing `COPY_SRC`), and the swapchain shows the frame.
|
||||
#[test]
|
||||
fn screenshot_of_a_surface_without_copy_src() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
// Sampled only so the test can look at what reached it.
|
||||
let surface = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let check = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
|
||||
let mut readback = Readback::default();
|
||||
let colors = [
|
||||
(1.0, [0, 0, 255, 255]),
|
||||
// A second screenshot reuses the texture.
|
||||
(0.0, [255, 0, 0, 255]),
|
||||
];
|
||||
for (r, bgra) in colors {
|
||||
let color = wgpu::Color {
|
||||
r,
|
||||
g: 0.0,
|
||||
b: 1.0 - r,
|
||||
a: 1.0,
|
||||
};
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback
|
||||
.target(&device, &surface, true)
|
||||
.unwrap_or_else(|| surface.clone());
|
||||
clear(&mut encoder, &frame, color);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
|
||||
readback.present(&device, &mut encoder, &surface);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
panic!("{error}");
|
||||
}
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
}
|
||||
assert!(!readback.drawn);
|
||||
}
|
||||
|
||||
/// A surface that can be copied from, or a frame nobody reads back,
|
||||
/// draws straight into the swapchain image.
|
||||
#[test]
|
||||
fn frames_draw_straight_to_a_readable_surface() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut readback = Readback::default();
|
||||
let readable = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
assert!(readback.target(&device, &readable, true).is_none());
|
||||
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
assert!(readback.target(&device, &surface, false).is_none());
|
||||
assert!(readback.texture.is_none() && !readback.drawn);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ impl Column {
|
||||
/// Values of type `T` per section key, in a dense list with stable keys and
|
||||
/// slots that move only when a section is removed.
|
||||
pub struct Resident<T> {
|
||||
/// The minimum Y of section index 0 (the level's column).
|
||||
min_y: i32,
|
||||
items: Vec<(SectionKey, T)>,
|
||||
slots: FxHashMap<SectionKey, u32>,
|
||||
columns: Vec<Column>,
|
||||
@@ -36,6 +38,7 @@ pub struct Resident<T> {
|
||||
impl<T> Default for Resident<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_y: crate::chunk::MIN_Y,
|
||||
items: Vec::new(),
|
||||
slots: FxHashMap::default(),
|
||||
columns: Vec::new(),
|
||||
@@ -61,6 +64,10 @@ impl<T> Resident<T> {
|
||||
pub fn get_mut(&mut self, slot: u32) -> &mut T {
|
||||
&mut self.items[slot as usize].1
|
||||
}
|
||||
/// Sections from now on count from `min_y` (a level of another column).
|
||||
pub fn set_min_y(&mut self, min_y: i32) {
|
||||
self.min_y = min_y;
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.slots.clear();
|
||||
@@ -144,10 +151,10 @@ pub struct Visible {
|
||||
}
|
||||
|
||||
/// The camera-relative origin of the section at `key`.
|
||||
fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
|
||||
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
|
||||
DVec3::new(
|
||||
f64::from(key.0 * 16),
|
||||
f64::from(crate::chunk::MIN_Y + key.1 * 16),
|
||||
f64::from(min_y + key.1 * 16),
|
||||
f64::from(key.2 * 16),
|
||||
) - camera
|
||||
}
|
||||
@@ -169,18 +176,19 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, camera, frustum, false)
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
min_y: i32,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, camera);
|
||||
let origin = origin(key, min_y, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
@@ -216,8 +224,8 @@ impl<T> Resident<T> {
|
||||
if !near && !beyond(column.x, column.z) {
|
||||
continue;
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), camera).as_vec3();
|
||||
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
@@ -228,7 +236,7 @@ impl<T> Resident<T> {
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
|
||||
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
key,
|
||||
@@ -286,7 +294,7 @@ mod tests {
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, camera, &frustum, false)
|
||||
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -329,8 +337,8 @@ mod tests {
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let min = origin((c.x, c.low, c.z), camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), camera).as_vec3();
|
||||
let min = origin((c.x, c.low, c.z), crate::chunk::MIN_Y, camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), crate::chunk::MIN_Y, camera).as_vec3();
|
||||
frustum.contains(min, top + Vec3::splat(16.0))
|
||||
})
|
||||
.count();
|
||||
|
||||
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
|
||||
|
||||
pub struct Uploads {
|
||||
belt: wgpu::util::StagingBelt,
|
||||
chunk: u64,
|
||||
}
|
||||
|
||||
impl Uploads {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
Self::with_chunk(device, CHUNK)
|
||||
}
|
||||
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
|
||||
/// a buffer of their own).
|
||||
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
|
||||
/// a buffer of its own, rounded up to a power of two).
|
||||
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
|
||||
Self {
|
||||
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
|
||||
chunk,
|
||||
}
|
||||
}
|
||||
/// A mapped staging slice of at least `size` bytes. The belt gives a
|
||||
/// write larger than its chunk a buffer of exactly that size, keeps it for
|
||||
/// good and only reuses it for writes that fit: an upload that grows a
|
||||
/// little every frame (the entity batch while mobs appear) took a new
|
||||
/// buffer each frame until the game ran out of memory (#2826). Rounded up
|
||||
/// to a power of two, the large buffers it keeps are reused, one per
|
||||
/// doubling.
|
||||
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
|
||||
let size = if size.get() > self.chunk {
|
||||
size.checked_next_power_of_two().unwrap_or(size)
|
||||
} else {
|
||||
size
|
||||
};
|
||||
self.belt.allocate(size, alignment)
|
||||
}
|
||||
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
|
||||
/// level of one array layer, padding rows to the copy alignment.
|
||||
pub fn texture(
|
||||
@@ -41,7 +58,7 @@ impl Uploads {
|
||||
};
|
||||
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
|
||||
.expect("non-zero alignment");
|
||||
let slice = self.belt.allocate(size, alignment);
|
||||
let slice = self.allocate(size, alignment);
|
||||
{
|
||||
let mut view = slice
|
||||
.get_mapped_range_mut()
|
||||
@@ -80,8 +97,20 @@ impl Uploads {
|
||||
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
|
||||
return;
|
||||
};
|
||||
self.belt
|
||||
.write_buffer(encoder, target, offset, size)
|
||||
// The belt's own `write_buffer` checks, kept now that a large write's
|
||||
// slice is rounded up past them.
|
||||
assert!(
|
||||
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
|
||||
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
|
||||
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
|
||||
);
|
||||
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
|
||||
let slice = self.allocate(size, alignment).slice(..size.get());
|
||||
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
|
||||
slice
|
||||
.get_mapped_range_mut()
|
||||
.expect("staging belt slices are mapped")
|
||||
.slice(..)
|
||||
.copy_from_slice(bytes);
|
||||
}
|
||||
/// Close this frame's staging buffers: they come back for reuse once the
|
||||
@@ -217,6 +246,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Staging buffers the belt holds, once every submitted frame is done
|
||||
/// with them. The belt only reports its buffers through `Debug`.
|
||||
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
// Finishing an empty frame takes back every recalled buffer.
|
||||
uploads.finish(&device.create_command_encoder(&Default::default()));
|
||||
let belt = format!("{:?}", uploads.belt);
|
||||
let count = |field: &str| -> usize {
|
||||
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
|
||||
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
|
||||
digits.and_then(|d| d.parse().ok()).expect(&belt)
|
||||
};
|
||||
count("active_chunks") + count("closed_chunks") + count("free_chunks")
|
||||
}
|
||||
|
||||
/// #2826: a write larger than the chunk that grows a little every frame
|
||||
/// (the entity batch while mobs appear) got a new exact-size buffer each
|
||||
/// frame, all of them kept, until the game ran out of memory.
|
||||
#[test]
|
||||
fn growing_large_writes_reuse_the_belt_buffers() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
const CHUNK: u64 = 1 << 16;
|
||||
const TARGET: usize = 4 * CHUNK as usize;
|
||||
let target = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: TARGET as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: TARGET as u64,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut uploads = Uploads::with_chunk(&device, CHUNK);
|
||||
let mut expected = vec![0u8; TARGET];
|
||||
let frames = 64;
|
||||
for frame in 0..frames {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
// One small write, then the large one, 1.5 to 3.5 chunks long.
|
||||
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
|
||||
expected[TARGET - 4..].fill(9);
|
||||
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
|
||||
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
|
||||
uploads.buffer(&mut encoder, &target, 0, &bytes);
|
||||
expected[..len].copy_from_slice(&bytes);
|
||||
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
// Only the written bytes land: the rest of the target is untouched.
|
||||
assert!(
|
||||
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
|
||||
"frame {frame}"
|
||||
);
|
||||
readback.unmap();
|
||||
}
|
||||
// A chunk for the small writes and a buffer per doubling of the large
|
||||
// one (2 and 4 chunks), maybe one more where a small write took the
|
||||
// start of a buffer the large one needed whole: not one per frame.
|
||||
let kept = kept_buffers(&device, &mut uploads);
|
||||
assert!(
|
||||
kept <= 4,
|
||||
"the belt kept {kept} buffers over {frames} frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn belt_buffer_writes_land_in_order_at_their_offsets() {
|
||||
let Some((device, queue)) = device() else {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The views a frame is drawn from. A flat screen has one; a headset has
|
||||
//! one per eye. Every view shares the frame's camera position, so the
|
||||
//! camera-relative world (section origins, entity offsets) is built once and
|
||||
//! only each view's rotation and lens differ.
|
||||
|
||||
use glam::Mat4;
|
||||
|
||||
/// The most views one frame draws.
|
||||
const MAX_VIEWS: usize = 2;
|
||||
|
||||
/// One eye's view of the camera-relative world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct View {
|
||||
/// Camera-relative world to eye: a rotation, plus the eye's offset from
|
||||
/// the camera on a headset.
|
||||
pub view: Mat4,
|
||||
/// Reversed-Z perspective: the near plane at depth 1, far at 0.
|
||||
pub projection: Mat4,
|
||||
/// Width over height of the image this view fills.
|
||||
pub aspect: f32,
|
||||
/// `tan(fov_y / 2)`, for shaders rebuilding view rays from NDC.
|
||||
pub tan_half_fov: f32,
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// The flat-screen view: vanilla's camera rotation and a symmetric
|
||||
/// vertical field of view.
|
||||
pub fn screen(yaw: f32, pitch: f32, fov_degrees: f32, aspect: f32, far: f32) -> Self {
|
||||
Self {
|
||||
view: crate::camera::view_matrix(yaw, pitch),
|
||||
projection: super::world_projection(fov_degrees, aspect, far),
|
||||
aspect,
|
||||
tan_half_fov: (fov_degrees.to_radians() / 2.0).tan(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip space from the camera-relative world, with `pose` (view bobbing,
|
||||
/// nausea spin) between the lens and the rotation.
|
||||
pub fn view_proj(&self, pose: Mat4) -> Mat4 {
|
||||
self.projection * pose * self.view
|
||||
}
|
||||
}
|
||||
|
||||
/// Every view of a frame, and the one geometry is culled with.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ViewSet {
|
||||
views: [View; MAX_VIEWS],
|
||||
len: usize,
|
||||
cull: View,
|
||||
}
|
||||
|
||||
impl ViewSet {
|
||||
/// A frame drawn from `view` alone, culled with it.
|
||||
pub fn single(view: View) -> Self {
|
||||
Self {
|
||||
views: [view; MAX_VIEWS],
|
||||
len: 1,
|
||||
cull: view,
|
||||
}
|
||||
}
|
||||
|
||||
/// The view the HUD, hand and screen-space effects follow.
|
||||
pub fn primary(&self) -> &View {
|
||||
&self.views[0]
|
||||
}
|
||||
|
||||
pub fn views(&self) -> &[View] {
|
||||
&self.views[..self.len]
|
||||
}
|
||||
|
||||
/// The view whose frustum covers every other view's.
|
||||
pub fn cull(&self) -> &View {
|
||||
&self.cull
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The formulas `Renderer` used before views existed, kept to pin the
|
||||
/// screen view to them bit for bit.
|
||||
fn legacy_view_proj(fov: f32, yaw: f32, pitch: f32, pose: Mat4, aspect: f32, far: f32) -> Mat4 {
|
||||
super::super::world_projection(fov, aspect, far)
|
||||
* pose
|
||||
* crate::camera::view_matrix(yaw, pitch)
|
||||
}
|
||||
|
||||
fn poses() -> [Mat4; 3] {
|
||||
[
|
||||
Mat4::IDENTITY,
|
||||
Mat4::from_translation(glam::Vec3::new(0.013, -0.027, 0.0))
|
||||
* Mat4::from_rotation_z(0.021),
|
||||
Mat4::from_rotation_y(0.4) * Mat4::from_scale(glam::Vec3::new(1.1, 0.9, 1.0)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_screen_view_matches_the_legacy_projection_bit_for_bit() {
|
||||
for (fov, yaw, pitch, aspect, far) in [
|
||||
(70.0, 0.0, 0.0, 16.0 / 9.0, 512.0),
|
||||
(110.0, -137.25, 61.5, 21.0 / 9.0, 8192.0),
|
||||
(30.0, 359.9, -90.0, 4.0 / 3.0, 96.0),
|
||||
(82.333, 12.5, 7.75, 1.0, 1024.0),
|
||||
] {
|
||||
let view = View::screen(yaw, pitch, fov, aspect, far);
|
||||
for pose in poses() {
|
||||
let old = legacy_view_proj(fov, yaw, pitch, pose, aspect, far);
|
||||
let new = view.view_proj(pose);
|
||||
assert_eq!(
|
||||
old.to_cols_array().map(f32::to_bits),
|
||||
new.to_cols_array().map(f32::to_bits),
|
||||
"fov {fov} yaw {yaw} pitch {pitch}"
|
||||
);
|
||||
}
|
||||
assert_eq!(view.view, crate::camera::view_matrix(yaw, pitch));
|
||||
assert_eq!(
|
||||
view.tan_half_fov.to_bits(),
|
||||
(fov.to_radians() / 2.0).tan().to_bits()
|
||||
);
|
||||
assert_eq!(view.aspect.to_bits(), aspect.to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_view_set_draws_and_culls_with_its_view() {
|
||||
let view = View::screen(45.0, 10.0, 70.0, 16.0 / 9.0, 512.0);
|
||||
let set = ViewSet::single(view);
|
||||
assert_eq!(set.views(), &[view]);
|
||||
assert_eq!(*set.primary(), view);
|
||||
assert_eq!(*set.cull(), view);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ struct TaaUniform {
|
||||
jitter: [f32; 4],
|
||||
}
|
||||
|
||||
/// TAA's GPU resources, absent when the device cannot compile the shader
|
||||
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
|
||||
/// from depth textures).
|
||||
struct TaaResources {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -57,9 +66,7 @@ pub struct Post {
|
||||
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
|
||||
taa_on: bool,
|
||||
target: Option<WorldTarget>,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
taa_uniform: wgpu::Buffer,
|
||||
taa_resources: Option<TaaResources>,
|
||||
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
|
||||
taa_frame: u32,
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
@@ -105,40 +112,11 @@ impl Post {
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
@@ -166,6 +144,88 @@ impl Post {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The OpenGL backend's GLSL translator does not support textureLoad
|
||||
// from depth textures: on devices running through OpenGL (such as older
|
||||
// Intel GPUs on Linux), TAA cannot compile its pipeline.
|
||||
let taa_resources = {
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let taa_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let taa_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: Some(&taa_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_taa"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: HISTORY_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => {
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Some(TaaResources {
|
||||
layout: taa_layout,
|
||||
pipeline: taa_pipeline,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: temporal anti-aliasing unavailable ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
@@ -175,15 +235,7 @@ impl Post {
|
||||
Self {
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
|
||||
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
layout,
|
||||
taa_layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
@@ -192,12 +244,18 @@ impl Post {
|
||||
upscale: 1.0,
|
||||
taa_on: false,
|
||||
target: None,
|
||||
taa_resources,
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether TAA is supported on this GPU/backend.
|
||||
pub fn supports_taa(&self) -> bool {
|
||||
self.taa_resources.is_some()
|
||||
}
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
@@ -212,7 +270,7 @@ impl Post {
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
|
||||
self.taa_on = on && self.taa;
|
||||
self.taa_on = on && self.taa && self.taa_resources.is_some();
|
||||
if !self.taa_on {
|
||||
self.taa_previous = None;
|
||||
return Mat4::IDENTITY;
|
||||
@@ -290,7 +348,8 @@ impl Post {
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
let taa = history_size.map(|output| {
|
||||
let taa = history_size.and_then(|output| {
|
||||
let res = self.taa_resources.as_ref()?;
|
||||
let history = [0, 1].map(|_| {
|
||||
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
|
||||
.create_view(&Default::default())
|
||||
@@ -298,7 +357,7 @@ impl Post {
|
||||
let resolve = [0, 1].map(|i| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: &self.taa_layout,
|
||||
layout: &res.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
@@ -318,18 +377,18 @@ impl Post {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.taa_uniform.as_entire_binding(),
|
||||
resource: res.uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let show = [0, 1].map(|i| self.bind(device, &history[i]));
|
||||
History {
|
||||
Some(History {
|
||||
size: output,
|
||||
history,
|
||||
resolve,
|
||||
show,
|
||||
}
|
||||
})
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
@@ -381,12 +440,12 @@ impl Post {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let Some(res) = &self.taa_resources else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(
|
||||
&self.taa_uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&self.taa_this_frame),
|
||||
);
|
||||
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
@@ -401,7 +460,7 @@ impl Post {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.taa_pipeline);
|
||||
pass.set_pipeline(&res.pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
@@ -485,4 +544,21 @@ mod tests {
|
||||
[0.5, 0.25, 0.75]
|
||||
);
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_post_pipelines_compile_or_fall_back() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
|
||||
// Copy and FXAA pipelines always compile; TAA compiles if supported by backend.
|
||||
let _ = post.supports_taa();
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,39 @@
|
||||
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
|
||||
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
|
||||
//! player makes here depart from it.
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
|
||||
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
|
||||
pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
|
||||
/// What the system reports for the display HDR output goes to, in nits in HDR output's
|
||||
/// scale: where its SDR white sits (the desktop's white) and the peak.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
}
|
||||
|
||||
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
|
||||
|
||||
/// Sets what the system reports (the renderer, when it opens its surface).
|
||||
pub fn report(reported: Option<Reported>) {
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
REPORTED[0].store(paper_white, Ordering::Relaxed);
|
||||
REPORTED[1].store(peak, Ordering::Relaxed);
|
||||
}
|
||||
/// What the system reports, if it does.
|
||||
pub fn reported() -> Option<Reported> {
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Preset {
|
||||
@@ -285,6 +311,16 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
pub fn offer_hdr(on: bool) {
|
||||
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -343,10 +379,18 @@ pub struct Graphics {
|
||||
pub upscaling: Upscaling,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
|
||||
pub hdr_paper_white: u32,
|
||||
/// HDR peak brightness the world's highlights reach, in nits.
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// what the system reports for the display ([`Graphics::peak_nits`]).
|
||||
pub hdr_peak: u32,
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
|
||||
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -381,8 +425,10 @@ impl Default for Graphics {
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
hdr: false,
|
||||
hdr_paper_white: 200,
|
||||
hdr_peak: 1000,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -445,8 +491,22 @@ impl Graphics {
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
|
||||
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(80, 480),
|
||||
}
|
||||
}
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
n => n.clamp(200, 2000),
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -464,7 +524,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -494,6 +554,8 @@ impl Graphics {
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
@@ -502,6 +564,22 @@ impl Graphics {
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
|
||||
/// system reports it, else 203 (BT.2408's reference white).
|
||||
pub fn paper_white_nits(&self) -> u32 {
|
||||
match self.hdr_paper_white {
|
||||
0 => reported().map_or(203, |r| r.paper_white),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The peak the world's highlights reach, in nits: the player's, or on Auto
|
||||
/// what the system reports, else 1000.
|
||||
pub fn peak_nits(&self) -> u32 {
|
||||
match self.hdr_peak {
|
||||
0 => reported().map_or(1000, |r| r.peak),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
if self.workers > 0 {
|
||||
@@ -611,12 +689,19 @@ mod tests {
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
|
||||
@@ -130,8 +130,21 @@ impl Style {
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
label,
|
||||
value,
|
||||
hover && active,
|
||||
active,
|
||||
dragging && active,
|
||||
);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's graphics settings screens, Simple and Advanced: which settings
|
||||
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
@@ -14,6 +14,8 @@ use crate::{
|
||||
pub enum Level {
|
||||
Simple,
|
||||
Advanced,
|
||||
/// HDR output's own settings, opened from Advanced while HDR is on.
|
||||
Hdr,
|
||||
}
|
||||
|
||||
/// Horizon's own settings (`graphics::Graphics`).
|
||||
@@ -38,8 +40,12 @@ pub enum H {
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
Hdr,
|
||||
HdrPaperWhite,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
BatteryMode,
|
||||
GraphicsApi,
|
||||
}
|
||||
@@ -120,6 +126,34 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Hdr => vec![
|
||||
Row::Header("HDR"),
|
||||
h(
|
||||
H::Hdr,
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"How bright highlights get. Auto: the peak your system reports.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"How bright white paper and menus look. Auto: your desktop's white.",
|
||||
),
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -230,20 +264,10 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
H::HdrSettings,
|
||||
None,
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"HDR: how bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"HDR: how bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
@@ -351,8 +375,18 @@ impl H {
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
|
||||
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0),
|
||||
// Auto, then 80..480 by 10.
|
||||
H::HdrPaperWhite => Some(match g.hdr_paper_white {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 70.0) / 410.0,
|
||||
}),
|
||||
// Auto, then 200..2000 by 50.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 150.0) / 1850.0,
|
||||
}),
|
||||
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
|
||||
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -403,8 +437,23 @@ impl H {
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
|
||||
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
format!("Auto ({} nits)", g.paper_white_nits()),
|
||||
),
|
||||
n => labelled("Paper White", format!("{n} nits")),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
|
||||
n => labelled("Peak Brightness", format!("{n} nits")),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -427,8 +476,20 @@ impl H {
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
|
||||
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
|
||||
H::HdrPaperWhite => {
|
||||
g.hdr_paper_white = match (v * 41.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 70 + step * 10,
|
||||
}
|
||||
}
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 150 + step * 50,
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -504,6 +565,25 @@ impl Item {
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// Why a control can't be used now, when it can't: HDR's screen opens only where
|
||||
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
}
|
||||
match self {
|
||||
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
@@ -605,6 +685,7 @@ pub enum Action {
|
||||
SliderDrag,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -624,15 +705,20 @@ struct Widget {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GraphicsUi {
|
||||
scroll: f32,
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
pub fn reset(&mut self) {
|
||||
self.scroll = 0.0;
|
||||
self.scroll = ui::Scroll::default();
|
||||
self.dragging = None;
|
||||
}
|
||||
/// The scrollbar's column: right of the descriptions.
|
||||
fn scrollbar_x(w: f32) -> f32 {
|
||||
STYLE.row_x(w) + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0
|
||||
}
|
||||
fn bounds(h: f32) -> (f32, f32) {
|
||||
(STYLE.band, h - STYLE.band)
|
||||
}
|
||||
@@ -657,7 +743,7 @@ impl GraphicsUi {
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, _) = Self::bounds(h);
|
||||
let x = STYLE.row_x(w);
|
||||
let mut y = top + 4.0 - self.scroll;
|
||||
let mut y = top + 4.0 - self.scroll.amount;
|
||||
let mut widgets = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
for row in extra_rows(level, s) {
|
||||
@@ -683,6 +769,17 @@ impl GraphicsUi {
|
||||
let other = match level {
|
||||
Level::Simple => Action::OpenAdvanced,
|
||||
Level::Advanced => Action::OpenSimple,
|
||||
// One Done, back to Advanced.
|
||||
Level::Hdr => {
|
||||
widgets.push(Widget {
|
||||
x: cx - 100.0,
|
||||
y: by,
|
||||
w: 200.0,
|
||||
target: Target::Button(Action::Done),
|
||||
list: false,
|
||||
});
|
||||
return (widgets, headers, content);
|
||||
}
|
||||
};
|
||||
widgets.push(Widget {
|
||||
x: cx - 155.0,
|
||||
@@ -702,7 +799,7 @@ impl GraphicsUi {
|
||||
}
|
||||
fn clamp_scroll(&mut self, h: f32, content: f32) {
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll = self.scroll.clamp(0.0, (content - (bottom - top)).max(0.0));
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
}
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
@@ -744,12 +841,10 @@ impl GraphicsUi {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let live = item.available();
|
||||
if hover && !live {
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
if hover && let Some(why) = blocked {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -759,6 +854,8 @@ impl GraphicsUi {
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
self.dragging == Some(item),
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
@@ -799,15 +896,16 @@ impl GraphicsUi {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0,
|
||||
Self::scrollbar_x(w),
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
self.scroll,
|
||||
self.scroll.amount,
|
||||
);
|
||||
let title = match level {
|
||||
Level::Simple => "Graphics",
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
if let Some(lines) = tooltip {
|
||||
@@ -825,9 +923,14 @@ impl GraphicsUi {
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Action> {
|
||||
let (widgets, _, _) = self.layout(level, s, w, h);
|
||||
let (widgets, _, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
if drag {
|
||||
// AbstractScrollArea.mouseDragged: the held scrollbar follows the pointer.
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, top, bottom, content);
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(item) = self.dragging
|
||||
&& let Some(wd) = widgets
|
||||
.iter()
|
||||
@@ -841,6 +944,13 @@ impl GraphicsUi {
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
// AbstractScrollArea.updateScrolling: a press on the scrollbar holds it, silently.
|
||||
if self
|
||||
.scroll
|
||||
.press((x, y), Self::scrollbar_x(w), top, bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
for wd in widgets {
|
||||
if !ui::hovered((x, y), wd.x, wd.y, wd.w, 20.0) {
|
||||
continue;
|
||||
@@ -851,7 +961,10 @@ impl GraphicsUi {
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
// A disabled control does nothing: no sound, no preset change.
|
||||
Target::Item(item) if !item.available() => Vec::new(),
|
||||
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -866,14 +979,16 @@ impl GraphicsUi {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
/// Lets go of a slider (true: it plays its click) or the scrollbar.
|
||||
pub fn release(&mut self) -> bool {
|
||||
self.scroll.release();
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
self.scroll.wheel(amount, 18.0, top, bottom, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,6 +996,53 @@ impl GraphicsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hdr_settings_open_where_hdr_is_offered() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 2000.0);
|
||||
let at = |ui: &mut GraphicsUi, s: &Settings, level, item| {
|
||||
let (widgets, _, _) = ui.layout(level, s, size.0, size.1);
|
||||
let w = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
(w.x + 1.0, w.y + 1.0)
|
||||
};
|
||||
let button = at(&mut ui, &s, Level::Advanced, Item::Horizon(H::HdrSettings));
|
||||
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
|
||||
ui.click(level, s, point, size, [480, 2000], false, false)
|
||||
};
|
||||
crate::graphics::offer_hdr(false);
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
// Not even with HDR saved on, from another computer.
|
||||
s.horizon.hdr = true;
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
s.horizon.hdr = false;
|
||||
crate::graphics::offer_hdr(true);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Advanced, button),
|
||||
[Action::ButtonClick, Action::OpenHdr]
|
||||
);
|
||||
// On HDR's screen, the sliders wait for the switch above them.
|
||||
let peak = at(&mut ui, &s, Level::Hdr, Item::Horizon(H::HdrPeak));
|
||||
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
|
||||
s.horizon.hdr = true;
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Hdr, peak),
|
||||
[Action::SliderDrag]
|
||||
);
|
||||
let on = |level, item| {
|
||||
extra_rows(level, &s)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
|
||||
};
|
||||
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
|
||||
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
|
||||
for item in [H::HdrBlackLevel, H::HdrPaperWhite, H::HdrUiBrightness] {
|
||||
assert!(on(Level::Hdr, item) && !on(Level::Advanced, item));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
|
||||
let mut s = Settings::default();
|
||||
@@ -960,29 +1122,68 @@ mod tests {
|
||||
assert!(max > 4.5);
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, &s, h, -1000.0);
|
||||
assert_eq!(ui.scroll, max);
|
||||
assert_eq!(ui.scroll.amount, max);
|
||||
}
|
||||
ui.scroll(level, &s, h, 0.25);
|
||||
assert_eq!(ui.scroll, max - 4.5);
|
||||
assert_eq!(ui.scroll.amount, max - 4.5);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
ui.scroll(level, &s, h, -0.25);
|
||||
assert_eq!(ui.scroll, 4.5);
|
||||
assert_eq!(ui.scroll.amount, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_can_be_held_and_dragged() {
|
||||
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
|
||||
// column, mouseDragged moves the list with the pointer, onRelease lets go.
|
||||
let mut s = Settings::default();
|
||||
let size = (480.0, 2.0 * STYLE.band + 60.0);
|
||||
let (top, bottom) = GraphicsUi::bounds(size.1);
|
||||
// On the scrollbar column, as `draw` paints it.
|
||||
let x =
|
||||
STYLE.row_x(size.0) + STYLE.control_width + STYLE.gap + STYLE.text_width(size.0) + 8.0;
|
||||
let first_row = |ui: &mut GraphicsUi| {
|
||||
ui.layout(Level::Advanced, &Settings::default(), size.0, size.1)
|
||||
.0[0]
|
||||
.y
|
||||
};
|
||||
let mut ui = GraphicsUi::default();
|
||||
let start = first_row(&mut ui);
|
||||
let at = |y| (x, y);
|
||||
let press = |ui: &mut GraphicsUi, s: &mut Settings, y, drag| {
|
||||
ui.click(Level::Advanced, s, at(y), size, [854, 480], drag, false)
|
||||
};
|
||||
// The press itself makes no sound and changes nothing.
|
||||
assert!(press(&mut ui, &mut s, top + 5.0, false).is_empty());
|
||||
assert!(press(&mut ui, &mut s, top + 15.0, true).is_empty());
|
||||
let moved = first_row(&mut ui);
|
||||
assert!(moved < start, "dragging down scrolls the list down");
|
||||
// Past the bottom of the list: the last row.
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
let content = GraphicsUi::content_height(Level::Advanced, &s);
|
||||
assert_eq!(first_row(&mut ui), start - (content - (bottom - top)));
|
||||
// Above the list: the first row again.
|
||||
press(&mut ui, &mut s, top - 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
// Released: drags no longer scroll, and no slider click plays.
|
||||
assert!(!ui.release());
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_clamps_before_positioning_graphics_rows() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
assert!(ui.scroll > 0.0);
|
||||
assert!(ui.scroll.amount > 0.0);
|
||||
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
if let Some((y, _)) = headers.first() {
|
||||
assert_eq!(*y, STYLE.band + 4.0);
|
||||
}
|
||||
@@ -992,7 +1193,7 @@ mod tests {
|
||||
expected.iter().map(|w| w.y).collect::<Vec<_>>()
|
||||
);
|
||||
ui.scroll(level, &s, 4000.0, -1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,23 +12,31 @@ struct Params {
|
||||
peak: f32,
|
||||
// 1 when this frame drew a world.
|
||||
world: f32,
|
||||
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
|
||||
// 1 greys what isn't expanded and tints what is (HORIZON_HDR_DEBUG=1).
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
// The display's black level in nits; 0 leaves the picture as it is.
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// the brightest pixels get, and the brightness where the lift starts and
|
||||
// where it is full.
|
||||
share: vec4<f32>,
|
||||
start: vec4<f32>,
|
||||
full: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
|
||||
// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the
|
||||
// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a
|
||||
// lantern's metal), and no channel goes past the peak. Brightness is the mean
|
||||
// of luminance and the brightest channel, so saturated light counts as bright.
|
||||
const LIFT_START: f32 = 0.18;
|
||||
const LIFT_FULL: f32 = 0.42;
|
||||
const STRENGTH: f32 = 0.7;
|
||||
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
|
||||
// `share` of the headroom, dimmer ones less down to none at its `start`
|
||||
// (lava's crust, a lantern's metal), and no channel goes past the peak.
|
||||
// Brightness is the mean of luminance and the brightest channel, so saturated
|
||||
// light counts as bright.
|
||||
|
||||
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
|
||||
// across its channels, as the vignette and other screen-wide tints darken it.
|
||||
@@ -56,37 +64,84 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
|
||||
}
|
||||
|
||||
// ST 2084's inverse: the PQ signal to nits.
|
||||
fn pq_nits(e: vec3<f32>) -> vec3<f32> {
|
||||
let p = pow(clamp(e, vec3(0.0), vec3(1.0)), vec3(1.0 / 78.84375));
|
||||
let y = max(p - 0.8359375, vec3(0.0)) / (18.8515625 - 18.6875 * p);
|
||||
return 10000.0 * pow(y, vec3(1.0 / 0.1593017578125));
|
||||
}
|
||||
|
||||
// ITU-R BT.2390's black level lift (its EETF without the knee) on the PQ signal
|
||||
// `e`: black moves up to the display's black level, the peak stays, and the lift
|
||||
// fades out by the fourth power on the way up, so only the shadows move. A display
|
||||
// that can't go darker than its black level would crush everything below it.
|
||||
fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
let top = pq(vec3(params.peak * params.paper * 80.0)).x;
|
||||
let n = min(e / top, vec3(1.0));
|
||||
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: once the world is kept, the frame's alpha starts at 0 again.
|
||||
@fragment fn fs_clear_alpha() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let c = textureLoad(frame, at, 0).rgb;
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
|
||||
// holds, the lower the brighter, and only where nothing was drawn over them
|
||||
// since: grey text over the white sun still looks like the sun darkened, but
|
||||
// it raised the frame's alpha.
|
||||
var lifted = false;
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
|
||||
&& shows_world(c, w.rgb) {
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
|
||||
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
|
||||
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
|
||||
let lift = smoothstep(params.start[tier], params.full[tier], y);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
light *= max(gain, 1.0);
|
||||
if params.debug > 0.5 {
|
||||
light = mix(light, vec3(gain, 0.0, gain), 0.6);
|
||||
}
|
||||
lifted = true;
|
||||
}
|
||||
}
|
||||
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
|
||||
// colour with a magenta tint, so a lift shows on any colour.
|
||||
if params.debug > 0.5 && params.world > 0.5 {
|
||||
if lifted {
|
||||
light *= vec3(1.0, 0.6, 1.0);
|
||||
} else {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
|
||||
}
|
||||
if params.black > 0.0 {
|
||||
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
@@ -59,6 +59,8 @@ pub struct HudAtlas {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Resolved player-profile names and UUIDs for inline head components.
|
||||
pub object_skins: HashMap<String, String>,
|
||||
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
|
||||
/// which puts their particles on vanilla's translucent layer.
|
||||
pub translucent: HashSet<String>,
|
||||
@@ -90,9 +92,9 @@ pub const MAP_SLOTS: usize = 256;
|
||||
/// The atlas slot the minimap extra draws into.
|
||||
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
|
||||
/// Vanilla registers a texture per skin. Here a skin evicted from its slot
|
||||
/// is not stored again, and its player falls back to the default skin, so
|
||||
/// the slots must cover a busy server's player list.
|
||||
/// Vanilla registers a texture per skin. Here the slots are shared
|
||||
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
|
||||
/// skin past them shows the default skin until a slot frees up.
|
||||
pub const SKIN_SLOTS: usize = 128;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
@@ -166,6 +168,31 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
/// (`AnimationMetadataSection.frameSize`), which is the top-left square unless
|
||||
/// it names other dimensions or the frames run along a row.
|
||||
fn first_frame(
|
||||
(w, h, pixels): (u32, u32, Vec<u8>),
|
||||
animation: Option<&AnimationMetadata>,
|
||||
) -> (u32, u32, Vec<u8>) {
|
||||
let [fw, fh] = animation.map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(side, side, strip_frame(&pixels, w, 0, 0, fw, fh))
|
||||
}
|
||||
_ => (w, h, pixels),
|
||||
}
|
||||
}
|
||||
/// `image` inside a one-texel border copied from its opposite edges, the
|
||||
/// texels repeat addressing reads past each edge.
|
||||
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
@@ -182,8 +209,9 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id,
|
||||
/// such as other namespaces' item textures; and the animated ones' names.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<String>) {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
@@ -227,31 +255,65 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
};
|
||||
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
|
||||
origins.iter().find_map(|(order, origin)| {
|
||||
let mut animated = false;
|
||||
let image = match origin {
|
||||
SpriteOrigin::File(texture) => {
|
||||
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
|
||||
load(texture, "textures").map(|(w, h, p)| {
|
||||
// Animated strips keep their first frame, like the packed files.
|
||||
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
|
||||
(w, w, p[..(w * w * 4) as usize].to_vec())
|
||||
// Its path under `textures/` in any namespace (a server
|
||||
// pack's `lobby:item/...` as well as vanilla's).
|
||||
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
|
||||
let file = atlas_sources::texture_path(root, texture, "textures");
|
||||
decode(&file).ok().map(|image| {
|
||||
// Animated strips keep their first frame and animate,
|
||||
// like the packed files.
|
||||
if path.starts_with("block/") || path.starts_with("item/") {
|
||||
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
(w, h, p)
|
||||
image
|
||||
}
|
||||
})
|
||||
}
|
||||
generated => atlas_sources::generate(generated, &load),
|
||||
};
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels), animated))
|
||||
})
|
||||
};
|
||||
let mut out: Vec<_> = crate::par::par_map(&names, first)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
out.sort_unstable_by_key(|(order, _)| *order);
|
||||
out.into_iter().map(|(_, image)| image).collect()
|
||||
out.sort_unstable_by_key(|(order, ..)| *order);
|
||||
let animated = out
|
||||
.iter()
|
||||
.filter(|(.., animated)| *animated)
|
||||
.map(|(_, image, _)| image.0.clone())
|
||||
.collect();
|
||||
(
|
||||
out.into_iter().map(|(_, image, _)| image).collect(),
|
||||
animated,
|
||||
)
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// Keep a ready profile skin when another source only has its default.
|
||||
pub fn set_object_skin(&mut self, key: String, skin: String) {
|
||||
use std::collections::hash_map::Entry;
|
||||
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
|
||||
self.object_skins.clear();
|
||||
}
|
||||
match self.object_skins.entry(key) {
|
||||
Entry::Occupied(entry)
|
||||
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
|
||||
}
|
||||
Entry::Occupied(mut entry) => {
|
||||
entry.insert(skin);
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(skin);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
|
||||
pub fn glyph(&self, ch: char) -> Option<(Rect, f32)> {
|
||||
let g = self.font.glyph(ch);
|
||||
@@ -418,37 +480,15 @@ impl HudAtlas {
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
};
|
||||
let (mut w, mut h, pixels) = decode(path).ok()?;
|
||||
let image = decode(path).ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = mcmeta
|
||||
.as_deref()
|
||||
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||
.and_then(|meta| {
|
||||
meta.get("animation")
|
||||
.and_then(|section| AnimationMetadata::from_json(§ion.to_string()).ok())
|
||||
});
|
||||
// An animated strip keeps its first frame: the region the mcmeta's
|
||||
// frame size picks (`AnimationMetadataSection.frameSize`), which is
|
||||
// the top-left square unless it names other dimensions or the
|
||||
// frames run along a row.
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// An animated strip keeps its first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let pixels = if frames {
|
||||
let stride = w;
|
||||
let [fw, fh] = animation.as_ref().map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(w, h) = (side, side);
|
||||
strip_frame(&pixels, stride, 0, 0, fw, fh)
|
||||
}
|
||||
_ => pixels,
|
||||
}
|
||||
let (w, h, pixels) = if frames {
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
pixels
|
||||
image
|
||||
};
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
@@ -500,7 +540,9 @@ impl HudAtlas {
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
let (more, more_animated) = atlas_sprites(root, &images);
|
||||
images.extend(more);
|
||||
animated.extend(more_animated);
|
||||
}
|
||||
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
|
||||
if let Some(edition) = images
|
||||
@@ -572,7 +614,15 @@ impl HudAtlas {
|
||||
let Some(rect) = sprites.get(&name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let layer = loaded.insert(root, &format!("minecraft:{name}"))?;
|
||||
// Another namespace's sprite is named with it (`lobby:item/...`).
|
||||
let id = if name.contains(':') {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let Ok(layer) = loaded.insert(root, &id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(animation) = loaded.animations.iter().find(|a| a.layer == layer).cloned()
|
||||
else {
|
||||
continue;
|
||||
@@ -605,6 +655,7 @@ impl HudAtlas {
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
object_skins: HashMap::new(),
|
||||
translucent,
|
||||
flags,
|
||||
sprite_animations,
|
||||
@@ -654,6 +705,8 @@ pub struct HudBatch {
|
||||
pub glint_alpha: f32,
|
||||
/// Screen's first vertex: the preceding HUD is part of its blurred background.
|
||||
pub blur_at: u32,
|
||||
/// Player object profiles encountered while painting text this frame.
|
||||
pub inline_profiles: Vec<serde_json::Value>,
|
||||
}
|
||||
/// How a run of HUD vertices blends and tests depth.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -705,6 +758,7 @@ impl HudBatch {
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Draw the vertices added since `start` with the inverting blend.
|
||||
@@ -1055,40 +1109,67 @@ impl HudBatch {
|
||||
let mut cursor = x;
|
||||
let mut previous = None;
|
||||
for (i, (ch, style)) in text.chars().enumerate() {
|
||||
let object = (ch == '\u{fffc}')
|
||||
.then_some(style.inline_object.as_ref())
|
||||
.flatten();
|
||||
if let Some(crate::text::InlineObject::Player { profile, .. }) = object
|
||||
&& self.inline_profiles.len() < 256
|
||||
&& !self.inline_profiles.contains(profile)
|
||||
{
|
||||
self.inline_profiles.push(profile.clone());
|
||||
}
|
||||
// Font.PreparedTextBuilder.accept: lines start 1 left of a
|
||||
// sequence's first character. Language.getVisualOrder makes a
|
||||
// sequence of each run of one style, which world text has.
|
||||
let first = i == 0 || world && previous != Some(style);
|
||||
previous = Some(style);
|
||||
// The style's font (`Style.getFont`), the default one unless set.
|
||||
let mut g = font.glyph_styled(ch, style);
|
||||
let mut g = if object.is_some() {
|
||||
crate::font::Glyph {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 8.0,
|
||||
advance: 8.0,
|
||||
offset: 0.0,
|
||||
}
|
||||
} else {
|
||||
font.glyph_styled(ch, style)
|
||||
};
|
||||
// Font.StringRenderOutput.accept: obfuscated characters draw a
|
||||
// random glyph of the same width each frame.
|
||||
if style.obfuscated == Some(true) && ch != ' ' {
|
||||
if object.is_none() && style.obfuscated == Some(true) && ch != ' ' {
|
||||
g = font.random_glyph(style, g.advance);
|
||||
}
|
||||
let c = style.color.map_or(color, |rgb| {
|
||||
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
|
||||
});
|
||||
let bold = style.is_bold();
|
||||
let bold = object.is_none() && style.is_bold();
|
||||
let advance = g.advance + if bold { g.offset } else { 0.0 };
|
||||
glyphs.push((
|
||||
cursor,
|
||||
g,
|
||||
c,
|
||||
style.shadow_color,
|
||||
bold,
|
||||
style.is_italic(),
|
||||
style.underlined == Some(true),
|
||||
style.strikethrough == Some(true),
|
||||
first,
|
||||
advance,
|
||||
object.cloned(),
|
||||
));
|
||||
cursor += advance;
|
||||
}
|
||||
self.patches.extend(font.take_patches());
|
||||
let white = font.white();
|
||||
let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the shadow's colour.
|
||||
let has_shadow = shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0));
|
||||
let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the default shadow colour.
|
||||
let shade = |c: u32| {
|
||||
let b = c.to_le_bytes();
|
||||
rgba(
|
||||
@@ -1099,20 +1180,48 @@ impl HudBatch {
|
||||
)
|
||||
};
|
||||
for &is_shadow in passes {
|
||||
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
|
||||
// In the world a glyph whose shadow is 0 has none (hasShadow).
|
||||
let has_shadow = shadow && !(world && shade(c) == 0);
|
||||
for (
|
||||
gx,
|
||||
g,
|
||||
c,
|
||||
custom_shadow,
|
||||
bold,
|
||||
italic,
|
||||
underline,
|
||||
strike,
|
||||
first,
|
||||
advance,
|
||||
object,
|
||||
) in &glyphs
|
||||
{
|
||||
if let Some(object) = object {
|
||||
if !is_shadow {
|
||||
self.draw_inline_object(atlas, object, *gx, y, alpha);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let shadow_color = if let Some(argb) = custom_shadow {
|
||||
let text_alpha = (c >> 24) as u8 as u16;
|
||||
let shadow_alpha = (argb >> 24) as u8 as u16;
|
||||
let alpha = (shadow_alpha * text_alpha / 255) as u8;
|
||||
rgba((argb >> 16) as u8, (argb >> 8) as u8, *argb as u8, alpha)
|
||||
} else {
|
||||
shade(*c)
|
||||
};
|
||||
let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
&& !(world && shadow_color == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
}
|
||||
let (dx, col) = if is_shadow {
|
||||
(g.offset, shade(c))
|
||||
(g.offset, shadow_color)
|
||||
} else {
|
||||
(0.0, c)
|
||||
(0.0, *c)
|
||||
};
|
||||
let (px, py) = (gx + dx, y + dx);
|
||||
let shear = |v: f32| if italic { 1.0 - 0.25 * v } else { 0.0 };
|
||||
let thick = if bold { 0.1 } else { 0.0 };
|
||||
let (px, py) = (*gx + dx, y + dx);
|
||||
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
|
||||
let thick = if *bold { 0.1 } else { 0.0 };
|
||||
let draw = |batch: &mut Self, ox: f32| match g.rect {
|
||||
Some(r) => {
|
||||
let (x0, x1, y0, y1) = (
|
||||
@@ -1157,24 +1266,86 @@ impl HudBatch {
|
||||
let start = self.vertices.len();
|
||||
draw(self, 0.0);
|
||||
lift(self, start, base);
|
||||
if bold {
|
||||
if *bold {
|
||||
let start = self.vertices.len();
|
||||
draw(self, g.offset);
|
||||
lift(self, start, base + 0.001);
|
||||
}
|
||||
let x0 = if first { px - 1.0 } else { px };
|
||||
let x0 = if *first { px - 1.0 } else { px };
|
||||
let start = self.vertices.len();
|
||||
if strike {
|
||||
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
|
||||
if *strike {
|
||||
self.rect_uv(x0, py + 3.5, px + *advance - x0, 1.0, white, col);
|
||||
}
|
||||
if underline {
|
||||
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
|
||||
if *underline {
|
||||
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
|
||||
}
|
||||
lift(self, start, base + 0.01);
|
||||
}
|
||||
}
|
||||
cursor - x
|
||||
}
|
||||
|
||||
fn draw_inline_object(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
object: &crate::text::InlineObject,
|
||||
x: f32,
|
||||
y: f32,
|
||||
alpha: u8,
|
||||
) {
|
||||
use crate::text::InlineObject;
|
||||
let tint = rgba(255, 255, 255, alpha);
|
||||
match object {
|
||||
InlineObject::Player { profile, hat, .. } => {
|
||||
let id = crate::skins::parse_profile(profile).id;
|
||||
let name = inline_profile_name(profile);
|
||||
let by_id = atlas.object_skins.get(&format!("id:{id:032x}"));
|
||||
let by_name = name.and_then(|name| {
|
||||
atlas
|
||||
.object_skins
|
||||
.get(&format!("name:{}", name.to_ascii_lowercase()))
|
||||
});
|
||||
let resolved = if profile.get("id").is_some() {
|
||||
by_id.or(by_name)
|
||||
} else {
|
||||
by_name.or(by_id)
|
||||
};
|
||||
let default = crate::tab_list::default_skin(id);
|
||||
let skin = resolved
|
||||
.filter(|path| atlas.sprites.contains_key(*path))
|
||||
.map_or(default, String::as_str);
|
||||
let Some(rect) = atlas.sprites.get(skin).copied() else {
|
||||
return;
|
||||
};
|
||||
for u in [8.0, 40.0].into_iter().take(if *hat { 2 } else { 1 }) {
|
||||
let du = (rect.u1 - rect.u0) / 64.0;
|
||||
let dv = (rect.v1 - rect.v0) / 64.0;
|
||||
self.rect_uv(
|
||||
x,
|
||||
y,
|
||||
8.0,
|
||||
8.0,
|
||||
Rect {
|
||||
u0: rect.u0 + du * u,
|
||||
v0: rect.v0 + dv * 8.0,
|
||||
u1: rect.u0 + du * (u + 8.0),
|
||||
v1: rect.v0 + dv * 16.0,
|
||||
w: 8,
|
||||
h: 8,
|
||||
},
|
||||
tint,
|
||||
);
|
||||
}
|
||||
}
|
||||
InlineObject::Atlas { sprite, .. } => {
|
||||
let key = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
|
||||
if let Some(rect) = atlas.sprites.get(key).copied() {
|
||||
self.rect_uv(x, y, 8.0, 8.0, rect, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
|
||||
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
|
||||
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
|
||||
@@ -1203,6 +1374,13 @@ impl HudBatch {
|
||||
behind
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_profile_name(profile: &serde_json::Value) -> Option<&str> {
|
||||
profile
|
||||
.as_str()
|
||||
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str))
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct HudUniform {
|
||||
@@ -1783,6 +1961,39 @@ mod tests {
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
let item = root.join("assets/lobby/textures/item/npc");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::create_dir_all(&item).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources": [{"type": "directory", "source": "item", "prefix": "item/"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A server pack's NPC skin: two 16x16 frames, green then red.
|
||||
write_strip(
|
||||
&item.join("skin.png"),
|
||||
16,
|
||||
32,
|
||||
&[[0, 255, 0, 255], [255, 0, 0, 255]],
|
||||
Some(r#"{"animation": {"frametime": 3}}"#),
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["lobby:item/npc/skin"];
|
||||
// One frame, not the whole strip, which a model's UVs would span.
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 255, 0, 255]);
|
||||
let (_, animation) = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.expect("the namespaced sprite animates");
|
||||
assert_eq!(animation.frame_count(), 2);
|
||||
}
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
@@ -1945,6 +2156,102 @@ mod tests {
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_shadow_color_overrides_text_call_shadow() {
|
||||
let root = two_glyph_font("component-shadow");
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let text = crate::text::Text::styled(
|
||||
"a",
|
||||
crate::text::Style {
|
||||
shadow_color: Some(0x8040_2010),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices.len(), 12);
|
||||
assert_eq!(batch.vertices[0].color, rgba(0x40, 0x20, 0x10, 0x80));
|
||||
batch.vertices.clear();
|
||||
batch.text_styled_world(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices.len(), 12);
|
||||
assert_eq!(batch.vertices[0].depth, 0.0);
|
||||
assert_eq!(batch.vertices[6].depth, 0.03);
|
||||
batch.vertices.clear();
|
||||
let no_shadow = crate::text::Text::styled(
|
||||
"a",
|
||||
crate::text::Style {
|
||||
shadow_color: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
batch.text_styled(&atlas, &no_shadow, 10.0, 10.0, u32::MAX, true);
|
||||
assert_eq!(batch.vertices.len(), 6);
|
||||
batch.vertices.clear();
|
||||
let gradient = crate::component::to_text(
|
||||
&serde_json::json!({
|
||||
"text": "",
|
||||
"extra": [
|
||||
{"text": "a", "color": "#ff0000"},
|
||||
{"text": "b", "color": "#0000ff"}
|
||||
]
|
||||
}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
batch.text_styled(&atlas, &gradient, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices[0].color, rgba(255, 0, 0, 255));
|
||||
assert_eq!(batch.vertices[6].color, rgba(0, 0, 255, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_object_draws_an_inline_head() {
|
||||
let root = two_glyph_font("inline-head");
|
||||
write_png(&root, "entity/player/slim/alex", 64, 64);
|
||||
write_png(&root, "entity/player/wide/steve", 64, 64);
|
||||
let mut atlas = HudAtlas::build(&root).unwrap();
|
||||
let text = crate::component::to_text(
|
||||
&serde_json::json!({"object":"player","player":{"id":"00000000-0000-0000-0000-000000000000"}}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
assert_eq!(
|
||||
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false),
|
||||
8.0
|
||||
);
|
||||
assert_eq!(batch.vertices.len(), 12); // face and hat, six vertices each
|
||||
assert_eq!(batch.inline_profiles.len(), 1);
|
||||
atlas.set_object_skin("name:elyapluggin".into(), "entity/player/wide/steve".into());
|
||||
let named = crate::component::to_text(
|
||||
&serde_json::json!({"object":"player","player":{"name":"elyapluggin"}}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
batch.vertices.clear();
|
||||
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
|
||||
let skin = atlas.sprites["entity/player/wide/steve"];
|
||||
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlas_object_draws_an_item_sprite_inline() {
|
||||
let root = two_glyph_font("inline-item");
|
||||
write_png(&root, "item/porkchop", 16, 16);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let text = crate::component::to_text(
|
||||
&serde_json::json!({
|
||||
"text": "a",
|
||||
"extra": [{"object": "atlas", "sprite": "minecraft:item/porkchop"}]
|
||||
}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let width = batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(width, atlas.font.glyph('a').advance + 8.0);
|
||||
assert_eq!(batch.vertices.len(), 12); // glyph and one sprite
|
||||
let sprite = atlas.sprites["item/porkchop"];
|
||||
assert_eq!(batch.vertices[6].uv, [sprite.u0, sprite.v0]);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
|
||||
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
|
||||
@@ -2190,6 +2497,7 @@ mod tests {
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
};
|
||||
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
|
||||
assert_eq!(
|
||||
|
||||
@@ -59,17 +59,21 @@ pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod potent_sulfur;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod section_mask;
|
||||
pub mod settings;
|
||||
pub mod shader_pack_list;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
|
||||
chunk::{Chunk, ColumnLayout, Light, Section, index},
|
||||
};
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
@@ -273,7 +273,7 @@ pub fn light_chunk(
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
region[1][1].expect("centre chunk");
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
// Highest occupied section in the region bounds the work.
|
||||
let mut top_section = 0;
|
||||
for chunk in region.iter().flatten().flatten() {
|
||||
@@ -286,7 +286,7 @@ pub fn light_chunk(
|
||||
}
|
||||
// One extra section above the highest occupied one: block light rises
|
||||
// up to 14 blocks into it (sky light there is always 15).
|
||||
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
|
||||
// then never touches the large `LightTables::cells` table.
|
||||
@@ -422,14 +422,21 @@ pub fn light_chunk(
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(h, &sky, &block, sky_light)
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
/// Extract just the centre column's light; never copy worker snapshot sections.
|
||||
fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Light> {
|
||||
/// Extract just the centre column's light (`sections` of it); never copy
|
||||
/// worker snapshot sections.
|
||||
fn pack_lighting(
|
||||
sections: usize,
|
||||
h: usize,
|
||||
sky: &[u8],
|
||||
block: &[u8],
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
(0..SECTIONS)
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
if si * 16 >= h {
|
||||
return Light::Uniform(open);
|
||||
@@ -460,13 +467,21 @@ fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Lig
|
||||
/// edits and immutable snapshots retained by other workers.
|
||||
pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
|
||||
for (section, light) in chunk.sections.iter_mut().zip(lights) {
|
||||
// A shared section already lit so stays as it is.
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (§ion.light, &light)
|
||||
&& a == b
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Arc::make_mut(section).light = light;
|
||||
}
|
||||
crate::chunk::share_uniform(&mut chunk.sections);
|
||||
}
|
||||
|
||||
/// `apply_lighting` for a chunk lit before: writes only the sections whose
|
||||
/// light changed and adds the cells that changed to `changed`.
|
||||
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
|
||||
changed.keyed(chunk.layout());
|
||||
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
|
||||
let old = §ion.light;
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
|
||||
@@ -506,7 +521,8 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
|
||||
mut above: [u8; 256],
|
||||
sky_light: bool,
|
||||
) {
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS).rev() {
|
||||
let sections = chunk.sections.len();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(sections).rev() {
|
||||
let sky = sky.as_ref().filter(|_| sky_light);
|
||||
let section = Arc::make_mut(&mut chunk.sections[si]);
|
||||
if sky.is_none() && block.is_none() && above.iter().all(|&v| v == above[0]) {
|
||||
@@ -526,6 +542,7 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
|
||||
section.light = Light::Full(data);
|
||||
section.compact();
|
||||
}
|
||||
crate::chunk::share_uniform(&mut chunk.sections);
|
||||
chunk.lit = true;
|
||||
chunk.light_loaded = true;
|
||||
}
|
||||
@@ -539,7 +556,8 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
|
||||
sky_light: bool,
|
||||
) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS) {
|
||||
let sections = chunk.sections.len();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(sections) {
|
||||
let sky = sky.as_ref().filter(|_| sky_light);
|
||||
if sky.is_none() && block.is_none() {
|
||||
continue;
|
||||
@@ -569,8 +587,15 @@ pub trait LightWorld {
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
|
||||
/// Replace the light of a section `section` returned.
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
|
||||
/// The column the world's chunks hold (its level's).
|
||||
fn layout(&self) -> ColumnLayout;
|
||||
}
|
||||
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
|
||||
fn layout(&self) -> ColumnLayout {
|
||||
self.values()
|
||||
.next()
|
||||
.map_or(ColumnLayout::VANILLA, |c| c.layout())
|
||||
}
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
|
||||
self.get(&(cx, cz))
|
||||
.filter(|c| c.lit)
|
||||
@@ -587,6 +612,7 @@ impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S
|
||||
/// gets written is copied out on the first write and stored back by `finish`.
|
||||
struct Region<'w> {
|
||||
world: &'w mut dyn LightWorld,
|
||||
layout: ColumnLayout,
|
||||
slots: Vec<Slot>,
|
||||
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
|
||||
index: FxHashMap<Pos, u32>,
|
||||
@@ -602,6 +628,7 @@ const NONE: u32 = u32::MAX;
|
||||
impl<'w> Region<'w> {
|
||||
fn new(world: &'w mut dyn LightWorld) -> Self {
|
||||
Self {
|
||||
layout: world.layout(),
|
||||
world,
|
||||
slots: Vec::new(),
|
||||
index: FxHashMap::default(),
|
||||
@@ -610,10 +637,8 @@ impl<'w> Region<'w> {
|
||||
}
|
||||
#[inline]
|
||||
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
let s = self.layout.section_index(y)?;
|
||||
let key = (x >> 4, s as i32, z >> 4);
|
||||
if self.last.0 != key {
|
||||
self.last = (key, self.load(key));
|
||||
}
|
||||
@@ -641,6 +666,12 @@ impl<'w> Region<'w> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
Some(self.slots[slot].section.get(index(x, y, z)))
|
||||
}
|
||||
/// The one state of the section holding `y`, when it has a single one.
|
||||
fn uniform_state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
let section = &self.slots[slot].section;
|
||||
(section.blocks.bits() == 0).then(|| section.get(0))
|
||||
}
|
||||
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
|
||||
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
@@ -702,12 +733,20 @@ type Pos = (i32, i32, i32);
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LightChanges {
|
||||
sections: FxHashMap<Pos, u32>,
|
||||
/// The column the section indexes count in.
|
||||
layout: ColumnLayout,
|
||||
}
|
||||
impl LightChanges {
|
||||
/// Changes in chunks of `layout`: what the relighter or a light job adds
|
||||
/// after this (they all come from one level).
|
||||
fn keyed(&mut self, layout: ColumnLayout) {
|
||||
debug_assert!(self.sections.is_empty() || self.layout == layout);
|
||||
self.layout = layout;
|
||||
}
|
||||
/// A cell whose stored light changed.
|
||||
#[inline]
|
||||
pub fn push(&mut self, (x, y, z): Pos) {
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
let key = (x >> 4, (y - self.layout.min_y()) >> 4, z >> 4);
|
||||
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
|
||||
}
|
||||
/// Cells of section `key` (x, section index, z) in the given classes.
|
||||
@@ -732,7 +771,7 @@ impl LightChanges {
|
||||
self.sections.keys().copied()
|
||||
}
|
||||
/// Every section (x, section index, z) whose cells lie within one block
|
||||
/// of a changed cell, section indexes outside `0..SECTIONS` left out (a
|
||||
/// of a changed cell, section indexes outside the column left out (a
|
||||
/// section next to several changed ones comes once for each).
|
||||
pub fn dirty_sections(&self, mut out: impl FnMut(Pos)) {
|
||||
// Per class, the bit set of neighbour offsets (dx, dy, dz in -1..=1,
|
||||
@@ -759,6 +798,7 @@ impl LightChanges {
|
||||
}
|
||||
reach
|
||||
};
|
||||
let sections = self.layout.sections() as i32;
|
||||
for (&(x, sy, z), &classes) in &self.sections {
|
||||
let mut offsets = 0;
|
||||
let mut rest = classes;
|
||||
@@ -770,7 +810,7 @@ impl LightChanges {
|
||||
let o = offsets.trailing_zeros() as i32;
|
||||
offsets &= offsets - 1;
|
||||
let s = sy + o / 3 % 3 - 1;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
if (0..sections).contains(&s) {
|
||||
out((x + o % 3 - 1, s, z + o / 9 - 1));
|
||||
}
|
||||
}
|
||||
@@ -794,11 +834,21 @@ fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
|
||||
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
|
||||
/// edge in the column (every block at or above it is a sky source).
|
||||
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
let mut y = MIN_Y + HEIGHT - 1;
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
let mut y = max_y - 1;
|
||||
let Some(mut top) = world.state(x, y, z) else {
|
||||
return MIN_Y;
|
||||
return min_y;
|
||||
};
|
||||
while y > MIN_Y {
|
||||
while y > min_y {
|
||||
// A section of one state that lets light through itself (the air of
|
||||
// a tall column) holds no source edge: go to its bottom block.
|
||||
if let Some(state) = world.uniform_state(x, y - 1, z)
|
||||
&& state == top
|
||||
&& !tables.edge_occluded(top as usize, top as usize)
|
||||
{
|
||||
y = ((y - 1 - min_y) & !15) + min_y;
|
||||
continue;
|
||||
}
|
||||
let Some(bottom) = world.state(x, y - 1, z) else {
|
||||
break;
|
||||
};
|
||||
@@ -808,7 +858,7 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
|
||||
top = bottom;
|
||||
y -= 1;
|
||||
}
|
||||
MIN_Y
|
||||
min_y
|
||||
}
|
||||
/// Vanilla's `checkBlock` + `runLightUpdates` for changed positions on
|
||||
/// resident, lit chunks: clear the light that may have come through or from
|
||||
@@ -825,9 +875,27 @@ pub fn relight(
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
let mut region = Region::new(world);
|
||||
changed.keyed(region.layout);
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
}
|
||||
/// The columns `changed` touched that border a loaded column not lit yet
|
||||
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
|
||||
/// darkening can take away light such a neighbour sends in and never put it
|
||||
/// back, and nothing relights the column when that neighbour's own light
|
||||
/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine
|
||||
/// keeps one light storage for every chunk, so its edits and its chunk
|
||||
/// lighting see each other. These columns need a full light job, which
|
||||
/// lights them from the blocks of their whole 3x3, that neighbour's included.
|
||||
pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> {
|
||||
let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect();
|
||||
columns.sort_unstable();
|
||||
columns.dedup();
|
||||
columns.retain(|&(x, z)| {
|
||||
(-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz)))
|
||||
});
|
||||
columns
|
||||
}
|
||||
fn relight_region(
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
@@ -840,6 +908,7 @@ fn relight_region(
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed)
|
||||
});
|
||||
if sky_light {
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
let mut seeds = positions.to_vec();
|
||||
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
crate::spans::time("lt_columns", || {
|
||||
@@ -851,13 +920,13 @@ fn relight_region(
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
for y in source..max_y {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
seeds.push((x, y, z));
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
while y >= min_y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
@@ -883,6 +952,24 @@ fn relight_layer(
|
||||
let Some(old) = level(world, sky, p) else {
|
||||
continue;
|
||||
};
|
||||
// `BlockLightEngine.checkNode`: a block emitting at least the light
|
||||
// already there keeps it and pulls its neighbours' light in. Clearing
|
||||
// it would darken and relight everything it lit (flowing lava next to
|
||||
// lava: thousands of cells per block).
|
||||
if !sky && !force && old > 0 {
|
||||
let emission = world
|
||||
.state(p.0, p.1, p.2)
|
||||
.map_or(0, |s| tables.cells[s as usize].emission);
|
||||
if emission >= old {
|
||||
if emission > old {
|
||||
set_level(world, sky, p, emission);
|
||||
changed.push(p);
|
||||
}
|
||||
decrease.push_back((p, 0));
|
||||
increase.push_back(p);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if old > 0 {
|
||||
set_level(world, sky, p, 0);
|
||||
changed.push(p);
|
||||
@@ -959,6 +1046,7 @@ fn relight_layer(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
@@ -986,11 +1074,18 @@ mod tests {
|
||||
)
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
|
||||
}
|
||||
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
|
||||
fn world_of(
|
||||
layout: crate::chunk::ColumnLayout,
|
||||
edit: impl Fn(&mut Chunk),
|
||||
) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
let mut chunks = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
let mut c = Chunk::with_layout(x, z, layout);
|
||||
for y in layout.min_y()..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
@@ -1054,6 +1149,70 @@ mod tests {
|
||||
// Without the shape test the room would be lit (slabs have no dampening).
|
||||
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
|
||||
}
|
||||
/// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0)
|
||||
/// was lit from the 3x3's blocks, its torch's light and the far torch's
|
||||
/// in (1, 0) both. Taking its torch away darkens everything the torch
|
||||
/// lit, the far torch's light at the border too, and `relight` cannot
|
||||
/// bring that back from a column it doesn't read. Columns like that are
|
||||
/// lit again whole; without it the border stays at 0 for good once
|
||||
/// (1, 0)'s light lands beside it.
|
||||
#[test]
|
||||
fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() {
|
||||
let tables = tables();
|
||||
let mut w: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
if (x, z) == (0, 0) {
|
||||
c.set(14, 0, 8, TORCH);
|
||||
} else if (x, z) == (1, 0) {
|
||||
c.set(6, 0, 8, TORCH);
|
||||
}
|
||||
w.insert((x, z), Arc::new(c));
|
||||
}
|
||||
}
|
||||
let light = |w: &HashMap<(i32, i32), Arc<Chunk>>, (x, z): (i32, i32)| {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c)
|
||||
})
|
||||
});
|
||||
light_chunk(®ion, &tables, true)
|
||||
};
|
||||
let keys: Vec<_> = w.keys().copied().collect();
|
||||
for key in &keys {
|
||||
let lights = light(&w, *key);
|
||||
let c = Arc::make_mut(w.get_mut(key).unwrap());
|
||||
apply_lighting(c, lights);
|
||||
c.lit = *key != (1, 0);
|
||||
}
|
||||
// The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13.
|
||||
assert_eq!(block(&w, 15, 0, 8), 13);
|
||||
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed);
|
||||
let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit));
|
||||
for &key in &again {
|
||||
let lights = light(&w, key);
|
||||
let c = Arc::make_mut(w.get_mut(&key).unwrap());
|
||||
apply_relighting(c, lights, &mut LightChanges::default());
|
||||
}
|
||||
// (1, 0)'s own job lands.
|
||||
let lights = light(&w, (1, 0));
|
||||
let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap());
|
||||
apply_lighting(c, lights);
|
||||
c.lit = true;
|
||||
assert_eq!(block(&w, 16, 0, 8), 8);
|
||||
assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border");
|
||||
assert_eq!(block(&w, 14, 0, 8), 6);
|
||||
assert!(again.contains(&(0, 0)), "{again:?}");
|
||||
}
|
||||
#[test]
|
||||
fn relight_keeps_unchanged_and_unlit_sections() {
|
||||
let mut w = world(|_| {});
|
||||
@@ -1089,6 +1248,49 @@ mod tests {
|
||||
assert!(same(&w, (1, 0)));
|
||||
assert_eq!(block(&w, 14, 0, 8), 13);
|
||||
}
|
||||
/// Emitters placed in light at most as bright as theirs (flowing lava
|
||||
/// next to lava) only brighten: nothing they lit is darkened and lit
|
||||
/// again, and the result is the full relight's.
|
||||
#[test]
|
||||
fn emitters_placed_in_their_own_light_only_brighten() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
});
|
||||
let block = |w: &HashMap<(i32, i32), Arc<Chunk>>, p: Pos| {
|
||||
w[&(p.0 >> 4, p.2 >> 4)].light(p.0 & 15, p.1, p.2 & 15) & 15
|
||||
};
|
||||
for (x, y, z) in [(9, 0, 8), (10, 0, 8), (10, 1, 8), (11, 1, 9), (8, 0, 9)] {
|
||||
let around: Vec<Pos> = (-15..=15)
|
||||
.flat_map(|dx| {
|
||||
(-3..=3).flat_map(move |dy| (-15..=15).map(move |dz| (x + dx, y + dy, z + dz)))
|
||||
})
|
||||
.collect();
|
||||
let before: Vec<u8> = around.iter().map(|&p| block(&w, p)).collect();
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, TORCH);
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
|
||||
for (&p, &was) in around.iter().zip(&before) {
|
||||
assert!(block(&w, p) >= was, "{p:?} darkened from {was}");
|
||||
}
|
||||
}
|
||||
// An emitter checked again with its block light unchanged changes
|
||||
// nothing, so no section is remeshed.
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables(), false, &[(8, 0, 8)], false, &mut changed);
|
||||
assert!(changed.is_empty());
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
|
||||
});
|
||||
for (si, s) in light_chunk(®ion, &tables(), true).iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
w[&(0, 0)].sections[si].light(i),
|
||||
"section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn random_border_relights_match_full_relight() {
|
||||
// Edits around every border of chunk (0, 0), at the top and bottom of
|
||||
@@ -1157,6 +1359,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_is_lit_to_its_top() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
for i in 0..256 {
|
||||
chunk.set(i & 15, 1600, i >> 4, STONE);
|
||||
}
|
||||
chunk.set(8, 1500, 8, TORCH);
|
||||
let region = [[None; 3], [None, Some(&chunk), None], [None; 3]];
|
||||
let lights = light_chunk(®ion, &tables(), true);
|
||||
assert_eq!(lights.len(), 131);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
assert_eq!(chunk.light(8, 1500, 8) & 15, 14);
|
||||
assert_eq!(chunk.light(8, 1501, 8) & 15, 13);
|
||||
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
|
||||
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
|
||||
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
|
||||
// The empty sections above are one shared allocation.
|
||||
let top = chunk.sections.len() - 1;
|
||||
assert!(Arc::ptr_eq(&chunk.sections[top], &chunk.sections[top - 10]));
|
||||
let other = Chunk::with_layout(1, 0, layout);
|
||||
apply_lighting(&mut other.clone(), vec![Light::Uniform(0xF0); 131]);
|
||||
let mut lit = other.clone();
|
||||
apply_lighting(&mut lit, vec![Light::Uniform(0xF0); 131]);
|
||||
assert!(Arc::ptr_eq(&chunk.sections[top], &lit.sections[top]));
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut w = world_of(layout, |c| {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, 1900, i >> 4, STONE);
|
||||
}
|
||||
});
|
||||
let edits = [
|
||||
((8, 1800, 8), TORCH),
|
||||
((8, 1900, 8), AIR),
|
||||
((3, 2020, 3), TORCH),
|
||||
];
|
||||
let mut changed = LightChanges::default();
|
||||
for ((x, y, z), state) in edits {
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, state);
|
||||
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
|
||||
}
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
|
||||
});
|
||||
let full = light_chunk(®ion, &tables(), true);
|
||||
assert_eq!(full.len(), 131);
|
||||
for (si, s) in full.iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
w[&(0, 0)].sections[si].light(i),
|
||||
"section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// y 2020 is section 130 of the tall column, the top one.
|
||||
assert!(
|
||||
changed
|
||||
.sections()
|
||||
.any(|(x, sy, z)| (x, sy, z) == (0, 130, 0))
|
||||
);
|
||||
let mut dirty = Vec::new();
|
||||
changed.dirty_sections(|s| dirty.push(s.1));
|
||||
assert!(dirty.iter().all(|&sy| (0..131).contains(&sy)), "{dirty:?}");
|
||||
}
|
||||
#[test]
|
||||
fn incremental_relight_matches_full_relight() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
@@ -1387,9 +1657,10 @@ mod tests {
|
||||
.iter()
|
||||
.all(|v| matches!(v, Light::Uniform(n) if *n == if sky { 0xF0 } else { 0 }))
|
||||
);
|
||||
let section = Arc::as_ptr(&chunk.sections[0]);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
assert_eq!(section, Arc::as_ptr(&chunk.sections[0]));
|
||||
// Uniform sections are one shared allocation (`share_uniform`).
|
||||
let first = &chunk.sections[0];
|
||||
assert!(chunk.sections.iter().all(|s| Arc::ptr_eq(s, first)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1460,7 +1731,7 @@ mod tests {
|
||||
let mut old = fixture.clone();
|
||||
let mut new = fixture.clone();
|
||||
baseline_apply(&mut old, baseline_pack(&fixture, h, &sky, &block));
|
||||
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut new, pack_lighting(SECTIONS, h, &sky, &block, true));
|
||||
for (a, b) in old.sections.iter().zip(&new.sections) {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(a.light(i), b.light(i));
|
||||
@@ -1477,14 +1748,20 @@ mod tests {
|
||||
let mut current = fixture.clone();
|
||||
let held = shared.then(|| current.clone());
|
||||
// Warm up and detach for the uniquely-owned scenario.
|
||||
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut current, pack_lighting(SECTIONS, h, &sky, &block, true));
|
||||
let started = Instant::now();
|
||||
for _ in 0..500 {
|
||||
let retained = shared.then(|| current.clone());
|
||||
if optimized {
|
||||
apply_lighting(
|
||||
black_box(&mut current),
|
||||
pack_lighting(h, black_box(&sky), black_box(&block), true),
|
||||
pack_lighting(
|
||||
SECTIONS,
|
||||
h,
|
||||
black_box(&sky),
|
||||
black_box(&block),
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
baseline_apply(
|
||||
|
||||
@@ -161,8 +161,40 @@ impl Column {
|
||||
self.spans.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state.
|
||||
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
|
||||
/// Open water and its floor lit as open sky, as the seed's far columns
|
||||
/// are: a chunk's own light fades a level per block of water, so the
|
||||
/// chunks a player passed drew a darker band through the seed's sea.
|
||||
/// The water is one span again.
|
||||
fn light_open_water(&mut self, kinds: &[u8]) {
|
||||
// Water; lava glows.
|
||||
let water = |span: &Span| {
|
||||
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
|
||||
kind & FLUID != 0 && kind >> 4 == 0
|
||||
};
|
||||
if !self.spans.last().is_some_and(water) {
|
||||
return;
|
||||
}
|
||||
let floor = self
|
||||
.spans
|
||||
.iter()
|
||||
.rposition(|span| !water(span))
|
||||
.unwrap_or(0);
|
||||
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
|
||||
if i >= floor {
|
||||
span.light |= 0xf0;
|
||||
}
|
||||
self.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
|
||||
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
|
||||
fn from_chunk(
|
||||
chunk: &Chunk,
|
||||
kinds: &[u8],
|
||||
water: Option<BlockStateId>,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> Self {
|
||||
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
|
||||
let face_light = |y: i32| {
|
||||
let mut sky = 0;
|
||||
@@ -187,7 +219,7 @@ impl Column {
|
||||
};
|
||||
let mut column = Self::default();
|
||||
for (sy, section) in chunk.sections.iter().enumerate() {
|
||||
let min = MIN_Y + sy as i32 * 16;
|
||||
let min = chunk.min_y() + sy as i32 * 16;
|
||||
let uniform = section.blocks.bits() == 0
|
||||
&& section.biomes.bits() == 0
|
||||
&& matches!(section.light, crate::chunk::Light::Uniform(_));
|
||||
@@ -197,11 +229,17 @@ impl Column {
|
||||
let step = if uniform { 16 } else { 1 };
|
||||
for offset in (0..16).step_by(step) {
|
||||
let y = min + offset;
|
||||
let state = BlockStateId(chunk.get(x, y, z));
|
||||
let kind = kind(state);
|
||||
let mut state = BlockStateId(chunk.get(x, y, z));
|
||||
let mut kind = kind(state);
|
||||
if kind & KEPT == 0 {
|
||||
continue;
|
||||
}
|
||||
if kind & WATERY != 0
|
||||
&& let Some(water) = water
|
||||
{
|
||||
state = water;
|
||||
kind = kinds[water.0 as usize];
|
||||
}
|
||||
let light = face_light(y);
|
||||
column.push(Span {
|
||||
min: y as i16,
|
||||
@@ -212,6 +250,7 @@ impl Column {
|
||||
});
|
||||
}
|
||||
}
|
||||
column.light_open_water(kinds);
|
||||
let mut water = None;
|
||||
for span in column.spans.iter().rev() {
|
||||
if kind(span.state) & FLUID != 0 {
|
||||
@@ -230,7 +269,7 @@ impl Column {
|
||||
&& let Some((height, state)) = water
|
||||
{
|
||||
column.surface = Some(Surface {
|
||||
height: MIN_Y,
|
||||
height: chunk.min_y(),
|
||||
state: BlockStateId(
|
||||
kinds.iter().position(|&kind| kind & AIR != 0).unwrap_or(0) as u32
|
||||
),
|
||||
@@ -243,15 +282,28 @@ impl Column {
|
||||
}
|
||||
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
|
||||
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
|
||||
let water = water_source(kinds);
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32)
|
||||
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
|
||||
}))
|
||||
}
|
||||
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
|
||||
/// emission is in the high nibble.
|
||||
/// The water source state: the first plain kept fluid without light
|
||||
/// (`minecraft:water` comes before lava, and its level 0 first).
|
||||
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
|
||||
kinds
|
||||
.iter()
|
||||
.position(|&kind| kind == KEPT | FLUID)
|
||||
.map(|i| BlockStateId(i as u32))
|
||||
}
|
||||
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
|
||||
/// holding water; the light emission is in the high nibble.
|
||||
const KEPT: u8 = 1;
|
||||
const FLUID: u8 = 2;
|
||||
const AIR: u8 = 4;
|
||||
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
|
||||
/// waterlogged blocks that aren't kept): far terrain draws water there, or
|
||||
/// kelp reaching the surface leaves holes in the sea.
|
||||
const WATERY: u8 = 8;
|
||||
/// What `Column::from_chunk` needs of each block state, read from the facts
|
||||
/// once: matching their names for every block made observing a chunk slow.
|
||||
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
@@ -270,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
|| f.full_collision
|
||||
|| f.name.ends_with("_leaves")
|
||||
|| chorus
|
||||
|| fluid);
|
||||
(u8::from(kept) * KEPT)
|
||||
| (u8::from(fluid) * FLUID)
|
||||
|| fluid
|
||||
|| nearly_full(&f.name));
|
||||
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
|
||||
(u8::from(kept || watery) * KEPT)
|
||||
| (u8::from(fluid || watery) * FLUID)
|
||||
| (u8::from(watery) * WATERY)
|
||||
| (u8::from(state == facts.air) * AIR)
|
||||
| (f.emission.min(15) << 4)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Blocks that fill their cell but neither draw solid nor collide as a full
|
||||
/// block: far terrain draws them as blocks, or patches of powder snow leave
|
||||
/// holes in mountains (and paths and fields dips).
|
||||
fn nearly_full(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"minecraft:powder_snow"
|
||||
| "minecraft:dirt_path"
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:honey_block"
|
||||
) || name.ends_with("_stairs")
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct TileKey {
|
||||
pub x: i32,
|
||||
@@ -435,6 +502,28 @@ impl Plan {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
|
||||
/// at level 0, also `filled`) rather than by a coarser ancestor standing
|
||||
/// in for it, or is past the far terrain.
|
||||
fn replaces(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
ready: &FxHashMap<TileKey, impl Sized>,
|
||||
filled: impl Fn(TileKey) -> bool,
|
||||
) -> bool {
|
||||
let Some(key) = (0..=MAX_LEVEL)
|
||||
.map(|level| TileKey {
|
||||
x: (x * 16).div_euclid(16 << level),
|
||||
z: (z * 16).div_euclid(16 << level),
|
||||
level,
|
||||
})
|
||||
.find(|key| self.nodes.contains(key))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
// A split node's child there is out of range: nothing is drawn.
|
||||
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
|
||||
}
|
||||
}
|
||||
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
|
||||
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
|
||||
@@ -534,6 +623,8 @@ struct Shared {
|
||||
kinds: std::sync::OnceLock<Vec<u8>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Reduced far detail: samplers approximate wide cells.
|
||||
approximate: AtomicBool,
|
||||
}
|
||||
/// One background worker and at most two outstanding sample jobs. Only the
|
||||
/// current view's quadtree and a bounded set of visited summaries are retained.
|
||||
@@ -571,8 +662,17 @@ pub struct Lod {
|
||||
active: FxHashSet<TileKey>,
|
||||
dirty: FxHashSet<TileKey>,
|
||||
removed: Vec<TileKey>,
|
||||
/// The columns left out: `full` and `retiring`.
|
||||
covered: FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
/// The full-detail columns `update` was last given.
|
||||
full: FxHashSet<(i32, i32)>,
|
||||
/// Columns that left the full-detail area, still left out (their
|
||||
/// full-detail meshes still drawn) until the tile planned there is ready
|
||||
/// (`Plan::replaces`). Tiles inside that area are never sampled: when the
|
||||
/// render distance shrinks, their coarsest ancestors would otherwise
|
||||
/// stand in for it for minutes.
|
||||
retiring: FxHashSet<(i32, i32)>,
|
||||
/// The covered columns the active meshes leave out, when a mesh job
|
||||
/// has finished since the last `take_cutout`.
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
@@ -589,6 +689,7 @@ impl Lod {
|
||||
kinds: std::sync::OnceLock::new(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
approximate: AtomicBool::new(false),
|
||||
});
|
||||
let worker = shared.clone();
|
||||
let mesh_context = context.clone();
|
||||
@@ -633,6 +734,9 @@ impl Lod {
|
||||
}
|
||||
};
|
||||
let [x, _, z] = key.origin();
|
||||
sample.set_approximate(
|
||||
shared.approximate.load(Ordering::Relaxed),
|
||||
);
|
||||
let samples =
|
||||
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
@@ -695,6 +799,7 @@ impl Lod {
|
||||
}
|
||||
Job::Sample(key) => {
|
||||
let [x, _, z] = key.origin();
|
||||
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
|
||||
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
Done::Sample { key, samples }
|
||||
}
|
||||
@@ -770,6 +875,8 @@ impl Lod {
|
||||
removed: Vec::new(),
|
||||
covered: FxHashSet::default(),
|
||||
covered_bounds: None,
|
||||
full: FxHashSet::default(),
|
||||
retiring: FxHashSet::default(),
|
||||
cutout: None,
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
@@ -991,9 +1098,11 @@ impl Lod {
|
||||
self.near_loading = loading;
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
|
||||
/// replans when it changes.
|
||||
/// replans when it changes, and tiles sampled from then on approximate
|
||||
/// wide cells (`GeneratorWorker::lod_grid`).
|
||||
pub fn set_reduced_detail(&mut self, reduced: bool) {
|
||||
self.reduced_detail = reduced;
|
||||
self.shared.approximate.store(reduced, Ordering::Relaxed);
|
||||
}
|
||||
fn held(&self) -> bool {
|
||||
self.small && self.near_loading
|
||||
@@ -1017,6 +1126,16 @@ impl Lod {
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
// Retiring fine columns are empty stand-ins, excluded from `active`
|
||||
// while their normal meshes still cover the gap. Request their detail
|
||||
// explicitly: neither the active-tile requests nor sampling an already
|
||||
// ready stand-in can fill it, so its GPU sections would never retire.
|
||||
keys.extend(retiring_detail(
|
||||
&self.plan,
|
||||
&self.retiring,
|
||||
&self.saved,
|
||||
&self.detail_masks,
|
||||
));
|
||||
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
|
||||
let mut chunks = FxHashSet::default();
|
||||
for key in keys {
|
||||
@@ -1029,7 +1148,8 @@ impl Lod {
|
||||
(ox + (index as i32 % 16) * stride).div_euclid(16),
|
||||
(oz + (index as i32 / 16) * stride).div_euclid(16),
|
||||
);
|
||||
if self.covered.contains(&chunk) {
|
||||
// A retiring column's tile is drawn soon: it needs its detail.
|
||||
if self.full.contains(&chunk) {
|
||||
self.detail_masks.entry(key).or_default()[index / 64] |= 1 << (index % 64);
|
||||
} else {
|
||||
chunks.insert(chunk);
|
||||
@@ -1151,31 +1271,13 @@ impl Lod {
|
||||
}
|
||||
/// Takes the new full-detail columns, when they changed (`None`: not).
|
||||
fn cover(&mut self, covered: Option<FxHashSet<(i32, i32)>>) {
|
||||
if let Some(covered) = covered
|
||||
&& self.covered != covered
|
||||
if let Some(full) = covered
|
||||
&& self.full != full
|
||||
{
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
self.covered = covered;
|
||||
self.replanned = true;
|
||||
self.reselect = true;
|
||||
self.retiring.extend(self.full.difference(&full).copied());
|
||||
self.retiring.retain(|column| !full.contains(column));
|
||||
self.full = full;
|
||||
self.leave_out();
|
||||
}
|
||||
// Complete resident columns need no duplicate noise work. Their real
|
||||
// summaries are retained when the full-detail chunks leave the view.
|
||||
@@ -1189,12 +1291,55 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Leaves out the full-detail and retiring columns: the tiles touching
|
||||
/// one that changed are meshed again.
|
||||
fn leave_out(&mut self) {
|
||||
let covered: FxHashSet<_> = self.full.union(&self.retiring).copied().collect();
|
||||
if self.covered == covered {
|
||||
return;
|
||||
}
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
// Only new covered columns need stand-ins.
|
||||
self.replanned |= !covered.is_subset(&self.covered);
|
||||
self.covered = covered;
|
||||
self.reselect = true;
|
||||
}
|
||||
/// Chooses the tiles to draw and meshes those that changed, when the
|
||||
/// plan, the samples, the covered columns or the active tiles changed.
|
||||
fn select(&mut self) {
|
||||
if !std::mem::take(&mut self.reselect) && self.dirty.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.retiring.is_empty() {
|
||||
let (plan, ready, saved, masks) =
|
||||
(&self.plan, &self.ready, &self.saved, &self.detail_masks);
|
||||
// A level-0 tile there is an empty stand-in (`cover`) until the
|
||||
// chunk's summary or generated detail fills it (`apply_fine`).
|
||||
self.retiring.retain(|&column| {
|
||||
!plan.replaces(column, ready, |key| {
|
||||
saved.contains_key(&column) || masks.get(&key) == Some(&[u64::MAX; 4])
|
||||
})
|
||||
});
|
||||
self.leave_out();
|
||||
}
|
||||
{
|
||||
let mut selected = Vec::new();
|
||||
for &key in &self.plan.roots {
|
||||
@@ -1495,11 +1640,84 @@ impl Drop for Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
|
||||
/// can release their normal meshes. The caller keeps the usual 32-column
|
||||
/// request budget and waits for the replacement mesh before changing cutout.
|
||||
fn retiring_detail<'a>(
|
||||
plan: &'a Plan,
|
||||
retiring: &'a FxHashSet<(i32, i32)>,
|
||||
saved: &'a Saved,
|
||||
masks: &'a FxHashMap<TileKey, [u64; 4]>,
|
||||
) -> impl Iterator<Item = TileKey> + 'a {
|
||||
retiring.iter().filter_map(move |&(x, z)| {
|
||||
let key = TileKey { x, z, level: 0 };
|
||||
(plan.nodes.contains(&key)
|
||||
&& !saved.contains_key(&(x, z))
|
||||
&& masks.get(&key) != Some(&[u64::MAX; 4]))
|
||||
.then_some(key)
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
#[test]
|
||||
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
|
||||
let column = (12, 0);
|
||||
let fine = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 0,
|
||||
};
|
||||
let mut plan = Plan::default();
|
||||
plan.nodes.insert(fine);
|
||||
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
|
||||
let retiring = [column].into_iter().collect();
|
||||
let saved = Saved::default();
|
||||
let mut masks = FxHashMap::default();
|
||||
// A covered stand-in is ready, but empty: ordinary sampling skips it,
|
||||
// and selection leaves it out of `active`. It cannot yet replace the
|
||||
// old full-detail column, even though the near world has unloaded it.
|
||||
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
|
||||
== Some(&[u64::MAX; 4])));
|
||||
assert_eq!(
|
||||
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
|
||||
[fine]
|
||||
);
|
||||
// The existing RequestDetail -> apply_fine path marks complete detail;
|
||||
// only then may selection retire it and publish the new mesh cutout.
|
||||
masks.insert(fine, [u64::MAX; 4]);
|
||||
assert!(
|
||||
retiring_detail(&plan, &retiring, &saved, &masks)
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
|
||||
== Some(&[u64::MAX; 4])));
|
||||
}
|
||||
#[test]
|
||||
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
|
||||
let fine = |x| TileKey { x, z: 0, level: 0 };
|
||||
let mut plan = Plan::default();
|
||||
plan.nodes.extend((0..4).map(fine));
|
||||
plan.nodes.insert(fine(5));
|
||||
let retiring = (0..5).map(|x| (x, 0)).collect();
|
||||
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let masks = [
|
||||
(fine(2), [u64::MAX; 4]),
|
||||
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
|
||||
requested.sort_unstable();
|
||||
// Saved or complete detail needs no request; an unplanned retiree and
|
||||
// a planned column that is not retiring must not consume the budget.
|
||||
assert_eq!(requested, [fine(0), fine(3)]);
|
||||
}
|
||||
#[test]
|
||||
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
|
||||
let leaves = |plan: &Plan| {
|
||||
plan.nodes
|
||||
@@ -1611,6 +1829,48 @@ mod tests {
|
||||
assert_eq!(selected, parent.children());
|
||||
}
|
||||
#[test]
|
||||
fn a_shrunk_render_distance_keeps_columns_until_their_planned_tiles_are_ready() {
|
||||
// Render distance 64 under LOD 64: every tile inside it is a level-0
|
||||
// stand-in for a covered column, and only the roots are sampled.
|
||||
let camera = [8, 8];
|
||||
let before = Plan::new(camera, 64, 64);
|
||||
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
|
||||
let mut ready: FxHashMap<TileKey, ()> = before
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| key.level == 0 || before.roots.contains(key))
|
||||
.map(|&key| (key, ()))
|
||||
.collect();
|
||||
ready.retain(|key, _| after.nodes.contains(key));
|
||||
let contains = |key: &TileKey, (x, z): (i32, i32)| {
|
||||
let [ox, _, oz] = key.origin();
|
||||
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
|
||||
};
|
||||
// 24 chunks out, now past the render distance: its planned tile was
|
||||
// split before, so never sampled, and a root stands in for it.
|
||||
let column = (24, 0);
|
||||
let mut selected = Vec::new();
|
||||
for &root in &after.roots {
|
||||
after.select(root, &ready, &mut selected);
|
||||
}
|
||||
let drawn = selected.iter().filter(|key| contains(key, column));
|
||||
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
|
||||
assert!(!after.replaces(column, &ready, |_| true));
|
||||
let planned = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 1,
|
||||
};
|
||||
assert!(after.nodes.contains(&planned) && before.split.contains(&planned));
|
||||
ready.insert(planned, ());
|
||||
assert!(after.replaces(column, &ready, |_| true));
|
||||
// 12 chunks out, its level-0 tile is the stand-in until filled.
|
||||
assert!(!after.replaces((12, 0), &ready, |_| false));
|
||||
assert!(after.replaces((12, 0), &ready, |_| true));
|
||||
// Past the far terrain nothing waits.
|
||||
assert!(after.replaces((100, 0), &ready, |_| false));
|
||||
}
|
||||
#[test]
|
||||
fn detail_survives_movement_across_a_split_threshold() {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
@@ -1662,6 +1922,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
|
||||
let span = |min, max, state, light| Span {
|
||||
min,
|
||||
max,
|
||||
state: BlockStateId(state),
|
||||
biome: 0,
|
||||
light,
|
||||
};
|
||||
// 0 air, 1 stone, 2 water, 3 lava.
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
|
||||
// A chunk's own light: a cave, the floor, water fading a level a block.
|
||||
let mut sea = Column {
|
||||
surface: None,
|
||||
spans: vec![
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0x10 | 2 << 12),
|
||||
span(48, 61, 2, 0x20),
|
||||
span(61, 62, 2, 0xe0),
|
||||
span(62, 63, 2, 0xf0),
|
||||
],
|
||||
};
|
||||
sea.light_open_water(&kinds);
|
||||
assert_eq!(
|
||||
sea.spans,
|
||||
[
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0xf0 | 2 << 12),
|
||||
span(48, 63, 2, 0xf0)
|
||||
]
|
||||
);
|
||||
// Land, and lava, keep their light.
|
||||
for top in [1, 3] {
|
||||
let mut column = Column {
|
||||
surface: None,
|
||||
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)],
|
||||
};
|
||||
let before = column.spans.clone();
|
||||
column.light_open_water(&kinds);
|
||||
assert_eq!(column.spans, before);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn nearly_full_blocks_are_kept() {
|
||||
assert!(nearly_full("minecraft:powder_snow"));
|
||||
assert!(nearly_full("minecraft:dirt_path"));
|
||||
assert!(nearly_full("minecraft:oak_stairs"));
|
||||
assert!(!nearly_full("minecraft:oak_fence"));
|
||||
assert!(!nearly_full("minecraft:wheat"));
|
||||
}
|
||||
#[test]
|
||||
fn kelp_to_the_surface_is_drawn_as_water() {
|
||||
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in [3, 4] {
|
||||
for y in MIN_Y..40 {
|
||||
chunk.set(x, y, 3, 1);
|
||||
}
|
||||
}
|
||||
for y in 40..63 {
|
||||
chunk.set(3, y, 3, 3);
|
||||
chunk.set(4, y, 3, 2);
|
||||
}
|
||||
let water = water_source(&kinds);
|
||||
assert_eq!(water, Some(BlockStateId(2)));
|
||||
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
|
||||
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
|
||||
assert_eq!(kelp.spans, sea.spans);
|
||||
assert_eq!(
|
||||
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
|
||||
Some((40, 63, BlockStateId(2)))
|
||||
);
|
||||
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
|
||||
assert_eq!(kelp.surface.unwrap().height, 40);
|
||||
}
|
||||
#[test]
|
||||
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
|
||||
let span = |min, max, state| Span {
|
||||
min,
|
||||
@@ -1857,6 +2193,24 @@ mod tests {
|
||||
chunk.set(8, 71, 8, chorus.0);
|
||||
chunk.set(8, 72, 8, chorus.0);
|
||||
chunk.set(8, 73, 8, flower.0);
|
||||
// Kelp to the surface is water; powder snow and a waterlogged stair
|
||||
// are kept as themselves.
|
||||
let state = |name: &str| assets.registry.default_state(name).unwrap();
|
||||
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
|
||||
let stair = facts
|
||||
.states
|
||||
.iter()
|
||||
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
|
||||
.map(|i| BlockStateId(i as u32))
|
||||
.unwrap();
|
||||
chunk.set(2, 54, 2, stone.0);
|
||||
for y in 55..64 {
|
||||
chunk.set(2, y, 2, kelp.0);
|
||||
}
|
||||
chunk.set(6, 90, 6, stone.0);
|
||||
chunk.set(6, 91, 6, powder_snow.0);
|
||||
chunk.set(10, 54, 10, stone.0);
|
||||
chunk.set(10, 55, 10, stair.0);
|
||||
lod.observe(Arc::new(chunk.clone()), &facts);
|
||||
receive_observed(&mut lod);
|
||||
let observed = &lod.saved[&(-1, -1)];
|
||||
@@ -1874,6 +2228,12 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
|
||||
);
|
||||
let kelp_column = &observed[2 * 16 + 2];
|
||||
assert_eq!(kelp_column.surface.unwrap().height, 55);
|
||||
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
|
||||
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
|
||||
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
|
||||
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
|
||||
|
||||
let fine = TileKey {
|
||||
x: -1,
|
||||
|
||||
@@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
|
||||
/// platform data folder (launcher/src/backend.rs `game_home`).
|
||||
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
|
||||
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Makes `game_home` return `home` for the rest of the run (`--home`).
|
||||
pub fn set_game_home(home: PathBuf) {
|
||||
let _ = HOME.set(home);
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
|
||||
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
|
||||
pub fn game_home() -> PathBuf {
|
||||
if let Some(home) = HOME.get() {
|
||||
return home.clone();
|
||||
}
|
||||
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon"),
|
||||
|
||||
@@ -123,9 +123,25 @@ mod vertex_tests {
|
||||
fn emission_keeps_the_colour_and_the_free_bits() {
|
||||
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
|
||||
assert_eq!(super::emitting(rgb, 0), rgb);
|
||||
assert_eq!(super::emitting(rgb, 9), 0xF656_3412);
|
||||
assert_eq!(super::emitting(rgb, 15), 0xF056_3412);
|
||||
assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb);
|
||||
assert_eq!(super::emitting(rgb, super::INCANDESCENT), 0xFD56_3412);
|
||||
assert_eq!(super::emitting(rgb, super::VERY_BRIGHT), 0xFB56_3412);
|
||||
assert_eq!(
|
||||
super::emitting(rgb, super::VERY_BRIGHT) | super::EMISSION_BITS,
|
||||
rgb
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn light_sources_shine_by_kind_not_light_level() {
|
||||
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
|
||||
assert_eq!(hdr_tier("glowstone", 15), BRIGHT);
|
||||
// A candle's flame is a particle: the block keeps only its wick.
|
||||
assert_eq!(hdr_tier("torch", 14), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("red_candle", 3), LOW);
|
||||
assert_eq!(hdr_tier("glow_lichen", 7), 0);
|
||||
assert_eq!(hdr_tier("stone", 0), 0);
|
||||
// Unlisted ones by their light level.
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
|
||||
}
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
@@ -263,6 +279,8 @@ enum Tint {
|
||||
/// `BlockTintSources.grassBlock`: grass in the world, none on its particles.
|
||||
GrassBlock,
|
||||
TallGrassUpper,
|
||||
SugarCane,
|
||||
LilyPad,
|
||||
Foliage,
|
||||
DryFoliage,
|
||||
Water,
|
||||
@@ -282,6 +300,8 @@ struct Info {
|
||||
/// `getShadeBrightness` (ambient occlusion contribution).
|
||||
shade: f32,
|
||||
emission: u8,
|
||||
/// `hdr_tier`.
|
||||
tier: u8,
|
||||
/// `emissiveRendering`: drawn and sampled at full brightness.
|
||||
emissive: bool,
|
||||
fluid: FluidState,
|
||||
@@ -329,6 +349,10 @@ pub struct MeshTables {
|
||||
lava_still: u16,
|
||||
lava_flow: u16,
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
}
|
||||
fn redstone_color(power: u8) -> u32 {
|
||||
// RedstoneWireBlock.COLORS
|
||||
let f = f32::from(power) / 15.0;
|
||||
@@ -377,10 +401,9 @@ impl MeshTables {
|
||||
([Tint::Grass; 2], 1)
|
||||
}
|
||||
}
|
||||
"fern" | "short_grass" | "potted_fern" | "bush" | "sugar_cane" => {
|
||||
([Tint::Grass; 2], 1)
|
||||
}
|
||||
"fern" | "short_grass" | "potted_fern" | "bush" => ([Tint::Grass; 2], 1),
|
||||
"grass_block" => ([Tint::GrassBlock; 2], 1),
|
||||
"sugar_cane" => ([Tint::SugarCane; 2], 1),
|
||||
"pink_petals" | "wildflowers" => ([Tint::None, Tint::Grass], 2),
|
||||
"spruce_leaves" => ([Tint::Constant(0x61_9961); 2], 1),
|
||||
"birch_leaves" => ([Tint::Constant(0x80_A755); 2], 1),
|
||||
@@ -395,7 +418,7 @@ impl MeshTables {
|
||||
([Tint::Constant(0xE0_C71C); 2], 1)
|
||||
}
|
||||
"melon_stem" | "pumpkin_stem" => ([Tint::Stem(prop("age")); 2], 1),
|
||||
"lily_pad" => ([Tint::Constant(0x20_8030); 2], 1),
|
||||
"lily_pad" => ([Tint::LilyPad; 2], 1),
|
||||
_ => ([Tint::None; 2], 0),
|
||||
};
|
||||
// getShadeBrightness overrides; the default is 0.2 for full collision shapes.
|
||||
@@ -450,6 +473,7 @@ impl MeshTables {
|
||||
permeable: !s.solid_render || s.dampening == 0,
|
||||
shade,
|
||||
emission: s.emission,
|
||||
tier: hdr_tier(name, s.emission),
|
||||
emissive,
|
||||
fluid: FluidState::from_block(state)?,
|
||||
model: s.render_shape == RenderShape::Model,
|
||||
@@ -491,6 +515,25 @@ impl MeshTables {
|
||||
tint_sources: i.tint_count,
|
||||
})
|
||||
}
|
||||
/// `BlockTintSource.color(state)`: block-model colour without a level.
|
||||
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
|
||||
/// Untinted faces, blank sources and invalid tint indices return `None`.
|
||||
pub fn default_tint(&self, state: BlockStateId, index: i32, grass: u32) -> Option<u32> {
|
||||
let info = self.info.get(state.0 as usize)?;
|
||||
if index < 0 || index >= i32::from(info.tint_count) {
|
||||
return None;
|
||||
}
|
||||
Some(match info.tints[index as usize] {
|
||||
Tint::None | Tint::SugarCane | Tint::Water => return None,
|
||||
Tint::Grass | Tint::GrassBlock | Tint::TallGrassUpper => grass & 0xFF_FFFF,
|
||||
Tint::Foliage => 0x48_B518,
|
||||
Tint::DryFoliage => 0x5C_3C32,
|
||||
Tint::LilyPad => 0x71_C35C,
|
||||
Tint::Constant(c) => c,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
Tint::Stem(age) => stem_color(age),
|
||||
})
|
||||
}
|
||||
/// `TerrainParticle`'s colour: tint source 0's `colorAsTerrainParticle`,
|
||||
/// which is the in-world (biome-blended) colour except on grass blocks,
|
||||
/// whose dirt crumbs stay untinted. `None` when the state has no source.
|
||||
@@ -513,6 +556,56 @@ impl MeshTables {
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
/// chunk mesher tints them (grass blocks included): `None` for an
|
||||
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
|
||||
pub fn block_tints(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
biomes: &Biomes,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
) -> impl Fn(i32) -> Option<u32> + use<> {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
});
|
||||
move |index| {
|
||||
let (colors, count) = sources?;
|
||||
if index < 0 || index >= count {
|
||||
return None;
|
||||
}
|
||||
colors[(index as usize).min(1)]
|
||||
}
|
||||
}
|
||||
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
|
||||
/// packing, the quad's own emission included) for `quad` of `state`
|
||||
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
|
||||
/// it: `light` is the world's stored light (sky << 4 | block) at a
|
||||
/// position. For block models drawn outside the chunk mesh.
|
||||
pub fn flat_light(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
quad: &horizon_assets::bake::ModelQuad,
|
||||
light: impl Fn([i32; 3]) -> u8,
|
||||
) -> u32 {
|
||||
let own = self.info.get(state.0 as usize);
|
||||
let full = own.is_some_and(|i| i.full);
|
||||
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
|
||||
let packed = light(at);
|
||||
let coords = match own {
|
||||
Some(own) => lighting::packed_coords(own, packed),
|
||||
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
|
||||
};
|
||||
lighting::with_emission(coords, quad.light_emission)
|
||||
}
|
||||
/// `BlockState.getOffset` for blocks with a random model offset.
|
||||
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
|
||||
offset(self.info.get(state.0 as usize)?, x, z)
|
||||
@@ -546,7 +639,7 @@ impl Padded {
|
||||
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
|
||||
let mut blocks = vec![0u32; P * P * P];
|
||||
let mut light = vec![0u8; P * P * P];
|
||||
let base_y = MIN_Y + sy * 16;
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
let mut own = Box::new([0u32; 4096]);
|
||||
@@ -580,13 +673,37 @@ impl Padded {
|
||||
}
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold the light its face emits, for
|
||||
/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none.
|
||||
/// The byte's other four bits stay set.
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
|
||||
/// byte's other four bits stay set.
|
||||
pub const EMISSION_BITS: u32 = 0x0F00_0000;
|
||||
/// `color` marked as emitting light `level` (0 to 15).
|
||||
pub fn emitting(color: u32, level: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24
|
||||
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
|
||||
/// a torch's, it only lights less around it. 0 stays as in SDR.
|
||||
pub const LOW: u8 = 1;
|
||||
pub const INCANDESCENT: u8 = 2;
|
||||
pub const BRIGHT: u8 = 3;
|
||||
/// The sun and lightning.
|
||||
pub const VERY_BRIGHT: u8 = 4;
|
||||
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
|
||||
pub fn emitting(color: u32, tier: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
|
||||
}
|
||||
/// The HDR tier of block `name` (no namespace), which emits light `level`.
|
||||
pub fn hdr_tier(name: &str, level: u8) -> u8 {
|
||||
match name {
|
||||
_ if level == 0 => 0,
|
||||
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
|
||||
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
|
||||
| "sea_lantern" => INCANDESCENT,
|
||||
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
|
||||
// Its flame is a particle.
|
||||
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
|
||||
// A faint glow in the dark, as in SDR.
|
||||
"glow_lichen" => 0,
|
||||
_ if level >= 10 => INCANDESCENT,
|
||||
_ => LOW,
|
||||
}
|
||||
}
|
||||
const UNSET: u32 = u32::MAX;
|
||||
const NO_BIOME: u16 = u16::MAX;
|
||||
@@ -731,7 +848,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
let pad = Padded::new(region, sy);
|
||||
out.visibility = visibility(&pad, &self.tables.info);
|
||||
let base_y = MIN_Y + sy * 16;
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
self.biomes,
|
||||
@@ -838,17 +955,15 @@ impl Mesher<'_> {
|
||||
}
|
||||
Some(match state.tints[(index as usize).min(1)] {
|
||||
Tint::None => return None,
|
||||
Tint::Grass | Tint::GrassBlock => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
|
||||
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
|
||||
Tint::Water => tints.get(Resolver::Water, x, y, z),
|
||||
Tint::Constant(c) => c,
|
||||
Tint::LilyPad => 0x20_8030,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
Tint::Stem(age) => {
|
||||
let a = u32::from(age);
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
}
|
||||
Tint::Stem(age) => stem_color(age),
|
||||
})
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -864,6 +979,7 @@ impl Mesher<'_> {
|
||||
state: &Info,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
let tier = state.tier.max(hdr_tier("", q.light_emission));
|
||||
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
|
||||
let directional = lighting::cardinal(self.nether_shading, shade_face);
|
||||
// Grey level per vertex before tinting, and vanilla light coords.
|
||||
@@ -903,7 +1019,7 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
face.layer as u16
|
||||
},
|
||||
emitting(pack_rgb(rgb), state.emission.max(q.light_emission)),
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -227,8 +227,11 @@ pub(super) fn tesselate(
|
||||
} else {
|
||||
0xFF_FFFF
|
||||
};
|
||||
// Lava emits light level 15.
|
||||
let emission = if water { 0 } else { 15 };
|
||||
let emission = if water {
|
||||
0
|
||||
} else {
|
||||
crate::mesher::INCANDESCENT
|
||||
};
|
||||
let color = |by: f32| {
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
|
||||
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
|
||||
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
|
||||
/// (block level << 4 | sky level << 20).
|
||||
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
|
||||
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
|
||||
}
|
||||
/// `coords` of a stored light byte (sky level << 4 | block level).
|
||||
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
|
||||
if state.emissive {
|
||||
return FULL_BRIGHT;
|
||||
}
|
||||
let packed = pad.light[pi(p[0], p[1], p[2])];
|
||||
let block = u32::from((packed & 15).max(state.emission));
|
||||
block << 4 | u32::from(packed >> 4) << 20
|
||||
}
|
||||
@@ -274,8 +277,28 @@ pub(super) fn flat(
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
) -> u32 {
|
||||
let cubic = shape(positions, face, own.full).cubic;
|
||||
coords(pad, own, if cubic { step(pos, face) } else { pos })
|
||||
coords(
|
||||
pad,
|
||||
own,
|
||||
flat_position(pos, positions, face, None, own.full),
|
||||
)
|
||||
}
|
||||
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
|
||||
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
|
||||
/// when the quad is cubic, else the block itself). `full` is the block's
|
||||
/// `isCollisionShapeFullBlock`.
|
||||
pub(super) fn flat_position(
|
||||
pos: [i32; 3],
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
cull: Option<D>,
|
||||
full: bool,
|
||||
) -> [i32; 3] {
|
||||
match cull {
|
||||
Some(d) => step(pos, d),
|
||||
None if shape(positions, face, full).cubic => step(pos, face),
|
||||
None => pos,
|
||||
}
|
||||
}
|
||||
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
|
||||
/// and smooth light coords, by vertex index.
|
||||
@@ -398,6 +421,39 @@ mod tests {
|
||||
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
|
||||
}
|
||||
#[test]
|
||||
fn flat_light_reads_the_neighbour_a_face_looks_at() {
|
||||
let pos = [4, 64, -2];
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
];
|
||||
// A cull face samples its neighbour, whatever the quad's shape.
|
||||
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
|
||||
assert_eq!(
|
||||
flat_position(pos, &inset, D::Up, Some(D::Up), false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
// A flush quad without a cull face samples its neighbour too: a block
|
||||
// drawn at a cell that is now opaque (a moving block, #3002) still
|
||||
// takes the light around it.
|
||||
assert_eq!(
|
||||
flat_position(pos, &full_top, D::Up, None, false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
let west = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
];
|
||||
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
|
||||
// An inset quad (a piston head's arm) takes the block's own light.
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
|
||||
}
|
||||
#[test]
|
||||
fn quad_shape_classifies_partial_and_cubic_faces() {
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
|
||||
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
|
||||
assert_eq!(tint("minecraft:pink_petals"), None);
|
||||
assert_eq!(tint("minecraft:stone"), None);
|
||||
}
|
||||
|
||||
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
|
||||
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
|
||||
/// as in the world, not left gray like its particles.
|
||||
#[test]
|
||||
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap();
|
||||
let center = Chunk::empty(0, 0);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
let tints = |name| {
|
||||
tables.block_tints(
|
||||
state(name),
|
||||
[5, 70, 9],
|
||||
®ion,
|
||||
&biomes,
|
||||
BiomeZoom::new(0),
|
||||
2,
|
||||
)
|
||||
};
|
||||
let grass = tints("minecraft:grass_block");
|
||||
assert!(grass(0).is_some());
|
||||
assert_eq!(grass(-1), None);
|
||||
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
|
||||
assert_eq!(tints("minecraft:stone")(0), None);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn info(solid: bool) -> Info {
|
||||
permeable: false,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: false,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! changed.
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid, MAP_COLORS},
|
||||
chunk::{Chunk, MIN_Y, SECTIONS},
|
||||
chunk::Chunk,
|
||||
fast_hash::FxHashMap,
|
||||
world::World,
|
||||
};
|
||||
@@ -113,7 +113,8 @@ fn scan_column(
|
||||
return (color | WALL, head as i16, 0);
|
||||
}
|
||||
}
|
||||
while y > MIN_Y {
|
||||
let bottom = chunk.min_y();
|
||||
while y > bottom {
|
||||
let (mut color, flags) = palette.get(chunk.get(x, y, z));
|
||||
if color == 0 {
|
||||
y -= 1;
|
||||
@@ -123,7 +124,7 @@ fn scan_column(
|
||||
let fluid = flags & FLUID;
|
||||
if fluid != 0 {
|
||||
let mut below = y - 1;
|
||||
while below > MIN_Y && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
|
||||
while below > bottom && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
|
||||
depth = depth.saturating_add(1);
|
||||
below -= 1;
|
||||
}
|
||||
@@ -134,11 +135,11 @@ fn scan_column(
|
||||
}
|
||||
return (color, y as i16, depth);
|
||||
}
|
||||
(0, MIN_Y as i16, 0)
|
||||
(0, bottom as i16, 0)
|
||||
}
|
||||
/// The highest block of a chunk worth scanning from.
|
||||
fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
|
||||
let surface = (0..SECTIONS)
|
||||
let surface = (0..chunk.sections.len())
|
||||
.rev()
|
||||
.find(|&s| {
|
||||
chunk
|
||||
@@ -146,7 +147,7 @@ fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
|
||||
.get(s)
|
||||
.is_some_and(|sec| !sec.is_uniform(air))
|
||||
})
|
||||
.map_or(MIN_Y, |s| MIN_Y + s as i32 * 16 + 15);
|
||||
.map_or(chunk.min_y(), |s| chunk.min_y() + s as i32 * 16 + 15);
|
||||
match layer {
|
||||
Layer::Surface => surface,
|
||||
Layer::Below(head) => surface.min(head),
|
||||
@@ -156,7 +157,7 @@ fn scan_tile(chunk: &Chunk, palette: &Palette, air: u32, layer: Layer, revision:
|
||||
let top = top_of(chunk, air, layer);
|
||||
let mut tile = Tile {
|
||||
color: [0; 256],
|
||||
height: [MIN_Y as i16; 256],
|
||||
height: [chunk.min_y() as i16; 256],
|
||||
depth: [0; 256],
|
||||
revision,
|
||||
layer,
|
||||
|
||||
@@ -36,11 +36,8 @@ pub struct Installed {
|
||||
pub description: String,
|
||||
/// Why it is not running, when it failed.
|
||||
pub problem: Option<String>,
|
||||
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
|
||||
/// Loaded with `--dev-mod`: always on.
|
||||
pub dev: bool,
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
/// A native mod (`game/native_hooks.rs`): built into the game, on until
|
||||
/// the player turns it off, and its settings page.
|
||||
pub native: Option<NativePage>,
|
||||
@@ -178,7 +175,6 @@ mod tests {
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
let mut list = ModList {
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct ModelInstance {
|
||||
pub layer: u32,
|
||||
pub shade: f32,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct PreparedModels {
|
||||
pub catalog: StateModels,
|
||||
pub placements: Vec<(BlockPos, BlockStateId)>,
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct BakedModel {
|
||||
pub cube: Option<[super::cube::CubeStyle; 6]>,
|
||||
}
|
||||
type VariantKey = (String, [u8; 3], bool);
|
||||
/// Empty by default: no state has a model (a dedicated server, which draws
|
||||
/// nothing).
|
||||
#[derive(Default)]
|
||||
pub struct StateModels {
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
|
||||
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
|
||||
];
|
||||
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
|
||||
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
|
||||
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
|
||||
/// view distance tops out at 127 anyway.
|
||||
pub const MAX_RENDER_DISTANCE: i32 = 128;
|
||||
const INACTIVITY: &[&str] = &["minimized", "afk"];
|
||||
const CLOUDS: &[&str] = &["false", "fast", "true"];
|
||||
const MAIN_HAND: &[&str] = &["left", "right"];
|
||||
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
|
||||
b("reducedDebugInfo", false),
|
||||
def("renderClouds", O(Clouds), V::Int(2)),
|
||||
i("cloudRange", 2, 128, 128),
|
||||
i("renderDistance", 2, 64, 12),
|
||||
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
|
||||
i("simulationDistance", 5, 32, 12),
|
||||
u("screenEffectScale", 1.0),
|
||||
def("soundDevice", O(AnyStr), V::Str(String::new())),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -318,6 +318,8 @@ pub enum Action {
|
||||
/// The pack screen and its state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackScreen {
|
||||
/// `resourcePack.title`, or `dataPack.title` from Create World.
|
||||
pub title: Text,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub in_world: bool,
|
||||
@@ -342,6 +344,7 @@ impl PackScreen {
|
||||
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
|
||||
pub fn new(
|
||||
m: &Measure,
|
||||
title: Text,
|
||||
width: i32,
|
||||
height: i32,
|
||||
in_world: bool,
|
||||
@@ -360,6 +363,7 @@ impl PackScreen {
|
||||
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
|
||||
.collect();
|
||||
let mut s = Self {
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
in_world,
|
||||
@@ -382,7 +386,7 @@ impl PackScreen {
|
||||
}
|
||||
/// The header layout: title, drop info and search box, centred.
|
||||
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
|
||||
let tw = m.width(&tr("resourcePack.title"));
|
||||
let tw = m.width(&self.title);
|
||||
let dw = m.width(&tr("pack.dropInfo"));
|
||||
let col = tw.max(dw).max(200);
|
||||
let frame = self.width.max(col);
|
||||
@@ -837,7 +841,7 @@ impl PackScreen {
|
||||
return out;
|
||||
}
|
||||
let [(tx, ty), (dx, dy), _] = self.header(m);
|
||||
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
|
||||
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
|
||||
d::string_widget(
|
||||
&mut out,
|
||||
m,
|
||||
@@ -1105,7 +1109,7 @@ mod icon_tests {
|
||||
pack("high_contrast", Some(contrast.clone())),
|
||||
pack("file/Iconless", None),
|
||||
];
|
||||
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
|
||||
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0);
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, &atlas, &mut patches);
|
||||
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
|
||||
let title = tr("resourcePack.title");
|
||||
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
|
||||
assert!(!screen.button_at(&m, 0.0, 0.0));
|
||||
assert!(!screen.button_at(
|
||||
&m,
|
||||
|
||||
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
|
||||
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
|
||||
/// when the metadata names no format.
|
||||
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
|
||||
compatibility_for(meta, RESOURCE_FORMAT)
|
||||
}
|
||||
/// `compatibility` against `current`, the resource or the data pack format.
|
||||
pub fn compatibility_for(
|
||||
meta: &serde_json::Value,
|
||||
current: (i64, i64),
|
||||
) -> crate::pack_screen::Compat {
|
||||
use crate::pack_screen::Compat;
|
||||
let pack = &meta["pack"];
|
||||
let current = RESOURCE_FORMAT;
|
||||
let range = if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
@@ -272,9 +278,14 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
|
||||
Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?,
|
||||
};
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
// Gson reads past a UTF-8 byte order mark; serde_json rejects it.
|
||||
serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
/// A pack whose compatibility is against `format`.
|
||||
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
Some(Pack {
|
||||
@@ -284,7 +295,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
raw_description,
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility(&meta),
|
||||
compatibility: compatibility_for(&meta, format),
|
||||
overlays: overlays_of(&meta),
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
@@ -305,7 +316,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
|
||||
out.extend(folder(packs_dir, RESOURCE_FORMAT));
|
||||
out
|
||||
}
|
||||
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
|
||||
/// finds them; their compatibility is against `format`.
|
||||
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
|
||||
let mut user: Vec<Pack> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
@@ -319,12 +336,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
pack(format!("file/{name}"), Text::literal(name), source)
|
||||
pack_for(format!("file/{name}"), Text::literal(name), source, format)
|
||||
})
|
||||
.collect();
|
||||
user.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out.extend(user);
|
||||
out
|
||||
user
|
||||
}
|
||||
/// A file a pack provides: its path (folder packs) and its name inside the pack.
|
||||
type Provided = (Option<PathBuf>, String);
|
||||
@@ -451,6 +467,22 @@ fn build_overlay_uncached(
|
||||
files.insert(name, (i, provided));
|
||||
}
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
let meta = format!("{name}.mcmeta");
|
||||
match files.get(&meta) {
|
||||
Some((meta_owner, _)) if meta_owner >= owner => None,
|
||||
_ => Some(meta),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
// Vanilla sits below every pack, so filters hide its files too.
|
||||
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
@@ -502,6 +534,7 @@ fn build_overlay_uncached(
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
@@ -538,6 +571,9 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
fs::write(out.join(&child), bytes)?;
|
||||
} else if let Some((owner, (path, inner))) = files.get(&child) {
|
||||
@@ -546,7 +582,15 @@ fn build_overlay_uncached(
|
||||
Some(p) => link(p, &target)?,
|
||||
None => {
|
||||
if let Some(Some(z)) = zips.get(*owner) {
|
||||
fs::write(&target, z.read(inner)?)?;
|
||||
// One unreadable entry costs that file, not the
|
||||
// pack: an error here failed the build and the
|
||||
// reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(&target, bytes)?,
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: skipping unreadable pack entry {inner}: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,6 +612,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
touched,
|
||||
hidden,
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
@@ -591,6 +636,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
&touched_dirs,
|
||||
&hidden,
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
@@ -781,7 +827,9 @@ impl SelectionModel {
|
||||
/// `available` in any order; `selected` as options.txt lists them,
|
||||
/// bottom first.
|
||||
pub fn new(mut available: Vec<Selectable>, selected: &[String]) -> Self {
|
||||
// PackRepository.available is a TreeMap by id (String.compareTo).
|
||||
// PackRepository.discoverAvailable: every source's packs go into one
|
||||
// TreeMap by id (String.compareTo), so `file/` packs sort among the
|
||||
// built-in ones.
|
||||
available.sort_by(|a, b| a.id.encode_utf16().cmp(b.id.encode_utf16()));
|
||||
available.dedup_by(|a, b| a.id == b.id);
|
||||
let mut m = Self {
|
||||
@@ -891,6 +939,25 @@ impl SelectionModel {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn mcmeta_with_utf8_bom_is_read() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let mut meta = b"\xEF\xBB\xBF".to_vec();
|
||||
meta.extend_from_slice(br#"{"pack":{"description":"d","pack_format":97}}"#);
|
||||
fs::write(dir.join("pack.mcmeta"), &meta).unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
|
||||
// Without a BOM the same JSON still reads; other leading bytes still fail.
|
||||
fs::write(dir.join("pack.mcmeta"), &meta[3..]).unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
[b"\xEF\xBB".as_slice(), &meta[3..]].concat(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_none());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
#[test]
|
||||
fn compatibility_ranges() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
assert!(compatible(&v(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use super::{Pack, Source, build_overlay_uncached};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(any(test, not(unix)))]
|
||||
use std::io::Read;
|
||||
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
|
||||
@@ -60,7 +59,6 @@ fn field(hash: &mut Sha256, bytes: &[u8]) {
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
}
|
||||
#[cfg(any(test, not(unix)))]
|
||||
fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
hash.update(file.metadata()?.len().to_le_bytes());
|
||||
@@ -238,7 +236,10 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
|
||||
}
|
||||
Source::Zip(path) => {
|
||||
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
|
||||
stamp(&mut hash, path)?;
|
||||
// By its bytes, not its stamp: a server pack found in the download cache is
|
||||
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
|
||||
// time rebuilt the whole overlay each time. One file reads in a moment.
|
||||
content(&mut hash, path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +350,27 @@ mod tests {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
/// Waits for the filesystem's clock to move on, as it does between a build and a
|
||||
/// person's next edit. On Unix the cache compares stamps (length, mtime, ctime, inode),
|
||||
/// and Linux takes them from a coarse clock (a few milliseconds a tick): an edit in the
|
||||
/// tick of the build before kept them all, and the tests failed at random.
|
||||
fn next_tick(&self) {
|
||||
let probe = self.0.with_extension("tick");
|
||||
// New bytes each time: NTFS keeps the time of a write that changes nothing.
|
||||
let stamp = |n: u32| {
|
||||
fs::write(&probe, n.to_le_bytes()).unwrap();
|
||||
fs::metadata(&probe).unwrap().modified().unwrap()
|
||||
};
|
||||
let start = stamp(0);
|
||||
// A second at most: no clock is that coarse, and a test never hangs on one.
|
||||
for n in 1..1000 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
if stamp(n) != start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&probe);
|
||||
}
|
||||
fn dir_pack(&self, name: &str) -> Pack {
|
||||
self.write(
|
||||
&format!("{name}/pack.mcmeta"),
|
||||
@@ -371,6 +393,7 @@ mod tests {
|
||||
output_signature(&cached).unwrap(),
|
||||
output_signature(&self.0.join("reference")).unwrap()
|
||||
);
|
||||
self.next_tick();
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
@@ -647,6 +670,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A ZIP whose bytes are the same keeps its overlay when only its time changes: a server
|
||||
/// pack found in the download cache is marked used on every join
|
||||
/// (`HttpUtil.updateModificationTime`), and rebuilding the overlay took most of a minute.
|
||||
#[test]
|
||||
fn a_zip_touched_but_unchanged_keeps_its_overlay() {
|
||||
let f = Fixture::new();
|
||||
f.write("vanilla/assets/minecraft/textures/b", b"vanilla");
|
||||
let files = vec![
|
||||
(
|
||||
"pack.mcmeta",
|
||||
br#"{"pack":{"description":"test","pack_format":97}}"#.to_vec(),
|
||||
),
|
||||
("assets/minecraft/textures/a", b"aaa".to_vec()),
|
||||
];
|
||||
f.write("pack.zip", &archive(&files, false));
|
||||
let zip = f.0.join("pack.zip");
|
||||
let pack = packs::pack("zip".into(), Text::empty(), Source::Zip(zip.clone())).unwrap();
|
||||
let (vanilla, out) = (f.0.join("vanilla"), f.0.join("cached"));
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
// A rebuild empties the output first: this file survives a cache hit only.
|
||||
fs::write(out.join("kept"), b"").unwrap();
|
||||
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&zip)
|
||||
.unwrap()
|
||||
.set_modified(later)
|
||||
.unwrap();
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
assert!(out.join("kept").exists(), "the overlay was rebuilt");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unrelated_directory_pack_symlink_cycle_disables_reuse() {
|
||||
|
||||
Loaded 100 of 930 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user