Merge origin/main into #9403; a registry whose pack tags fail keeps the game's own

A load_tags error used to store an empty table for that registry, so every
#tag of it, vanilla's included, matched nothing in advancement predicates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-05 03:52:41 +01:00
commit 167f156560
649 files changed
+55029 -5841

No files matched your search

+2 -1
View File
@@ -1,3 +1,4 @@
self-hosted-runner:
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
labels: [horizon-game, ludovics-mac-mini]
# windows-11-vs2026-arm is GitHub's Windows ARM64 image (2026-08), newer than actionlint's label list.
labels: [horizon-game, ludovics-mac-mini, windows-11-vs2026-arm]
+5 -5
View File
@@ -22,7 +22,7 @@ jobs:
outputs:
platforms: ${{ steps.platforms.outputs.platforms }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- id: platforms
run: echo "platforms=$(python3 -c 'import json, tools.horizon_mod_binstall as m; print(json.dumps(m.PLATFORMS))')" >> "$GITHUB_OUTPUT"
build:
@@ -35,7 +35,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -67,7 +67,7 @@ jobs:
[ -f "$binary.exe" ] && binary="$binary.exe"
mkdir -p dist
tar -czf "dist/$(basename "$binary" | sed 's/\.exe$//')-${{ matrix.target }}.tar.gz" -C "$(dirname "$binary")" "$(basename "$binary")"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: cargo-horizon-mod-${{ matrix.target }}
path: dist/*.tar.gz
@@ -81,8 +81,8 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
pattern: cargo-horizon-mod-*
path: dist
+93 -10
View File
@@ -41,10 +41,10 @@ jobs:
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
@@ -126,7 +126,7 @@ jobs:
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Does this pull request change the launcher or a crate it builds?
@@ -159,6 +159,89 @@ jobs:
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
# 90-minute job. The Nightly and launcher releases build and check these binaries on every run.
windows-arm64:
name: Windows ARM64 game and launcher
if: github.event_name == 'workflow_dispatch'
runs-on: windows-11-vs2026-arm
timeout-minutes: 90
env:
RUSTFLAGS: -C target-feature=+crt-static
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Use every runner core for Cargo
shell: pwsh
run: |
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
shared-key: windows-arm64-game
- uses: Swatinem/rust-cache@v2
with:
workspaces: launcher
shared-key: windows-arm64-launcher
- name: Check LLVM assembler used by ring
shell: pwsh
run: |
$llvmBin = Join-Path $env:ProgramFiles 'LLVM\bin'
if (-not (Get-Command clang -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $llvmBin 'clang.exe'))) {
$env:PATH = "$llvmBin;$env:PATH"
"PATH=$env:PATH" >> $env:GITHUB_ENV
}
$clang = Get-Command clang -ErrorAction Stop
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Start every game executable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
$output = & $client --help 2>&1
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
$path = "target/aarch64-pc-windows-msvc/release/$binary"
$output = & $path --help 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --help failed" }
}
foreach ($binary in @('horizon-prepare.exe', 'horizon-server.exe')) {
$path = "target/aarch64-pc-windows-msvc/release/$binary"
$output = & $path --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --version failed" }
Write-Output ($output -join "`n")
}
- name: Test and build the native Windows ARM64 launcher
working-directory: launcher
run: |
cargo test --release --locked --target aarch64-pc-windows-msvc --lib
cargo build --release --locked --target aarch64-pc-windows-msvc
- name: Check the launcher's PE machine
working-directory: launcher
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
- name: Check launcher DLL imports and command-line startup
working-directory: launcher
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
python ../tools/check_imports.py $launcher
$output = & $launcher --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
Write-Output ($output -join "`n")
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
@@ -169,7 +252,7 @@ jobs:
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
@@ -204,13 +287,13 @@ jobs:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- uses: actions/setup-java@v4
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '25'
@@ -552,11 +635,11 @@ jobs:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- uses: actions/setup-java@v4
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '25'
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
@@ -63,7 +63,7 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
+30 -10
View File
@@ -1,6 +1,6 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server, Windows x64
# and ARM64 on GitHub, and macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
@@ -45,7 +45,7 @@ jobs:
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -73,7 +73,7 @@ jobs:
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: original-exporters
path: exporters/
@@ -83,6 +83,10 @@ jobs:
build:
needs: exporters
name: build ${{ matrix.name }}
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
# ARM64 files out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
strategy:
fail-fast: false
matrix:
@@ -99,8 +103,18 @@ jobs:
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
pe_arch: x64
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: windows-arm64
os: windows-11-vs2026-arm
target: aarch64-pc-windows-msvc
dir: horizon-windows-arm64
asset: horizon-windows-arm64.zip
exe: .exe
pe_arch: arm64
# The ARM64 runner builds and runs native ARM64 executables; keep the C runtime in the archive.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
@@ -128,7 +142,7 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -156,6 +170,12 @@ jobs:
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: Verify Windows executable architecture
if: runner.os == 'Windows'
run: |
python tools/check_pe_arch.py "${{ matrix.pe_arch }}" \
"$BIN/horizon-client${{ matrix.exe }}" "$BIN/horizon-ctl${{ matrix.exe }}" \
"$BIN/horizon-prepare${{ matrix.exe }}" "$BIN/horizon-server${{ matrix.exe }}"
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
@@ -188,7 +208,7 @@ jobs:
- name: Baseline x86-64 CPUs only
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
if: runner.os != 'macOS'
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64'
run: |
set -euo pipefail
rustup component add llvm-tools
@@ -239,14 +259,14 @@ jobs:
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: runner.environment == 'self-hosted' && !matrix.target
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: original-exporters
path: exporters
@@ -277,7 +297,7 @@ jobs:
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
@@ -299,7 +319,7 @@ jobs:
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
+47 -15
View File
@@ -1,5 +1,5 @@
name: launcher
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
# Builds the launcher on Linux, macOS, Windows x64 and ARM64, and releases it by itself (docs/release-flow.md): after a
# push to main that changes the launcher or a crate it builds in (the paths below),
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
# for the game.
@@ -15,8 +15,9 @@ name: launcher
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
# Windows ships each architecture's launcher in its ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe or horizon-launcher-setup-arm64.exe, which opens the per-user installer instead of Home
# (launcher/src/installer.rs).
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
# gh workflow run launcher --ref main -f dry_run=false -f force=true
@@ -73,7 +74,7 @@ jobs:
version: ${{ steps.decide.outputs.version }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: mode
@@ -102,6 +103,10 @@ jobs:
needs: decide
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
# ARM64 files out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
strategy:
fail-fast: false
matrix:
@@ -125,9 +130,20 @@ jobs:
os: windows-latest
asset: horizon-launcher-windows-x64.zip
binary: horizon-launcher.exe
pe_arch: x64
setup_asset: horizon-launcher-setup.exe
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
# which only the Visual C++ redistributable installs.
rustflags: -C target-feature=+crt-static
- name: windows-arm64
os: windows-11-vs2026-arm
target: aarch64-pc-windows-msvc
asset: horizon-launcher-windows-arm64.zip
binary: horizon-launcher.exe
pe_arch: arm64
setup_asset: horizon-launcher-setup-arm64.exe
# The C runtime linked in, so the standalone ARM64 launcher needs no redistributable.
rustflags: -C target-feature=+crt-static
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the app and then the .dmg can take a few minutes each.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
@@ -150,7 +166,7 @@ jobs:
working-directory: launcher
shell: bash
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
@@ -182,6 +198,9 @@ jobs:
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: Verify Windows executable architecture
if: runner.os == 'Windows'
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
@@ -212,7 +231,8 @@ jobs:
"$launcher" links --json
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
# (exit 3); a launcher or a script that fails still fails the step.
# (exit 3); a launcher or a script that fails still fails the step. The Windows ARM64 launcher always has
# one: it installs the x64 build where a release has no ARM64 one (backend.rs, GAME_FALLBACK).
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
versions=$(retry "$launcher" --json versions)
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
@@ -236,6 +256,11 @@ jobs:
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
if [[ "$RUNNER_OS" == "Windows" ]]; then
if ! python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$client"; then
# A Nightly without an ARM64 build: the ARM64 launcher installs the x64 one (backend.rs, GAME_FALLBACK).
[[ "${{ matrix.pe_arch }}" == arm64 ]] && python ../tools/check_pe_arch.py x64 "$client" || exit 1
echo "::warning::the latest game has no ARM64 build: the launcher installed the x64 one"
fi
python ../tools/check_imports.py "$client"
elif [[ "$RUNNER_OS" == "Linux" ]]; then
soft python3 ../tools/check_imports.py "$client"
@@ -295,8 +320,8 @@ jobs:
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
if: runner.os == 'Windows'
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
- uses: actions/upload-artifact@v4
run: cp "$BIN/${{ matrix.binary }}" "out/${{ matrix.setup_asset }}"
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.asset }}
path: launcher/out/*
@@ -314,12 +339,12 @@ jobs:
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
pattern: horizon-launcher-*
@@ -331,13 +356,13 @@ jobs:
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
TAG: ${{ needs.decide.outputs.tag }}
run: bash tools/ci/sign_release.sh "$TAG" dist/horizon-launcher-*
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
@@ -351,7 +376,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Notes
@@ -361,7 +386,7 @@ jobs:
python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
@@ -373,6 +398,13 @@ jobs:
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
# Never marked latest: Horizon's own releases stay the repository's latest.
- name: The Windows ARM64 launcher, if it built
# Its build may fail without holding the release (continue-on-error): the release then has no ARM64
# files, and ARM64 launchers stay on their version until one has (backend::launcher_update_from).
run: |
for file in horizon-launcher-windows-arm64.zip horizon-launcher-setup-arm64.exe; do
[[ -f "dist/$file" ]] || echo "::warning::$file is missing: this release goes out without it"
done
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
@@ -387,7 +419,7 @@ jobs:
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: launcher-release-notes
path: notes/
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
run:
working-directory: crates/horizon-mod-registry
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
+6 -2
View File
@@ -24,7 +24,7 @@ jobs:
pull-requests: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
@@ -52,4 +52,8 @@ jobs:
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "$branch"
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
# The repo doesn't let GitHub Actions open pull requests (that setting would also let it approve them):
# the branch is pushed, and a maintainer opens the PR from it.
if ! gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'; then
echo "::warning::Stamped fragments are on $branch; open a pull request from it (GitHub Actions may not open one here)."
fi
+21 -9
View File
@@ -92,7 +92,7 @@ jobs:
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
@@ -124,14 +124,14 @@ jobs:
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: nightly-notes
path: nightly-notes.md
@@ -155,12 +155,12 @@ jobs:
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
pattern: horizon-*
@@ -171,16 +171,20 @@ jobs:
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
else echo "::warning::horizon-windows-arm64.zip is missing: SHA256SUMS goes out without it"; fi
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
"${arm64[@]}" \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: needs.pick.outputs.dry_run == 'true'
with:
name: ephemeral.pub
@@ -195,10 +199,10 @@ jobs:
contents: write
issues: read # the open release-blocker issues (gate)
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
@@ -230,12 +234,20 @@ jobs:
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
arm64=()
if [[ -f dist/horizon-windows-arm64.zip ]]; then
arm64=("dist/horizon-windows-arm64.zip#Game files the launcher installs (Nightly): Windows ARM64 (horizon-windows-arm64.zip)")
else
echo "::warning::horizon-windows-arm64.zip is missing: the Nightly goes out without it (ARM64 launchers install the x64 build)"
fi
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
# 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#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)" \
"${arm64[@]}" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
issues: write # answers in the thread
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: steps.when.outputs.run == 'true'
with:
fetch-depth: 0
@@ -82,7 +82,7 @@ jobs:
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Logs of the failed harnesses
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: nightly-parity-logs
path: .local/parity-runner/logs/
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
@@ -69,7 +69,7 @@ jobs:
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: parity-main-logs
path: .local/parity-main-logs/
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
# harnesses at a time) up to a few hours, and any pull request's job may land there.
timeout-minutes: ${{ inputs.full && 360 || 180 }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
# main into it.
@@ -89,7 +89,7 @@ jobs:
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: parity-logs
path: .local/parity-runner/logs/
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
urgent: ${{ steps.decide.outputs.urgent }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: mode
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
run:
working-directory: crates/horizon-telemetry-server
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
+1 -1
View File
@@ -30,7 +30,7 @@ Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same t
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
+870
View File
@@ -0,0 +1,870 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:00:15Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "nvidia",
"driver": "NVIDIA 595.71.05",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.408,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 66658.183,
"stdev": 61839.502,
"min": 1827.42,
"max": 124993.694,
"ci95": 153630.237
},
"startup.world_ms": {
"n": 3,
"mean": 74754.411,
"stdev": 59189.587,
"min": 13600.651,
"max": 131761.773,
"ci95": 147046.952
},
"chunks.village.load_ms": {
"n": 3,
"mean": 5571.499,
"stdev": 1900.32,
"min": 3378.38,
"max": 6730.394,
"ci95": 4721.037
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 15287.0,
"stdev": 527.501,
"min": 14788.0,
"max": 15839.0,
"ci95": 1310.49
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2958.557,
"stdev": 376.543,
"min": 2526.453,
"max": 3216.434,
"ci95": 935.46
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 368.457,
"stdev": 90.668,
"min": 300.631,
"max": 471.438,
"ci95": 225.25
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 91.04,
"stdev": 40.506,
"min": 46.124,
"max": 124.796,
"ci95": 100.63
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 581.0,
"stdev": 48.0,
"min": 533.0,
"max": 629.0,
"ci95": 119.248
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1277.531,
"stdev": 25.546,
"min": 1253.879,
"max": 1304.623,
"ci95": 63.466
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.529,
"stdev": 0.127,
"min": 0.396,
"max": 0.649,
"ci95": 0.315
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.185,
"stdev": 0.034,
"min": 0.162,
"max": 0.224,
"ci95": 0.085
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3318.3,
"stdev": 911.608,
"min": 2266.627,
"max": 3883.075,
"ci95": 2264.743
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 16565.333,
"stdev": 172.755,
"min": 16367.0,
"max": 16683.0,
"ci95": 429.182
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2333.626,
"stdev": 569.124,
"min": 1745.365,
"max": 2881.457,
"ci95": 1413.897
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 336.615,
"stdev": 185.666,
"min": 208.133,
"max": 549.487,
"ci95": 461.257
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 160.734,
"stdev": 127.586,
"min": 39.777,
"max": 294.05,
"ci95": 316.967
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 666.333,
"stdev": 24.44,
"min": 645.0,
"max": 693.0,
"ci95": 60.718
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1346.816,
"stdev": 42.279,
"min": 1305.547,
"max": 1390.037,
"ci95": 105.035
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.669,
"stdev": 0.084,
"min": 0.598,
"max": 0.761,
"ci95": 0.208
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.222,
"stdev": 0.032,
"min": 0.197,
"max": 0.258,
"ci95": 0.079
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2463.434,
"stdev": 1056.0,
"min": 1527.516,
"max": 3608.283,
"ci95": 2623.462
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 14226.667,
"stdev": 307.077,
"min": 14038.0,
"max": 14581.0,
"ci95": 762.883
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2192.874,
"stdev": 1320.904,
"min": 673.829,
"max": 3071.415,
"ci95": 3281.573
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 345.406,
"stdev": 294.904,
"min": 8.977,
"max": 559.232,
"ci95": 732.642
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 172.573,
"stdev": 149.386,
"min": 1.038,
"max": 274.089,
"ci95": 371.125
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 629.0,
"stdev": 32.0,
"min": 597.0,
"max": 661.0,
"ci95": 79.499
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1352.223,
"stdev": 51.951,
"min": 1301.492,
"max": 1405.314,
"ci95": 129.065
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.512,
"stdev": 0.099,
"min": 0.406,
"max": 0.603,
"ci95": 0.246
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.178,
"stdev": 0.038,
"min": 0.135,
"max": 0.206,
"ci95": 0.094
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 6260.675,
"stdev": 3077.348,
"min": 2976.342,
"max": 9077.55,
"ci95": 7645.173
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 16015.333,
"stdev": 209.872,
"min": 15826.0,
"max": 16241.0,
"ci95": 521.393
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1412.095,
"stdev": 962.145,
"min": 714.773,
"max": 2509.775,
"ci95": 2390.293
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 182.479,
"stdev": 177.563,
"min": 13.364,
"max": 367.429,
"ci95": 441.126
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 88.451,
"stdev": 87.578,
"min": 1.526,
"max": 176.667,
"ci95": 217.572
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 799.667,
"stdev": 18.475,
"min": 789.0,
"max": 821.0,
"ci95": 45.899
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1491.572,
"stdev": 36.719,
"min": 1459.775,
"max": 1531.76,
"ci95": 91.221
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.697,
"stdev": 0.155,
"min": 0.602,
"max": 0.876,
"ci95": 0.386
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 0.325,
"stdev": 0.115,
"min": 0.239,
"max": 0.456,
"ci95": 0.286
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1545.896,
"stdev": 30.591,
"min": 1524.965,
"max": 1581.004,
"ci95": 75.999
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1348.389,
"stdev": 52.5,
"min": 1296.172,
"max": 1401.168,
"ci95": 130.428
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 37594,
"seconds": 12.0,
"avg_fps": 3132.783,
"low_1pct_fps": 471.438,
"low_01pct_fps": 124.796,
"p50_ms": 0.271,
"p95_ms": 0.494,
"p99_ms": 1.127,
"p999_ms": 2.15,
"max_ms": 155.204,
"cpu_p50_ms": 0.168,
"gpu_p50_ms": 0.104,
"histogram": [
37151,
394,
30,
8,
5,
2,
2,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 38598,
"seconds": 12.0,
"avg_fps": 3216.434,
"low_1pct_fps": 300.631,
"low_01pct_fps": 46.124,
"p50_ms": 0.267,
"p95_ms": 0.446,
"p99_ms": 1.034,
"p999_ms": 1.941,
"max_ms": 493.993,
"cpu_p50_ms": 0.162,
"gpu_p50_ms": 0.104,
"histogram": [
38177,
387,
16,
8,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
1,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 30318,
"seconds": 12.0,
"avg_fps": 2526.453,
"low_1pct_fps": 333.302,
"low_01pct_fps": 102.199,
"p50_ms": 0.305,
"p95_ms": 0.757,
"p99_ms": 1.69,
"p999_ms": 3.539,
"max_ms": 170.856,
"cpu_p50_ms": 0.224,
"gpu_p50_ms": 0.106,
"histogram": [
29368,
759,
139,
35,
11,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 34578,
"seconds": 12.0,
"avg_fps": 2881.457,
"low_1pct_fps": 549.487,
"low_01pct_fps": 294.05,
"p50_ms": 0.32,
"p95_ms": 0.483,
"p99_ms": 1.213,
"p999_ms": 2.5,
"max_ms": 5.358,
"cpu_p50_ms": 0.197,
"gpu_p50_ms": 0.154,
"histogram": [
34119,
379,
64,
9,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 28489,
"seconds": 12.0,
"avg_fps": 2374.055,
"low_1pct_fps": 208.133,
"low_01pct_fps": 39.777,
"p50_ms": 0.313,
"p95_ms": 0.937,
"p99_ms": 1.863,
"p999_ms": 4.318,
"max_ms": 571.48,
"cpu_p50_ms": 0.211,
"gpu_p50_ms": 0.151,
"histogram": [
27193,
1071,
140,
51,
19,
9,
1,
2,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 20945,
"seconds": 12.0,
"avg_fps": 1745.365,
"low_1pct_fps": 252.226,
"low_01pct_fps": 148.375,
"p50_ms": 0.343,
"p95_ms": 1.505,
"p99_ms": 2.758,
"p999_ms": 5.341,
"max_ms": 11.642,
"cpu_p50_ms": 0.258,
"gpu_p50_ms": 0.162,
"histogram": [
18145,
2293,
337,
106,
38,
13,
6,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 36856,
"seconds": 12.0,
"avg_fps": 3071.415,
"low_1pct_fps": 559.232,
"low_01pct_fps": 274.089,
"p50_ms": 0.286,
"p95_ms": 0.492,
"p99_ms": 1.179,
"p999_ms": 2.711,
"max_ms": 6.718,
"cpu_p50_ms": 0.135,
"gpu_p50_ms": 0.127,
"histogram": [
36366,
405,
60,
13,
10,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 34001,
"seconds": 12.0,
"avg_fps": 2833.377,
"low_1pct_fps": 468.008,
"low_01pct_fps": 242.591,
"p50_ms": 0.295,
"p95_ms": 0.589,
"p99_ms": 1.41,
"p999_ms": 3.048,
"max_ms": 7.7,
"cpu_p50_ms": 0.206,
"gpu_p50_ms": 0.126,
"histogram": [
33314,
544,
106,
23,
10,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 8086,
"seconds": 12.0,
"avg_fps": 673.829,
"low_1pct_fps": 8.977,
"low_01pct_fps": 1.038,
"p50_ms": 0.302,
"p95_ms": 0.665,
"p99_ms": 2.495,
"p999_ms": 15.716,
"max_ms": 4849.364,
"cpu_p50_ms": 0.194,
"gpu_p50_ms": 0.128,
"histogram": [
7808,
166,
50,
26,
8,
2,
3,
5,
2,
5,
1,
2,
2,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 12141,
"seconds": 12.0,
"avg_fps": 1011.738,
"low_1pct_fps": 166.643,
"low_01pct_fps": 87.16,
"p50_ms": 0.641,
"p95_ms": 2.642,
"p99_ms": 4.468,
"p999_ms": 7.821,
"max_ms": 22.536,
"cpu_p50_ms": 0.456,
"gpu_p50_ms": 0.138,
"histogram": [
7840,
2996,
866,
278,
87,
38,
16,
11,
2,
5,
0,
0,
0,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 30118,
"seconds": 12.0,
"avg_fps": 2509.775,
"low_1pct_fps": 367.429,
"low_01pct_fps": 176.667,
"p50_ms": 0.316,
"p95_ms": 0.771,
"p99_ms": 1.931,
"p999_ms": 4.2,
"max_ms": 14.633,
"cpu_p50_ms": 0.239,
"gpu_p50_ms": 0.127,
"histogram": [
29101,
775,
173,
38,
16,
8,
4,
1,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 8577,
"seconds": 12.0,
"avg_fps": 714.773,
"low_1pct_fps": 13.364,
"low_01pct_fps": 1.526,
"p50_ms": 0.416,
"p95_ms": 2.257,
"p99_ms": 4.36,
"p999_ms": 212.217,
"max_ms": 1051.428,
"cpu_p50_ms": 0.28,
"gpu_p50_ms": 0.132,
"histogram": [
7017,
978,
377,
111,
39,
24,
10,
6,
1,
0,
2,
2,
0,
0,
0,
0,
0,
0,
1,
0,
1,
2,
4,
2
]
}
],
"label": "horizon-desktop"
}
+870
View File
@@ -0,0 +1,870 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T14:47:19Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "nvidia",
"driver": "NVIDIA 595.71.05",
"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,
"load_avg_1m": 29.665,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 45386.16,
"stdev": 38970.921,
"min": 1077.276,
"max": 74343.156,
"ci95": 96816.948
},
"startup.world_ms": {
"n": 3,
"mean": 86585.608,
"stdev": 71331.593,
"min": 11201.581,
"max": 153020.143,
"ci95": 177211.803
},
"chunks.village.load_ms": {
"n": 3,
"mean": 11082.587,
"stdev": 5351.291,
"min": 7620.454,
"max": 17246.096,
"ci95": 13294.417
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 48440.333,
"stdev": 656.16,
"min": 48060.0,
"max": 49198.0,
"ci95": 1630.124
},
"fps.village.avg_fps": {
"n": 3,
"mean": 853.775,
"stdev": 345.646,
"min": 524.913,
"max": 1214.059,
"ci95": 858.701
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 130.235,
"stdev": 117.39,
"min": 9.024,
"max": 243.388,
"ci95": 291.635
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 70.65,
"stdev": 68.628,
"min": 1.125,
"max": 138.345,
"ci95": 170.496
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 2202.333,
"stdev": 33.307,
"min": 2165.0,
"max": 2229.0,
"ci95": 82.745
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1652.647,
"stdev": 73.452,
"min": 1568.063,
"max": 1700.344,
"ci95": 182.478
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.493,
"stdev": 0.169,
"min": 0.319,
"max": 0.657,
"ci95": 0.42
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.692,
"stdev": 0.207,
"min": 0.531,
"max": 0.925,
"ci95": 0.513
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 15019.921,
"stdev": 12855.4,
"min": 5613.942,
"max": 29668.11,
"ci95": 31937.162
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 80224.667,
"stdev": 678.709,
"min": 79626.0,
"max": 80962.0,
"ci95": 1686.142
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 898.208,
"stdev": 446.3,
"min": 382.944,
"max": 1163.61,
"ci95": 1108.759
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 178.513,
"stdev": 107.096,
"min": 54.933,
"max": 244.255,
"ci95": 266.064
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 103.369,
"stdev": 78.403,
"min": 12.974,
"max": 152.87,
"ci95": 194.779
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 2201.667,
"stdev": 34.429,
"min": 2163.0,
"max": 2229.0,
"ci95": 85.532
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1809.063,
"stdev": 93.103,
"min": 1707.98,
"max": 1891.305,
"ci95": 231.3
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.585,
"stdev": 0.147,
"min": 0.493,
"max": 0.754,
"ci95": 0.365
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.989,
"stdev": 0.725,
"min": 0.57,
"max": 1.826,
"ci95": 1.802
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 12399.047,
"stdev": 15052.073,
"min": 3174.115,
"max": 29768.486,
"ci95": 37394.441
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 64737.0,
"stdev": 16149.666,
"min": 46089.0,
"max": 74089.0,
"ci95": 40121.232
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 735.22,
"stdev": 601.75,
"min": 108.108,
"max": 1307.912,
"ci95": 1494.952
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 134.654,
"stdev": 147.391,
"min": 1.252,
"max": 292.881,
"ci95": 366.169
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 62.062,
"stdev": 66.294,
"min": 0.202,
"max": 132.042,
"ci95": 164.697
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 1942.333,
"stdev": 251.725,
"min": 1657.0,
"max": 2133.0,
"ci95": 625.369
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1812.661,
"stdev": 102.065,
"min": 1697.912,
"max": 1893.313,
"ci95": 253.564
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.421,
"stdev": 0.227,
"min": 0.193,
"max": 0.646,
"ci95": 0.563
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.513,
"stdev": 0.194,
"min": 0.303,
"max": 0.686,
"ci95": 0.483
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 31815.539,
"stdev": 36758.245,
"min": 9582.166,
"max": 74243.988,
"ci95": 91319.913
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 90876.333,
"stdev": 1525.588,
"min": 89793.0,
"max": 92621.0,
"ci95": 3790.075
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 712.657,
"stdev": 252.815,
"min": 450.565,
"max": 955.041,
"ci95": 628.077
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 117.93,
"stdev": 85.922,
"min": 24.389,
"max": 193.34,
"ci95": 213.46
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 72.407,
"stdev": 65.468,
"min": 3.577,
"max": 133.895,
"ci95": 162.646
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 2783.667,
"stdev": 48.881,
"min": 2741.0,
"max": 2837.0,
"ci95": 121.436
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1931.359,
"stdev": 106.681,
"min": 1808.184,
"max": 1994.242,
"ci95": 265.031
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.03,
"stdev": 0.685,
"min": 0.564,
"max": 1.817,
"ci95": 1.702
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 1.025,
"stdev": 0.315,
"min": 0.765,
"max": 1.376,
"ci95": 0.783
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2051.333,
"stdev": 147.151,
"min": 1884.176,
"max": 2161.316,
"ci95": 365.573
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1810.92,
"stdev": 94.623,
"min": 1707.342,
"max": 1892.828,
"ci95": 235.075
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 6301,
"seconds": 12.004,
"avg_fps": 524.913,
"low_1pct_fps": 9.024,
"low_01pct_fps": 1.125,
"p50_ms": 0.555,
"p95_ms": 2.072,
"p99_ms": 4.067,
"p999_ms": 282.455,
"max_ms": 3525.74,
"cpu_p50_ms": 0.531,
"gpu_p50_ms": 0.218,
"histogram": [
4878,
1060,
217,
86,
19,
15,
6,
2,
5,
0,
1,
2,
0,
0,
0,
0,
0,
0,
0,
2,
0,
4,
3,
1
]
},
{
"phase": "village",
"repeat": 1,
"frames": 9869,
"seconds": 12.001,
"avg_fps": 822.353,
"low_1pct_fps": 138.293,
"low_01pct_fps": 72.479,
"p50_ms": 0.976,
"p95_ms": 2.871,
"p99_ms": 5.065,
"p999_ms": 9.942,
"max_ms": 23.337,
"cpu_p50_ms": 0.925,
"gpu_p50_ms": 0.245,
"histogram": [
5262,
3404,
771,
248,
81,
49,
23,
11,
12,
3,
0,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 14570,
"seconds": 12.001,
"avg_fps": 1214.059,
"low_1pct_fps": 243.388,
"low_01pct_fps": 138.345,
"p50_ms": 0.655,
"p95_ms": 1.486,
"p99_ms": 2.883,
"p999_ms": 5.366,
"max_ms": 26.145,
"cpu_p50_ms": 0.62,
"gpu_p50_ms": 0.239,
"histogram": [
11502,
2683,
257,
77,
25,
23,
1,
1,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 4595,
"seconds": 11.999,
"avg_fps": 382.944,
"low_1pct_fps": 54.933,
"low_01pct_fps": 12.974,
"p50_ms": 2.269,
"p95_ms": 5.193,
"p99_ms": 8.719,
"p999_ms": 21.933,
"max_ms": 243.247,
"cpu_p50_ms": 1.826,
"gpu_p50_ms": 0.502,
"histogram": [
273,
1654,
1395,
836,
172,
122,
52,
38,
26,
10,
7,
5,
0,
2,
1,
0,
0,
0,
1,
0,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 13964,
"seconds": 12.001,
"avg_fps": 1163.61,
"low_1pct_fps": 244.255,
"low_01pct_fps": 152.87,
"p50_ms": 0.682,
"p95_ms": 1.524,
"p99_ms": 2.877,
"p999_ms": 5.801,
"max_ms": 7.977,
"cpu_p50_ms": 0.57,
"gpu_p50_ms": 0.467,
"histogram": [
11067,
2532,
235,
82,
23,
16,
6,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 13777,
"seconds": 12.0,
"avg_fps": 1148.071,
"low_1pct_fps": 236.351,
"low_01pct_fps": 144.264,
"p50_ms": 0.691,
"p95_ms": 1.637,
"p99_ms": 3.131,
"p999_ms": 5.794,
"max_ms": 9.301,
"cpu_p50_ms": 0.57,
"gpu_p50_ms": 0.465,
"histogram": [
10826,
2511,
284,
106,
23,
16,
5,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 1224,
"seconds": 11.322,
"avg_fps": 108.108,
"low_1pct_fps": 1.252,
"low_01pct_fps": 0.202,
"p50_ms": 0.439,
"p95_ms": 3.015,
"p99_ms": 7.359,
"p999_ms": 2880.23,
"max_ms": 7005.436,
"cpu_p50_ms": 0.303,
"gpu_p50_ms": 0.258,
"histogram": [
1081,
31,
49,
10,
3,
7,
23,
12,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
2
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 15695,
"seconds": 12.0,
"avg_fps": 1307.912,
"low_1pct_fps": 292.881,
"low_01pct_fps": 132.042,
"p50_ms": 0.624,
"p95_ms": 1.219,
"p99_ms": 2.173,
"p999_ms": 4.841,
"max_ms": 16.764,
"cpu_p50_ms": 0.551,
"gpu_p50_ms": 0.401,
"histogram": [
13558,
1932,
140,
37,
14,
6,
3,
1,
1,
1,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 9476,
"seconds": 12.0,
"avg_fps": 789.641,
"low_1pct_fps": 109.83,
"low_01pct_fps": 53.941,
"p50_ms": 0.802,
"p95_ms": 3.406,
"p99_ms": 5.844,
"p999_ms": 14.977,
"max_ms": 22.481,
"cpu_p50_ms": 0.686,
"gpu_p50_ms": 0.403,
"histogram": [
5819,
2151,
787,
455,
112,
65,
36,
18,
7,
7,
7,
5,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 5352,
"seconds": 11.878,
"avg_fps": 450.565,
"low_1pct_fps": 24.389,
"low_01pct_fps": 3.577,
"p50_ms": 1.431,
"p95_ms": 4.548,
"p99_ms": 7.601,
"p999_ms": 96.086,
"max_ms": 714.027,
"cpu_p50_ms": 1.376,
"gpu_p50_ms": 0.398,
"histogram": [
1412,
2323,
811,
460,
138,
84,
49,
34,
13,
9,
8,
2,
1,
1,
0,
1,
0,
0,
1,
2,
1,
1,
1,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8789,
"seconds": 12.001,
"avg_fps": 732.365,
"low_1pct_fps": 136.06,
"low_01pct_fps": 79.749,
"p50_ms": 0.972,
"p95_ms": 3.329,
"p99_ms": 5.68,
"p999_ms": 9.7,
"max_ms": 18.289,
"cpu_p50_ms": 0.935,
"gpu_p50_ms": 0.383,
"histogram": [
4529,
3007,
715,
287,
120,
61,
37,
16,
12,
1,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 11461,
"seconds": 12.001,
"avg_fps": 955.041,
"low_1pct_fps": 193.34,
"low_01pct_fps": 133.895,
"p50_ms": 0.787,
"p95_ms": 2.026,
"p99_ms": 3.953,
"p999_ms": 6.511,
"max_ms": 9.761,
"cpu_p50_ms": 0.765,
"gpu_p50_ms": 0.379,
"histogram": [
7754,
3114,
328,
165,
46,
33,
14,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-desktop"
}
+854
View File
@@ -0,0 +1,854 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T12:44:40Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.39,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1516.155,
"stdev": 34.227,
"min": 1476.782,
"max": 1538.813,
"ci95": 85.032
},
"startup.world_ms": {
"n": 3,
"mean": 4080.9,
"stdev": 748.712,
"min": 3273.516,
"max": 4752.301,
"ci95": 1860.054
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3363.749,
"stdev": 224.552,
"min": 3105.622,
"max": 3514.059,
"ci95": 557.864
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 15473.667,
"stdev": 339.602,
"min": 15123.0,
"max": 15801.0,
"ci95": 843.686
},
"fps.village.avg_fps": {
"n": 3,
"mean": 999.753,
"stdev": 41.901,
"min": 970.527,
"max": 1047.758,
"ci95": 104.095
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 336.821,
"stdev": 17.834,
"min": 318.068,
"max": 353.566,
"ci95": 44.306
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 263.782,
"stdev": 31.556,
"min": 227.767,
"max": 286.582,
"ci95": 78.395
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1147.654,
"stdev": 31.82,
"min": 1116.875,
"max": 1180.422,
"ci95": 79.052
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.414,
"stdev": 0.036,
"min": 0.373,
"max": 0.44,
"ci95": 0.089
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.52,
"stdev": 0.071,
"min": 0.44,
"max": 0.577,
"ci95": 0.177
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3685.368,
"stdev": 844.43,
"min": 2932.117,
"max": 4598.204,
"ci95": 2097.85
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 16795.333,
"stdev": 437.802,
"min": 16325.0,
"max": 17191.0,
"ci95": 1087.648
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 592.615,
"stdev": 20.339,
"min": 580.401,
"max": 616.094,
"ci95": 50.529
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 250.069,
"stdev": 31.227,
"min": 217.065,
"max": 279.149,
"ci95": 77.58
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 151.684,
"stdev": 77.064,
"min": 64.033,
"max": 208.811,
"ci95": 191.454
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1064.344,
"stdev": 148.185,
"min": 901.203,
"max": 1190.609,
"ci95": 368.142
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.424,
"stdev": 0.003,
"min": 0.422,
"max": 0.427,
"ci95": 0.007
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.617,
"stdev": 0.016,
"min": 0.599,
"max": 0.629,
"ci95": 0.039
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 3594.311,
"stdev": 1381.026,
"min": 2011.153,
"max": 4551.541,
"ci95": 3430.936
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 14527.0,
"stdev": 319.629,
"min": 14166.0,
"max": 14774.0,
"ci95": 794.068
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 789.003,
"stdev": 25.076,
"min": 766.83,
"max": 816.216,
"ci95": 62.297
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 276.887,
"stdev": 51.785,
"min": 217.373,
"max": 311.665,
"ci95": 128.651
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 169.278,
"stdev": 29.119,
"min": 137.156,
"max": 193.944,
"ci95": 72.341
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 982.995,
"stdev": 287.819,
"min": 651.109,
"max": 1164.063,
"ci95": 715.039
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.397,
"stdev": 0.028,
"min": 0.38,
"max": 0.429,
"ci95": 0.069
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.569,
"stdev": 0.045,
"min": 0.533,
"max": 0.62,
"ci95": 0.112
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 5682.993,
"stdev": 1272.063,
"min": 4476.873,
"max": 7012.069,
"ci95": 3160.234
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 15640.0,
"stdev": 204.105,
"min": 15507.0,
"max": 15875.0,
"ci95": 507.067
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 716.475,
"stdev": 8.139,
"min": 707.613,
"max": 723.617,
"ci95": 20.221
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 313.736,
"stdev": 3.759,
"min": 311.007,
"max": 318.023,
"ci95": 9.338
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 270.042,
"stdev": 19.572,
"min": 247.802,
"max": 284.642,
"ci95": 48.624
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 997.474,
"stdev": 231.733,
"min": 790.266,
"max": 1247.703,
"ci95": 575.704
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.385,
"stdev": 0.023,
"min": 0.359,
"max": 0.401,
"ci95": 0.057
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 0.609,
"stdev": 0.044,
"min": 0.56,
"max": 0.645,
"ci95": 0.109
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1213.255,
"stdev": 81.897,
"min": 1136.078,
"max": 1299.172,
"ci95": 203.461
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1034.279,
"stdev": 188.802,
"min": 816.453,
"max": 1150.961,
"ci95": 469.049
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2167.747,
"stdev": 14.039,
"min": 2153.721,
"max": 2181.799,
"ci95": 34.878
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 1889.468,
"stdev": 24.444,
"min": 1862.799,
"max": 1910.807,
"ci95": 60.727
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 12573,
"seconds": 12.0,
"avg_fps": 1047.758,
"low_1pct_fps": 353.566,
"low_01pct_fps": 286.582,
"p50_ms": 0.921,
"p95_ms": 1.077,
"p99_ms": 2.675,
"p999_ms": 2.946,
"max_ms": 5.36,
"cpu_p50_ms": 0.44,
"gpu_p50_ms": 0.914,
"histogram": [
11056,
1291,
218,
6,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 11646,
"seconds": 12.0,
"avg_fps": 970.527,
"low_1pct_fps": 318.068,
"low_01pct_fps": 227.767,
"p50_ms": 0.973,
"p95_ms": 1.264,
"p99_ms": 2.805,
"p999_ms": 3.808,
"max_ms": 5.305,
"cpu_p50_ms": 0.577,
"gpu_p50_ms": 0.969,
"histogram": [
7035,
4334,
230,
39,
6,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 11771,
"seconds": 11.999,
"avg_fps": 980.973,
"low_1pct_fps": 338.828,
"low_01pct_fps": 276.996,
"p50_ms": 0.985,
"p95_ms": 1.157,
"p99_ms": 2.779,
"p999_ms": 3.068,
"max_ms": 4.308,
"cpu_p50_ms": 0.542,
"gpu_p50_ms": 0.98,
"histogram": [
6880,
4663,
208,
18,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 7394,
"seconds": 12.001,
"avg_fps": 616.094,
"low_1pct_fps": 253.993,
"low_01pct_fps": 182.207,
"p50_ms": 1.565,
"p95_ms": 1.915,
"p99_ms": 3.329,
"p999_ms": 4.661,
"max_ms": 7.32,
"cpu_p50_ms": 0.599,
"gpu_p50_ms": 1.551,
"histogram": [
82,
6988,
144,
162,
14,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 6965,
"seconds": 12.0,
"avg_fps": 580.401,
"low_1pct_fps": 217.065,
"low_01pct_fps": 64.033,
"p50_ms": 1.654,
"p95_ms": 1.983,
"p99_ms": 3.233,
"p999_ms": 3.893,
"max_ms": 81.863,
"cpu_p50_ms": 0.629,
"gpu_p50_ms": 1.645,
"histogram": [
11,
6622,
153,
174,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 6977,
"seconds": 12.001,
"avg_fps": 581.35,
"low_1pct_fps": 279.149,
"low_01pct_fps": 208.811,
"p50_ms": 1.659,
"p95_ms": 2.006,
"p99_ms": 3.273,
"p999_ms": 3.982,
"max_ms": 5.889,
"cpu_p50_ms": 0.623,
"gpu_p50_ms": 1.653,
"histogram": [
17,
6606,
164,
186,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 9793,
"seconds": 11.998,
"avg_fps": 816.216,
"low_1pct_fps": 301.624,
"low_01pct_fps": 176.734,
"p50_ms": 1.171,
"p95_ms": 1.499,
"p99_ms": 2.928,
"p999_ms": 3.333,
"max_ms": 23.472,
"cpu_p50_ms": 0.533,
"gpu_p50_ms": 1.163,
"histogram": [
259,
9293,
182,
56,
2,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 9202,
"seconds": 12.0,
"avg_fps": 766.83,
"low_1pct_fps": 217.373,
"low_01pct_fps": 137.156,
"p50_ms": 1.226,
"p95_ms": 1.912,
"p99_ms": 3.396,
"p999_ms": 6.003,
"max_ms": 13.562,
"cpu_p50_ms": 0.62,
"gpu_p50_ms": 1.219,
"histogram": [
635,
8130,
261,
124,
27,
15,
7,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 9408,
"seconds": 12.001,
"avg_fps": 783.964,
"low_1pct_fps": 311.665,
"low_01pct_fps": 193.944,
"p50_ms": 1.22,
"p95_ms": 1.541,
"p99_ms": 2.865,
"p999_ms": 3.298,
"max_ms": 13.861,
"cpu_p50_ms": 0.555,
"gpu_p50_ms": 1.214,
"histogram": [
100,
9066,
204,
34,
2,
0,
1,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 8618,
"seconds": 12.0,
"avg_fps": 718.194,
"low_1pct_fps": 312.177,
"low_01pct_fps": 277.681,
"p50_ms": 1.338,
"p95_ms": 1.664,
"p99_ms": 3.068,
"p999_ms": 3.364,
"max_ms": 4.008,
"cpu_p50_ms": 0.622,
"gpu_p50_ms": 1.329,
"histogram": [
62,
8301,
130,
125,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8492,
"seconds": 12.001,
"avg_fps": 707.613,
"low_1pct_fps": 311.007,
"low_01pct_fps": 284.642,
"p50_ms": 1.35,
"p95_ms": 1.707,
"p99_ms": 3.08,
"p999_ms": 3.329,
"max_ms": 3.796,
"cpu_p50_ms": 0.645,
"gpu_p50_ms": 1.342,
"histogram": [
32,
8216,
111,
133,
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": 8684,
"seconds": 12.001,
"avg_fps": 723.617,
"low_1pct_fps": 318.023,
"low_01pct_fps": 247.802,
"p50_ms": 1.318,
"p95_ms": 1.699,
"p99_ms": 2.875,
"p999_ms": 3.573,
"max_ms": 5.186,
"cpu_p50_ms": 0.56,
"gpu_p50_ms": 1.306,
"histogram": [
46,
8389,
199,
49,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-macmini"
}
+854
View File
@@ -0,0 +1,854 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:17:26Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.712,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1528.444,
"stdev": 181.21,
"min": 1319.58,
"max": 1643.783,
"ci95": 450.188
},
"startup.world_ms": {
"n": 3,
"mean": 12368.716,
"stdev": 6475.37,
"min": 7168.429,
"max": 19621.62,
"ci95": 16087.01
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6926.208,
"stdev": 3712.691,
"min": 4208.555,
"max": 11156.419,
"ci95": 9223.581
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 46790.333,
"stdev": 1381.89,
"min": 45236.0,
"max": 47880.0,
"ci95": 3433.082
},
"fps.village.avg_fps": {
"n": 3,
"mean": 453.505,
"stdev": 14.384,
"min": 441.517,
"max": 469.455,
"ci95": 35.735
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 222.897,
"stdev": 10.449,
"min": 212.173,
"max": 233.048,
"ci95": 25.96
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 173.55,
"stdev": 31.798,
"min": 142.967,
"max": 206.437,
"ci95": 78.996
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1544.565,
"stdev": 26.422,
"min": 1516.75,
"max": 1569.328,
"ci95": 65.64
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.528,
"stdev": 0.018,
"min": 0.511,
"max": 0.548,
"ci95": 0.045
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 1.496,
"stdev": 0.049,
"min": 1.453,
"max": 1.549,
"ci95": 0.121
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8297.116,
"stdev": 689.909,
"min": 7716.447,
"max": 9059.776,
"ci95": 1713.966
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 78617.667,
"stdev": 494.532,
"min": 78228.0,
"max": 79174.0,
"ci95": 1228.586
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 216.265,
"stdev": 6.372,
"min": 209.501,
"max": 222.156,
"ci95": 15.831
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 137.027,
"stdev": 3.787,
"min": 133.07,
"max": 140.617,
"ci95": 9.408
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 114.683,
"stdev": 7.985,
"min": 106.585,
"max": 122.551,
"ci95": 19.839
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1420.638,
"stdev": 247.812,
"min": 1266.047,
"max": 1706.469,
"ci95": 615.649
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.423,
"stdev": 0.059,
"min": 0.355,
"max": 0.458,
"ci95": 0.148
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 1.605,
"stdev": 0.269,
"min": 1.295,
"max": 1.779,
"ci95": 0.668
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 8354.659,
"stdev": 2877.749,
"min": 5454.947,
"max": 11209.93,
"ci95": 7149.301
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 74315.667,
"stdev": 63.106,
"min": 74246.0,
"max": 74369.0,
"ci95": 156.776
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 274.828,
"stdev": 4.993,
"min": 270.041,
"max": 280.004,
"ci95": 12.404
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 165.765,
"stdev": 7.21,
"min": 157.538,
"max": 170.985,
"ci95": 17.912
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 140.185,
"stdev": 9.74,
"min": 129.976,
"max": 149.377,
"ci95": 24.198
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1306.578,
"stdev": 97.011,
"min": 1199.094,
"max": 1387.641,
"ci95": 241.007
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.416,
"stdev": 0.029,
"min": 0.392,
"max": 0.448,
"ci95": 0.071
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 1.636,
"stdev": 0.121,
"min": 1.496,
"max": 1.708,
"ci95": 0.301
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17124.873,
"stdev": 3461.08,
"min": 13599.217,
"max": 20517.544,
"ci95": 8598.494
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 91909.0,
"stdev": 815.603,
"min": 91157.0,
"max": 92776.0,
"ci95": 2026.235
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 242.657,
"stdev": 3.405,
"min": 240.66,
"max": 246.588,
"ci95": 8.459
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 110.95,
"stdev": 14.622,
"min": 99.907,
"max": 127.532,
"ci95": 36.325
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 51.329,
"stdev": 28.253,
"min": 30.391,
"max": 83.465,
"ci95": 70.191
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1451.688,
"stdev": 127.471,
"min": 1310.969,
"max": 1559.43,
"ci95": 316.681
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.614,
"stdev": 0.017,
"min": 0.594,
"max": 0.627,
"ci95": 0.043
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 2.233,
"stdev": 0.05,
"min": 2.194,
"max": 2.289,
"ci95": 0.124
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1697.984,
"stdev": 29.735,
"min": 1667.406,
"max": 1726.797,
"ci95": 73.871
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1428.19,
"stdev": 120.658,
"min": 1333.0,
"max": 1563.891,
"ci95": 299.756
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4921.753,
"stdev": 30.436,
"min": 4890.971,
"max": 4951.831,
"ci95": 75.613
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4079.43,
"stdev": 10.978,
"min": 4068.049,
"max": 4089.956,
"ci95": 27.273
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 5634,
"seconds": 12.001,
"avg_fps": 469.455,
"low_1pct_fps": 223.469,
"low_01pct_fps": 171.246,
"p50_ms": 2.054,
"p95_ms": 2.492,
"p99_ms": 3.993,
"p999_ms": 5.425,
"max_ms": 6.645,
"cpu_p50_ms": 1.453,
"gpu_p50_ms": 1.965,
"histogram": [
0,
2082,
3324,
193,
24,
9,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 5395,
"seconds": 12.001,
"avg_fps": 449.543,
"low_1pct_fps": 233.048,
"low_01pct_fps": 206.437,
"p50_ms": 2.155,
"p95_ms": 2.611,
"p99_ms": 4.068,
"p999_ms": 4.456,
"max_ms": 5.305,
"cpu_p50_ms": 1.549,
"gpu_p50_ms": 2.043,
"histogram": [
0,
902,
4255,
206,
29,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 5298,
"seconds": 12.0,
"avg_fps": 441.517,
"low_1pct_fps": 212.173,
"low_01pct_fps": 142.967,
"p50_ms": 2.171,
"p95_ms": 3.249,
"p99_ms": 4.138,
"p999_ms": 5.591,
"max_ms": 9.676,
"cpu_p50_ms": 1.485,
"gpu_p50_ms": 2.092,
"histogram": [
0,
709,
4314,
229,
36,
6,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 2606,
"seconds": 12.002,
"avg_fps": 217.137,
"low_1pct_fps": 140.617,
"low_01pct_fps": 106.585,
"p50_ms": 4.471,
"p95_ms": 5.926,
"p99_ms": 6.575,
"p999_ms": 7.735,
"max_ms": 12.187,
"cpu_p50_ms": 1.74,
"gpu_p50_ms": 4.238,
"histogram": [
0,
0,
0,
221,
2094,
172,
110,
8,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 2514,
"seconds": 12.0,
"avg_fps": 209.501,
"low_1pct_fps": 137.393,
"low_01pct_fps": 122.551,
"p50_ms": 4.627,
"p95_ms": 6.304,
"p99_ms": 6.93,
"p999_ms": 7.971,
"max_ms": 8.506,
"cpu_p50_ms": 1.779,
"gpu_p50_ms": 4.409,
"histogram": [
0,
0,
2,
35,
2097,
200,
162,
17,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 2667,
"seconds": 12.005,
"avg_fps": 222.156,
"low_1pct_fps": 133.07,
"low_01pct_fps": 114.913,
"p50_ms": 4.37,
"p95_ms": 5.756,
"p99_ms": 6.742,
"p999_ms": 8.444,
"max_ms": 8.984,
"cpu_p50_ms": 1.295,
"gpu_p50_ms": 4.152,
"histogram": [
0,
0,
0,
440,
1978,
144,
85,
16,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 3294,
"seconds": 12.003,
"avg_fps": 274.44,
"low_1pct_fps": 157.538,
"low_01pct_fps": 129.976,
"p50_ms": 3.521,
"p95_ms": 4.814,
"p99_ms": 5.592,
"p999_ms": 7.491,
"max_ms": 7.868,
"cpu_p50_ms": 1.703,
"gpu_p50_ms": 3.277,
"histogram": [
0,
4,
55,
2967,
130,
122,
10,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 3241,
"seconds": 12.002,
"avg_fps": 270.041,
"low_1pct_fps": 168.771,
"low_01pct_fps": 141.201,
"p50_ms": 3.578,
"p95_ms": 4.942,
"p99_ms": 5.631,
"p999_ms": 6.498,
"max_ms": 7.601,
"cpu_p50_ms": 1.708,
"gpu_p50_ms": 3.323,
"histogram": [
0,
2,
8,
2970,
110,
144,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 3360,
"seconds": 12.0,
"avg_fps": 280.004,
"low_1pct_fps": 170.985,
"low_01pct_fps": 149.377,
"p50_ms": 3.477,
"p95_ms": 4.678,
"p99_ms": 5.462,
"p999_ms": 6.626,
"max_ms": 6.788,
"cpu_p50_ms": 1.496,
"gpu_p50_ms": 3.23,
"histogram": [
0,
1,
5,
3125,
129,
90,
10,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 2888,
"seconds": 12.0,
"avg_fps": 240.66,
"low_1pct_fps": 127.532,
"low_01pct_fps": 83.465,
"p50_ms": 3.984,
"p95_ms": 5.171,
"p99_ms": 6.823,
"p999_ms": 9.168,
"max_ms": 15.341,
"cpu_p50_ms": 2.194,
"gpu_p50_ms": 3.786,
"histogram": [
0,
0,
4,
2048,
638,
144,
33,
16,
3,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 2889,
"seconds": 12.001,
"avg_fps": 240.722,
"low_1pct_fps": 99.907,
"low_01pct_fps": 40.132,
"p50_ms": 3.961,
"p95_ms": 5.706,
"p99_ms": 7.364,
"p999_ms": 12.597,
"max_ms": 49.156,
"cpu_p50_ms": 2.289,
"gpu_p50_ms": 3.802,
"histogram": [
0,
5,
126,
1859,
599,
194,
72,
22,
8,
1,
2,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 2959,
"seconds": 12.0,
"avg_fps": 246.588,
"low_1pct_fps": 105.411,
"low_01pct_fps": 30.391,
"p50_ms": 3.851,
"p95_ms": 5.353,
"p99_ms": 6.248,
"p999_ms": 9.05,
"max_ms": 54.378,
"cpu_p50_ms": 2.216,
"gpu_p50_ms": 3.667,
"histogram": [
0,
2,
58,
2196,
462,
196,
32,
10,
1,
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-macmini"
}
+42
View File
@@ -0,0 +1,42 @@
# Horizon Nightly 2026-11-04 vs vanilla 26.3 on the Mac mini and the desktop (2026-11-04)
> **The Mac mini reports (`*-macmini-rd*.json`) are offscreen, not comparable.** Horizon rendered offscreen (no presenting)
> while vanilla drew into a window, so the Mac rows below are not a Horizon-vs-vanilla comparison, and the website does not
> publish them: it keeps the Mac mini's 2026-10-22 runs. They are kept here only as a record of that day's offscreen run.
The desktop runs are the ones behind playhorizon.dev/benchmarks as of 2026-11-04 (horizon-rust/horizon-site#58): `tools/perf_bench.py`
reports, render distance 12 and 32, three games each, every game from a fresh world (seed 1234), 1280x720, VSync off. Files:
`{horizon,vanilla}-macmini-rd{12,32}.json` and `{horizon,vanilla}-desktop-rd{12,32}.json` next to this page.
## Builds
- **Horizon:** the Nightly 2026-11-04 release bundle (67dad0d), as the launcher installs it: macOS arm64 on the Mac,
Linux x86_64 on the desktop. Its resources come from the bundle's own `horizon-prepare`. perf_bench ran from main
a55b9a2e52 and wrote that checkout's crate version and commit; `app.version` and `app.commit` were set to the bundle's by hand.
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25, the official launcher's JVM flags.
## Machines and conditions
- **Mac mini, Apple M4 Pro (14 cores, 20-core GPU), macOS: offscreen, not comparable.** The session was locked, so Horizon ran `--offscreen`: a locked
session gives a window no frames, and windowed Horizon measured 0 FPS. Vanilla drew into its window. Offscreen skips
presenting, which favours Horizon, so its averages aren't held at the 144 Hz display as they were on 2026-10-22. Each game
started at a 1-minute load of 17-18 (the run waited for < 20); during the runs the load rose to 30-50.
- **Desktop, RTX 4080 + i7-13700KF, NixOS, NVIDIA 595.** Both games drew into a headless sway rendering on the RTX 4080 (never
on its screen), both through Wayland on Vulkan. Vanilla went through SDL's Wayland driver: through Xwayland each of its
frames took about 2 s on this day. The machine also runs CI and agents' builds: each game started at a load of 28-30 on
24 threads (the run waited for < 30) and the load rose to 40-70 during the runs. Vanilla at RD 32 crashed twice under that
load (`GPU timeout attempting to acquire next frame`) before a third series finished. Start-up times are inflated by the
load and by a cold disk on each series' first start (start → world up to 130-235 s).
- **Order:** RD 12 ran Horizon then vanilla; RD 32 ran vanilla then Horizon.
## Results (average over the flights of the three games; FPS average / 1% low)
| Machine | RD | Horizon | Vanilla | Horizon ÷ vanilla (avg, 1% low) |
|---|---|---|---|---|
| Mac mini (**offscreen, not comparable**) | 12 | 774 / 294 | 132 / 27 | — |
| Mac mini (**offscreen, not comparable**) | 32 | 297 / 159 | 68 / 16 | — |
| Desktop | 12 | 2,224 / 308 | 1,824 / 201 | 1.2x, 1.5x |
| Desktop | 32 | 800 / 140 | 696 / 64 | 1.15x, 2.2x |
Neither machine was idle, so these say how both games behave on busy machines. They are not clean ratios; for those, see
`windows-9800x3d-5080-2026-10-29.md`. The scripts and logs are in horizon-parity `shots/site-bench-2026-11-04/`.
+787
View File
@@ -0,0 +1,787 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:28:55Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "NVIDIA",
"driver": "1.4.329 NVIDIA 595.71.05",
"backend": "vulkan",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.788,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 121075.467,
"stdev": 30646.429,
"min": 93272.034,
"max": 153935.834,
"ci95": 76136.094
},
"startup.world_ms": {
"n": 3,
"mean": 191063.8,
"stdev": 34368.16,
"min": 153895.533,
"max": 221692.034,
"ci95": 85382.134
},
"chunks.village.load_ms": {
"n": 3,
"mean": 18724.322,
"stdev": 9639.048,
"min": 13107.549,
"max": 29854.376,
"ci95": 23946.655
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2161.532,
"stdev": 287.109,
"min": 1830.069,
"max": 2332.81,
"ci95": 713.276
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 162.575,
"stdev": 29.799,
"min": 136.023,
"max": 194.804,
"ci95": 74.03
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 27.752,
"stdev": 3.186,
"min": 24.403,
"max": 30.746,
"ci95": 7.916
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.village.rss_mb": {
"n": 3,
"mean": 2902.054,
"stdev": 158.708,
"min": 2747.244,
"max": 3064.393,
"ci95": 394.285
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.691,
"stdev": 0.097,
"min": 0.601,
"max": 0.794,
"ci95": 0.241
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8852.332,
"stdev": 530.809,
"min": 8350.811,
"max": 9408.241,
"ci95": 1318.709
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1841.581,
"stdev": 1186.781,
"min": 471.209,
"max": 2529.847,
"ci95": 2948.365
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 255.087,
"stdev": 217.128,
"min": 5.798,
"max": 402.877,
"ci95": 539.419
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 92.882,
"stdev": 81.722,
"min": 0.61,
"max": 156.13,
"ci95": 203.024
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 3008.178,
"stdev": 149.07,
"min": 2875.082,
"max": 3169.256,
"ci95": 370.34
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.687,
"stdev": 0.132,
"min": 0.562,
"max": 0.825,
"ci95": 0.327
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 21215.745,
"stdev": 22821.547,
"min": 8034.077,
"max": 47567.797,
"ci95": 56696.441
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1525.483,
"stdev": 1472.651,
"min": 633.984,
"max": 3225.275,
"ci95": 3658.563
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 191.398,
"stdev": 272.942,
"min": 10.346,
"max": 505.335,
"ci95": 678.08
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 49.506,
"stdev": 66.145,
"min": 1.104,
"max": 124.874,
"ci95": 164.326
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 3062.136,
"stdev": 128.594,
"min": 2962.541,
"max": 3207.313,
"ci95": 319.472
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 1.029,
"stdev": 0.573,
"min": 0.595,
"max": 1.678,
"ci95": 1.423
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 33255.15,
"stdev": 20451.99,
"min": 16655.144,
"max": 56102.104,
"ci95": 50809.661
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1769.12,
"stdev": 996.863,
"min": 818.512,
"max": 2806.557,
"ci95": 2476.544
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 195.916,
"stdev": 195.285,
"min": 11.586,
"max": 400.567,
"ci95": 485.154
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 52.202,
"stdev": 51.208,
"min": 1.461,
"max": 103.864,
"ci95": 127.217
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 2957.948,
"stdev": 340.935,
"min": 2586.504,
"max": 3256.625,
"ci95": 846.998
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.436,
"stdev": 1.406,
"min": 0.462,
"max": 3.048,
"ci95": 3.494
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 3107.953,
"stdev": 141.888,
"min": 3020.105,
"max": 3271.645,
"ci95": 352.498
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2985.317,
"stdev": 210.327,
"min": 2761.164,
"max": 3178.35,
"ci95": 522.523
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 21963,
"seconds": 12.001,
"avg_fps": 1830.069,
"low_1pct_fps": 136.023,
"low_01pct_fps": 30.746,
"p50_ms": 0.331,
"p95_ms": 1.153,
"p99_ms": 3.001,
"p999_ms": 9.997,
"max_ms": 326.36,
"histogram": [
20336,
1238,
167,
108,
40,
23,
12,
10,
8,
7,
5,
1,
2,
1,
2,
0,
1,
1,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 27994,
"seconds": 12.0,
"avg_fps": 2332.81,
"low_1pct_fps": 194.804,
"low_01pct_fps": 28.107,
"p50_ms": 0.321,
"p95_ms": 0.771,
"p99_ms": 1.356,
"p999_ms": 3.836,
"max_ms": 331.931,
"histogram": [
27270,
652,
34,
13,
5,
2,
3,
4,
3,
2,
0,
1,
0,
0,
0,
0,
1,
0,
1,
1,
0,
2,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 27861,
"seconds": 12.0,
"avg_fps": 2321.717,
"low_1pct_fps": 156.898,
"low_01pct_fps": 24.403,
"p50_ms": 0.281,
"p95_ms": 0.852,
"p99_ms": 1.73,
"p999_ms": 6.331,
"max_ms": 301.991,
"histogram": [
26932,
726,
124,
42,
4,
4,
3,
2,
9,
2,
3,
1,
1,
1,
0,
1,
1,
0,
2,
0,
2,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 30359,
"seconds": 12.0,
"avg_fps": 2529.847,
"low_1pct_fps": 356.586,
"low_01pct_fps": 121.906,
"p50_ms": 0.353,
"p95_ms": 0.563,
"p99_ms": 1.405,
"p999_ms": 4.444,
"max_ms": 20.302,
"histogram": [
29756,
460,
57,
52,
11,
5,
3,
5,
2,
4,
1,
0,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 5781,
"seconds": 12.268,
"avg_fps": 471.209,
"low_1pct_fps": 5.798,
"low_01pct_fps": 0.61,
"p50_ms": 0.359,
"p95_ms": 0.77,
"p99_ms": 1.78,
"p999_ms": 20.826,
"max_ms": 5834.37,
"histogram": [
5586,
158,
12,
11,
0,
1,
3,
2,
0,
0,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
2,
3
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 30285,
"seconds": 12.0,
"avg_fps": 2523.687,
"low_1pct_fps": 402.877,
"low_01pct_fps": 156.13,
"p50_ms": 0.351,
"p95_ms": 0.539,
"p99_ms": 1.574,
"p999_ms": 3.683,
"max_ms": 16.022,
"histogram": [
29666,
490,
74,
35,
7,
2,
2,
1,
1,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 11685,
"seconds": 16.293,
"avg_fps": 717.19,
"low_1pct_fps": 10.346,
"low_01pct_fps": 1.104,
"p50_ms": 0.299,
"p95_ms": 1.119,
"p99_ms": 2.568,
"p999_ms": 12.054,
"max_ms": 5250.537,
"histogram": [
10993,
502,
102,
39,
14,
8,
6,
4,
4,
1,
1,
2,
0,
2,
0,
0,
0,
0,
1,
0,
0,
2,
0,
4
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 38704,
"seconds": 12.0,
"avg_fps": 3225.275,
"low_1pct_fps": 505.335,
"low_01pct_fps": 124.874,
"p50_ms": 0.283,
"p95_ms": 0.36,
"p99_ms": 0.909,
"p999_ms": 2.015,
"max_ms": 132.801,
"histogram": [
38385,
279,
25,
3,
2,
0,
1,
0,
2,
4,
1,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 7609,
"seconds": 12.002,
"avg_fps": 633.984,
"low_1pct_fps": 58.513,
"low_01pct_fps": 22.539,
"p50_ms": 1.141,
"p95_ms": 4.438,
"p99_ms": 9.113,
"p999_ms": 27.496,
"max_ms": 64.424,
"histogram": [
3452,
2398,
956,
368,
143,
96,
61,
46,
20,
29,
6,
10,
6,
7,
7,
0,
1,
3,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 10168,
"seconds": 12.423,
"avg_fps": 818.512,
"low_1pct_fps": 11.586,
"low_01pct_fps": 1.461,
"p50_ms": 0.267,
"p95_ms": 0.892,
"p99_ms": 3.176,
"p999_ms": 198.757,
"max_ms": 1882.64,
"histogram": [
9733,
205,
121,
49,
12,
9,
8,
2,
2,
0,
1,
2,
1,
3,
0,
3,
2,
0,
0,
2,
4,
5,
1,
3
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 33679,
"seconds": 12.0,
"avg_fps": 2806.557,
"low_1pct_fps": 400.567,
"low_01pct_fps": 103.864,
"p50_ms": 0.324,
"p95_ms": 0.411,
"p99_ms": 1.167,
"p999_ms": 3.508,
"max_ms": 83.572,
"histogram": [
33272,
315,
46,
22,
2,
1,
4,
2,
9,
1,
2,
2,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 20188,
"seconds": 12.0,
"avg_fps": 1682.292,
"low_1pct_fps": 175.594,
"low_01pct_fps": 51.28,
"p50_ms": 0.374,
"p95_ms": 1.37,
"p99_ms": 2.821,
"p999_ms": 8.947,
"max_ms": 53.494,
"histogram": [
18109,
1592,
314,
83,
36,
19,
7,
6,
6,
1,
3,
1,
4,
2,
3,
0,
1,
1,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-desktop"
}
+787
View File
@@ -0,0 +1,787 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T15:46:25Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "NVIDIA",
"driver": "1.4.329 NVIDIA 595.71.05",
"backend": "vulkan",
"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,
"load_avg_1m": 28.799,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 112742.179,
"stdev": 27885.804,
"min": 81110.692,
"max": 133773.748,
"ci95": 69277.769
},
"startup.world_ms": {
"n": 3,
"mean": 235049.179,
"stdev": 31461.958,
"min": 203486.748,
"max": 266409.692,
"ci95": 78162.144
},
"chunks.village.load_ms": {
"n": 3,
"mean": 79828.05,
"stdev": 109107.365,
"min": 16685.972,
"max": 205814.266,
"ci95": 271059.597
},
"fps.village.avg_fps": {
"n": 3,
"mean": 525.667,
"stdev": 390.505,
"min": 101.073,
"max": 869.433,
"ci95": 970.146
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 44.209,
"stdev": 39.491,
"min": 1.644,
"max": 79.658,
"ci95": 98.109
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 10.635,
"stdev": 9.759,
"min": 0.686,
"max": 20.192,
"ci95": 24.244
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.village.rss_mb": {
"n": 3,
"mean": 3296.229,
"stdev": 296.464,
"min": 2956.234,
"max": 3500.773,
"ci95": 736.517
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.23,
"stdev": 0.449,
"min": 0.897,
"max": 1.74,
"ci95": 1.114
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 61172.527,
"stdev": 17434.762,
"min": 46779.931,
"max": 80559.429,
"ci95": 43313.846
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 599.408,
"stdev": 492.96,
"min": 64.173,
"max": 1034.813,
"ci95": 1224.68
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 61.923,
"stdev": 56.534,
"min": 0.737,
"max": 112.221,
"ci95": 140.449
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 15.771,
"stdev": 14.11,
"min": 0.098,
"max": 27.463,
"ci95": 35.054
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 3278.964,
"stdev": 301.465,
"min": 2947.268,
"max": 3536.27,
"ci95": 748.941
},
"tick.forest.client_ms": {
"n": 3,
"mean": 5.908,
"stdev": 8.813,
"min": 0.693,
"max": 16.084,
"ci95": 21.895
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 61762.098,
"stdev": 3569.17,
"min": 57919.097,
"max": 64972.973,
"ci95": 8867.026
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1175.044,
"stdev": 131.94,
"min": 1093.801,
"max": 1327.281,
"ci95": 327.785
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 119.758,
"stdev": 68.419,
"min": 77.252,
"max": 198.683,
"ci95": 169.976
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 24.224,
"stdev": 18.982,
"min": 12.308,
"max": 46.114,
"ci95": 47.159
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 3275.349,
"stdev": 317.864,
"min": 2924.113,
"max": 3543.23,
"ci95": 789.681
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.654,
"stdev": 0.066,
"min": 0.579,
"max": 0.699,
"ci95": 0.163
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 113439.529,
"stdev": 64746.162,
"min": 55046.887,
"max": 183068.386,
"ci95": 160851.363
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 485.832,
"stdev": 317.254,
"min": 121.312,
"max": 699.617,
"ci95": 788.165
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 28.612,
"stdev": 23.334,
"min": 2.229,
"max": 46.542,
"ci95": 57.97
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 4.56,
"stdev": 3.408,
"min": 0.75,
"max": 7.318,
"ci95": 8.467
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 3248.708,
"stdev": 480.962,
"min": 2700.645,
"max": 3600.484,
"ci95": 1194.873
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.832,
"stdev": 2.076,
"min": 0.618,
"max": 4.229,
"ci95": 5.157
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 3371.958,
"stdev": 280.347,
"min": 3059.926,
"max": 3602.613,
"ci95": 696.476
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 3291.249,
"stdev": 316.441,
"min": 2935.975,
"max": 3542.848,
"ci95": 786.147
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1248,
"seconds": 12.348,
"avg_fps": 101.073,
"low_1pct_fps": 1.644,
"low_01pct_fps": 0.686,
"p50_ms": 1.225,
"p95_ms": 11.161,
"p99_ms": 226.597,
"p999_ms": 1457.605,
"max_ms": 1457.874,
"histogram": [
548,
232,
97,
118,
51,
49,
24,
39,
16,
17,
13,
6,
3,
2,
5,
3,
2,
2,
4,
2,
3,
8,
1,
3
]
},
{
"phase": "village",
"repeat": 1,
"frames": 10435,
"seconds": 12.002,
"avg_fps": 869.433,
"low_1pct_fps": 79.658,
"low_01pct_fps": 20.192,
"p50_ms": 0.813,
"p95_ms": 2.537,
"p99_ms": 5.685,
"p999_ms": 13.292,
"max_ms": 351.251,
"histogram": [
7598,
2071,
393,
171,
55,
53,
22,
24,
20,
10,
9,
2,
2,
1,
3,
0,
0,
0,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 7278,
"seconds": 12.0,
"avg_fps": 606.495,
"low_1pct_fps": 51.326,
"low_01pct_fps": 11.026,
"p50_ms": 1.105,
"p95_ms": 3.854,
"p99_ms": 7.067,
"p999_ms": 24.484,
"max_ms": 443.95,
"histogram": [
3206,
2621,
800,
360,
112,
71,
34,
26,
12,
13,
3,
5,
2,
7,
2,
1,
1,
0,
1,
0,
0,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 975,
"seconds": 15.193,
"avg_fps": 64.173,
"low_1pct_fps": 0.737,
"low_01pct_fps": 0.098,
"p50_ms": 0.864,
"p95_ms": 5.083,
"p99_ms": 77.689,
"p999_ms": 10225.237,
"max_ms": 10225.237,
"histogram": [
680,
99,
104,
12,
30,
13,
8,
4,
2,
3,
2,
1,
1,
3,
0,
1,
0,
1,
2,
3,
1,
2,
1,
2
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 8391,
"seconds": 12.0,
"avg_fps": 699.237,
"low_1pct_fps": 72.811,
"low_01pct_fps": 19.752,
"p50_ms": 1.087,
"p95_ms": 2.927,
"p99_ms": 5.909,
"p999_ms": 15.268,
"max_ms": 252.557,
"histogram": [
3342,
3998,
656,
231,
56,
26,
15,
19,
12,
12,
11,
6,
1,
3,
1,
0,
0,
1,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 12418,
"seconds": 12.0,
"avg_fps": 1034.813,
"low_1pct_fps": 112.221,
"low_01pct_fps": 27.463,
"p50_ms": 0.837,
"p95_ms": 1.394,
"p99_ms": 3.491,
"p999_ms": 13.217,
"max_ms": 245.59,
"histogram": [
11155,
1004,
100,
64,
29,
16,
16,
10,
2,
6,
5,
3,
4,
2,
0,
1,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 13126,
"seconds": 12.0,
"avg_fps": 1093.801,
"low_1pct_fps": 77.252,
"low_01pct_fps": 12.308,
"p50_ms": 0.714,
"p95_ms": 1.402,
"p99_ms": 2.875,
"p999_ms": 12.067,
"max_ms": 238.158,
"histogram": [
11605,
1207,
191,
54,
17,
11,
7,
12,
4,
4,
2,
3,
2,
1,
0,
0,
0,
0,
1,
2,
3,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 15928,
"seconds": 12.0,
"avg_fps": 1327.281,
"low_1pct_fps": 198.683,
"low_01pct_fps": 46.114,
"p50_ms": 0.67,
"p95_ms": 1.172,
"p99_ms": 2.111,
"p999_ms": 7.067,
"max_ms": 193.743,
"histogram": [
14891,
856,
102,
42,
7,
6,
8,
6,
0,
5,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 13249,
"seconds": 12.0,
"avg_fps": 1104.051,
"low_1pct_fps": 83.338,
"low_01pct_fps": 14.249,
"p50_ms": 0.711,
"p95_ms": 1.403,
"p99_ms": 3.046,
"p999_ms": 10.625,
"max_ms": 451.869,
"histogram": [
11824,
1165,
124,
58,
13,
16,
11,
14,
7,
7,
3,
2,
1,
0,
0,
0,
1,
0,
0,
1,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 1469,
"seconds": 12.109,
"avg_fps": 121.312,
"low_1pct_fps": 2.229,
"low_01pct_fps": 0.75,
"p50_ms": 2.12,
"p95_ms": 13.173,
"p99_ms": 125.189,
"p999_ms": 727.045,
"max_ms": 1939.723,
"histogram": [
196,
440,
438,
136,
50,
36,
24,
25,
22,
17,
17,
13,
6,
11,
8,
4,
1,
4,
4,
4,
3,
7,
2,
1
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8396,
"seconds": 12.001,
"avg_fps": 699.617,
"low_1pct_fps": 46.542,
"low_01pct_fps": 7.318,
"p50_ms": 1.022,
"p95_ms": 2.433,
"p99_ms": 5.528,
"p999_ms": 13.993,
"max_ms": 305.464,
"histogram": [
3248,
4509,
361,
113,
52,
37,
21,
26,
14,
4,
3,
2,
0,
0,
0,
0,
0,
0,
1,
1,
2,
2,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 7639,
"seconds": 12.0,
"avg_fps": 636.566,
"low_1pct_fps": 37.064,
"low_01pct_fps": 5.612,
"p50_ms": 1.023,
"p95_ms": 3.0,
"p99_ms": 7.11,
"p999_ms": 17.521,
"max_ms": 268.877,
"histogram": [
3051,
3794,
413,
158,
66,
43,
35,
21,
31,
12,
3,
4,
1,
1,
0,
0,
0,
0,
0,
0,
4,
2,
0,
0
]
}
],
"label": "vanilla-desktop"
}
+771
View File
@@ -0,0 +1,771 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T12:54:29Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.628,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 12652.182,
"stdev": 3231.342,
"min": 9526.004,
"max": 15979.309,
"ci95": 8027.746
},
"startup.world_ms": {
"n": 3,
"mean": 31329.516,
"stdev": 10016.129,
"min": 20777.004,
"max": 40705.309,
"ci95": 24883.451
},
"chunks.village.load_ms": {
"n": 3,
"mean": 12623.084,
"stdev": 4958.724,
"min": 7251.986,
"max": 17026.894,
"ci95": 12319.147
},
"fps.village.avg_fps": {
"n": 3,
"mean": 92.729,
"stdev": 58.527,
"min": 28.713,
"max": 143.496,
"ci95": 145.402
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 16.52,
"stdev": 10.949,
"min": 4.001,
"max": 24.308,
"ci95": 27.2
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 11.515,
"stdev": 7.155,
"min": 3.549,
"max": 17.394,
"ci95": 17.774
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1834.469,
"stdev": 329.198,
"min": 1455.688,
"max": 2051.516,
"ci95": 817.839
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.903,
"stdev": 1.114,
"min": 1.131,
"max": 3.18,
"ci95": 2.767
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 14590.694,
"stdev": 3260.615,
"min": 10942.613,
"max": 17221.108,
"ci95": 8100.47
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 106.817,
"stdev": 104.558,
"min": 43.857,
"max": 227.513,
"ci95": 259.759
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 23.0,
"stdev": 26.394,
"min": 7.338,
"max": 53.473,
"ci95": 65.57
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 14.841,
"stdev": 15.773,
"min": 5.157,
"max": 33.042,
"ci95": 39.186
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1814.245,
"stdev": 154.974,
"min": 1635.453,
"max": 1910.125,
"ci95": 385.007
},
"tick.forest.client_ms": {
"n": 3,
"mean": 1.452,
"stdev": 0.542,
"min": 0.826,
"max": 1.777,
"ci95": 1.346
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 13309.049,
"stdev": 8088.157,
"min": 6394.403,
"max": 22203.194,
"ci95": 20093.716
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 164.405,
"stdev": 141.4,
"min": 62.162,
"max": 325.77,
"ci95": 351.285
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 36.242,
"stdev": 37.051,
"min": 14.736,
"max": 79.024,
"ci95": 92.047
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 20.898,
"stdev": 17.899,
"min": 8.855,
"max": 41.466,
"ci95": 44.468
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1724.453,
"stdev": 347.692,
"min": 1323.5,
"max": 1942.734,
"ci95": 863.784
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.866,
"stdev": 0.248,
"min": 0.58,
"max": 1.03,
"ci95": 0.617
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17989.43,
"stdev": 3969.295,
"min": 13444.423,
"max": 20774.261,
"ci95": 9861.07
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 162.084,
"stdev": 160.057,
"min": 68.44,
"max": 346.896,
"ci95": 397.635
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 33.74,
"stdev": 36.146,
"min": 11.158,
"max": 75.429,
"ci95": 89.798
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 17.194,
"stdev": 14.232,
"min": 7.903,
"max": 33.579,
"ci95": 35.357
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1977.602,
"stdev": 108.627,
"min": 1872.516,
"max": 2089.453,
"ci95": 269.866
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.772,
"stdev": 0.332,
"min": 0.463,
"max": 1.123,
"ci95": 0.825
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2135.313,
"stdev": 54.617,
"min": 2072.344,
"max": 2169.828,
"ci95": 135.686
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1836.44,
"stdev": 223.694,
"min": 1578.141,
"max": 1965.781,
"ci95": 555.732
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3253.456,
"stdev": 68.323,
"min": 3178.409,
"max": 3312.05,
"ci95": 169.737
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3144.363,
"stdev": 69.868,
"min": 3069.394,
"max": 3207.66,
"ci95": 173.576
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1272,
"seconds": 12.002,
"avg_fps": 105.979,
"low_1pct_fps": 21.25,
"low_01pct_fps": 17.394,
"p50_ms": 7.527,
"p95_ms": 22.34,
"p99_ms": 40.461,
"p999_ms": 54.28,
"max_ms": 60.7,
"histogram": [
0,
0,
0,
56,
161,
170,
180,
167,
184,
118,
71,
61,
26,
25,
28,
12,
10,
3,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1723,
"seconds": 12.007,
"avg_fps": 143.496,
"low_1pct_fps": 24.308,
"low_01pct_fps": 13.602,
"p50_ms": 5.317,
"p95_ms": 16.181,
"p99_ms": 29.771,
"p999_ms": 56.832,
"max_ms": 90.209,
"histogram": [
0,
0,
63,
413,
271,
273,
185,
148,
112,
89,
47,
43,
28,
22,
18,
4,
5,
1,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 346,
"seconds": 12.05,
"avg_fps": 28.713,
"low_1pct_fps": 4.001,
"low_01pct_fps": 3.549,
"p50_ms": 23.684,
"p95_ms": 110.057,
"p99_ms": 225.236,
"p999_ms": 281.776,
"max_ms": 281.776,
"histogram": [
0,
0,
0,
4,
8,
6,
6,
12,
21,
20,
23,
25,
24,
32,
50,
28,
24,
27,
17,
11,
6,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 589,
"seconds": 12.001,
"avg_fps": 49.081,
"low_1pct_fps": 8.19,
"low_01pct_fps": 6.325,
"p50_ms": 15.443,
"p95_ms": 53.004,
"p99_ms": 94.69,
"p999_ms": 158.114,
"max_ms": 158.114,
"histogram": [
0,
0,
0,
2,
7,
20,
30,
46,
53,
48,
56,
62,
61,
66,
63,
18,
22,
20,
10,
4,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 2731,
"seconds": 12.004,
"avg_fps": 227.513,
"low_1pct_fps": 53.473,
"low_01pct_fps": 33.042,
"p50_ms": 3.801,
"p95_ms": 7.955,
"p99_ms": 14.177,
"p999_ms": 24.479,
"max_ms": 38.218,
"histogram": [
0,
0,
526,
1157,
432,
257,
151,
88,
46,
32,
13,
15,
6,
6,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 527,
"seconds": 12.016,
"avg_fps": 43.857,
"low_1pct_fps": 7.338,
"low_01pct_fps": 5.157,
"p50_ms": 16.784,
"p95_ms": 57.353,
"p99_ms": 111.984,
"p999_ms": 193.905,
"max_ms": 193.905,
"histogram": [
0,
0,
0,
1,
2,
9,
16,
29,
40,
61,
50,
54,
52,
62,
57,
35,
28,
11,
13,
5,
2,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 746,
"seconds": 12.001,
"avg_fps": 62.162,
"low_1pct_fps": 14.965,
"low_01pct_fps": 12.372,
"p50_ms": 13.76,
"p95_ms": 35.144,
"p99_ms": 57.63,
"p999_ms": 80.827,
"max_ms": 80.827,
"histogram": [
0,
0,
2,
13,
12,
41,
37,
53,
67,
82,
80,
99,
82,
76,
58,
17,
11,
12,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 3910,
"seconds": 12.002,
"avg_fps": 325.77,
"low_1pct_fps": 79.024,
"low_01pct_fps": 41.466,
"p50_ms": 2.685,
"p95_ms": 5.125,
"p99_ms": 8.342,
"p999_ms": 20.782,
"max_ms": 30.571,
"histogram": [
0,
78,
2526,
892,
203,
95,
49,
27,
13,
11,
7,
3,
2,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 1268,
"seconds": 12.044,
"avg_fps": 105.282,
"low_1pct_fps": 14.736,
"low_01pct_fps": 8.855,
"p50_ms": 6.542,
"p95_ms": 25.245,
"p99_ms": 50.465,
"p999_ms": 79.065,
"max_ms": 146.809,
"histogram": [
0,
0,
4,
171,
190,
169,
167,
133,
105,
93,
53,
43,
35,
41,
26,
13,
11,
10,
3,
1,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 822,
"seconds": 12.011,
"avg_fps": 68.44,
"low_1pct_fps": 14.632,
"low_01pct_fps": 10.101,
"p50_ms": 11.396,
"p95_ms": 35.387,
"p99_ms": 55.24,
"p999_ms": 99.005,
"max_ms": 99.005,
"histogram": [
0,
0,
0,
10,
42,
62,
55,
86,
89,
97,
64,
73,
74,
72,
48,
21,
17,
8,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 4165,
"seconds": 12.006,
"avg_fps": 346.896,
"low_1pct_fps": 75.429,
"low_01pct_fps": 33.579,
"p50_ms": 2.541,
"p95_ms": 4.838,
"p99_ms": 7.998,
"p999_ms": 20.209,
"max_ms": 53.577,
"histogram": [
0,
640,
2307,
855,
178,
93,
34,
22,
15,
3,
3,
8,
2,
2,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 851,
"seconds": 12.0,
"avg_fps": 70.916,
"low_1pct_fps": 11.158,
"low_01pct_fps": 7.903,
"p50_ms": 10.128,
"p95_ms": 36.498,
"p99_ms": 68.434,
"p999_ms": 126.541,
"max_ms": 126.541,
"histogram": [
0,
0,
0,
38,
61,
87,
76,
69,
84,
100,
63,
53,
68,
53,
48,
15,
9,
16,
9,
2,
0,
0,
0,
0
]
}
],
"label": "vanilla-macmini"
}
+771
View File
@@ -0,0 +1,771 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:08:37Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"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,
"load_avg_1m": 18.159,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 8607.88,
"stdev": 949.596,
"min": 8010.323,
"max": 9702.854,
"ci95": 2359.119
},
"startup.world_ms": {
"n": 3,
"mean": 44182.88,
"stdev": 4031.933,
"min": 40172.323,
"max": 48235.854,
"ci95": 10016.685
},
"chunks.village.load_ms": {
"n": 3,
"mean": 17227.171,
"stdev": 5351.234,
"min": 13892.279,
"max": 23399.566,
"ci95": 13294.274
},
"fps.village.avg_fps": {
"n": 3,
"mean": 76.237,
"stdev": 45.661,
"min": 28.922,
"max": 120.041,
"ci95": 113.437
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 17.319,
"stdev": 12.99,
"min": 5.593,
"max": 31.283,
"ci95": 32.272
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 12.642,
"stdev": 9.463,
"min": 4.308,
"max": 22.93,
"ci95": 23.51
},
"memory.village.rss_mb": {
"n": 3,
"mean": 2674.987,
"stdev": 30.71,
"min": 2641.352,
"max": 2701.531,
"ci95": 76.294
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.341,
"stdev": 0.407,
"min": 0.924,
"max": 1.737,
"ci95": 1.012
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 34112.389,
"stdev": 19305.487,
"min": 22633.094,
"max": 56401.09,
"ci95": 47961.36
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 72.848,
"stdev": 51.859,
"min": 18.307,
"max": 121.528,
"ci95": 128.837
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 22.096,
"stdev": 20.299,
"min": 3.973,
"max": 44.03,
"ci95": 50.429
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 16.093,
"stdev": 14.608,
"min": 3.688,
"max": 32.194,
"ci95": 36.291
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 2519.729,
"stdev": 82.097,
"min": 2426.297,
"max": 2580.328,
"ci95": 203.957
},
"tick.forest.client_ms": {
"n": 3,
"mean": 1.115,
"stdev": 0.411,
"min": 0.726,
"max": 1.545,
"ci95": 1.02
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 19066.516,
"stdev": 9372.33,
"min": 13163.781,
"max": 29873.383,
"ci95": 23284.039
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 69.735,
"stdev": 46.656,
"min": 16.524,
"max": 103.635,
"ci95": 115.909
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 17.636,
"stdev": 13.616,
"min": 3.179,
"max": 30.217,
"ci95": 33.827
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 12.115,
"stdev": 10.798,
"min": 2.584,
"max": 23.842,
"ci95": 26.825
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 2557.143,
"stdev": 84.375,
"min": 2463.781,
"max": 2627.945,
"ci95": 209.616
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 1.056,
"stdev": 0.233,
"min": 0.815,
"max": 1.281,
"ci95": 0.58
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 62214.712,
"stdev": 4884.13,
"min": 58937.045,
"max": 67828.14,
"ci95": 12133.83
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 51.809,
"stdev": 7.118,
"min": 45.235,
"max": 59.369,
"ci95": 17.685
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 6.336,
"stdev": 1.097,
"min": 5.545,
"max": 7.588,
"ci95": 2.725
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.458,
"stdev": 0.756,
"min": 2.827,
"max": 4.296,
"ci95": 1.878
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 2604.227,
"stdev": 15.043,
"min": 2588.234,
"max": 2618.094,
"ci95": 37.371
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.957,
"stdev": 0.03,
"min": 0.926,
"max": 0.985,
"ci95": 0.074
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2731.016,
"stdev": 56.767,
"min": 2688.516,
"max": 2795.484,
"ci95": 141.03
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2577.339,
"stdev": 77.295,
"min": 2488.203,
"max": 2625.875,
"ci95": 192.028
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4612.571,
"stdev": 20.245,
"min": 4595.363,
"max": 4634.879,
"ci95": 50.296
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4271.925,
"stdev": 26.308,
"min": 4246.207,
"max": 4298.785,
"ci95": 65.357
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 349,
"seconds": 12.067,
"avg_fps": 28.922,
"low_1pct_fps": 5.593,
"low_01pct_fps": 4.308,
"p50_ms": 24.684,
"p95_ms": 92.764,
"p99_ms": 150.338,
"p999_ms": 232.136,
"max_ms": 232.136,
"histogram": [
0,
0,
0,
0,
0,
0,
2,
3,
13,
17,
26,
31,
40,
46,
50,
32,
25,
20,
30,
10,
4,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1441,
"seconds": 12.004,
"avg_fps": 120.041,
"low_1pct_fps": 31.283,
"low_01pct_fps": 22.93,
"p50_ms": 7.267,
"p95_ms": 13.622,
"p99_ms": 23.449,
"p999_ms": 42.564,
"max_ms": 44.657,
"histogram": [
0,
0,
0,
0,
0,
64,
543,
397,
218,
100,
54,
24,
20,
7,
7,
5,
2,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 958,
"seconds": 12.013,
"avg_fps": 79.749,
"low_1pct_fps": 15.082,
"low_01pct_fps": 10.689,
"p50_ms": 9.138,
"p95_ms": 28.848,
"p99_ms": 52.247,
"p999_ms": 93.55,
"max_ms": 93.55,
"histogram": [
0,
0,
0,
0,
0,
19,
144,
232,
148,
111,
64,
56,
59,
57,
32,
17,
9,
6,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 220,
"seconds": 12.017,
"avg_fps": 18.307,
"low_1pct_fps": 3.973,
"low_01pct_fps": 3.688,
"p50_ms": 40.635,
"p95_ms": 157.363,
"p99_ms": 235.253,
"p999_ms": 271.153,
"max_ms": 271.153,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
3,
6,
14,
17,
25,
25,
17,
27,
33,
24,
16,
11,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1459,
"seconds": 12.005,
"avg_fps": 121.528,
"low_1pct_fps": 44.03,
"low_01pct_fps": 32.194,
"p50_ms": 7.381,
"p95_ms": 12.386,
"p99_ms": 18.354,
"p999_ms": 29.428,
"max_ms": 32.695,
"histogram": [
0,
0,
0,
0,
0,
0,
357,
642,
300,
80,
28,
28,
14,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 945,
"seconds": 12.006,
"avg_fps": 78.709,
"low_1pct_fps": 18.284,
"low_01pct_fps": 12.397,
"p50_ms": 9.554,
"p95_ms": 27.657,
"p99_ms": 43.253,
"p999_ms": 80.663,
"max_ms": 80.663,
"histogram": [
0,
0,
0,
0,
0,
0,
18,
304,
191,
119,
67,
59,
53,
70,
34,
15,
10,
3,
2,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 201,
"seconds": 12.164,
"avg_fps": 16.524,
"low_1pct_fps": 3.179,
"low_01pct_fps": 2.584,
"p50_ms": 44.933,
"p95_ms": 155.019,
"p99_ms": 263.919,
"p999_ms": 387.044,
"max_ms": 387.044,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
2,
4,
7,
20,
8,
29,
18,
25,
29,
29,
17,
8,
4,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 1244,
"seconds": 12.004,
"avg_fps": 103.635,
"low_1pct_fps": 30.217,
"low_01pct_fps": 23.842,
"p50_ms": 8.349,
"p95_ms": 17.445,
"p99_ms": 28.276,
"p999_ms": 38.037,
"max_ms": 45.85,
"histogram": [
0,
0,
0,
0,
0,
0,
162,
453,
312,
129,
71,
47,
32,
18,
16,
3,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 1069,
"seconds": 12.005,
"avg_fps": 89.047,
"low_1pct_fps": 19.512,
"low_01pct_fps": 9.92,
"p50_ms": 9.541,
"p95_ms": 20.819,
"p99_ms": 31.409,
"p999_ms": 99.668,
"max_ms": 101.942,
"histogram": [
0,
0,
0,
0,
0,
0,
64,
266,
283,
160,
112,
74,
51,
33,
17,
4,
1,
2,
1,
1,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 551,
"seconds": 12.181,
"avg_fps": 45.235,
"low_1pct_fps": 5.545,
"low_01pct_fps": 3.252,
"p50_ms": 14.921,
"p95_ms": 50.78,
"p99_ms": 105.154,
"p999_ms": 307.476,
"max_ms": 307.476,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
42,
137,
72,
58,
50,
47,
57,
39,
20,
15,
8,
3,
2,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 718,
"seconds": 12.094,
"avg_fps": 59.369,
"low_1pct_fps": 7.588,
"low_01pct_fps": 4.296,
"p50_ms": 12.178,
"p95_ms": 38.273,
"p99_ms": 65.694,
"p999_ms": 232.789,
"max_ms": 232.789,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
140,
202,
115,
72,
48,
47,
43,
19,
19,
6,
4,
0,
3,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 610,
"seconds": 12.003,
"avg_fps": 50.822,
"low_1pct_fps": 5.875,
"low_01pct_fps": 2.827,
"p50_ms": 13.894,
"p95_ms": 44.208,
"p99_ms": 75.023,
"p999_ms": 353.686,
"max_ms": 353.686,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
67,
115,
125,
88,
55,
54,
43,
25,
13,
15,
7,
0,
1,
2,
0,
0
]
}
],
"label": "vanilla-macmini"
}
+28 -7
View File
@@ -489,15 +489,36 @@ pub fn parse_duration(text: &str) -> Option<i64> {
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
(60 * MINUTE, DurationUnit::Hour)
} else {
(up(24 * 60 * MINUTE), "day")
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
+16
View File
@@ -327,3 +327,19 @@ fn an_allow_line_without_letters_allows_nothing() {
assert!(f.matches("nigger"), "{line}");
}
}
#[test]
fn duration_parts_preserve_rounding_without_overflow() {
for (millis, expected) in [
(1, (1, DurationUnit::Minute)),
(60_000, (1, DurationUnit::Minute)),
(61_000, (2, DurationUnit::Minute)),
(3_600_000, (60, DurationUnit::Minute)),
(3_600_001, (2, DurationUnit::Hour)),
(172_800_000, (48, DurationUnit::Hour)),
(172_800_001, (3, DurationUnit::Day)),
(i64::MAX, (106_751_991_168, DurationUnit::Day)),
] {
assert_eq!(duration_parts(millis), expected);
}
}
@@ -143,7 +143,9 @@ impl<'a> Fields<'a> {
/// `RandomizableContainer.tryLoadLootTable` / `trySaveLootTable`; true when the
/// block entity has a loot table (and so saves no items).
fn loot_table(&mut self) -> bool {
let Some(table) = text(self.input.get("LootTable")) else {
let Some(table) =
text(self.input.get("LootTable")).and_then(|id| crate::identifier::normalize_id(&id))
else {
return false;
};
self.put("LootTable", string(&table));
@@ -905,6 +907,16 @@ mod tests {
assert_eq!(out.get("LootTable"), Some(&string("minecraft:chests/x")));
}
#[test]
fn invalid_loot_table_leaves_items_intact() {
let out = canon(
"{id:\"minecraft:barrel\",x:1,y:2,z:3,LootTable:\"INVALID TABLE\",\
Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}",
);
assert!(out.get("Items").is_some());
assert!(out.get("LootTable").is_none());
}
#[test]
fn items_are_validated_and_sorted_by_slot() {
let out = canon(
+5 -3
View File
@@ -207,9 +207,11 @@ fn style_flag(v: Option<&Value>) -> Option<bool> {
}
}
fn untrusted_http_url(url: &str) -> bool {
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
// https only, scheme case-insensitive.
/// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
/// https only, scheme case-insensitive. Every `open_url` a server sends must
/// pass it: anything else (`file:`, a UNC path, a custom protocol handler)
/// could start a program through the system's URL handler.
pub fn untrusted_http_url(url: &str) -> bool {
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
+192 -3
View File
@@ -388,6 +388,36 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
}
}
/// `Language.loadFromJson` for one `lang/<code>.json`: its entries go into `map` in file order.
/// A file that is plain JSON reads as before; any other goes through Gson's lenient reader
/// (`#`, `//` and `/* */` comments, unquoted or single-quoted names and values, raw control
/// characters in strings, `=` or `=>` and `;`), which vanilla reads lang files with (expected
/// from `Language.loadFromJson`, Gson). A file that is not JSON even so adds nothing; a value
/// that is not a string, number or boolean (`GsonHelper.convertToString`) ends the file there.
fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
use horizon_world::datafix::json::{Json, parse_lenient};
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(bytes) {
map.extend(entries);
return;
}
let text = String::from_utf8_lossy(bytes);
// Gson's JsonReader skips a leading byte order mark.
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
let utf16: Vec<u16> = text.encode_utf16().collect();
let Some(Json::Obj(entries)) = parse_lenient(&utf16) else {
return;
};
for (key, value) in entries {
let value = match value {
Json::Str(s) => String::from_utf16_lossy(&s),
Json::Num(n) => n,
Json::Bool(b) => b.to_string(),
Json::Null | Json::Arr(_) | Json::Obj(_) => break,
};
map.insert(String::from_utf16_lossy(&key), value);
}
}
impl Language {
/// ClientLanguage.loadFrom: en_us first, then the selected language, each
/// from every namespace and every source in priority order (later sources
@@ -413,9 +443,7 @@ impl Language {
else {
continue;
};
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(&bytes) {
map.extend(entries);
}
read_lang_file(&bytes, &mut map);
}
}
}
@@ -875,4 +903,165 @@ mod tests {
["en_us", "fr_fr"]
);
}
fn read(body: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
read_lang_file(body.as_bytes(), &mut map);
map
}
fn entries(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
#[test]
fn a_plain_json_lang_file_reads_as_before() {
let body = "{\"a\": \"Apple \\\"quoted\\\" \\u00e9\", \"b\": \"\", \"a\": \"Again\"}";
let strict: HashMap<String, String> = serde_json::from_str(body).unwrap();
assert_eq!(read(body), strict);
assert_eq!(read(body), entries(&[("a", "Again"), ("b", "")]));
}
#[test]
fn lang_files_read_with_comments() {
let want = entries(&[("a", "Apple"), ("b", "Banana # not a comment // either")]);
for body in [
"# header\n{\n# a comment\n\"a\": \"Apple\",\n\"b\": \"Banana # not a comment // either\"\n}\n",
"// header\n{\"a\": \"Apple\", // after\n\"b\": \"Banana # not a comment // either\"}",
"/* header\n over lines */{\"a\": /* inline */ \"Apple\", \"b\": \"Banana # not a comment // either\"}",
] {
assert_eq!(read(body), want, "{body}");
}
}
#[test]
fn lang_files_read_unquoted_and_single_quoted_names_and_values() {
assert_eq!(
read("{a: 'Apple', 'b': \"Banana\", c: Cherry}"),
entries(&[("a", "Apple"), ("b", "Banana"), ("c", "Cherry")])
);
// A single-quoted string may hold a double quote, and the reverse.
assert_eq!(
read("{'a': 'say \"hi\"', \"b\": \"it's\"}"),
entries(&[("a", "say \"hi\""), ("b", "it's")])
);
}
#[test]
fn lang_files_read_raw_control_characters_in_strings() {
assert_eq!(
read("{\"a\": \"tab\there\", \"b\": \"two\nlines\"}"),
entries(&[("a", "tab\there"), ("b", "two\nlines")])
);
}
#[test]
fn lang_files_read_gson_separators_and_a_byte_order_mark() {
assert_eq!(
read("\u{feff}{\"a\" = \"Apple\"; \"b\" => \"Banana\"}"),
entries(&[("a", "Apple"), ("b", "Banana")])
);
// A trailing comma before `}` is not JSON even to Gson's lenient reader.
assert_eq!(read("{# c\n\"a\": \"Apple\",}"), HashMap::new());
}
#[test]
fn numbers_and_booleans_read_as_their_text() {
// GsonHelper.convertToString: a primitive reads as its text.
assert_eq!(
read("{\"a\": 1.50, \"b\": true, \"c\": -2e3}"),
entries(&[("a", "1.50"), ("b", "true"), ("c", "-2e3")])
);
}
#[test]
fn a_value_that_is_no_text_ends_the_file_there() {
assert_eq!(
read("# c\n{\"a\": \"Apple\", \"b\": [\"x\"], \"c\": \"Cherry\"}"),
entries(&[("a", "Apple")])
);
assert_eq!(read("{\"a\": null, \"b\": \"Banana\"}"), HashMap::new());
}
#[test]
fn a_lang_file_that_is_not_json_adds_nothing() {
for body in [
"# c\n{\"a\": \"Apple\"",
"[\"a\"]",
"\"a\"",
"",
"{\"a\": \"Apple\"} {",
] {
assert_eq!(read(body), HashMap::new(), "{body:?}");
}
}
#[test]
fn a_pack_lang_file_with_comments_layers_over_english() {
let base = Mem(HashMap::from([(
"assets/minecraft/lang/en_us.json".into(),
br#"{"a":"Apple","b":"Banana"}"#.to_vec(),
)]));
let pack = Mem(HashMap::from([(
"assets/minecraft/lang/fr_fr.json".into(),
b"{\n# Fruits\n\"a\": \"Pomme\"\n}\n".to_vec(),
)]));
let sources: [&dyn ResourceSource; 2] = [&base, &pack];
let fr = Language::load(&sources, "fr_fr");
assert_eq!(fr.get("a"), Some("Pomme"));
assert_eq!(fr.get("b"), Some("Banana"));
}
/// Every lang file of a downloaded pack, e.g. BACAP Language Pack:
/// `HORIZON_LANG_PACK_DIR=<pack>/assets/minecraft/lang cargo test -p horizon-client-core
/// lang::tests::every_lang_file_of_a_pack_reads -- --ignored --nocapture`.
#[test]
#[ignore = "needs a downloaded pack in HORIZON_LANG_PACK_DIR"]
fn every_lang_file_of_a_pack_reads() {
let Ok(dir) = std::env::var("HORIZON_LANG_PACK_DIR") else {
return;
};
let mut failed = Vec::new();
let mut paths: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
paths.sort();
for path in paths {
let bytes = std::fs::read(&path).unwrap();
let strict = serde_json::from_slice::<HashMap<String, String>>(&bytes).is_ok();
let mut map = HashMap::new();
read_lang_file(&bytes, &mut map);
// An independent count: the distinct `"name":` that start a line.
let text = String::from_utf8_lossy(&bytes);
let names: std::collections::HashSet<&str> = text
.lines()
.filter_map(|line| {
let rest = line.trim_start().strip_prefix('"')?;
let mut escaped = false;
let end = rest.char_indices().find_map(|(i, c)| {
let close = c == '"' && !escaped;
escaped = c == '\\' && !escaped;
close.then_some(i)
})?;
rest[end + 1..]
.trim_start()
.starts_with(':')
.then(|| &rest[..end])
})
.collect();
println!(
"{}: strict {strict}, {} keys, {} names",
path.display(),
map.len(),
names.len()
);
if map.is_empty() || map.len() != names.len() {
failed.push(path);
}
}
assert!(failed.is_empty(), "{failed:?}");
}
}
@@ -194,6 +194,20 @@ impl NbtPath {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Whether this selects a compound-field chain, including filtered or quoted fields.
/// Matching a root or child does not change that subtree's writer provenance.
pub fn is_field_path(&self, fields: &[&str]) -> bool {
let mut fields = fields.iter();
for node in &self.nodes {
match node {
Node::MatchRoot(_) => {}
Node::Child(name) | Node::MatchChild(name, _)
if fields.next().copied() == Some(name.as_str()) => {}
_ => return false,
}
}
fields.next().is_none()
}
fn not_found(&self, node: usize) -> PathError {
let end = self.ends[node];
PathError::new(
@@ -826,6 +840,25 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
for text in [
"blockState.properties",
"\"blockState\".\"properties\"",
"{source:1b}.blockState.properties",
"blockState{id:\"minecraft:piston_head\"}.properties",
] {
let path = NbtPath::parse(text).unwrap();
assert!(path.is_field_path(&["blockState", "properties"]));
assert!(!path.is_field_path(&["blockState"]));
}
assert!(NbtPath::parse("{source:1b}").unwrap().is_field_path(&[]));
assert!(
!NbtPath::parse("blockState[].properties")
.unwrap()
.is_field_path(&["blockState", "properties"])
);
}
fn tag(s: &str) -> Tag {
parse_snbt(s).unwrap().0
+115 -13
View File
@@ -8,11 +8,20 @@ pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to
// ---- the coloured form -----------------------------------------------------
/// Writer provenance for a live moving-piston command read, or its selected subtree.
#[derive(Clone, Copy)]
pub enum PistonPart {
Entity,
State,
Properties,
}
struct Pretty {
indentation: String,
result: Text,
indent_depth: usize,
depth: usize,
piston: Option<PistonPart>,
}
fn is_numeric(t: &Tag) -> bool {
matches!(
@@ -58,6 +67,10 @@ impl Pretty {
self.lit("]", None);
}
fn sub(&mut self, tag: &Tag, indent: bool) {
self.sub_with(tag, indent, None);
}
fn sub_with(&mut self, tag: &Tag, indent: bool, piston: Option<PistonPart>) {
let previous = std::mem::replace(&mut self.piston, piston);
if indent {
self.indent_depth += 1;
}
@@ -67,6 +80,7 @@ impl Pretty {
self.indent_depth -= 1;
}
self.depth -= 1;
self.piston = previous;
}
fn visit(&mut self, tag: &Tag) {
match tag {
@@ -157,7 +171,11 @@ impl Pretty {
"\n"
};
let n = c.len();
for (i, (k, v)) in java_hash_order(c).into_iter().enumerate() {
let entries = match self.piston {
Some(part) => piston_hash_order(c, part),
None => java_hash_order(c),
};
for (i, (k, v)) in entries.into_iter().enumerate() {
self.lit(&entry, None);
let key = string_of(k);
if !key.is_empty()
@@ -171,7 +189,12 @@ impl Pretty {
}
self.lit(":", None);
self.lit(" ", None);
self.sub(v, true);
let child = match (self.piston, key.as_str()) {
(Some(PistonPart::Entity), "blockState") => Some(PistonPart::State),
(Some(PistonPart::State), "properties") => Some(PistonPart::Properties),
_ => None,
};
self.sub_with(v, true, child);
if i + 1 < n {
self.lit(",", None);
self.lit(spacing, None);
@@ -186,22 +209,61 @@ impl Pretty {
}
}
}
fn hash_bucket(len: usize, key: &horizon_world::nbt::JavaString) -> usize {
let mut capacity = 16usize;
while len > capacity * 3 / 4 {
capacity *= 2;
}
let h = string_of(key)
.encode_utf16()
.fold(0u32, |h, ch| h.wrapping_mul(31).wrapping_add(u32::from(ch)));
((h ^ (h >> 16)) as usize) & (capacity - 1)
}
fn piston_hash_order(
c: &horizon_world::nbt::Compound,
part: PistonPart,
) -> Vec<(&horizon_world::nbt::JavaString, &Tag)> {
let mut entries: Vec<_> = c.iter().collect();
match part {
PistonPart::Entity => {
// saveAdditional, then the base's components and saveMetadata.
let fields = [
"blockState",
"facing",
"progress",
"extending",
"source",
"components",
"id",
"x",
"y",
"z",
];
entries.sort_by_key(|(k, _)| {
(
hash_bucket(c.len(), k),
fields
.iter()
.position(|s| *s == string_of(k))
.unwrap_or(fields.len()),
)
});
}
PistonPart::Properties => {
// StateDefinition builds property codecs in sorted-name order.
// DFU's nested map-pair encoder writes the second codec first.
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
}
PistonPart::State => return java_hash_order(c),
}
entries
}
/// The iteration order of the `HashMap<String, Tag>` a `CompoundTag` holds,
/// for a map that grew to its size by insertion: buckets by
/// `HashMap.hash(String.hashCode())`, keys sharing a bucket in key order.
pub fn java_hash_order(
c: &horizon_world::nbt::Compound,
) -> Vec<(&horizon_world::nbt::JavaString, &Tag)> {
let mut capacity = 16usize;
while c.len() > capacity * 3 / 4 {
capacity *= 2;
}
let bucket = |k: &horizon_world::nbt::JavaString| {
let h = string_of(k)
.encode_utf16()
.fold(0u32, |h, ch| h.wrapping_mul(31).wrapping_add(u32::from(ch)));
((h ^ (h >> 16)) as usize) & (capacity - 1)
};
// Keys sharing a bucket iterate in insertion order: the order vanilla's
// savers write them, as far as it is known here.
const WRITE_ORDER: &[&str] = &[
@@ -268,7 +330,7 @@ pub fn java_hash_order(
.unwrap_or(WRITE_ORDER.len())
};
let mut entries: Vec<_> = c.iter().collect();
entries.sort_by_key(|(k, _)| (bucket(k), rank(k)));
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), rank(k)));
entries
}
/// `NbtUtils.toPrettyComponent`: the coloured, unindented form.
@@ -281,6 +343,19 @@ pub fn pretty_indented(tag: &Tag, indentation: &str) -> Text {
result: Text::empty(),
indent_depth: 0,
depth: 0,
piston: None,
};
p.visit(tag);
p.result
}
/// Command-only writer order; ordinary pretty printing keeps its existing policy.
pub fn pretty_piston(tag: &Tag, part: PistonPart) -> Text {
let mut p = Pretty {
indentation: String::new(),
result: Text::empty(),
indent_depth: 0,
depth: 0,
piston: Some(part),
};
p.visit(tag);
p.result
@@ -295,6 +370,33 @@ mod tests {
to_snbt(&parse_snbt(s).unwrap().0)
}
#[test]
fn live_piston_writer_order_does_not_change_default_pretty() {
let tag = parse_snbt(
r#"{id:"minecraft:piston",blockState:{id:"minecraft:piston_head",properties:{facing:"east",short:"false",type:"normal"}},components:{},x:1,y:100,z:0,facing:5b,progress:0.0f,source:1b,extending:1b}"#,
).unwrap().0;
let ordinary = r#"{blockState: {id: "minecraft:piston_head", properties: {facing: "east", short: "false", type: "normal"}}, components: {}, x: 1, facing: 5b, progress: 0.0f, y: 100, z: 0, id: "minecraft:piston", source: 1b, extending: 1b}"#;
assert_eq!(pretty(&tag).plain(), ordinary);
assert_eq!(
pretty_piston(&tag, PistonPart::Entity).plain(),
r#"{blockState: {id: "minecraft:piston_head", properties: {short: "false", facing: "east", type: "normal"}}, components: {}, x: 1, facing: 5b, progress: 0.0f, y: 100, z: 0, source: 1b, id: "minecraft:piston", extending: 1b}"#,
);
let state = tag.get("blockState").unwrap();
assert_eq!(
pretty_piston(state, PistonPart::State).plain(),
r#"{id: "minecraft:piston_head", properties: {short: "false", facing: "east", type: "normal"}}"#,
);
let props = state.get("properties").unwrap();
assert_eq!(
pretty_piston(props, PistonPart::Properties).plain(),
r#"{short: "false", facing: "east", type: "normal"}"#,
);
assert_eq!(
pretty(props).plain(),
r#"{facing: "east", short: "false", type: "normal"}"#
);
assert_eq!(pretty(&tag).plain(), ordinary);
}
#[test]
fn compact_form_matches_vanilla() {
assert_eq!(
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
+52 -1
View File
@@ -119,6 +119,19 @@ impl Button {
][face],
}
}
/// The face button that plays this one's part on a Nintendo pad going by its letters
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
/// its own inverse, so it turns a part into the button playing it and back.
pub fn by_letter(self) -> Button {
match self {
Button::South => Button::East,
Button::East => Button::South,
Button::West => Button::North,
Button::North => Button::West,
other => other,
}
}
}
/// A pad's inputs at one moment. Sticks are `[x, y]` with right and up
@@ -142,6 +155,14 @@ impl PadState {
pub fn held(&self, button: Button) -> bool {
self.buttons & button.bit() != 0
}
/// A Nintendo pad's buttons read by their letters (`Button::by_letter`).
pub fn by_letter(&self) -> PadState {
let mut state = *self;
for part in [Button::South, Button::East, Button::West, Button::North] {
state.set(part, self.held(part.by_letter()));
}
state
}
/// Two sources at once (a real pad and `horizon-ctl pad`): buttons held on
/// either, sticks and triggers added.
pub fn merge(&self, other: &PadState) -> PadState {
@@ -454,6 +475,10 @@ pub struct PadSettings {
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
pub glyphs: GlyphStyle,
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
/// by default on the Switch only.
pub nintendo_letters: bool,
pub keyboard: TextKeyboard,
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
pub pointer_speed: f32,
@@ -478,6 +503,7 @@ impl Default for PadSettings {
trigger_threshold: 0.4,
toggle_sneak: true,
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
pointer_speed: 1.0,
scroll_speed: 12.0,
@@ -545,6 +571,7 @@ impl PadSettings {
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
@@ -568,7 +595,7 @@ impl PadSettings {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nkeyboard:{}\n",
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nnintendoLetters:{}\nkeyboard:{}\n",
bool_text(self.enabled),
bool_text(self.vibration),
bool_text(self.adaptive_triggers),
@@ -581,6 +608,7 @@ impl PadSettings {
self.trigger_threshold,
bool_text(self.toggle_sneak),
self.glyphs.name(),
bool_text(self.nintendo_letters),
self.keyboard.name(),
);
text.push_str(&format!(
@@ -1391,7 +1419,9 @@ mod tests {
s.invert_y = true;
s.look_sensitivity = 0.75;
s.glyphs = GlyphStyle::PlayStation;
s.nintendo_letters = !s.nintendo_letters;
let text = s.serialize();
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
assert!(text.contains("bind_key.jump:east\n"));
assert!(text.contains("bind_key.drop:none\n"));
assert!(!text.contains("key.sneak"));
@@ -1437,6 +1467,27 @@ mod tests {
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
}
#[test]
fn a_nintendo_pad_by_its_letters_confirms_with_a() {
// The pad's A sits on the right (East): going by letters it plays South, confirm and jump.
let mut pad = PadState::default();
pad.set(Button::East, true);
pad.set(Button::LeftBumper, true);
pad.left = [0.5, -0.25];
let parts = pad.by_letter();
assert!(parts.held(Button::South));
assert!(!parts.held(Button::East));
// Other buttons and the sticks stay; reading by letters again gives the pad back.
assert!(parts.held(Button::LeftBumper));
assert_eq!(parts.left, [0.5, -0.25]);
assert_eq!(parts.by_letter(), pad);
// A part's glyph is the letter of the button playing it.
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
@@ -355,6 +355,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -377,6 +378,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
@@ -352,6 +352,7 @@ impl RenderAssets {
}
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
sprites.insert(root, &format!("minecraft:block/destroy_oit_stage_{stage}"))?;
}
let fusion = if draws {
let _step = crate::spans::startup::step("core.fusion");
+216 -14
View File
@@ -208,6 +208,18 @@ fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: b
type Stream = cpal::Stream;
#[cfg(not(feature = "audio"))]
type Stream = std::convert::Infallible;
/// A level sound retained for the network broadcaster, in playback order.
#[derive(Clone, Debug)]
pub struct CapturedSound {
pub event: String,
pub position: [f64; 3],
pub volume: f32,
pub pitch: f32,
pub source: usize,
pub entity: Option<u32>,
pub except_entity: Option<u32>,
}
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
@@ -224,7 +236,7 @@ pub struct Audio {
source_override: Option<usize>,
/// Positional sounds played since the last drain, kept while the world
/// is open to the network so guests hear them too.
pub capture: Option<Vec<(String, [f64; 3], f32, f32)>>,
pub capture: Option<Vec<CapturedSound>>,
/// How many times each positional sound played (the control socket's `status`),
/// with or without an audio device.
pub heard: std::collections::BTreeMap<String, u32>,
@@ -693,17 +705,27 @@ fn open_device(
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
let build = |stream_config: &cpal::StreamConfig| match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, stream_config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, stream_config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, stream_config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, stream_config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, stream_config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, stream_config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, stream_config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, stream_config, mixer.clone()),
other => Err(format!("unsupported sample format {other}").into()),
}?;
};
let short = audio_stream_config(&config);
let stream = match build(&short) {
Ok(stream) => stream,
// A device that refuses the short fixed buffer still plays with cpal's default one.
Err(e) if short != config.config() => {
log::info!("Horizon: audio output refused a short buffer ({e}); using the default");
build(&config.config())?
}
Err(e) => return Err(e),
};
stream.play()?;
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
@@ -784,10 +806,35 @@ fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
_ => true,
}
}
/// The cpal `StreamConfig` to open an output stream with. On Linux the ALSA
/// backend's `BufferSize::Default` means a ~100 ms buffer, so sound effects
/// come out about 100 ms late: request a short fixed buffer there. Other
/// platforms keep cpal's default behaviour.
#[cfg(feature = "audio")]
fn audio_stream_config(config: &cpal::SupportedStreamConfig) -> cpal::StreamConfig {
#[cfg(target_os = "linux")]
{
let range = match config.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
cpal::SupportedBufferSize::Unknown => (256, 4096),
};
let target = 1024u32.clamp(range.0, range.1);
let mut sc = config.config();
sc.buffer_size = cpal::BufferSize::Fixed(target);
sc
}
#[cfg(not(target_os = "linux"))]
{
config.config()
}
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
stream_config: &cpal::StreamConfig,
mixer: Arc<Mutex<Mixer>>,
) -> Result<cpal::Stream> {
let rate = config.sample_rate().0;
@@ -795,7 +842,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
let mut scratch = Vec::new();
let on_error = mixer.clone();
let stream = device.build_output_stream(
&config.config(),
stream_config,
move |data: &mut [T], _| {
scratch.resize(data.len(), 0.0f32);
if let Ok(mut m) = mixer.lock() {
@@ -1262,7 +1309,17 @@ impl Audio {
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
capture.push(CapturedSound {
event: event.to_owned(),
position: at,
volume,
pitch,
source: self.source_override.unwrap_or_else(|| {
crate::settings::sound_source(&crate::sound_events::normalize(event))
}),
entity: None,
except_entity: None,
});
}
*self.heard.entry(event.to_owned()).or_default() += 1;
self.sync_volumes();
@@ -1301,6 +1358,66 @@ impl Audio {
preview.preview(self, source, volume);
self.preview = preview;
}
/// An incoming entity-sound packet: retain its seed and category without rebroadcasting it.
pub fn play_entity_seeded(
&mut self,
event: &str,
source: usize,
volume: f32,
pitch: f32,
seed: i64,
entity: crate::sound_engine::SoundEntity,
) {
*self.heard.entry(event.to_owned()).or_default() += 1;
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::entity_bound(
&event,
source,
volume,
pitch,
LegacyRandom::new(seed),
entity,
);
self.play_instance(instance, false);
}
/// A level's entity overload: preserve the emitter and excluded player for
/// the server broadcaster while the local sound follows the same object.
pub fn play_bound(
&mut self,
event: &str,
source: usize,
volume: f32,
pitch: f32,
entity: crate::sound_engine::SoundEntity,
except_entity: Option<u32>,
) {
if let Some(capture) = self.capture.as_mut() {
capture.push(CapturedSound {
event: event.to_owned(),
position: entity.state().position,
volume,
pitch,
source,
entity: Some(entity.id),
except_entity,
});
}
let seed = self.random.next_long();
self.play_entity_seeded(event, source, volume, pitch, seed, entity);
}
/// Removal belongs to the old object even if its numeric id is immediately reused.
pub fn remove_sound_entity(&mut self, id: u32) {
for entity in self
.engine
.bound_entities()
.into_iter()
.filter(|e| e.id == id)
{
let mut state = entity.state();
state.removed = true;
entity.update(state);
}
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
@@ -1311,7 +1428,17 @@ impl Audio {
seed: i64,
) {
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
capture.push((event.to_owned(), at, volume, pitch));
capture.push(CapturedSound {
event: event.to_owned(),
position: at,
volume,
pitch,
source: self.source_override.unwrap_or_else(|| {
crate::settings::sound_source(&crate::sound_events::normalize(event))
}),
entity: None,
except_entity: None,
});
}
if at.is_some() {
*self.heard.entry(event.to_owned()).or_default() += 1;
@@ -1451,6 +1578,81 @@ impl Sounds for Audio {
mod tests {
use super::*;
#[test]
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
audio.capture = Some(Vec::new());
let mut expected = audio.random.clone();
audio.play_on(
7,
"minecraft:block.amethyst_block.chime",
Some([1.0; 3]),
0.5,
1.0,
);
let entity = crate::sound_engine::SoundEntity::new(37, [2.123456789, 64.0, -3.0], false);
audio.play_bound(
"minecraft:block.amethyst_block.chime",
6,
2.0,
1.2,
entity,
Some(17),
);
let sounds = audio.capture.as_ref().unwrap();
assert_eq!(sounds.len(), 2);
assert_eq!(
(sounds[0].source, sounds[0].entity, sounds[0].except_entity),
(7, None, None)
);
let bound = &sounds[1];
assert_eq!(
(bound.source, bound.entity, bound.except_entity),
(6, Some(37), Some(17))
);
assert_eq!(bound.position, [2.123456789, 64.0, -3.0]);
assert_eq!((bound.volume, bound.pitch), (2.0, 1.2));
expected.next_long();
expected.next_long();
assert_eq!(audio.random.next_long(), expected.next_long());
}
#[test]
fn entity_sound_packets_neither_rebroadcast_nor_advance_the_local_seed_generator() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
audio.capture = Some(Vec::new());
let mut expected = audio.random.clone();
let entity = crate::sound_engine::SoundEntity::new(7, [1.0, 2.0, 3.0], false);
audio.play_entity_seeded("minecraft:fixture.bound", 6, 0.7, 1.2, 42, entity);
assert!(audio.capture.as_ref().unwrap().is_empty());
assert_eq!(audio.random.next_long(), expected.next_long());
}
#[test]
fn removal_marks_only_existing_references_to_that_entity() {
use crate::sound_engine::SoundEntity;
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
let old = SoundEntity::new(7, [0.0; 3], false);
let other = SoundEntity::new(8, [0.0; 3], false);
for entity in [old.clone(), other.clone()] {
audio.engine.add(
SoundInstance::entity_bound(
"minecraft:fixture.bound",
6,
1.0,
1.0,
LegacyRandom::new(42),
entity,
),
true,
);
}
audio.remove_sound_entity(7);
let replacement = SoundEntity::new(7, [10.0; 3], false);
assert!(old.state().removed);
assert!(!other.state().removed);
assert!(!replacement.state().removed);
}
#[test]
fn button_click_uses_vanilla_ui_parameters_without_advancing_world_random() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
+4
View File
@@ -49,6 +49,10 @@ impl AuthSession {
access_token: access_token.to_owned(),
}
}
pub fn access_token(&self) -> &str {
&self.access_token
}
/// 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 =
@@ -89,6 +89,7 @@ pub struct BlockEntities {
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
asleep: FxHashSet<Pos>,
}
/// A structure or test instance block: the block entities whose box the frame may draw.
@@ -229,6 +230,7 @@ impl BlockEntities {
}
/// `removeBlockEntityTicker`: no slot, made or pending.
fn cancel(&mut self, pos: &Pos) {
self.asleep.remove(pos);
self.freed |= self.live.remove(pos).is_some();
self.pending.remove(pos);
}
@@ -297,14 +299,25 @@ impl BlockEntities {
self.map.get(pos)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.asleep.remove(pos);
self.map.get_mut(pos)
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&Pos, &mut BlockEntity)> {
self.asleep.clear();
self.map.iter_mut()
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut BlockEntity> {
self.asleep.clear();
self.map.values_mut()
}
pub fn sleep(&mut self, pos: Pos) {
if self.map.contains_key(&pos) {
self.asleep.insert(pos);
}
}
pub fn is_asleep(&self, pos: &Pos) -> bool {
self.asleep.contains(pos)
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
@@ -320,6 +333,7 @@ impl BlockEntities {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
self.asleep.remove(&pos);
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
@@ -346,6 +360,7 @@ impl BlockEntities {
if absent {
self.add(pos);
}
self.asleep.remove(&pos);
let entity = self.map.entry(pos).or_insert_with(make);
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
@@ -358,6 +373,7 @@ impl BlockEntities {
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.asleep.remove(pos);
self.cancel(pos);
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
@@ -369,6 +385,7 @@ impl BlockEntities {
/// The block entity at `pos`, out of the map for a while: its slot stays
/// for `put` to bring it back (a tick working on it).
pub fn take(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.asleep.remove(pos);
let taken = self.map.remove(pos);
if taken.is_some() {
self.out.insert(*pos);
@@ -378,6 +395,7 @@ impl BlockEntities {
/// Brings back what `take` took, in its slot.
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
self.out.remove(&pos);
self.asleep.remove(&pos);
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
@@ -399,6 +417,7 @@ impl BlockEntities {
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
self.asleep.clear();
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
@@ -416,6 +435,7 @@ impl BlockEntities {
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.asleep.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -433,6 +453,7 @@ impl BlockEntities {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.attachments.remove(&p);
self.asleep.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
@@ -452,6 +473,7 @@ impl BlockEntities {
/// `LevelChunk.setBlockState` on a block entity's position, from state
/// `old` to `new`: a ticker made, kept or dropped.
pub fn state_changed(&mut self, pos: Pos, old: u32, new: u32) {
self.asleep.remove(&pos);
if self.states.is_empty() || !self.map.contains_key(&pos) {
return;
}
@@ -542,6 +564,51 @@ mod tests {
found
}
#[test]
fn a_sleeping_block_entity_wakes_on_any_change() {
let p = (1, 64, 1);
let mut b = level();
b.sleep(p);
assert!(!b.is_asleep(&p));
b.insert(p, hopper());
let asleep = |b: &mut BlockEntities, wake: &dyn Fn(&mut BlockEntities)| {
b.sleep(p);
assert!(b.is_asleep(&p));
wake(b);
b.is_asleep(&p)
};
assert!(asleep(&mut b, &|_| {}));
assert!(!asleep(&mut b, &|b| {
b.get_mut(&p);
}));
assert!(!asleep(&mut b, &|b| {
b.insert(p, hopper());
}));
assert!(!asleep(&mut b, &|b| {
b.get_or_insert_with(p, hopper);
}));
assert!(!asleep(&mut b, &|b| {
let held = b.take(&p).unwrap();
b.put(p, held);
}));
assert!(!asleep(&mut b, &|b| b.state_changed(p, 1, 1)));
assert!(!asleep(&mut b, &|b| b.forget(&p)));
assert!(!asleep(&mut b, &|b| {
b.iter_mut().count();
}));
assert!(!asleep(&mut b, &|b| {
b.values_mut().count();
}));
assert!(!asleep(&mut b, &|b| b.retain(|_, _| true)));
assert!(!asleep(&mut b, &|b| b.load_chunk(vec![(p, hopper())])));
assert!(!asleep(&mut b, &|b| {
b.remove(&p);
b.insert(p, hopper());
}));
b.sleep(p);
b.clear();
assert!(!b.is_asleep(&p));
}
#[test]
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
let mut b = level();
let modded = || BlockEntity::Mod(Box::default());
@@ -939,6 +939,14 @@ impl BlockEntityAnims {
/// Drops the state of block entities that are gone or changed kind:
/// `present` holds the loaded block entities' tickers, sorted by position.
pub fn retain_present(&mut self, present: &[([i32; 3], Ticker)]) {
self.retain_animations(present, false);
}
/// An integrated server owns bell state independently of renderer visibility.
/// Its world ticker removes bells when their actual block changes.
pub fn retain_present_preserving_bells(&mut self, present: &[([i32; 3], Ticker)]) {
self.retain_animations(present, true);
}
fn retain_animations(&mut self, present: &[([i32; 3], Ticker)], keep_bells: bool) {
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| {
present
.binary_search_by_key(p, |(q, _)| *q)
@@ -948,7 +956,9 @@ impl BlockEntityAnims {
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
self.shulkers
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
if !keep_bells {
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
}
self.conduits
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
self.skulls
@@ -696,11 +696,19 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
/// reader instead (a twentieth of a font pixel, invisible) rather than
/// z-fighting with the outline under it.
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
text_vertices(scene.glyph, call, out);
}
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
/// y down as the font's, with its outline when it has one. A native mod's
/// block entity draws its text this way (`native_draw_text`).
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
let g = (scene.glyph)(ch);
let g = glyph(ch);
if let Some(cell) = g.rect {
// BakedSheetGlyph: the glyph's own box from the pen, not a
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
@@ -340,6 +340,17 @@ impl BrewingStand {
}
out
}
pub fn idle(&self) -> bool {
let bottles = [
self.items[0].is_some(),
self.items[1].is_some(),
self.items[2].is_some(),
];
self.brew_time <= 0
&& self.items[3].is_none()
&& (self.fuel > 0 || self.items[4].is_none())
&& self.last_bottles == Some(bottles)
}
fn brew(&mut self, brewing: &Brewing, items: &Items, out: &mut BrewTick) {
let Some(ingredient) = self.items[3].clone() else {
return;
@@ -455,6 +466,27 @@ mod tests {
assert_eq!(stand.brew_time, 0);
}
#[test]
fn a_brewing_stand_is_idle_only_while_its_tick_changes_nothing() {
let mut stand = BrewingStand::new();
assert!(!stand.idle());
stand.last_bottles = Some([false; 3]);
assert!(stand.idle());
stand.items[0] = Some(ItemStack::new("minecraft:potion", 1));
assert!(!stand.idle());
stand.last_bottles = Some([true, false, false]);
assert!(stand.idle());
stand.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
assert!(!stand.idle());
stand.fuel = 3;
assert!(stand.idle());
stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
assert!(!stand.idle());
stand.items[3] = None;
stand.brew_time = 5;
assert!(!stand.idle());
}
/// #3312: `PotionContents.getPotionDescription` names the effects with
/// translatable components, so a Russian game reads them from ru_ru.json
/// (the prepared one; skipped without it), as the tooltip's other lines.
+202 -21
View File
@@ -174,6 +174,12 @@ impl ChatOptions {
#[derive(Clone, Debug)]
struct Message {
added: u64,
/// Local hour and minute it came in (PvP & HUD's chat timestamps).
time: (u8, u8),
/// How many times in a row it came (PvP & HUD's compact chat), 1 for once.
repeats: u32,
/// The lines it wrapped to the last time it was laid out (0 while hidden).
wrapped: usize,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
@@ -186,6 +192,32 @@ struct Line {
added: u64,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
/// The whole message's text, for PvP & HUD's copy on click.
message: std::sync::Arc<str>,
}
/// PvP & HUD's chat options (`pvp::ChatQol`): each changes the lines, so a change rewraps them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ChatQol {
/// Each message starts with the local time it came in.
pub timestamps: bool,
/// A message equal to the one before it raises that one's count ("(x3)") instead of adding lines.
pub compact: bool,
}
/// Whether `next` repeats `last`: the same text, kind and tag, neither one signed (a server may
/// delete a signed message by itself).
fn repeats(last: &Message, next: &Message) -> bool {
last.signature.is_none()
&& next.signature.is_none()
&& last.source == next.source
&& last.tag == next.tag
&& last.content == next.content
}
/// The local hour and minute.
fn local_time() -> (u8, u8) {
use chrono::Timelike;
let now = chrono::Local::now();
(now.hour() as u8, now.minute() as u8)
}
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
@@ -270,6 +302,14 @@ pub struct Chat {
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
/// too new to replace, with the tick from which they may be.
deletions: Vec<(Box<Signature>, u64)>,
qol: ChatQol,
/// PvP & HUD's chat search is on: the screen shows its prompt.
pub search_prompt: bool,
/// PvP & HUD's chat search: only messages holding this (lower case) show.
search: Option<String>,
/// The tests' clock in place of the local time.
#[cfg(test)]
clock: Option<(u8, u8)>,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -331,6 +371,9 @@ impl Chat {
font,
Message {
added: ticks,
time: (0, 0),
repeats: 1,
wrapped: 0,
content,
tag: tag.map(std::sync::Arc::new),
source: ChatSource::Player,
@@ -352,6 +395,9 @@ impl Chat {
font,
Message {
added: ticks,
time: (0, 0),
repeats: 1,
wrapped: 0,
content,
tag,
source,
@@ -360,7 +406,7 @@ impl Chat {
focused,
);
}
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
fn push_message(&mut self, font: &crate::font::Font, mut message: Message, focused: bool) {
if !self.abilities.shows(message.source) {
return;
}
@@ -370,12 +416,52 @@ impl Chat {
if self.log {
println!("[CHAT] {plain}");
}
self.add_lines(font, &message, focused);
message.time = self.now();
self.received += 1;
if self.qol.compact
&& let Some(last) = self.messages.first()
&& repeats(last, &message)
{
// The newest lines are that message's: they make way for its lines with the count.
let mut last = self.messages.remove(0);
let shown = last.wrapped.min(self.lines.len());
self.lines.drain(..shown);
// A reader scrolled up keeps their view: only lines the merge adds move it.
let (scroll, new_since_scroll) = (self.scroll, self.new_since_scroll);
last.repeats += 1;
last.added = message.added;
last.time = message.time;
last.wrapped = self.add_lines(font, &last, focused);
if scroll > 0 {
self.scroll = scroll;
self.new_since_scroll = new_since_scroll;
self.scroll_by(last.wrapped as i32 - shown as i32, focused);
}
self.messages.insert(0, last);
return;
}
message.wrapped = self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
fn now(&self) -> (u8, u8) {
#[cfg(test)]
if let Some(time) = self.clock {
return time;
}
local_time()
}
/// The lines `message` wraps to (none when the chat search hides it), added newest first.
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) -> usize {
// What the chat shows: legacy `§` codes format the text, they aren't part of it.
let whole = message.content.strip_legacy().plain();
if let Some(query) = &self.search
&& !whole.to_lowercase().contains(query.as_str())
{
return 0;
}
let whole: std::sync::Arc<str> = whole.into();
let mut count = 0;
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
9 + 4 + 2
@@ -383,8 +469,9 @@ impl Chat {
0
};
let width = self.max_width() - icon;
let content = self.displayed(&message.content);
let content = self.message_text(message);
for line in font.wrap_components(&content, width) {
count += 1;
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -395,10 +482,12 @@ impl Chat {
added: message.added,
content: line,
tag: message.tag.clone(),
message: whole.clone(),
},
);
}
self.lines.truncate(100);
count
}
/// What a message's lines wrap (ComponentRenderUtils.wrapComponents):
/// without Chat Colors its `§` codes are stripped (stripColor); with
@@ -410,6 +499,67 @@ impl Chat {
content.strip_legacy()
}
}
/// A message's lines before wrapping: [`Chat::displayed`], with PvP & HUD's time before it
/// and count after it, in grey.
fn message_text(&self, message: &Message) -> Text {
let content = self.displayed(&message.content);
if !self.qol.timestamps && message.repeats <= 1 {
return content;
}
let mut out = Text::empty();
if self.qol.timestamps {
let (h, m) = message.time;
let time = Text::literal(format!("{h:02}:{m:02}"));
out = out.append(
Text::translate_with_fallback("horizon.chat.timestamp", "[%s] ", &[time])
.with_color(text::GRAY),
);
}
out = out.append(content);
if message.repeats > 1 {
let count = Text::literal(message.repeats.to_string());
out = out.append(
Text::translate_with_fallback("horizon.chat.repeats", " (x%s)", &[count])
.with_color(text::GRAY),
);
}
out
}
/// PvP & HUD's chat options; a change lays the lines out again.
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
if self.qol != qol {
self.qol = qol;
self.rewrap(&atlas.font);
}
}
/// PvP & HUD's chat search: only the messages holding `query` (any case) show; `None` or
/// an empty query shows them all again.
pub fn set_search(&mut self, atlas: &HudAtlas, query: Option<&str>) {
self.set_search_with(&atlas.font, query);
}
fn set_search_with(&mut self, font: &crate::font::Font, query: Option<&str>) {
// Called each frame while searching: no work (nor allocation) for the same query.
let blank = |q: &str| q.trim().is_empty();
let same = match (&self.search, query) {
(None, None) => true,
(None, Some(q)) => blank(q),
(Some(_), None) => false,
(Some(current), Some(q)) => q.chars().flat_map(char::to_lowercase).eq(current.chars()),
};
if same {
return;
}
let query = query
.map(str::to_lowercase)
.filter(|q| !q.trim().is_empty());
if self.search != query {
self.search = query;
self.rewrap(font);
}
}
pub fn searching(&self) -> bool {
self.search.is_some()
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
@@ -419,16 +569,15 @@ impl Chat {
self.scroll = 0;
self.new_since_scroll = false;
self.lines.clear();
let messages: Vec<Message> = self
.messages
.iter()
.rev()
.filter(|m| self.abilities.shows(m.source))
.cloned()
.collect();
for m in &messages {
self.add_lines(font, m, false);
let mut messages = std::mem::take(&mut self.messages);
for m in messages.iter_mut().rev() {
m.wrapped = if self.abilities.shows(m.source) {
self.add_lines(font, m, false)
} else {
0
};
}
self.messages = messages;
}
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
/// the messages it now hides leave the chat, those it shows again return.
@@ -490,6 +639,9 @@ impl Chat {
}
*message = Message {
added: message.added,
time: message.time,
repeats: 1,
wrapped: 0,
content: Self::deleted_marker(),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
@@ -694,15 +846,42 @@ impl Chat {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
let restricted = focused && self.abilities.any_restriction();
if restricted {
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
hud_layout::paint(batch, atlas, &prompt);
}
if focused && self.search_prompt {
let row = if restricted { 2 } else { 1 };
let prompt = self.search_prompt(&atlas.font, batch.height as i32, row);
hud_layout::paint(batch, atlas, &prompt);
}
}
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
/// background row and the red, underlined "Chat is restricted" line in
/// the row above the messages, clipped to the chat's width.
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
let prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
color: Some(text::RED),
underlined: Some(true),
..Style::default()
});
self.prompt_above(font, height, prompt, 1)
}
/// PvP & HUD's chat search: a yellow "Search chat:" line above the lines while the box holds
/// the query.
fn search_prompt(&self, font: &crate::font::Font, height: i32, row: i32) -> Vec<GuiDraw> {
let label = Text::translate_with_fallback("horizon.chat.search", "Search chat:", &[]);
self.prompt_above(font, height, label.with_color(text::YELLOW), row)
}
/// A line `row` lines above the shown ones, on the chat's background.
fn prompt_above(
&self,
font: &crate::font::Font,
height: i32,
prompt: Text,
row: i32,
) -> Vec<GuiDraw> {
let o = &self.options;
let scale = o.scale as f32;
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
@@ -715,12 +894,8 @@ impl Chat {
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let top = chat_bottom - (shown as i32 + 1) * entry_height;
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
color: Some(text::RED),
underlined: Some(true),
..Style::default()
});
let top = chat_bottom - (shown as i32 + row) * entry_height;
let mut prompt = prompt;
// ComponentRenderUtils.clipText.
let mut width = 0.0;
let fits = prompt
@@ -765,6 +940,12 @@ impl Chat {
) -> Option<ClickEvent> {
self.style_at(font, height, x, y)?.click
}
/// The whole text of the message under a GUI-space cursor while chat is focused (PvP &
/// HUD's copy on click), without its time or count.
pub fn message_at(&self, height: i32, x: f64, y: f64) -> Option<&str> {
let (line, x) = self.line_at(height, x, y)?;
(x >= 0.0).then_some(&*line.message)
}
/// The hover text under a GUI-space cursor while chat is focused
/// (ChatScreen.render's `renderComponentHoverEffect`).
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
@@ -34,6 +34,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
added: 0,
content,
tag: None,
message: "".into(),
},
);
}
@@ -51,6 +52,7 @@ fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
message: "hi".into(),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
@@ -701,3 +703,140 @@ fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
let mixed = format!("{}\u{1f600}", "b".repeat(255));
assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
}
/// PvP & HUD's chat options: the time before each message, and a repeated message counted on
/// its own lines instead of filling the chat.
#[test]
fn timestamps_and_compact_chat_change_the_lines_not_the_messages() {
let font = font();
let mut chat = Chat {
clock: Some((9, 5)),
..Chat::default()
};
chat.qol.compact = true;
let says = |chat: &mut Chat, text: &str| {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
};
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
says(&mut chat, "gg");
says(&mut chat, "gg");
says(&mut chat, "gg");
assert_eq!(shown(&chat), ["gg (x3)"]);
says(&mut chat, "wp");
says(&mut chat, "gg");
assert_eq!(shown(&chat), ["gg", "wp", "gg (x3)"]);
assert_eq!(chat.received, 5, "every message still counts as received");
chat.qol.timestamps = true;
chat.rewrap(&font);
assert_eq!(
shown(&chat),
["[09:05] gg", "[09:05] wp", "[09:05] gg (x3)"]
);
// Copying takes the message as it came: no time, no count.
assert_eq!(&*chat.lines[2].message, "gg");
// Turned off, the history shows each repeat as it was counted.
chat.qol = ChatQol::default();
chat.rewrap(&font);
assert_eq!(shown(&chat), ["gg", "wp", "gg (x3)"]);
}
/// PvP & HUD's chat search: only the messages holding the query show, in any case; clearing
/// it brings every line back.
#[test]
fn the_chat_search_keeps_the_matching_messages() {
let font = font();
let mut chat = Chat::default();
for text in ["Alpha one", "beta", "ALPHABET soup"] {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
}
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
chat.set_search_with(&font, Some("alpha"));
assert!(chat.searching());
assert_eq!(shown(&chat), ["ALPHABET soup", "Alpha one"]);
// A new message that doesn't match stays out of sight until the search ends.
chat.add_message(
&font,
Text::literal("gamma"),
None,
ChatSource::Server,
0,
false,
);
assert_eq!(shown(&chat).len(), 2);
chat.set_search_with(&font, Some(" "));
assert!(!chat.searching());
assert_eq!(
shown(&chat),
["gamma", "ALPHABET soup", "beta", "Alpha one"]
);
}
/// Search and copy read a message as the chat shows it: legacy `§` codes format it, they
/// aren't text.
#[test]
fn the_chat_search_and_copy_skip_legacy_codes() {
let font = font();
let mut chat = Chat::default();
for text in ["He§cllo", "§aplain"] {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
}
assert_eq!(&*chat.lines[1].message, "Hello");
chat.set_search_with(&font, Some("hello"));
assert_eq!(chat.lines.len(), 1);
// `c` and `a` are codes here, not letters of either message.
chat.set_search_with(&font, Some("c"));
assert_eq!(chat.lines.len(), 0);
}
/// A repeat merged by compact chat leaves a reader scrolled up where they were.
#[test]
fn a_compact_repeat_keeps_the_scrolled_view_still() {
let font = font();
let mut chat = Chat::default();
chat.qol.compact = true;
let says = |chat: &mut Chat, text: &str| {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
true,
);
};
for i in 0..60 {
says(&mut chat, &format!("line {i}"));
}
says(&mut chat, "gg");
chat.scroll_by(5, true);
assert_eq!(chat.scroll, 5);
says(&mut chat, "gg");
assert_eq!(chat.lines[0].content.plain(), "gg (x2)");
assert_eq!(chat.scroll, 5);
// A new message still pushes the view up by its line.
says(&mut chat, "new");
assert_eq!(chat.scroll, 6);
}
@@ -120,6 +120,16 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
) -> std::result::Result<bool, Self::Error> {
self.blocks.may_reach_out(chunk, section_y)
}
fn section_is_empty(
&mut self,
chunk: Self::Chunk,
section_y: i32,
) -> std::result::Result<bool, Self::Error> {
self.blocks.section_is_empty(chunk, section_y)
}
fn missing_chunks_stay_missing(&self) -> bool {
self.blocks.missing_chunks_stay_missing()
}
fn context_shape(
&mut self,
block: CollisionBlock,
@@ -424,6 +434,26 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
Ok(self.section_reaches_out(chunk, section_y))
}
/// From the section's palette: every block it can hold has an empty shape (air, plants).
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
}))
}
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
fn missing_chunks_stay_missing(&self) -> bool {
true
}
fn is_suffocating(
&mut self,
_: Self::Chunk,
@@ -555,6 +585,9 @@ impl BlockCollisionWorld for EmptyCollisions {
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(None)
}
fn missing_chunks_stay_missing(&self) -> bool {
true
}
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
Err(io::Error::other("no chunk is resident"))
}
@@ -215,6 +215,13 @@ impl Campfire {
}
changed
}
pub fn idle(&self, lit: bool) -> bool {
if lit {
self.items.iter().all(Option::is_none)
} else {
self.progress.iter().all(|&p| p <= 0)
}
}
}
impl Skull {
pub fn new(
@@ -729,6 +736,19 @@ mod tests {
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
}
#[test]
fn a_campfire_is_idle_only_while_its_tick_changes_nothing() {
let mut c = Campfire::default();
assert!(c.idle(true) && c.idle(false));
assert!(c.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
assert!(!c.idle(true));
assert!(c.idle(false));
c.cook_tick();
assert!(!c.idle(false));
c.cooldown_tick();
assert!(c.idle(false));
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
-24
View File
@@ -431,30 +431,6 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
)
}
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
if REPORTED.swap(true, Ordering::SeqCst) {
return;
}
let report = Report {
time: Local::now(),
description: "RAM limit reached".into(),
error: message,
stack: String::new(),
thread: thread_name(side),
side,
details: system_details(side),
log: crate::logging::recent_lines(),
};
match report.save(home) {
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
}
}
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
+4 -3
View File
@@ -608,7 +608,7 @@ pub struct DataStore {
/// `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/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` 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>>>,
@@ -617,7 +617,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` 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/")?;
@@ -625,7 +625,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" | "data_maps" => kind,
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -656,6 +656,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"damage_type",
"data_maps",
"dialog",
"function",
+13 -2
View File
@@ -87,7 +87,11 @@ fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
// primed TNT flash (5) and at column overlay - 16 for 16..=31 (a creeper's
// swelling). Alpha 1 changes nothing.
fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay == 1u || overlay == 8u { return HURT_OVERLAY; }
if overlay == 1u || overlay == 8u {
// PvP & HUD's hit colour (pvp.rs `Hit::texel`) rides in colored_count.y; 0 is vanilla's.
let hit = globals.colored_count.y;
return select(HURT_OVERLAY, unpack4x8unorm(hit), hit != 0u);
}
if overlay == 5u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[15]); }
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
return vec4(1.0);
@@ -246,11 +250,18 @@ fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
return vec2(scaled.x * c - scaled.y * s - offset0,
scaled.x * s + scaled.y * c + offset1);
}
// PvP & HUD's glint colour (pvp.rs `Tweaks::glint_texel`, 0 for vanilla's): the sample's
// brightest channel over the chosen colour, so the shimmer keeps its pattern and strength.
fn tinted_glint(glint: vec4<f32>, packed: u32) -> vec4<f32> {
if packed == 0u { return glint; }
let tint = unpack4x8unorm(packed).rgb;
return vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
}
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
// is squared along with the sample.
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
let alpha = globals.fog_ends.w;
let g = alpha * glint;
let g = alpha * tinted_glint(glint, globals.colored_count.z);
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
}
// CRUMBLING_UV_BEGIN
+91 -23
View File
@@ -46,7 +46,7 @@ impl Corner {
Corner::BottomRight => "Bottom right",
}
}
fn parse(s: &str, default: Corner) -> Corner {
pub fn parse(s: &str, default: Corner) -> Corner {
Self::ALL
.into_iter()
.find(|c| c.name() == s)
@@ -161,15 +161,20 @@ pub struct Extras {
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (all by default), and their auras and
/// trails (off by default).
/// on by default), how much of other players' to show (no line until chosen:
/// `effective_others`), and their auras and trails (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: horizon_cosmetics::prefs::Others,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_auras: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
pub three_d_skin_layers: bool,
/// The PvP & HUD page's options (`pvp`).
pub pvp: crate::pvp::Pvp,
}
impl Default for Extras {
fn default() -> Self {
@@ -218,9 +223,11 @@ impl Default for Extras {
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
cosmetics_others: None,
cosmetics_auras: false,
cosmetics_owner: false,
three_d_skin_layers: false,
pvp: Default::default(),
}
}
}
@@ -228,6 +235,11 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Extras {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -320,18 +332,20 @@ impl Extras {
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).unwrap_or(e.cosmetics_others)
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
_ => {}
key => {
e.pvp.parse_line(key, value);
}
}
}
e
}
pub fn serialize(&self) -> String {
format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
let mut text = format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
@@ -381,11 +395,15 @@ impl Extras {
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
self.cosmetics_others.name(),
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_auras),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
)
);
self.pvp.serialize(&mut text);
text
}
/// Adopts the settings `options.txt` held before they moved here, while
/// this file (`own`) has no line of its own: 3D skin layers was an
@@ -556,6 +574,8 @@ pub enum Item {
CosmeticsMine,
CosmeticsOthers,
CosmeticsAuras,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -622,6 +642,7 @@ impl Item {
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
Item::Pvp(o) => o.caption_key(),
}
}
/// The language key of the description shown as the row's tooltip.
@@ -650,6 +671,7 @@ impl Item {
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
/// The caption in the current language (English when the key is missing).
@@ -687,6 +709,7 @@ impl Item {
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsAuras => "Others' auras and trails",
Item::Pvp(o) => o.caption(),
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -697,6 +720,7 @@ impl Item {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
@@ -762,9 +786,10 @@ impl Item {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden, the game never asks what they wear."
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
}
@@ -817,6 +842,7 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::CosmeticsAuras => e.cosmetics_auras,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
}
@@ -824,13 +850,14 @@ impl Item {
pub fn value(self, e: &Extras) -> String {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.cosmetics_others {
match e.effective_others() {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
@@ -871,10 +898,12 @@ impl Item {
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
let o = e.cosmetics_others;
e.cosmetics_others = if back { o.next().next() } else { o.next() };
// From what the button shows; the result is the player's choice from now on.
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
}
@@ -894,31 +923,70 @@ mod tests {
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
let mut e = Extras {
cosmetics_owner: true,
..Extras::default()
};
assert_eq!(
(e.cosmetics_others, e.cosmetics_auras, e.cosmetics_mine),
(e.effective_others(), e.cosmetics_auras, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::NameTags);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::Hidden);
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Others::NameTags);
assert_eq!(Extras::parse(&e.serialize()), e);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
// Ownership is the service's, never written.
assert_eq!(
Extras::parse(&e.serialize()),
Extras {
cosmetics_owner: false,
..e.clone()
}
);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Others::NameTags
Some(Others::NameTags)
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Others::Hidden
Some(Others::Hidden)
);
}
#[test]
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
assert_eq!(e.effective_others(), Others::Hidden);
assert_eq!(
Item::CosmeticsOthers.value(&e),
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
);
assert!(!e.serialize().contains("cosmeticsOthers"));
// The service says the account owns one: shown, still not a choice.
e.cosmetics_owner = true;
assert_eq!(e.effective_others(), Others::Shown);
assert!(!e.serialize().contains("cosmeticsOthers"));
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
e.cosmetics_owner = false;
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Shown));
let saved = Extras::parse(&e.serialize());
assert_eq!(saved.effective_others(), Others::Shown);
// An owner who chose Hidden keeps it.
let hidden = Extras {
cosmetics_owner: true,
..Extras::parse("cosmeticsOthers:hidden\n")
};
assert_eq!(hidden.effective_others(), Others::Hidden);
}
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
+35 -2
View File
@@ -212,8 +212,11 @@ pub struct FogInputs<'a> {
pub boss_world_fog: bool,
/// The camera entity is a spectator (`LavaFogEnvironment`).
pub spectator: bool,
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
/// The camera entity has Fire Resistance (`LavaFogEnvironment`), or PvP & HUD's clear lava
/// asks for its fog.
pub fire_resistance: bool,
/// PvP & HUD's clear water: the water vision `FogState` reaches after 600 ticks under water.
pub full_water_vision: bool,
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
@@ -224,7 +227,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
let rd = input.render_distance_chunks as f32 * 16.0;
let sky = input.sky;
let eye_in_water = input.fog_type == FogType::Water;
let water_vision = state.water_vision(eye_in_water);
let water_vision = if input.full_water_vision && eye_in_water {
1.0
} else {
state.water_vision(eye_in_water)
};
// Base colour of the applicable environment.
let mut color = match input.fog_type {
FogType::Lava => rgb(0x99_1A00),
@@ -451,6 +458,7 @@ mod tests {
boss_world_fog: false,
spectator: false,
fire_resistance: false,
full_water_vision: false,
night_vision: 0.0,
}
}
@@ -673,6 +681,31 @@ mod tests {
assert_eq!(s.water_vision(false), 0.0);
}
#[test]
fn clear_water_sees_as_far_as_after_thirty_seconds_under_water() {
let sky = Sky::default();
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.fog_type = FogType::Water;
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
let mut state = FogState::default();
state.tick(true);
let first = setup_fog(&tables, &state, &input);
assert_eq!(first.environmental_end, 24.0);
for _ in 0..600 {
state.tick(true);
}
let settled = setup_fog(&tables, &state, &input);
assert_eq!(settled.environmental_end, 96.0);
input.full_water_vision = true;
let mut fresh = FogState::default();
fresh.tick(true);
let clear = setup_fog(&tables, &fresh, &input);
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
}
#[test]
fn rain_fog_pulls_fog_in() {
let sky = Sky {
fog_end: 1024.0,
+129 -32
View File
@@ -209,6 +209,7 @@ mod view;
mod visbuffer;
mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -524,7 +525,8 @@ impl SectionGpu {
}
}
/// GPU culling's record of it at `key`, its quads within `bounds`
/// (`SectionMesh::bounds`), `FADING` while it fades (`gpu_fading`).
/// (`SectionMesh::bounds`) and in their buckets by facing side (`sides`),
/// `FADING` while it fades (`gpu_fading`).
fn record(&self, key: SectionKey, bounds: Option<[Vec3; 2]>) -> gpu_culling::Record {
let boxed = gpu_culling::rounded_box(bounds);
gpu_culling::Record::new(
@@ -534,6 +536,7 @@ impl SectionGpu {
self.solid,
boxed,
)
.facing(&self.sides)
.fading(self.gpu_fading)
}
/// Whether GPU frames' walk lists it within the render distance
@@ -881,6 +884,8 @@ pub struct Frame<'a> {
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// The PvP & HUD page's outline and hit colour (`pvp::Frame`; default: vanilla's).
pub pvp: crate::pvp::Frame,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -931,6 +936,9 @@ pub struct Frame<'a> {
pub additive: &'a [Vertex],
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
pub see_through: &'a [Vertex],
/// Native mods' lines, boxes, beams and text (`native_world_primitives`), drawn at the end
/// of the world pass, before the first-person hand.
pub world_primitives: world_primitives::Batch<'a>,
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
/// person).
pub shadow_only: &'a [Vertex],
@@ -1156,6 +1164,8 @@ pub struct Renderer {
lightning_pipeline: wgpu::RenderPipeline,
lightning: lightning::Renderer,
see_through_pipeline: wgpu::RenderPipeline,
/// Native mods' world primitives (`Frame::world_primitives`).
world_primitives: world_primitives::WorldPrimitives,
text_polygon_offset_pipeline: wgpu::RenderPipeline,
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
@@ -1358,6 +1368,8 @@ pub struct Renderer {
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
hdr_lighting_setting: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -1817,18 +1829,28 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
/// The backends to open a device on, each tried in turn until one gives a
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows,
/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one
/// before has no surface, adapter or device. A native crash in the driver can't be
/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64,
/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other
/// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then
/// OpenGL. Each is tried when the one before has no surface, adapter or device. A native
/// crash in the driver can't be
/// caught in the game; the launcher switches the setting to DirectX 12 after one at
/// the renderer's start (#746). macOS has Metal only.
fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec<wgpu::Backends> {
fn backend_order(
api: GraphicsApi,
os: &str,
arch: &str,
wgpu_backend: bool,
) -> Vec<wgpu::Backends> {
use wgpu::Backends;
if os == "macos" || wgpu_backend {
return vec![Backends::default()];
}
let windows = os == "windows";
match api {
GraphicsApi::Auto if windows && arch == "aarch64" => {
vec![Backends::DX12, Backends::VULKAN, Backends::GL]
}
GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL],
GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL],
GraphicsApi::Dx12 if windows => vec![Backends::DX12],
@@ -1969,7 +1991,12 @@ async fn open_device(
) -> Result<Opened> {
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
let mut first = None;
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
for (order, allow_cpu) in device_search(
api.with_env(),
std::env::consts::OS,
std::env::consts::ARCH,
wgpu_backend,
) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
Ok(opened) => {
if let Some(first) = &first {
@@ -2001,10 +2028,11 @@ async fn open_device(
fn device_search(
api: GraphicsApi,
os: &str,
arch: &str,
wgpu_backend: bool,
) -> Vec<(Vec<wgpu::Backends>, bool)> {
let chosen = backend_order(api, os, wgpu_backend);
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
let chosen = backend_order(api, os, arch, wgpu_backend);
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, arch, wgpu_backend)
.into_iter()
.filter(|b| !chosen.contains(b))
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
@@ -4435,6 +4463,7 @@ impl Renderer {
on_off(gpu_timer.is_some()),
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
Ok(Self {
display: DisplayWatch::default(),
display_fresh: false,
@@ -4522,6 +4551,7 @@ impl Renderer {
lightning_pipeline,
lightning: lightning::Renderer::new(),
see_through_pipeline,
world_primitives,
text_polygon_offset_pipeline,
celestial_pipeline,
celestial_buffer,
@@ -4636,6 +4666,7 @@ impl Renderer {
frame_gen: frame_gen::FrameGen::new(),
entity_atlas: atlas_view.clone(),
hdr: None,
hdr_lighting_setting: true,
emission_stats,
hdr_output,
frame_format,
@@ -6106,6 +6137,13 @@ impl Renderer {
None => "HDR: not offered by this display".into(),
}
}
/// The HDR Lighting setting (`Hdr::look`).
pub fn set_hdr_lighting(&mut self, on: bool) {
self.hdr_lighting_setting = on;
if let Some(hdr) = &mut self.hdr {
hdr.look = on;
}
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
/// Offscreen (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot
@@ -6150,6 +6188,9 @@ impl Renderer {
let format = self.frame_format;
self.hdr =
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
self.configure_surface();
match output {
Some(output) => log::info!(
@@ -6768,12 +6809,18 @@ impl Renderer {
if let Some(atlases) = &self.pack_material_atlases {
for kind in 0..2 {
let arrays: Vec<_> = arrays.iter().map(|pair| pair[kind].clone()).collect();
atlases[kind].copy(encoder, &arrays, self.animations.changed().iter().copied());
atlases[kind].copy(
&self.device,
encoder,
&arrays,
self.animations.changed().iter().copied(),
);
}
}
}
if let Some(atlas) = &self.pack_atlas {
atlas.copy(
&self.device,
encoder,
&self.sprite_textures,
self.animations.changed().iter().copied(),
@@ -7215,8 +7262,10 @@ impl Renderer {
},
colored_count: [
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
0,
0,
// PvP & HUD's hit colour: entity.wgsl's hurt texel, 0 for vanilla's.
frame.pvp.hurt_texel,
// PvP & HUD's glint colour: entity.wgsl's `tinted_glint`, 0 for vanilla's.
frame.pvp.glint,
0,
],
emissive_layers: {
@@ -7412,6 +7461,7 @@ impl Renderer {
radius,
frustum: &view,
reach: self.occlusion.reach().filter(|_| culling),
eye,
};
let prepared = crate::spans::time("gpu culling", || {
gpu.prepare(
@@ -7423,7 +7473,7 @@ impl Renderer {
)
});
// A frame whose planes leave the test's integers (the camera millions of blocks
// above or below the level) is the CPU's.
// above or below the level), or whose eye is not a number, is the CPU's.
gpu_frame = prepared == gpu_culling::Prepared::Gpu;
if prepared == gpu_culling::Prepared::OutOfMemory {
log::warn!("Horizon renderer: GPU culling off: its buffers do not fit");
@@ -8080,6 +8130,19 @@ impl Renderer {
frame.weather,
frame.weather_time,
);
// Native mods' world primitives, drawn as the world is: its jittered projection, the
// line widths in the frame's pixels as the gizmo lines'.
let world_primitives = !frame.title && {
let (uploads, encoder) = (&mut self.uploads, &mut encoder);
self.world_primitives.prepare(
&self.device,
&mut |buffer, bytes| uploads.buffer(encoder, buffer, 0, bytes),
&frame.world_primitives,
jittered,
[self.config.width, self.config.height],
&self.entity_atlas,
)
};
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
// The same per-line colour and pixel width layout as the gizmo lines. A high
@@ -8095,12 +8158,14 @@ impl Renderer {
line
})
};
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0)
.chain(stroke(0xff57_ffe1, width))
stroke(0xff00_0000, 7.0 * scale)
.chain(stroke(custom.unwrap_or(0xff57_ffe1), width * scale))
.collect()
} else {
stroke(0x6600_0000, width).collect()
stroke(custom.unwrap_or(0x6600_0000), width * scale).collect()
};
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
@@ -8159,7 +8224,7 @@ impl Renderer {
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting;
hdr.lighting = hdr_lighting && hdr.look;
}
let oit_on = self.improved_transparency
&& !frame.title
@@ -10001,6 +10066,11 @@ impl Renderer {
0..1,
);
}
// Native mods' primitives: after the entities and the rest of the world, into its
// targets (its resolution, reversed depth and TAA), before the hand.
if world_primitives {
self.world_primitives.draw(&mut pass);
}
if hand_in_world {
pass.set_pipeline(&self.depth_reset);
pass.set_bind_group(0, &self.group, &[]);
@@ -11668,27 +11738,49 @@ mod tests {
assert!(!is_software(wgpu::DeviceType::VirtualGpu));
}
#[test]
fn auto_tries_vulkan_then_directx_12_then_opengl() {
fn auto_uses_directx_12_first_on_windows_arm64() {
use super::{GraphicsApi, backend_order};
use wgpu::Backends;
let order = |api, os| backend_order(api, os, false);
let order = |api, os, arch| backend_order(api, os, arch, false);
assert_eq!(
order(GraphicsApi::Auto, "windows"),
order(GraphicsApi::Auto, "windows", "x86_64"),
[Backends::VULKAN, Backends::DX12, Backends::GL]
);
assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]);
assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]);
assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]);
assert_eq!(
order(GraphicsApi::Auto, "linux"),
order(GraphicsApi::Auto, "windows", "aarch64"),
[Backends::DX12, Backends::VULKAN, Backends::GL]
);
assert_eq!(
order(GraphicsApi::Vulkan, "windows", "aarch64"),
[Backends::VULKAN]
);
assert_eq!(
order(GraphicsApi::Dx12, "windows", "aarch64"),
[Backends::DX12]
);
assert_eq!(
order(GraphicsApi::OpenGl, "windows", "aarch64"),
[Backends::GL]
);
assert_eq!(
order(GraphicsApi::Auto, "linux", "x86_64"),
[Backends::VULKAN, Backends::GL]
);
assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]);
assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]);
assert_eq!(
order(GraphicsApi::Dx12, "linux", "x86_64"),
[Backends::VULKAN]
);
assert_eq!(
order(GraphicsApi::OpenGl, "linux", "x86_64"),
[Backends::GL]
);
// WGPU_BACKEND picks the backends itself; macOS has Metal only.
for api in GraphicsApi::ALL {
assert_eq!(backend_order(api, "windows", true), [Backends::default()]);
assert_eq!(order(api, "macos"), [Backends::default()]);
assert_eq!(
backend_order(api, "windows", "aarch64", true),
[Backends::default()]
);
assert_eq!(order(api, "macos", "aarch64"), [Backends::default()]);
}
}
#[test]
@@ -11696,28 +11788,33 @@ mod tests {
use super::{GraphicsApi, device_search};
use wgpu::Backends;
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
let search = |api, os, arch| device_search(api, os, arch, false);
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
assert_eq!(
device_search(GraphicsApi::Dx12, "windows", false),
search(GraphicsApi::Dx12, "windows", "x86_64"),
[(vec![d], false), (vec![g], false), (vec![d], true)]
);
assert_eq!(
device_search(GraphicsApi::Vulkan, "windows", false),
search(GraphicsApi::Vulkan, "windows", "x86_64"),
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
);
assert_eq!(
device_search(GraphicsApi::Auto, "windows", false),
search(GraphicsApi::Auto, "windows", "x86_64"),
[(vec![v, d, g], false), (vec![v, d, g], true)]
);
assert_eq!(
device_search(GraphicsApi::OpenGl, "linux", false),
search(GraphicsApi::Auto, "windows", "aarch64"),
[(vec![d, v, g], false), (vec![d, v, g], true)]
);
assert_eq!(
search(GraphicsApi::OpenGl, "linux", "x86_64"),
[(vec![g], false), (vec![v], false), (vec![g], true)]
);
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
let all = Backends::default();
for (os, env) in [("windows", true), ("macos", false)] {
assert_eq!(
device_search(GraphicsApi::Vulkan, os, env),
device_search(GraphicsApi::Vulkan, os, "x86_64", env),
[(vec![all], false), (vec![all], true)]
);
}
@@ -517,6 +517,7 @@ impl Scene {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -548,6 +549,7 @@ impl Scene {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &self.celestial,
stars: f
File diff suppressed because it is too large. Load diff
@@ -1,15 +1,18 @@
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
// `cull`: one invocation per section id keeps the sections whose column is
// within the render distance, that cave culling's graph reaches (occlusion.rs)
// and whose box is in the frustum, writes each one's origin and counts it in
// its page, per block of 64 ids. `scan`: one workgroup per page turns the
// blocks' counts into where each block's draws start, and the page's count.
// `scatter`: each block writes its sections' draws there, in id order, so a
// page's draws come in the same order every frame (atomics alone would order
// them as the GPU schedules, and where faces of two sections meet at the same
// depth the later draw keeps the pixel). Nothing here computes with floats: the
// origins are the CPU's bits, copied, and the frustum test adds integers the
// CPU computed. `cull` leaves sections fading in or out to the CPU (`FADING`).
// and whose box is in the frustum, writes each one's origin and the runs of its
// solid quads that can face the eye, a draw each, and counts them in its page,
// per block of 64 ids. `scan`: one workgroup per page turns the blocks' counts
// into where each block's draws start, and the page's count. `scatter`: each
// block writes its sections' draws there, in id order and each section's in its
// runs' order, so a page's draws come in the same order every frame (atomics
// alone would order them as the GPU schedules, and where faces of two sections
// meet at the same depth the later draw keeps the pixel). Nothing here computes
// with floats: the origins are the CPU's bits, copied, the frustum test adds
// integers the CPU computed, and facing compares keys that order as the floats
// the CPU compares. `cull` leaves sections fading in or out to the CPU
// (`FADING`).
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
@@ -31,12 +34,13 @@ struct Params {
// Each frustum plane's w term, its margin in, then 0, 0.
w: array<vec4<i32>, 2>,
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
// section y; its height in sections and the level's; REACH and SKY; 0.
// section y; its height in sections and the level's; REACH, SKY and EYE;
// where the eye's keys start in `tables`.
reach: vec4<i32>,
reach_height: i32,
reach_sections: i32,
flags: u32,
pad: u32,
eyes: u32,
}
// gpu_culling.rs `Record`.
@@ -44,9 +48,16 @@ struct Section {
// Section x, y (from the level's bottom), z, and its arena page (-1: a
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, its box
// (gpu_culling.rs `rounded_box`), then its flags (`FADING`).
// The first vertex of its solid quads, its solid quads (where its last
// bucket by facing side ends), its box (gpu_culling.rs `rounded_box`), then
// its flags (`FADING`).
a: vec4<u32>,
// Where its first six buckets end (mesher/sides.rs `BUCKETS`): +X, +Z, the
// other bucket, +Y, -Z, -X.
ends: array<u32, 6>,
// What its buckets facing +X, +Z, +Y, -Z, -X and -Y compare the eye's keys
// with (gpu_culling.rs `Record::facing`).
keys: array<u32, 6>,
}
// gpu_culling.rs `Update`: the record for id `id.x`.
@@ -59,8 +70,9 @@ struct Update {
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
// gpu_culling.rs `tables`: the origins' f32 bits along x, y and z (n of them);
// per frustum plane its terms for those origins, n each; per plane and axis its
// terms for the 32 box codes; then each page's region of `draws` (its first
// draw and its size).
// terms for the 32 box codes; with an eye (`EYE`), the keys of the eye less
// each origin, n of them; then each page's region of `draws` (its first draw
// and its size).
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
@@ -70,7 +82,8 @@ struct Update {
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id its page + 1 when it is drawn this frame, else 0.
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | its draws when it is drawn this
// frame, else 0; then each draw's first quad and quads.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
// a bit each (32 a word, lowest first).
@@ -93,6 +106,13 @@ const REACH: u32 = 1u;
const SKY: u32 = 2u;
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
const FADING: u32 = 1u;
// In `params.flags`: solid quads facing away from the eye are left out, its
// keys in `tables` from `params.eyes`.
const EYE: u32 = 4u;
// Words per id in `runs`, and where its page starts in the first, its draws
// (at most 4: gpu_culling.rs `RUNS`) below.
const RUN_WORDS: u32 = 9u;
const PAGE_SHIFT: u32 = 3u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
@@ -170,6 +190,34 @@ fn in_frustum(c: vec3<u32>, b: u32) -> bool {
return true;
}
// Where bucket `b` of section `id` ends (mesher/sides.rs `BUCKETS` order): its
// solid quads for the last.
fn bucket_end(id: u32, b: u32) -> u32 {
if b == 6u {
return sections[id].a.y;
}
return sections[id].ends[b];
}
// mesher/sides.rs `Sides::visible_from` for bucket `b` of section `id` (+X, +Z,
// the other bucket, +Y, -Z, -X, -Y), with `eye` the keys of the eye less its
// origin: whether its quads can face the eye. -Y compares the eye's own y, as a
// GPU frame has no water mirror. The keys order as the floats the CPU compares.
fn seen(id: u32, b: u32, eye: vec3<u32>) -> bool {
if b == 2u {
return true;
}
let t = sections[id].keys[b - select(0u, 1u, b > 2u)];
switch b {
case 0u: { return eye.x > t; }
case 1u: { return eye.z > t; }
case 3u: { return eye.y > t; }
case 4u: { return eye.z < t; }
case 5u: { return eye.x < t; }
default: { return eye.y < t; }
}
}
// One invocation per changed record; the CPU lists each id once.
@compute @workgroup_size(64)
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -186,7 +234,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
if id >= params.ids {
return;
}
runs[id] = 0u;
let at = id * RUN_WORDS;
runs[at] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|| !reached(s.key.xyz) {
@@ -199,11 +248,52 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
if page >= params.pages || any(c >= params.cells.xyz) || !in_frustum(c, s.a.z) {
return;
}
// Kept, whether or not a quad of it can face the eye.
atomicAdd(&counts[0], 1u);
let nx = params.cells.x;
let ny = params.cells.y;
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
runs[id] = page + 1u;
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
// gfx.rs `facing_runs`: the runs of its buckets that can face the eye, an
// empty bucket breaking none, a draw each; without an eye, its solid quads.
var n = 0u;
if (params.flags & EYE) == 0u {
runs[at + 1u] = 0u;
runs[at + 2u] = s.a.y;
n = 1u;
} else {
let e = params.eyes;
let eye = vec3<u32>(tables[e + c.x], tables[e + nx + c.y], tables[e + nx + ny + c.z]);
var start = 0u;
var first = 0u;
var open = false;
for (var b = 0u; b < 7u; b++) {
let end = bucket_end(id, b);
if end == start {
continue;
}
let shown = seen(id, b, eye);
if shown && !open {
open = true;
first = start;
} else if !shown && open {
runs[at + 1u + 2u * n] = first;
runs[at + 2u + 2u * n] = start - first;
n += 1u;
open = false;
}
start = end;
}
if open {
runs[at + 1u + 2u * n] = first;
runs[at + 2u + 2u * n] = start - first;
n += 1u;
}
}
if n == 0u {
return;
}
runs[at] = (page << PAGE_SHIFT) | n;
atomicAdd(&counts[block_count(id / BLOCK, page)], n);
}
var<workgroup> partial: array<u32, 256>;
@@ -242,49 +332,54 @@ fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index
start += count;
}
if lane == 255u {
// A region has room for every record of its page; were one short,
// its draws past it go.
// A region has room for every draw of its page's records; were one
// short, its draws past it go.
let total = min(partial[255], region(page).y);
atomicStore(&counts[STATS + page], total);
atomicAdd(&counts[0], partial[255]);
atomicAdd(&counts[1], total);
}
}
var<workgroup> held: array<u32, 64>;
// One workgroup per block of ids: each section's draw goes after those of the
// block's earlier sections in its page.
// One workgroup per block of ids: each section's draws go after those of the
// block's earlier sections in its page, in its runs' order.
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let block = group.y * ROW + group.x;
let id = block * BLOCK + lane;
var mine = 0u;
if id < params.ids {
mine = runs[id];
mine = runs[id * RUN_WORDS];
}
held[lane] = mine;
workgroupBarrier();
if mine == 0u {
// Its draws below PAGE_SHIFT, its page above.
let n = mine & 7u;
if n == 0u {
return;
}
let page = mine - 1u;
let page = mine >> PAGE_SHIFT;
var slot = atomicLoad(&counts[block_count(block, page)]);
for (var other = 0u; other < lane; other++) {
if held[other] == mine {
slot += 1u;
// A section not drawn holds 0: no draws.
if (held[other] >> PAGE_SHIFT) == page {
slot += held[other] & 7u;
}
}
let r = region(page);
if slot >= r.y {
return;
let at = id * RUN_WORDS;
let base = sections[id].a.x;
for (var k = 0u; k < n; k++) {
if slot + k >= r.y {
return;
}
// A run of its solid quads, from its first vertex, with its origin.
let d = (r.x + slot + k) * 5u;
draws[d] = runs[at + 2u + 2u * k] * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = base + runs[at + 1u + 2u * k] * 4u;
draws[d + 4u] = id;
}
// All of its solid quads, from its first vertex, with its origin.
let s = sections[id];
let d = (r.x + slot) * 5u;
draws[d] = s.a.y * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = s.a.x;
draws[d + 4u] = id;
}
+5 -1
View File
@@ -168,6 +168,9 @@ pub struct Hdr {
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
pub look: bool,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
@@ -313,6 +316,7 @@ impl Hdr {
ui: 1.0,
masks: MASKS,
lighting: true,
look: true,
calibration: None,
}
}
@@ -553,7 +557,7 @@ impl Hdr {
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(world && self.look)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(pq)),
]);
@@ -173,7 +173,8 @@ struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
// Its four floats' bits (`SURFACE_FORMAT` is an integer format).
@location(1) surface: vec4<u32>,
}
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
@@ -209,7 +210,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
var out: HorizonMaterialSolid;
let mark = horizon_material_mark(in, texel);
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
out.surface = bitcast<vec4<u32>>(vec4(horizon_surface.xyz, in.clip.z));
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
@@ -335,9 +336,12 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
}
";
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// The surface target (the post stage's `scene_surface`): the bits of four floats, x the normal
/// (two snorm16 in its bits), y smoothness, z the pack's data, w the fragment's depth. Stored
/// as unsigned integers, not `Rgba32Float`: RGBA32UI is colour-renderable on every backend,
/// where OpenGL (GLES 3 semantics) refuses RGBA32F as a render attachment, and integers keep
/// the packed normal's bits exact.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Uint;
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`, and the vertex stage in its module when `vertex`.
@@ -687,7 +687,7 @@ impl NativePost {
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
texture_entry(11, wgpu::TextureSampleType::Uint),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
@@ -1547,4 +1547,306 @@ file = \"b.wgsl\"
"{fourth:?}"
);
}
/// OpenGL refuses `Rgba32Float` as a render attachment (GLES 3 semantics, absent
/// `EXT_color_buffer_float`) but always renders to the integer formats, so the surface
/// is an integer format, and `surface_at` gets the floats back from the stored bits.
#[test]
fn the_surface_is_an_integer_target_that_the_post_stage_reads_bit_exact() {
assert_eq!(
super::super::native_material::SURFACE_FORMAT.sample_type(None, None),
Some(wgpu::TextureSampleType::Uint)
);
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[[post]]\nfile = \"a.wgsl\"\n";
// The surface's fields on the colour: normal x, smoothness, data, and 1 when present.
let a = "fn post(input: PostInput) -> vec4<f32> {\n \
let s = surface_at(vec2<i32>(input.pixel));\n \
return vec4<f32>(s.normal.x, s.smoothness, s.data, select(0.0, 1.0, s.present));\n}\n";
let files = [(MANIFEST, manifest), ("a.wgsl", a)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = screen.create_view(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// What the material stage stores, as the bits of floats: the normal +x (octahedral
// (1, 0), two snorm16), smoothness 0.5, data 0.25 and the fragment's depth 0.5.
let writer = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<u32> {
return bitcast<vec4<u32>>(vec4<f32>(
bitcast<f32>(pack2x16snorm(vec2<f32>(1.0, 0.0))), 0.5, 0.25, 0.5));
}"
.into(),
),
});
let writer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &writer,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &writer,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: super::super::native_material::SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let mut read = |depth_of_world: f64| {
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame.surface_view,
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(&writer);
pass.draw(0..3, 0..1);
}
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(depth_of_world as f32),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
post.run(
&device,
&mut encoder,
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
None,
);
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
buffer.unmap();
pixel
};
// Present where the world's depth is the stored one: +x, 0.5, 0.25 as 8-bit.
assert_eq!(read(0.5), [255, 128, 64, 255]);
// Hidden by something nearer drawn since: the stored values no longer show.
assert_eq!(read(0.25), [0, 0, 0, 0]);
}
/// The surface holds float bit patterns, some of which are NaNs (a normal's two snorm16
/// can form one). The material stage writes `bitcast<vec4<u32>>(vec4<f32>(..))`; this
/// runs that same expression (not the whole material fragment, which needs a full terrain
/// pipeline) on bits fed as integers and checks the texture holds them exactly: payloads,
/// signs and denormals included.
#[test]
fn the_surface_keeps_every_bit_of_the_floats_written_through_the_material_stage_bitcast() {
let Some((device, queue)) = device() else {
return;
};
let cases: [[u32; 4]; 4] = [
[0x7fc0_1234, 0xffbf_ffff, 0x7fff_8001, 0x7f80_0001],
[0x8000_0000, 0x0000_0001, 0x007f_ffff, 0x8000_0001],
[0xffff_ffff, 0x7f80_0000, 0xff80_0000, 0x3f80_0000],
[0x7fff_8001, 0x0000_0000, 0x1234_5678, 0xdead_beef],
];
let mut bits = [[0u32; 4]; 16];
bits[..4].copy_from_slice(&cases);
let inputs = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&inputs, 0, bytemuck::cast_slice(&bits));
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@group(0) @binding(0) var<uniform> bits: array<vec4<u32>, 16>;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<u32> {
let v = bitcast<vec4<f32>>(bits[u32(p.y) * 4u + u32(p.x)]);
return bitcast<vec4<u32>>(v);
}"
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: super::super::native_material::SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: inputs.as_entire_binding(),
}],
});
let surface = texture(
&device,
super::super::native_material::SURFACE_FORMAT,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let row = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: row * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = surface.create_view(&Default::default());
let mut pass = 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(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
surface.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
surface.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = buffer.slice(..).get_mapped_range().unwrap().to_vec();
buffer.unmap();
for (i, expected) in bits.iter().enumerate() {
let (x, y) = (i % 4, i / 4);
let at = y * row as usize + x * 16;
let got: [u32; 4] = std::array::from_fn(|c| {
u32::from_ne_bytes(data[at + c * 4..at + c * 4 + 4].try_into().unwrap())
});
assert_eq!(
got.map(|v| format!("{v:08x}")),
expected.map(|v| format!("{v:08x}")),
"texel {x},{y}"
);
}
}
}
@@ -4,8 +4,8 @@
//!
//! Flat draws, which read a table per sprite layer, get the game's own layout
//! ([`PackAtlas::stitched`]): every sprite at its own size, placed by vanilla's `Stitcher` with its
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7 (the padding stays
//! empty for now). Other draws get a grid of array-sized cells ([`PackAtlas::new`]).
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7. Other draws get
//! a grid of array-sized cells ([`PackAtlas::new`]).
#[path = "pack_atlas/stitch.rs"]
mod stitch;
@@ -25,12 +25,54 @@ pub(super) struct PackAtlas {
stitched: Option<Stitched>,
}
/// A stitched atlas: where each layer's sprite is.
/// A stitched atlas: where each layer's sprite is, and the pipeline that draws them in.
struct Stitched {
/// Each layer's sprite at the first level: its top left corner and its side.
rects: Vec<[u32; 3]>,
padding: u32,
pipeline: wgpu::RenderPipeline,
/// One per sprite array.
groups: Vec<wgpu::BindGroup>,
}
/// One sprite drawn into one level of a stitched atlas (`BLIT`'s instance).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct BlitSprite {
/// The sprite's top left corner, side and padding at that level.
rect: [i32; 4],
/// Its layer in its array, the array's level it comes from, the atlas level's size.
source: [u32; 4],
}
/// Draws sprites into a stitched atlas level, padding included: a pixel of the padding repeats
/// the sprite's nearest edge pixel, as vanilla's `animate_sprite` shaders do with a clamped sampler.
const BLIT: &str = r#"
struct Out {
@builtin(position) position: vec4<f32>,
@location(0) @interpolate(flat) rect: vec4<i32>,
@location(1) @interpolate(flat) source: vec2<u32>,
}
@group(0) @binding(0) var sprites: texture_2d_array<f32>;
@vertex
fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1) source: vec4<u32>) -> Out {
var corners = array<vec2<f32>, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0));
let pixel = vec2<f32>(rect.xy - rect.w) + corners[v] * f32(rect.z + 2 * rect.w);
let size = vec2<f32>(source.zw);
var out: Out;
out.position = vec4(pixel.x / size.x * 2.0 - 1.0, 1.0 - pixel.y / size.y * 2.0, 0.0, 1.0);
out.rect = rect;
out.source = source.xy;
return out;
}
@fragment
fn fs(in: Out) -> @location(0) vec4<f32> {
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
}
"#;
/// The side a layer of `sprite`-wide arrays has in a stitched atlas: its own, when the arrays
/// hold it scaled up by a power of two (else the array's).
fn atlas_side(native: u32, sprite: u32) -> u32 {
@@ -134,7 +176,7 @@ impl PackAtlas {
separate_ao: false,
stitched: None,
};
atlas.copy(encoder, arrays, 0..layers);
atlas.copy(device, encoder, arrays, 0..layers);
Some(atlas)
}
@@ -181,10 +223,76 @@ impl PackAtlas {
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack atlas sprites"),
source: wgpu::ShaderSource::Wgsl(BLIT.into()),
});
let group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack atlas sprites"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack atlas sprites"),
bind_group_layouts: &[Some(&group_layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shader pack atlas sprites"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<BlitSprite>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &wgpu::vertex_attr_array![0 => Sint32x4, 1 => Uint32x4],
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
});
let groups = arrays
.iter()
.map(|array| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack atlas sprites"),
layout: &group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&array.create_view(
&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
},
)),
}],
})
})
.collect();
let rects = layout
.holders
.iter()
@@ -199,9 +307,14 @@ impl PackAtlas {
sprite,
split,
separate_ao: false,
stitched: Some(Stitched { rects }),
stitched: Some(Stitched {
rects,
padding: layout.padding,
pipeline,
groups,
}),
};
atlas.copy(encoder, arrays, 0..layers);
atlas.copy(device, encoder, arrays, 0..layers);
Some(atlas)
}
@@ -236,34 +349,25 @@ impl PackAtlas {
/// Copies `layers` from the sprite arrays again, every level.
pub fn copy(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
layers: impl IntoIterator<Item = u32>,
) {
if let Some(stitched) = &self.stitched {
return self.draw_sprites(stitched, device, encoder, layers);
}
for layer in layers {
let Some(array) = arrays.get((layer / self.split) as usize) else {
continue;
};
// Where the sprite goes, and from which array level: a stitched sprite keeps its own
// side, which the arrays hold that many halvings down.
let [x, y, side] = match &self.stitched {
Some(s) => match s.rects.get(layer as usize) {
Some(&rect) => rect,
None => continue,
},
None => [
layer % self.columns * self.sprite,
layer / self.columns * self.sprite,
self.sprite,
],
};
let from = (self.sprite / side).ilog2();
let (column, row) = (layer % self.columns, layer / self.columns);
for level in 0..self.texture.mip_level_count() {
let edge = side >> level;
let edge = self.sprite >> level;
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: array,
mip_level: from + level,
mip_level: level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
@@ -275,8 +379,8 @@ impl PackAtlas {
texture: &self.texture,
mip_level: level,
origin: wgpu::Origin3d {
x: x >> level,
y: y >> level,
x: column * edge,
y: row * edge,
z: 0,
},
aspect: wgpu::TextureAspect::All,
@@ -290,6 +394,91 @@ impl PackAtlas {
}
}
}
/// Draws `layers` into every level of a stitched atlas, from the arrays' level where each
/// sprite has its own size.
fn draw_sprites(
&self,
stitched: &Stitched,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
layers: impl IntoIterator<Item = u32>,
) {
let mut layers: Vec<u32> = layers
.into_iter()
.filter(|&l| (l as usize) < stitched.rects.len())
.collect();
if layers.is_empty() {
return;
}
layers.sort_unstable_by_key(|&l| (l / self.split, l));
let levels = self.texture.mip_level_count();
let mut sprites = Vec::with_capacity(layers.len() * levels as usize);
for level in 0..levels {
let size = [
self.texture.width() >> level,
self.texture.height() >> level,
];
for &layer in &layers {
let [x, y, side] = stitched.rects[layer as usize];
sprites.push(BlitSprite {
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
source: [
layer % self.split,
(self.sprite / side).ilog2() + level,
size[0],
size[1],
],
});
}
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack atlas sprites"),
contents: bytemuck::cast_slice(&sprites),
usage: wgpu::BufferUsages::VERTEX,
},
);
let per_level = layers.len() as u32;
for level in 0..levels {
let view = self.texture.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: level,
mip_level_count: Some(1),
..Default::default()
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack atlas sprites"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&stitched.pipeline);
pass.set_vertex_buffer(0, buffer.slice(..));
let first = level * per_level;
let mut start = 0;
while start < layers.len() {
let array = layers[start] / self.split;
let end = start
+ layers[start..]
.iter()
.take_while(|&&l| l / self.split == array)
.count();
if let Some(group) = stitched.groups.get(array as usize) {
pass.set_bind_group(0, group, &[]);
pass.draw(0..6, first + start as u32..first + end as u32);
}
start = end;
}
}
}
}
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
@@ -425,6 +614,13 @@ impl PackAtlas {
"vec4(0.0)"
};
let attributes = [
// section_instance stores 1 - visibility in a positive origin's mantissa.
// Negative origins encode a distant-terrain handover, fully visible here
// just as terrain.wgsl's instance_visibility treats them.
(
"mc_chunkFade",
"(hz_origin.w > 0.0 ? 1.0 - float(floatBitsToUint(hz_origin.w) & 0x7fffffu) / 8388608.0 : 1.0)",
),
("mc_Entity", entity),
("at_midBlock", mid_block),
(
@@ -452,6 +648,10 @@ impl PackAtlas {
}
}
#[cfg(test)]
#[path = "pack_atlas/fade_test.rs"]
mod fade_test;
#[cfg(test)]
#[path = "pack_atlas/midpoint_test.rs"]
mod midpoint_test;
@@ -585,9 +785,9 @@ mod tests {
/// Sprites of 2, 4 and 1 px in a 4 px array, no mipmaps (one pixel of padding). Worked through
/// vanilla's `Stitcher` by hand: holders of 6, 4 and 3 px at (0, 0), (8, 0) and (8, 4) in a 16
/// by 8 atlas, each sprite with its own pixels.
/// by 8 atlas. Each sprite has its own pixels, and its padding repeats its edge.
#[test]
fn stitched_sprites_keep_their_own_size() {
fn stitched_sprites_keep_their_own_size_and_padding() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
@@ -633,17 +833,26 @@ mod tests {
let i = y * 256 + x * 4;
[data[i], data[i + 1], data[i + 2], data[i + 3]]
};
// Layer 0's own 2 px: (layer 0, x, y); its padding is empty.
// Layer 0's own 2 px: (layer 0, x, y); its padding repeats the nearest edge pixel.
assert_eq!(
[at(9, 1), at(10, 1), at(10, 2)],
[[0, 0, 0, 255], [0, 16, 0, 255], [0, 16, 16, 255]]
);
assert_eq!([at(8, 0), at(11, 3), at(11, 1)], [[0; 4]; 3]);
// Layer 1 fills its 4 px; layer 2 is one pixel.
assert_eq!(
[at(1, 1), at(4, 4), at(9, 5), at(10, 6)],
[[50, 0, 0, 255], [50, 48, 48, 255], [100, 0, 0, 255], [0; 4]]
[at(8, 0), at(11, 3), at(11, 1)],
[[0, 0, 0, 255], [0, 16, 16, 255], [0, 16, 0, 255]]
);
// Layer 1 fills its 4 px, padded; layer 2 is one pixel in the middle of its padding.
assert_eq!(
[at(1, 1), at(4, 4), at(0, 5), at(5, 0)],
[
[50, 0, 0, 255],
[50, 48, 48, 255],
[50, 0, 48, 255],
[50, 48, 0, 255]
]
);
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
}
#[test]
@@ -0,0 +1,406 @@
//! Original pack-to-pixel controls of terrain's visibility input, with no external fixture.
use super::PackAtlas;
use crate::gfx::{section_instance, section_visibility};
use crate::mesher::TerrainVertex;
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use wgpu::util::DeviceExt;
struct ProbePack(PathBuf);
impl Drop for ProbePack {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn terrain_fade_reaches_pack_pixels_through_real_instances() {
let instance = crate::gfx::test_gpu::instance();
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
Ok(adapter) => adapter,
Err(error) => {
eprintln!("SKIP terrain fade GPU control: no adapter: {error}");
return;
}
};
eprintln!("terrain fade adapter: {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let mut atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
atlas.separate_ao = true;
queue.submit([encoder.finish()]);
let folder = ProbePack(
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
// The guard, initializer, varying and integer output are independent probe code.
let vertex = "#version 330\nout vec4 observation;\n\
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }";
let geometry_vertex = vertex
.replace("float first = mc_chunkFade;", "")
.replace("mc_chunkFade, first,", "mc_chunkFade, mc_chunkFade,")
.replace("observation", "vertex_observation");
let geometry_shader = "#version 330\nlayout(triangles) in;\n\
layout(triangle_strip, max_vertices=3) out;\nin vec4 vertex_observation[];\n\
out vec4 observation;\nconst float mc_chunkFade = 0.8125;\nvoid main() {\n\
for (int k=0; k<3; ++k) {\nobservation = vertex_observation[k];\n\
observation.y = mc_chunkFade;\n\
gl_Position = vec4(k == 1 ? 3.0 : -1.0, k == 2 ? 3.0 : -1.0, 0.0, 1.0);\n\
EmitVertex();\n}\nEndPrimitive();\n}";
for name in ["gbuffers_terrain", "gbuffers_water", "shadow"] {
for (extension, code) in [("vsh", vertex), ("fsh", fragment)] {
std::fs::write(folder.0.join(format!("shaders/{name}.{extension}")), code).unwrap();
}
}
let uploaded = Instant::now();
let cases = [
("first frame", Some(&uploaded), 0, 1000, 0, 0.0),
("137ms", Some(&uploaded), 137, 1000, 0, 0.137),
("midpoint", Some(&uploaded), 500, 1000, 0, 0.5),
("finished", Some(&uploaded), 1000, 1000, 0, 1.0),
("disabled", Some(&uploaded), 0, 0, 0, 1.0),
("no upload transition", None, 0, 1000, 0, 1.0),
("distant handover", Some(&uploaded), 500, 1000, 17, 1.0),
];
let mut count = 0;
for requested in [false, true] {
std::fs::write(
folder.0.join("shaders/shaders.properties"),
if requested {
"shadow.enabled=true\niris.features.optional=FADE_VARIABLE\niris.features.required=FADE_VARIABLE\n"
} else {
"shadow.enabled=true\n"
},
)
.unwrap();
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, notes) = horizon_shaders::read_properties(&pack, &mut macros);
assert!(notes.is_empty(), "{notes:?}");
for flat in [false, true] {
for geometry in [false, true].into_iter().filter(|g| !*g || flat) {
let geometry_path = folder.0.join("shaders/shadow.gsh");
if geometry {
std::fs::write(&geometry_path, geometry_shader).unwrap();
} else if geometry_path.exists() {
std::fs::remove_file(&geometry_path).unwrap();
}
std::fs::write(
folder.0.join("shaders/shadow.vsh"),
if geometry {
geometry_vertex.as_str()
} else {
vertex
},
)
.unwrap();
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&horizon_shaders::GameInputs {
terrain: Some(atlas.terrain_inputs(flat)),
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
inputs: atlas.terrain_inputs(true),
pull: super::PULL.to_owned(),
index: "@".to_owned(),
}),
..Default::default()
},
);
for (name, program) in [
("terrain", &post.terrain),
("water", &post.water),
("shadow", &post.shadow),
] {
if geometry && name != "shadow" {
continue;
}
let program = program
.as_ref()
.unwrap_or_else(|| panic!("{name}: {:?}", post.notes));
for &(case, timestamp, elapsed, fade_ms, handover, expected) in &cases {
let visibility = section_visibility(
timestamp,
fade_ms,
uploaded + Duration::from_millis(elapsed),
);
let origin = section_instance([48.0, 0.0, -16.0], handover, visibility);
let pixel = sample(
&device,
&queue,
&atlas,
program.program.as_ref().unwrap(),
flat,
geometry,
origin,
);
let fade = if requested { expected } else { 1.0 };
let expected = [
fade,
if geometry { 0.8125 } else { fade },
128.0 / 255.0,
0.25,
];
assert!(
pixel
.iter()
.zip(expected)
.all(|(a, b)| a.is_finite() && (a - b).abs() < 2e-7),
"{name} {case} flat={flat} geometry={geometry} requested={requested}: {pixel:?} != {expected:?}"
);
count += 1;
}
}
}
}
}
eprintln!("terrain fade pack-to-pixel cases executed: {count}");
assert_eq!(count, 98);
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
program: &horizon_shaders::Program,
flat: bool,
geometry: bool,
origin: [f32; 4],
) -> [f32; 4] {
let buffer = |bytes: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytes,
usage,
})
};
let uniform = buffer(
&vec![0; program.interface.uniform_size as usize],
wgpu::BufferUsages::UNIFORM,
);
let origins = buffer(
bytemuck::bytes_of(&origin),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
.into_iter()
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
.collect();
let data = buffer(
bytemuck::cast_slice(&vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
wgpu::BufferUsages::STORAGE,
);
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
binding: n,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: if storage {
wgpu::BufferBindingType::Storage { read_only: true }
} else {
wgpu::BufferBindingType::Uniform
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let entries = [
vec![binding(0, false)],
if flat {
vec![binding(0, true), binding(1, true)]
} else {
vec![]
},
vec![],
if flat {
vec![binding(0, true), binding(1, true), binding(2, true)]
} else {
vec![]
},
];
let layouts: Vec<_> = entries
.iter()
.map(|entries| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries,
})
})
.collect();
let bindings = [
vec![(0, &uniform)],
if flat {
vec![(0, &slots), (1, &origins)]
} else {
vec![]
},
vec![],
if flat {
vec![(0, &ids), (1, &data), (2, &rects)]
} else {
vec![]
},
];
let groups: Vec<_> = layouts
.iter()
.zip(bindings)
.map(|(layout, buffers)| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout,
entries: &buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
})
.collect();
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
..Default::default()
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex.clone());
let fs = module(program.fragment.clone());
let vertices = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &crate::gfx::TERRAIN_ATTRIBUTES,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &wgpu::vertex_attr_array![5 => Float32x4],
}),
];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &vertices[..if flat { 1 } else { 2 }],
},
primitive: wgpu::PrimitiveState {
topology: if geometry {
wgpu::PrimitiveTopology::TriangleList
} else {
wgpu::PrimitiveTopology::PointList
},
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Uint,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Uint,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let view = output.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
for (n, group) in groups.iter().enumerate() {
pass.set_bind_group(n as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(1, origins.slice(..));
}
pass.draw(0..if geometry { 3 } else { 1 }, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: Default::default(),
},
output.size(),
);
queue.submit([encoder.finish()]);
read.map_async(wgpu::MapMode::Read, .., |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
*bytemuck::from_bytes(&read.get_mapped_range(..).unwrap())
}
@@ -123,7 +123,7 @@ fn mid_tex_coord_follows_each_quads_mapped_corners() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size` works out: 16 by
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size_and_padding` works out: 16 by
/// 8, sprites of 2, 4 and 1 px at (9, 1), (1, 1) and (9, 5)): flat draws map a quad's UVs onto its
/// sprite's own pixels, so a corner is half the sprite's own side from `mc_midTexCoord`.
#[test]
@@ -678,6 +678,43 @@ fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
}
}
/// `format` where `renders` says it can be a colour target, else the nearest float format that can:
/// the same channels at 16 bits, then `Rgba8Unorm`. On OpenGL the 32-bit and 16-bit float formats
/// render only with `GL_EXT_color_buffer_float`/`half_float`, and wgpu aborts on creating a target
/// without. Other formats pass through. Float colour buffers are only sampled (never blended), so
/// RENDER_ATTACHMENT is the only usage that decides.
fn renderable_float(
format: wgpu::TextureFormat,
renders: impl Fn(wgpu::TextureFormat) -> bool,
) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let half = match format {
T::R32Float | T::R16Float => T::R16Float,
T::Rg32Float | T::Rg16Float => T::Rg16Float,
T::Rgba32Float | T::Rgba16Float => T::Rgba16Float,
_ => return format,
};
if renders(format) {
format
} else if renders(half) {
half
} else {
T::Rgba8Unorm
}
}
/// [`texture_format`] made renderable on this adapter (see [`renderable_float`]).
fn target_format(
f: Format,
features: wgpu::Features,
adapter: &wgpu::Adapter,
) -> wgpu::TextureFormat {
renderable_float(texture_format(f, features), |format| {
(adapter.get_texture_format_features(format).allowed_usages)
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
})
}
/// The SNORM wgpu format of SNORM pack format `f`, where this adapter can render to it. Iris
/// stores such a buffer clamped to [-1, 1] and quantised to its bits; elsewhere the half floats of
/// [`texture_format`] stand in.
@@ -1891,7 +1928,8 @@ impl PackPost {
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
let f = post.config.buffers[i].format;
snorm_format(f, features, adapter).unwrap_or_else(|| texture_format(f, features))
snorm_format(f, features, adapter)
.unwrap_or_else(|| target_format(f, features, adapter))
});
let sky_clear = Some(&post.config.buffers[0])
.filter(|b| b.clear)
@@ -1944,7 +1982,9 @@ impl PackPost {
// buffers 0 to 7.
let shadow_formats: Option<[wgpu::TextureFormat; SHADOW_COLORS]> =
post.shadow_pass.then(|| {
std::array::from_fn(|n| texture_format(shadow_config.colors[n].format, features))
std::array::from_fn(|n| {
target_format(shadow_config.colors[n].format, features, adapter)
})
});
let shadow_space: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
shadow_formats
@@ -5284,6 +5324,27 @@ mod tests {
assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat);
}
#[test]
fn float_targets_fall_back_when_not_renderable() {
let all = |_| true;
assert_eq!(renderable_float(T::Rgba32Float, all), T::Rgba32Float);
// OpenGL without GL_EXT_color_buffer_float: only half floats render.
let half_only = |f: T| matches!(f, T::R16Float | T::Rg16Float | T::Rgba16Float);
assert_eq!(renderable_float(T::Rgba32Float, half_only), T::Rgba16Float);
assert_eq!(renderable_float(T::Rg32Float, half_only), T::Rg16Float);
assert_eq!(renderable_float(T::R32Float, half_only), T::R16Float);
assert_eq!(renderable_float(T::Rgba16Float, half_only), T::Rgba16Float);
// Neither renders: the 8-bit last resort.
assert_eq!(renderable_float(T::Rgba32Float, |_| false), T::Rgba8Unorm);
assert_eq!(renderable_float(T::Rg16Float, |_| false), T::Rgba8Unorm);
// Other formats are never replaced.
assert_eq!(renderable_float(T::Rgba8Uint, |_| false), T::Rgba8Uint);
assert_eq!(
renderable_float(T::Rgb10a2Unorm, |_| false),
T::Rgb10a2Unorm
);
}
#[test]
fn samplers_read_custom_textures_of_their_type() {
use wgpu::TextureSampleType as T;
@@ -22,7 +22,7 @@ fn hdr_pack(sized: bool, format: &str) -> Post {
add(
&format!("{program}.fsh"),
&format!(
"#version 120\n/* RENDERTARGETS:{buffer} */\n/* const int colortex{buffer}Format={format}; */\nvoid main() {{ gl_FragData[0]=vec4(0,0.5,0,1); }}\n"
"#version 120\n/* RENDERTARGETS:{buffer} */\n/*\nconst int colortex{buffer}Format={format};\n*/\nvoid main() {{ gl_FragData[0]=vec4(0,0.5,0,1); }}\n"
),
);
if sized {
@@ -62,6 +62,16 @@ fn hdr_pack(sized: bool, format: &str) -> Post {
post
}
// Check the fixture contract even when the render tests cannot find an adapter.
#[test]
fn clear_fixture_loads_the_requested_formats_without_a_gpu() {
for sized in [false, true] {
for format in ["RGBA16F", "RGBA8"] {
hdr_pack(sized, format);
}
}
}
fn hdr_clear_case(sized: bool, attachment_limit: u32, byte_budget: u32, format: &str) {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
@@ -595,6 +595,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -618,6 +619,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -438,6 +438,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -461,6 +462,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -328,6 +328,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -351,6 +352,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -1447,6 +1447,7 @@ mod initial_history_tests {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -1470,6 +1471,7 @@ mod initial_history_tests {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -0,0 +1,563 @@
//! Native mods' world primitives (`native_world_primitives`, docs/native-mods.md): lines, boxes,
//! beams and text a mod hands the game each frame, drawn here with Horizon's own pipelines at
//! the end of the world pass, after the entities and before the first-person hand. They draw
//! into the world's targets, so they take the world's reversed depth, its draw resolution and
//! TAA's jitter; a mod never touches wgpu for them.
use super::Vertex;
use glam::Mat4;
/// A frame's primitives, camera-relative, as the game tessellated them: quads of 4 vertices
/// from the shared atlas (plain colours on its white texel, text on its glyphs, beams on the
/// beacon beam sprite) and lines as `gizmos::camera_relative` gives them. `*_through` ones are
/// seen through walls: no depth test.
#[derive(Clone, Copy, Debug, Default)]
pub struct Batch<'a> {
pub quads: &'a [Vertex],
pub quads_through: &'a [Vertex],
pub lines: &'a [[f32; 8]],
pub lines_through: &'a [[f32; 8]],
}
impl Batch<'_> {
pub fn is_empty(&self) -> bool {
self.quads.is_empty()
&& self.quads_through.is_empty()
&& self.lines.is_empty()
&& self.lines_through.is_empty()
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Globals {
view_proj: [[f32; 4]; 4],
viewport: [f32; 4],
}
/// The pipelines and buffers that draw a [`Batch`].
pub struct WorldPrimitives {
/// Depth tested, then seen through walls.
quad_pipelines: [wgpu::RenderPipeline; 2],
line_pipelines: [wgpu::RenderPipeline; 2],
layout: wgpu::BindGroupLayout,
globals: wgpu::Buffer,
sampler: wgpu::Sampler,
quads: wgpu::Buffer,
lines: wgpu::Buffer,
indices: wgpu::Buffer,
index_quads: u32,
group: Option<wgpu::BindGroup>,
/// Quads depth tested, quads through walls, lines depth tested, lines through walls.
counts: [u32; 4],
}
/// Writes bytes into a buffer as part of the frame (the renderer's staging uploads, or the
/// queue in a test).
pub type Upload<'u> = &'u mut dyn FnMut(&wgpu::Buffer, &[u8]);
impl WorldPrimitives {
/// Pipelines drawing into `color_format` over a reversed Depth32Float depth.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("World primitives"),
source: wgpu::ShaderSource::Wgsl(include_str!("world_primitives.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World primitives"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("World primitives"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let quad_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint32];
let line_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
let quad_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &quad_attributes,
})];
let line_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &line_attributes,
})];
let pipeline = |label: &str,
entry: [&str; 2],
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology,
through: bool| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(entry[0]),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
topology,
// Seen from inside or behind as well: a box around the camera, a beam.
cull_mode: None,
..Default::default()
},
// Blended over what is drawn, writing no depth: a translucent box doesn't hide
// the primitives behind it. Reversed depth: nearer is greater.
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(if through {
wgpu::CompareFunction::Always
} else {
wgpu::CompareFunction::GreaterEqual
}),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry[1]),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let quads = ["vs_quad", "fs_quad"];
let lines = ["vs_line", "fs_line"];
let list = wgpu::PrimitiveTopology::TriangleList;
let strip = wgpu::PrimitiveTopology::TriangleStrip;
let quad_pipelines = [
pipeline("World primitives", quads, &quad_buffers, list, false),
pipeline(
"World primitives through walls",
quads,
&quad_buffers,
list,
true,
),
];
let line_pipelines = [
pipeline("World primitive lines", lines, &line_buffers, strip, false),
pipeline(
"World primitive lines through walls",
lines,
&line_buffers,
strip,
true,
),
];
let buffer = |label, size, usage| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: usage | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
quad_pipelines,
line_pipelines,
globals: buffer(
"World primitive globals",
std::mem::size_of::<Globals>() as u64,
wgpu::BufferUsages::UNIFORM,
),
sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("World primitives"),
..Default::default()
}),
layout,
quads: buffer("World primitive quads", 0, wgpu::BufferUsages::VERTEX),
lines: buffer("World primitive lines", 0, wgpu::BufferUsages::VERTEX),
indices: buffer("World primitive indices", 0, wgpu::BufferUsages::INDEX),
index_quads: 0,
group: None,
counts: [0; 4],
}
}
/// Uploads this frame's `batch`, drawn with `view_proj` (camera-relative world to clip,
/// jittered as the world is) into a frame `viewport` pixels big, sampling `atlas`. Whether
/// there is anything to draw.
pub fn prepare(
&mut self,
device: &wgpu::Device,
upload: Upload<'_>,
batch: &Batch<'_>,
view_proj: Mat4,
viewport: [u32; 2],
atlas: &wgpu::TextureView,
) -> bool {
let quads = [batch.quads, batch.quads_through].map(|q| (q.len() / 4) as u32);
let lines = [batch.lines, batch.lines_through].map(|l| l.len() as u32);
self.counts = [quads[0], quads[1], lines[0], lines[1]];
if batch.is_empty() {
return false;
}
let grow = |buffer: &mut wgpu::Buffer, bytes: u64| {
if buffer.size() < bytes {
*buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("World primitives"),
size: bytes.next_power_of_two(),
usage: buffer.usage(),
mapped_at_creation: false,
});
}
};
let total_quads = quads[0] + quads[1];
if total_quads > 0 {
let vertices: Vec<Vertex> = [batch.quads, batch.quads_through]
.into_iter()
.flat_map(|q| &q[..q.len() / 4 * 4])
.copied()
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
grow(&mut self.quads, bytes.len() as u64);
upload(&self.quads, bytes);
if total_quads > self.index_quads {
self.index_quads = total_quads.next_power_of_two();
let indices: Vec<u32> = (0..self.index_quads)
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q * 4 + i))
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&indices);
grow(&mut self.indices, bytes.len() as u64);
upload(&self.indices, bytes);
}
}
if lines[0] + lines[1] > 0 {
let all: Vec<[f32; 8]> = batch
.lines
.iter()
.chain(batch.lines_through)
.copied()
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&all);
grow(&mut self.lines, bytes.len() as u64);
upload(&self.lines, bytes);
}
let globals = Globals {
view_proj: view_proj.to_cols_array_2d(),
viewport: [viewport[0] as f32, viewport[1] as f32, 0.0, 0.0],
};
upload(&self.globals, bytemuck::bytes_of(&globals));
self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("World primitives"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(atlas),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
}));
true
}
/// Draws what [`Self::prepare`] uploaded into a pass over the world's colour and depth:
/// the depth-tested quads and lines, then those seen through walls over everything.
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
let Some(group) = &self.group else {
return;
};
let [quads, quads_through, lines, lines_through] = self.counts;
pass.set_bind_group(0, group, &[]);
for (through, (quad_range, line_range)) in [
(0..quads, 0..lines),
(quads..quads + quads_through, lines..lines + lines_through),
]
.into_iter()
.enumerate()
{
if !quad_range.is_empty() {
pass.set_pipeline(&self.quad_pipelines[through]);
pass.set_vertex_buffer(0, self.quads.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(quad_range.start * 6..quad_range.end * 6, 0, 0..1);
}
if !line_range.is_empty() {
pass.set_pipeline(&self.line_pipelines[through]);
pass.set_vertex_buffer(0, self.lines.slice(..));
pass.draw(0..4, line_range);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Vec3;
const SIZE: u32 = 64;
/// RGBA, as `Vertex::color` holds it.
const RED: u32 = 0xFF00_00FF;
const GREEN: u32 = 0xFF00_FF00;
const BLUE: u32 = 0xFFFF_0000;
/// ARGB, as a line carries it.
const MAGENTA_ARGB: u32 = 0xFFFF_00FF;
/// A camera-relative quad of plain colour facing the camera, `z` blocks ahead of it, over
/// the pixels `x0..x1`, `y0..y1` of the frame (y down).
fn quad(view_proj: Mat4, [x0, y0, x1, y1]: [f32; 4], z: f32, rgba: u32) -> [Vertex; 4] {
let depth = view_proj.project_point3(Vec3::new(0.0, 0.0, -z)).z;
let inverse = view_proj.inverse();
let at = |x: f32, y: f32| {
let ndc = Vec3::new(
x / SIZE as f32 * 2.0 - 1.0,
1.0 - y / SIZE as f32 * 2.0,
depth,
);
inverse.project_point3(ndc).to_array()
};
[at(x0, y0), at(x0, y1), at(x1, y1), at(x1, y0)].map(|pos| Vertex {
pos,
uv: [0.5, 0.5],
layer: 0,
color: rgba,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
})
}
/// A horizontal line, 4 pixels wide, over the pixels `x0..x1` of row `y`, `z` blocks ahead.
fn line(view_proj: Mat4, [x0, x1]: [f32; 2], y: f32, z: f32, argb: u32) -> [f32; 8] {
let q = quad(view_proj, [x0, y, x1, y], z, 0);
let [a, b] = [q[0].pos, q[3].pos];
[
a[0],
a[1],
a[2],
b[0],
b[1],
b[2],
f32::from_bits(argb),
4.0,
]
}
/// A wall 5 blocks ahead fills the depth (reversed: nearer is greater). Behind it, a
/// depth-tested quad and line stay hidden while the same seen through walls show; a
/// depth-tested quad in front of the wall shows.
#[test]
fn depth_tested_primitives_hide_behind_walls_and_see_through_ones_do_not() {
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
pollster::block_on(instance.request_adapter(&Default::default()))
.ok()
.and_then(|adapter| {
pollster::block_on(adapter.request_device(&Default::default())).ok()
})
else {
eprintln!("no GPU adapter: skipped");
return;
};
let view_proj = super::super::world_projection(70.0, 1.0, 512.0);
let wall = view_proj.project_point3(Vec3::new(0.0, 0.0, -5.0)).z;
// Reversed depth: 10 blocks away is less than the wall, 2 blocks away more.
assert!(view_proj.project_point3(Vec3::new(0.0, 0.0, -10.0)).z < wall);
assert!(view_proj.project_point3(Vec3::new(0.0, 0.0, -2.0)).z > wall);
let format = wgpu::TextureFormat::Rgba8Unorm;
let texture = |size: u32, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = texture(
SIZE,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
SIZE,
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
// The atlas: one white texel, what plain colours sample.
let atlas = texture(
1,
format,
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
);
queue.write_texture(
atlas.as_image_copy(),
&[255; 4],
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4),
rows_per_image: None,
},
atlas.size(),
);
let (half, full) = (SIZE as f32 / 2.0, SIZE as f32);
// Top left: behind the wall, depth tested. Top right: behind it, through walls.
// Bottom left: in front of it, depth tested. Bottom right: lines behind it, the
// depth-tested one along row 40, the one through walls along row 56.
let quads = [
quad(view_proj, [0.0, 0.0, half, half], 10.0, RED),
quad(view_proj, [0.0, half, half, full], 2.0, BLUE),
]
.concat();
let quads_through = quad(view_proj, [half, 0.0, full, half], 10.0, GREEN);
let lines = [line(view_proj, [half, full], 40.0, 10.0, MAGENTA_ARGB)];
let lines_through = [line(view_proj, [half, full], 56.0, 10.0, MAGENTA_ARGB)];
let batch = Batch {
quads: &quads,
quads_through: &quads_through,
lines: &lines,
lines_through: &lines_through,
};
let mut primitives = WorldPrimitives::new(&device, format);
let atlas_view = atlas.create_view(&Default::default());
assert!(primitives.prepare(
&device,
&mut |buffer, bytes| queue.write_buffer(buffer, 0, bytes),
&batch,
view_proj,
[SIZE, SIZE],
&atlas_view,
));
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(wall),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
primitives.draw(&mut pass);
}
let readback = 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: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: None,
},
},
color.size(),
);
queue.submit([encoder.finish()]);
readback.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let texels = readback.slice(..).get_mapped_range().unwrap().to_vec();
let at = |x: u32, y: u32| {
let i = ((y * SIZE + x) * 4) as usize;
[texels[i], texels[i + 1], texels[i + 2], texels[i + 3]]
};
let black = [0, 0, 0, 255];
assert_eq!(
at(16, 16),
black,
"a depth-tested quad behind the wall is hidden"
);
assert_eq!(
at(48, 16),
[0, 255, 0, 255],
"a quad through walls shows behind it"
);
assert_eq!(
at(16, 48),
[0, 0, 255, 255],
"a depth-tested quad before it shows"
);
assert_eq!(
at(48, 40),
black,
"a depth-tested line behind the wall is hidden"
);
assert_eq!(
at(48, 56),
[255, 0, 255, 255],
"a line through walls shows behind it"
);
// The line is its width (4 pixels) thick, not more.
assert_eq!(at(48, 52), black);
}
}
@@ -0,0 +1,77 @@
// Native mods' world primitives (gfx/world_primitives.rs): quads from the shared atlas and lines
// with a width in pixels, camera-relative, at the end of the world pass.
struct Globals {
// Camera-relative world to clip space, with TAA's jitter (what the world was drawn with).
view_proj: mat4x4<f32>,
// The frame's size in pixels (line widths are in its pixels).
viewport: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
struct QuadOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
// A beam's sprite v range (start, length), else 0.
@location(2) @interpolate(flat) wrap: vec2<f32>,
}
// `Vertex` (mesher.rs): position, atlas UV, layer, RGBA colour, light.
@vertex fn vs_quad(@location(0) pos: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: u32) -> QuadOut {
var out: QuadOut;
out.clip = globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
out.color = color;
// Layer 6 is a beacon beam's: its v repeats inside the sprite, whose range rides in the
// light bytes (block_entity_render::beam).
if (layer & 0xffffu) == 6u {
out.wrap = vec2(f32(light & 0xffffu), f32(light >> 16u)) / 65535.0;
} else {
out.wrap = vec2(0.0);
}
return out;
}
@fragment fn fs_quad(in: QuadOut) -> @location(0) vec4<f32> {
var uv = in.uv;
if in.wrap.y > 0.0 { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let color = textureSample(atlas, atlas_sampler, uv) * in.color;
if color.a <= 0.0 { discard; }
return color;
}
struct LineOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
}
// One instance per line (gizmos::camera_relative): its ends, ARGB colour and width in pixels,
// a strip of 4 vertices across it, as terrain.wgsl's `vs_debug_line`.
@vertex fn vs_line(@builtin(vertex_index) vertex: u32,
@location(0) start: vec3<f32>, @location(1) end: vec3<f32>,
@location(2) argb: vec4<f32>, @location(3) width: f32) -> LineOut {
let position = select(start, end, vertex >= 2u);
let view_shrink = 1.0 - 1.0 / 256.0;
let line_start = globals.view_proj * vec4(position * view_shrink, 1.0);
let line_end = globals.view_proj * vec4((position + (end - start)) * view_shrink, 1.0);
let ndc1 = line_start.xyz / line_start.w;
let ndc2 = line_end.xyz / line_end.w;
let screen = globals.viewport.xy;
let direction = normalize((ndc2.xy - ndc1.xy) * screen);
var offset = vec2(-direction.y, direction.x) * width / screen;
if offset.x < 0.0 { offset = -offset; }
if vertex % 2u != 0u { offset = -offset; }
var out: LineOut;
out.clip = vec4((ndc1 + vec3(offset, 0.0)) * line_start.w, line_start.w);
// ARGB in a little-endian u32 reads as B, G, R, A.
out.color = argb.zyxw;
return out;
}
@fragment fn fs_line(in: LineOut) -> @location(0) vec4<f32> {
return in.color;
}
+8 -1
View File
@@ -676,6 +676,9 @@ pub struct Graphics {
/// 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,
/// HDR Lighting: Horizon's own HDR look, light sources, the sun and sunlight above paper
/// white. Off, HDR output shows the same picture as SDR, for shader packs or the vanilla look.
pub hdr_lighting: bool,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
@@ -747,6 +750,7 @@ impl Default for Graphics {
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
hdr_lighting: true,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
shadow_cache: false,
@@ -864,6 +868,7 @@ impl Graphics {
}
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
@@ -901,7 +906,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -946,6 +951,7 @@ impl Graphics {
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
bool_text(self.hdr_lighting),
self.hdr_key,
bool_text(self.battery),
bool_text(self.shadow_cache),
@@ -1124,6 +1130,7 @@ mod tests {
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
hdr_lighting: false,
hdr_key: "key.keyboard.h".into(),
battery: true,
shadow_cache: true,
@@ -100,6 +100,7 @@ pub enum H {
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
HdrLighting,
BatteryMode,
ShadowCache,
GraphicsApi,
@@ -532,6 +533,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
None,
"How bright the HUD and menus are, against paper white.",
),
h(
H::HdrLighting,
None,
"Light sources, the sun and sunlight shine past white. Off: just as SDR.",
),
],
),
(
@@ -824,6 +830,7 @@ impl H {
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
},
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
H::HdrKey => {
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
}
@@ -948,6 +955,7 @@ impl H {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
H::Hdr => g.hdr = !g.hdr,
H::HdrLighting => g.hdr_lighting = !g.hdr_lighting,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
@@ -998,6 +1006,7 @@ impl Item {
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
@@ -1013,6 +1022,7 @@ impl Item {
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
@@ -1048,6 +1058,7 @@ impl Item {
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => {
+64 -13
View File
@@ -598,15 +598,16 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
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 = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
// Animated strips keep their first frame and animate, like the
// packed files, under any folder: vanilla animates every sprite of
// these atlases (a server pack's `wynn/...` as well as `item/...`).
let mcmeta = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
if animated || path.starts_with("block/") || path.starts_with("item/") {
first_frame(image, animation.as_ref())
} else {
image
@@ -1115,7 +1116,7 @@ impl HudAtlas {
}
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
!s.contains("title/background") && !s.contains("presets")
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
@@ -1515,6 +1516,8 @@ pub struct HudBatch {
pub end_portal: EndPortalFill,
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
pub glint_alpha: f32,
/// PvP & HUD's glint colour (`pvp::Tweaks::glint_texel`), 0 for vanilla's.
pub glint_colour: u32,
/// A resource pack text shader's `GameTime`: the day's fraction
/// (`gameTime % 24000` and the partial tick, over 24000).
pub game_time: f32,
@@ -1575,6 +1578,7 @@ impl HudBatch {
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
glint_colour: 0,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
@@ -2255,13 +2259,19 @@ struct HudUniform {
end_portal: [[f32; 4]; 3],
}
impl HudUniform {
fn new(size: [f32; 4], fill: EndPortalFill) -> Self {
fn new(size: [f32; 4], fill: EndPortalFill, glint_colour: u32) -> Self {
let [r, g, b, _] = glint_colour.to_le_bytes().map(|c| f32::from(c) / 255.0);
let tint = if glint_colour == 0 {
[-1.0, 0.0, 0.0]
} else {
[r, g, b]
};
Self {
size,
end_portal: [
fill.sprites[0],
fill.sprites[1],
[fill.game_time, 0.0, 0.0, 0.0],
[fill.game_time, tint[0], tint[1], tint[2]],
],
}
}
@@ -2318,7 +2328,7 @@ impl HudRenderer {
});
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("HUD size"),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default(), 0)),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -2691,6 +2701,7 @@ impl HudRenderer {
batch.glint_alpha,
],
batch.end_portal,
batch.glint_colour,
)),
);
if let Some(pack_text) = &self.pack_text {
@@ -3356,6 +3367,45 @@ mod tests {
.expect("the namespaced sprite animates");
assert_eq!(animation.frame_count(), 2);
}
#[test]
fn an_animated_texture_outside_item_folders_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-dir-anim-{}", std::process::id()));
let atlases = root.join("assets/minecraft/atlases");
let custom = root.join("assets/minecraft/textures/custom");
fs::create_dir_all(&atlases).unwrap();
fs::create_dir_all(&custom).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources": [{"type": "directory", "source": "custom", "prefix": "custom/"}]}"#,
)
.unwrap();
// A server pack's item under its own folder: two 16x16 frames, blue then yellow, and
// a still 16x32 texture beside it.
write_strip(
&custom.join("gem.png"),
16,
32,
&[[0, 0, 255, 255], [255, 255, 0, 255]],
Some(r#"{"animation": {"frametime": 20}}"#),
);
write_strip(&custom.join("banner.png"), 16, 32, &[[9, 9, 9, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["custom/gem"];
// One frame, not the whole strip squeezed into a slot.
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the sprite animates");
assert_eq!(animation.frame_count(), 2);
// A still texture there stays whole, as vanilla stitches it.
let banner = atlas.sprites["custom/banner"];
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
}
/// A resource root with one plain item texture and these CIT files (relative path, contents).
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
let root =
@@ -4131,6 +4181,7 @@ texture=wide",
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
glint_colour: 0,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
+7 -2
View File
@@ -1,7 +1,8 @@
struct Hud {
size: vec4<f32>,
// Blend::EndPortal: atlas rects (u0, v0, width, height) of end_sky.png and
// end_portal.png, then vanilla's GameTime in x.
// end_portal.png, then vanilla's GameTime in x and PvP & HUD's glint colour in yzw
// (y below 0: vanilla's).
end_portal: array<vec4<f32>, 3>,
}
@group(0) @binding(0) var<uniform> hud: Hud;
@@ -35,7 +36,11 @@ struct Out {
if in.uv.x >= 2.0 {
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
if mask < 0.1 { discard; }
let glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
var glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
let tint = hud.end_portal[2].yzw;
if tint.x >= 0.0 {
glint = vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
}
return vec4(glint.rgb * glint.rgb, mask);
}
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
+2
View File
@@ -83,6 +83,8 @@ pub mod post_glsl;
pub mod potent_sulfur;
pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
+268 -21
View File
@@ -202,6 +202,17 @@ const DETAIL_LEVEL: u8 = 2;
/// The most chunks one detail request asks for, one request each 250 ms:
/// generated ones arrive at the decoration threads' pace, cached ones at once.
const DETAIL_REQUEST: usize = 512;
/// How long one frame spends applying far detail and visited summaries
/// (`Lod::receive`).
///
/// Cached chunks answer a `DETAIL_REQUEST` all at once, and applying one
/// copies the columns of every tile over it (`Arc::make_mut` on a snapshot a
/// mesh job shares) and reduces the chunk to that tile's cell width. A whole
/// burst in one frame cost 273 ms of it on the render thread, flying over far
/// terrain at `lodDistance` 256: 18 FPS average, 482 ms at the 99th
/// percentile, where the median frame was 6 ms. What the budget leaves waits
/// in `Lod::detail_backlog` for the frames after it.
const DETAIL_BUDGET: std::time::Duration = std::time::Duration::from_millis(3);
/// Top solid face and optional fluid face, in absolute block coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Surface {
@@ -1036,21 +1047,40 @@ impl ReducedColumns {
levels: std::array::from_fn(|_| std::sync::OnceLock::new()),
}
}
/// Builds the reduction levels whose bit is set in `levels`
/// (`Shared::detail_widths`), off the render thread.
fn warm(self, levels: u8) -> Self {
for level in 0..self.levels.len() {
if levels & (1 << level) != 0 {
self.level(level);
}
}
self
}
/// The chunk reduced to cells `2 << level` blocks wide, built once.
fn level(&self, level: usize) -> &[Column] {
let width = 2 << level;
self.levels[level].get_or_init(|| {
// Timed on whichever thread first asks for this width, so the
// frame profile tells a reduction apart from the column copying
// around it (`Lod::apply_detail` does both).
crate::spans::time("lod_reduce", || {
let columns = &self.columns;
(0..16 / width)
.flat_map(|z| {
(0..16 / width)
.map(move |x| Column::reduced(columns, x * width, z * width, width))
})
.collect()
})
})
}
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
if width == 1 {
return &self.columns[z * 16 + x];
}
let level = width.trailing_zeros() as usize - 1;
let reduced = self.levels[level].get_or_init(|| {
let columns = &self.columns;
(0..16 / width)
.flat_map(|z| {
(0..16 / width)
.map(move |x| Column::reduced(columns, x * width, z * width, width))
})
.collect()
});
&reduced[(z / width) * (16 / width) + x / width]
&self.level(level)[(z / width) * (16 / width) + x / width]
}
}
enum Job {
@@ -1065,6 +1095,14 @@ enum Job {
refreshes: u64,
},
}
/// A chunk's columns on their way to the tiles over it: what `Lod::receive`
/// defers when a frame's [`DETAIL_BUDGET`] is spent.
enum Summary {
/// A chunk's detail, and the `refreshes` of the request it answers.
Detail((i32, i32), ReducedColumns, u64),
/// A visited chunk's summary.
Observed((i32, i32), ReducedColumns),
}
enum Done {
/// A chunk's detail, and the `refreshes` of the request it answers.
Detail((i32, i32), ReducedColumns, u64),
@@ -1149,6 +1187,19 @@ struct Shared {
stop: AtomicBool,
/// Reduced far detail: samplers approximate wide cells.
approximate: AtomicBool,
/// The reduction levels `Lod::apply_detail` reads, one bit each (level
/// `i` is a cell `2 << i` blocks wide), so the far terrain thread builds
/// them before it sends a chunk's columns on.
///
/// Learned rather than derived from the plan: a chunk's ancestors are
/// read only where they were sampled, which the plan alone does not say.
/// `apply_detail` sets a bit the first time it reads that width, so the
/// first chunk of a width still reduces on the render thread and every
/// chunk after it finds the work done. Bits are never cleared: a width
/// the view has stopped using costs the worker a reduction nothing
/// reads, and holding all four is exactly the eager behaviour this
/// replaces.
detail_widths: std::sync::atomic::AtomicU8,
/// On small machines, hold queued far meshing while near terrain loads.
mesh_held: AtomicBool,
/// The near terrain is loading: the far threads step behind it
@@ -1183,6 +1234,11 @@ pub struct Lod {
pending: FxHashSet<TileKey>,
detail_masks: FxHashMap<TileKey, [u64; 4]>,
detail_request_pending: bool,
/// Detail and visited summaries past a frame's [`DETAIL_BUDGET`], applied
/// on the frames after it. Bounded by what the worker sends: a detail
/// request is one per 250 ms and only for chunks whose detail is missing,
/// so this drains between bursts rather than growing with the view.
detail_backlog: std::collections::VecDeque<Summary>,
/// `refresh`es so far (`Job::RequestDetail`), and the areas of the last
/// ones, each with its number.
refreshes: u64,
@@ -1290,6 +1346,7 @@ impl Lod {
mesh_wake: Condvar::new(),
stop: AtomicBool::new(false),
approximate: AtomicBool::new(false),
detail_widths: std::sync::atomic::AtomicU8::new(0),
mesh_held: AtomicBool::new(false),
near_loading: AtomicBool::new(false),
});
@@ -1468,7 +1525,12 @@ impl Lod {
'work: loop {
for (key, columns) in sampler.drain_details() {
if tx
.send(Done::Detail(key, ReducedColumns::new(columns), asked))
.send(Done::Detail(
key,
ReducedColumns::new(columns)
.warm(worker.detail_widths.load(Ordering::Relaxed)),
asked,
))
.is_err()
{
break 'work;
@@ -1514,7 +1576,11 @@ impl Lod {
};
let columns = summarize(&chunk, kinds);
sampler.observed(key, &columns);
Done::Observed(key, ReducedColumns::new(columns))
Done::Observed(
key,
ReducedColumns::new(columns)
.warm(worker.detail_widths.load(Ordering::Relaxed)),
)
}
Job::RequestDetail {
chunks,
@@ -1570,6 +1636,7 @@ impl Lod {
pending: FxHashSet::default(),
detail_masks: FxHashMap::default(),
detail_request_pending: false,
detail_backlog: std::collections::VecDeque::new(),
refreshes: 0,
refreshed: VecDeque::new(),
detail_request_at: std::time::Instant::now(),
@@ -2098,6 +2165,12 @@ impl Lod {
};
let [ox, _, oz] = key.origin();
let stride = key.size() / 16;
// What the far terrain thread should have ready next time.
if stride > 1 {
self.shared
.detail_widths
.fetch_or(1 << (stride.trailing_zeros() - 1), Ordering::Relaxed);
}
let x0 = ((cx * 16 - ox + stride - 1).div_euclid(stride)).clamp(0, 16);
let x1 = (((cx + 1) * 16 - ox + stride - 1).div_euclid(stride)).clamp(0, 16);
let z0 = ((cz * 16 - oz + stride - 1).div_euclid(stride)).clamp(0, 16);
@@ -2269,6 +2342,12 @@ impl Lod {
fn request_detail(&mut self, camera: [i32; 2]) {
if self.held()
|| self.detail_request_pending
// Nothing new while summaries wait: a chunk whose detail is still
// in the backlog has its mask clear, so it would be asked for
// again, and answered from the cache at once. Without this the
// backlog grew by a request every 250 ms and never drained --
// 0.5 FPS, with `update` averaging 9.7 s a frame.
|| !self.detail_backlog.is_empty()
|| self.detail_request_at.elapsed() < std::time::Duration::from_millis(250)
{
return;
@@ -2495,17 +2574,25 @@ impl Lod {
self.wanted_stale = true;
}
fn receive(&mut self, meshes: &mut Vec<(TileKey, SectionMesh)>) {
let started = std::time::Instant::now();
// What an earlier frame left, before anything new: a chunk's detail
// must not overtake the detail of a chunk that arrived before it.
while started.elapsed() < DETAIL_BUDGET {
let Some(summary) = self.detail_backlog.pop_front() else {
break;
};
self.apply_summary(summary);
}
while let Ok(done) = self.results.try_recv() {
match done {
// Read before a `refresh` over it since: asked again (its
// bits stay clear), never kept over the newer data.
Done::Detail(key, _, asked) if self.refreshed_since(key, asked) => {}
Done::Detail(key, columns, _) => {
crate::spans::time("lod_detail", || self.finish_detail(key, columns))
}
Done::Observed(key, columns) => {
crate::spans::time("lod_detail", || self.finish_observed(key, columns))
}
Done::Detail(key, columns, asked) => self.take_summary(
Summary::Detail(key, columns, asked),
started.elapsed() < DETAIL_BUDGET,
),
Done::Observed(key, columns) => self.take_summary(
Summary::Observed(key, columns),
started.elapsed() < DETAIL_BUDGET,
),
Done::DetailRequested => self.detail_request_pending = false,
Done::Sample {
key,
@@ -2530,6 +2617,28 @@ impl Lod {
}
}
}
/// Applies a summary while the frame has budget and nothing is already
/// waiting, else leaves it for a later frame.
fn take_summary(&mut self, summary: Summary, within_budget: bool) {
if within_budget && self.detail_backlog.is_empty() {
self.apply_summary(summary);
} else {
self.detail_backlog.push_back(summary);
}
}
/// A chunk's columns applied to the tiles over it.
///
/// `refreshed_since` is tested here and not when the result arrives, so a
/// refresh over the chunk while the summary waited still drops it: it was
/// read before that refresh, and the tiles ask again with their bits
/// clear.
fn apply_summary(&mut self, summary: Summary) {
crate::spans::time("lod_detail", || match summary {
Summary::Detail(key, _, asked) if self.refreshed_since(key, asked) => {}
Summary::Detail(key, columns, _) => self.finish_detail(key, columns),
Summary::Observed(key, columns) => self.finish_observed(key, columns),
});
}
/// A finished mesh: shown when it is newer than what the tile shows now.
/// A result newer than the frame's current one is still useful while a
/// newer job is pending — re-marking a tile re-dirties its neighbours and
@@ -3640,6 +3749,144 @@ mod tests {
reduced.get(0, 0, 16);
assert_eq!(built(&reduced), [4, 16]);
}
/// A summary past a frame's budget waits for a later frame rather than
/// being dropped, and no new detail is asked for while any waits.
///
/// Without the second part the backlog grew by a `DETAIL_REQUEST` every
/// 250 ms and never drained: a chunk whose summary is still waiting has
/// its detail mask clear, so it was asked for again and the cache
/// answered at once. That measured 0.5 FPS, `update` averaging 9.7 s.
#[test]
fn a_summary_past_the_budget_waits_and_holds_back_the_next_request() {
let mut lod = Lod::scheduler_for_test();
let chunk = (3, 5);
let columns = || ReducedColumns::new(Arc::new(std::array::from_fn(|_| Column::default())));
// Within budget and with nothing waiting, a summary applies at once.
lod.take_summary(Summary::Observed(chunk, columns()), true);
assert!(lod.detail_backlog.is_empty(), "applied, not deferred");
assert!(lod.saved.contains_key(&chunk), "and it reached `saved`");
// Past the budget it waits, and anything after it waits behind it so
// a chunk's detail cannot overtake a chunk that arrived earlier.
lod.take_summary(Summary::Observed((9, 9), columns()), false);
lod.take_summary(Summary::Observed((10, 9), columns()), true);
assert_eq!(
lod.detail_backlog.len(),
2,
"the second waits behind the first"
);
assert!(!lod.saved.contains_key(&(9, 9)));
// Nothing new is asked for while summaries wait, whatever the
// request interval allows. Asserted on `detail_request_at`, which
// `request_detail` records once it is past its guards: a test
// scheduler has no active tile wanting detail, so `pending` would
// stay false either way and prove nothing.
let long_ago = std::time::Instant::now() - std::time::Duration::from_secs(10);
lod.detail_request_at = long_ago;
lod.detail_request_pending = false;
lod.request_detail([0, 0]);
assert_eq!(lod.detail_request_at, long_ago, "held back by the backlog");
// A later frame applies what waited, in the order it arrived.
while let Some(summary) = lod.detail_backlog.pop_front() {
lod.apply_summary(summary);
}
assert!(lod.saved.contains_key(&(9, 9)), "nothing was dropped");
assert!(lod.saved.contains_key(&(10, 9)));
// Drained, requests resume.
lod.detail_request_at = long_ago;
lod.request_detail([0, 0]);
assert!(
lod.detail_request_at > long_ago,
"asked once the backlog drained"
);
}
/// `warm` builds exactly the levels asked for, off the render thread, and
/// `get` then finds them built.
#[test]
fn warming_builds_the_levels_asked_for_and_no_others() {
let built = |reduced: &ReducedColumns| {
reduced
.levels
.iter()
.enumerate()
.filter(|(_, level)| level.get().is_some())
.map(|(level, _)| 2usize << level)
.collect::<Vec<_>>()
};
let chunk = || {
Arc::new(std::array::from_fn::<Column, 256, _>(|i| Column {
surface: None,
spans: vec![Span {
min: 0,
max: 4 + (i % 3) as i16,
state: BlockStateId(1),
biome: 0,
light: 240,
}]
.into(),
}))
};
// No bits: nothing is built, as before.
assert!(built(&ReducedColumns::new(chunk()).warm(0)).is_empty());
// Widths 2 and 8 are levels 0 and 2.
let warmed = ReducedColumns::new(chunk()).warm(0b101);
assert_eq!(built(&warmed), [2, 8]);
// Reading a warmed width builds nothing more; an unwarmed one does.
warmed.get(0, 0, 2);
warmed.get(6, 6, 8);
assert_eq!(built(&warmed), [2, 8], "already built");
warmed.get(0, 0, 4);
assert_eq!(built(&warmed), [2, 4, 8]);
// Warming is the same reduction as reading would have made.
let lazy = ReducedColumns::new(chunk());
for (x, z, width) in [(0, 0, 2), (6, 6, 8), (4, 8, 4), (0, 0, 16)] {
assert_eq!(
warmed.get(x, z, width),
lazy.get(x, z, width),
"width {width} at {x},{z}"
);
}
}
/// Applying a chunk's detail records the cell widths it read, so the far
/// terrain thread reduces them before it sends the next chunk.
#[test]
fn applying_detail_records_the_widths_it_read() {
let mut lod = Lod::scheduler_for_test();
assert_eq!(lod.shared.detail_widths.load(Ordering::Relaxed), 0);
let chunk = (0, 0);
let columns = || ReducedColumns::new(Arc::new(std::array::from_fn(|_| Column::default())));
// Only tiles in `ready` are read. A level-0 tile has cells one block
// wide, which is the chunk's own columns: no reduction, no bit.
lod.ready.insert(
TileKey {
x: 0,
z: 0,
level: 0,
},
Arc::new(vec![Column::default(); CELLS * CELLS]),
);
lod.apply_detail(chunk, &columns());
assert_eq!(
lod.shared.detail_widths.load(Ordering::Relaxed),
0,
"full-detail cells read the chunk itself"
);
// A level-2 tile has cells 4 blocks wide: reduction level 1.
lod.ready.insert(
TileKey {
x: 0,
z: 0,
level: 2,
},
Arc::new(vec![Column::default(); CELLS * CELLS]),
);
lod.apply_detail(chunk, &columns());
assert_eq!(lod.shared.detail_widths.load(Ordering::Relaxed), 0b10);
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
#[test]
@@ -103,6 +103,22 @@ impl Sides {
}
}
/// A sort with these parts' `ends` and `planes`, for tests.
#[cfg(test)]
pub(crate) fn from_parts(
ends: [u32; 14],
grouped: bool,
split: bool,
planes: [f32; 7],
) -> Self {
Self {
ends,
grouped,
split,
planes,
}
}
/// The number of solid quads the buckets hold.
pub fn quads(&self) -> u32 {
self.ends[13]
@@ -128,6 +144,26 @@ impl Sides {
self.runs(seen, part, draw);
}
/// For GPU culling's copy of `visible_from` with every part (`gfx::gpu_culling`),
/// which then only compares: where each bucket of a mesh sorted for one pass ends,
/// in `BUCKETS` order, and what `visible_from` compares the eye with for each. That
/// is the lowest plane less `MARGIN` for a bucket facing a positive direction, the
/// highest plus `MARGIN` for one facing a negative direction, and 0 for the other
/// bucket, which it never compares. `None` for a mesh sorted for two passes
/// (`group_parts`), whose buckets lie in two stretches each.
pub fn thresholds(&self) -> Option<([u32; 7], [f32; 7])> {
if self.grouped {
return None;
}
let t: [f32; 7] = std::array::from_fn(|i| match BUCKETS[i] {
Bucket::Side(_, true) => self.planes[i] - MARGIN,
Bucket::Side(_, false) => self.planes[i] + MARGIN,
Bucket::Other => 0.0,
});
// A bucket's cutout quads end it (`slot`).
Some((std::array::from_fn(|b| self.ends[b * 2 + 1]), t))
}
/// The runs of `part`'s quads facing every way.
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
self.runs(|_| true, part, draw);
@@ -131,6 +131,16 @@ impl ModList {
/// Turns installed mod `i` on or off (a dev mod stays on). A native mod's
/// line says `false` when off, so that the launcher's next build leaves
/// it out, and `true` when on again.
/// Turn the installed mod `id` on (nothing when it isn't installed).
pub fn turn_on(&mut self, id: &str) {
let Some(m) = self.installed.iter().find(|m| m.id == id && !m.dev) else {
return;
};
if m.native.is_some() {
self.disabled.remove(&m.id);
}
self.enabled.insert(m.id.clone());
}
pub fn toggle(&mut self, i: usize) {
let Some(m) = self.installed.get(i).filter(|m| !m.dev) else {
return;
+53 -10
View File
@@ -1273,6 +1273,8 @@ pub enum Page {
Controller,
PadBinds,
Extras,
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
Pvp,
/// CreditsAndAttributionScreen.
Credits,
/// Horizon Settings: every page Horizon adds, as a mod's settings
@@ -1307,6 +1309,8 @@ enum Act {
ResetPadKey(&'static str),
ResetPadKeys,
ToggleMod(usize),
/// PvP & HUD's PvP preset (`pvp::apply_preset`).
PvpPreset,
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
PickShaderPack(usize),
OpenShaderPackFolder,
@@ -1564,6 +1568,9 @@ pub struct OptionsUi {
pub selected_pad_action: Option<&'static str>,
/// The glyphs the binds list shows (the setting, else the pad's family).
pub pad_style: GlyphStyle,
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
/// Nintendo's: each part shows the letter of the button playing it.
pub pad_by_letter: bool,
pub languages: Vec<LanguageInfo>,
language_choice: Option<String>,
language_search: ui::EditBox,
@@ -1645,6 +1652,7 @@ impl Default for OptionsUi {
selected_native_key: None,
selected_pad_action: None,
pad_style: GlyphStyle::Xbox,
pad_by_letter: false,
languages: Vec::new(),
language_choice: None,
language_search: search,
@@ -1736,6 +1744,7 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
@@ -1936,6 +1945,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
Act::Cycle(opt) => Some(opt.tooltip_key(s)),
Act::Push(Page::Language) => Some("options.language.tooltip".into()),
Act::Push(Page::Accessibility) => Some("options.accessibility.tooltip".into()),
Act::PvpPreset => Some("horizon.pvp.preset.tooltip".into()),
_ => None,
};
if let Some(key) = key.filter(|key| lang::has(key)) {
@@ -2342,6 +2352,7 @@ impl OptionsUi {
Page::Controller => "horizon.pages.controller",
Page::PadBinds => "horizon.pages.controller_binds",
Page::Extras => "horizon.pages.extras",
Page::Pvp => "horizon.pages.pvp",
Page::Credits => "credits_and_attribution.screen.title",
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
@@ -2438,7 +2449,7 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => Vec::new(),
Page::Extras => {
Page::Extras | Page::Pvp => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
let extra = |item: crate::extras::Item| {
@@ -2465,7 +2476,20 @@ impl OptionsUi {
widget
}
};
let mut rows = Vec::new();
// The PvP preset first; on the Extras page beside the way to the PvP & HUD page.
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
let mut rows = vec![Row::Buttons(
preset,
(self.page() == Page::Extras).then(|| {
button(
0.0,
0.0,
150.0,
tr("horizon.pages.open.pvp"),
Act::Push(Page::Pvp),
)
}),
)];
let mut section = Vec::new();
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
rows.extend(
@@ -2475,7 +2499,14 @@ impl OptionsUi {
);
section.clear();
};
for row in crate::extras::rows() {
let page_rows = match self.page() {
Page::Pvp => crate::pvp::rows()
.into_iter()
.map(|r| r.map(crate::extras::Item::Pvp))
.collect(),
_ => crate::extras::rows(),
};
for row in page_rows {
match row {
Err(header) => {
flush(&mut rows, &mut section);
@@ -2517,7 +2548,7 @@ impl OptionsUi {
Page::SplitSetup
},
),
None,
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
@@ -3233,6 +3264,7 @@ impl OptionsUi {
});
let mut message = match pad.binding(action) {
Some(b) => {
let b = if self.pad_by_letter { b.by_letter() } else { b };
let [r, g, bl] = b.color(self.pad_style);
Text::literal(b.label(self.pad_style))
.with_color(u32::from(r) << 16 | u32::from(g) << 8 | u32::from(bl))
@@ -4288,6 +4320,10 @@ impl OptionsUi {
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PvpPreset => {
crate::pvp::apply_preset(s);
events.push(Event::Changed);
}
Act::AccessibilityHelp => events.push(Event::AccessibilityHelp),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
@@ -5229,6 +5265,10 @@ impl OptionsUi {
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PvpPreset => {
crate::pvp::apply_preset(s);
events.push(Event::Changed);
}
Act::AccessibilityHelp => events.push(Event::AccessibilityHelp),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
@@ -7584,6 +7624,7 @@ mod tests {
Page::Extras,
Page::Mods,
Page::SplitSetup,
Page::Pvp,
Page::Agent,
]
);
@@ -8323,20 +8364,22 @@ mod tests {
.unwrap();
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
use horizon_cosmetics::prefs::Others;
assert_eq!(s.extras.cosmetics_others, Others::Shown);
// Not chosen, nothing owned: shows Hidden.
assert_eq!(s.extras.cosmetics_others, None);
assert_eq!(s.extras.effective_others(), Others::Hidden);
// Forward
// Forward (wraps to Shown), now a choice
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Others::NameTags);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
// Backward with shift
ui.shift = true;
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Others::Shown);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
// Wrap-around backward with shift
// Backward again
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
assert_eq!(s.extras.cosmetics_others, Others::Hidden);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
ui.shift = false;
}
}
File diff suppressed because it is too large. Load diff
+341
View File
@@ -0,0 +1,341 @@
//! The PvP & HUD page's HUD widgets (`docs/pvp.md`): boxes of lines in the screen corners,
//! placed by `extras_hud::Corners` beside mods' boxes. Each widget keeps its lines, their
//! text and widths, and rebuilds them only when what it shows changes, so a frame that
//! changes nothing allocates nothing.
use crate::text::Text;
/// The icon at a line's left: an item the game draws, or an atlas sprite.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Icon {
None,
/// The armour slot (`Game::armor`: 0 head to 3 feet).
Armour(u8),
/// An inventory slot's item (`OFFHAND_SLOT` for the off hand), drawn without its count.
Slot(usize),
/// A sprite by its atlas name (`mob_effect/speed`), 18 pixels square.
Sprite(String),
}
impl Icon {
pub fn size(&self) -> f32 {
match self {
Icon::None => 0.0,
Icon::Armour(_) | Icon::Slot(_) => 16.0,
Icon::Sprite(_) => 18.0,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Line {
pub icon: Icon,
pub text: Text,
/// A second, grey text under the first (an effect's time left).
pub detail: Option<Text>,
/// The widest of its texts, in GUI pixels.
pub text_width: f32,
pub colour: u32,
}
/// Padding inside a widget's box.
pub const PAD: f32 = 2.0;
const TEXT_HEIGHT: f32 = 8.0;
/// Space between an icon and its text.
const ICON_GAP: f32 = 2.0;
impl Line {
pub fn new(icon: Icon, text: String, width: &dyn Fn(&str) -> f32) -> Self {
Self {
icon,
text_width: width(&text),
text: Text::literal(text),
detail: None,
colour: 0xFFFF_FFFF,
}
}
pub fn with_detail(mut self, detail: String, width: &dyn Fn(&str) -> f32) -> Self {
self.text_width = self.text_width.max(width(&detail));
self.detail = Some(Text::literal(detail));
self
}
pub fn height(&self) -> f32 {
let text = if self.detail.is_some() {
2.0 * TEXT_HEIGHT + 2.0
} else {
TEXT_HEIGHT
};
text.max(self.icon.size()) + 2.0
}
/// Where the text starts, from the line's left.
pub fn text_x(&self, icon_column: f32) -> f32 {
if icon_column > 0.0 {
icon_column + ICON_GAP
} else {
0.0
}
}
}
/// One widget's lines, rebuilt from `K` (what they show) only when it changes. The caller
/// fills [`Widget::next`] each frame (reusing its storage), then calls [`Widget::refresh`].
#[derive(Default)]
pub struct Widget<K> {
shown: Option<K>,
/// What the widget shows this frame, filled by the caller.
pub next: K,
pub lines: Vec<Line>,
/// The box's size, padding included.
pub size: (f32, f32),
}
impl<K: PartialEq + Default> Widget<K> {
/// Rebuilds the lines with `build` when `next` differs from what they show; true if so.
pub fn refresh(&mut self, build: impl FnOnce(&K, &mut Vec<Line>)) -> bool {
if self.shown.as_ref() == Some(&self.next) {
return false;
}
self.lines.clear();
build(&self.next, &mut self.lines);
self.size = size(&self.lines);
let next = std::mem::take(&mut self.next);
// The old key's storage comes back as `next` for the following frames.
self.next = self.shown.replace(next).unwrap_or_default();
true
}
/// Forget the lines (the widget was turned off).
pub fn clear(&mut self) {
self.shown = None;
self.lines.clear();
self.size = (0.0, 0.0);
}
}
/// The width of the icons' column: the widest icon of the lines.
pub fn icon_column(lines: &[Line]) -> f32 {
lines.iter().map(|l| l.icon.size()).fold(0.0, f32::max)
}
/// A box around `lines`, one under the other.
pub fn size(lines: &[Line]) -> (f32, f32) {
if lines.is_empty() {
return (0.0, 0.0);
}
let icons = icon_column(lines);
let width = lines
.iter()
.map(|l| l.text_x(icons) + l.text_width)
.fold(0.0, f32::max);
let height = lines.iter().map(Line::height).sum::<f32>();
(width + 2.0 * PAD, height + 2.0 * PAD - 2.0)
}
/// How armour durability shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Durability {
/// Uses left.
Number,
/// Uses left in percent of the item's maximum, rounded.
Percent,
}
/// An item's durability text: none for an item without durability.
pub fn durability(damage: u32, max: u32, mode: Durability) -> Option<String> {
if max == 0 {
return None;
}
let left = max.saturating_sub(damage);
Some(match mode {
Durability::Number => left.to_string(),
Durability::Percent => format!("{}%", (left * 100 + max / 2) / max),
})
}
/// The colour of a durability text: `ItemStack.getBarColor`'s hue, green full to red empty.
pub fn durability_colour(damage: u32, max: u32) -> u32 {
if max == 0 {
return 0xFFFF_FFFF;
}
let left = max.saturating_sub(damage) as f32 / max as f32;
// Mth.hsvToRgb(f / 3, 1, 1): from red (0) through yellow to green (1/3).
let (r, g) = if left >= 0.5 {
((2.0 - 2.0 * left) * 255.0, 255.0)
} else {
(255.0, 2.0 * left * 255.0)
};
0xFF00_0000 | (r as u32) << 16 | (g as u32) << 8
}
/// `Direction.fromYRot`: the horizontal direction a yaw faces, as F3's "Facing" names it.
pub fn facing(yaw: f32) -> &'static str {
match (f64::from(yaw) / 90.0 + 0.5).floor() as i64 & 3 {
0 => "south",
1 => "west",
2 => "north",
_ => "east",
}
}
/// The combo counter: hits in a row on what this player attacked, each counted when the
/// server reports that entity hurt (`ClientboundDamageEventPacket`) soon after the attack;
/// back to 0 when this player is hurt or no hit lands for [`Combo::EXPIRE`] seconds.
#[derive(Default)]
pub struct Combo {
count: u32,
/// The entity attacked last and when.
target: Option<(i32, f64)>,
last_hit: f64,
}
impl Combo {
/// How long after an attack the server's hurt report still counts it.
pub const CONFIRM: f64 = 1.0;
pub const EXPIRE: f64 = 3.0;
pub fn attacked(&mut self, id: i32, now: f64) {
self.target = Some((id, now));
}
/// The server reports entity `id` hurt. `by_me`: whether this player is
/// the damage's direct entity, when the report names one (melee and
/// spear hits do; Horizon's LAN hub names none for player victims). A
/// report that names nobody counts only as the answer to the last swing.
pub fn hurt(&mut self, id: i32, by_me: Option<bool>, now: f64) {
let confirmed = match by_me {
Some(mine) => mine,
None => self
.target
.is_some_and(|(t, at)| t == id && now - at <= Self::CONFIRM),
};
if confirmed {
self.target = None;
self.count += 1;
self.last_hit = now;
}
}
/// This player was hurt.
pub fn broken(&mut self) {
self.count = 0;
self.target = None;
}
pub fn count(&mut self, now: f64) -> u32 {
if now - self.last_hit > Self::EXPIRE {
self.count = 0;
}
self.count
}
}
#[cfg(test)]
mod tests {
use super::*;
fn width(s: &str) -> f32 {
6.0 * s.chars().count() as f32
}
#[test]
fn durability_shows_uses_left_as_a_number_or_a_percent() {
// A diamond chestplate (528 uses) after 100 hits.
assert_eq!(
durability(100, 528, Durability::Number).as_deref(),
Some("428")
);
assert_eq!(
durability(100, 528, Durability::Percent).as_deref(),
Some("81%")
);
assert_eq!(
durability(0, 528, Durability::Percent).as_deref(),
Some("100%")
);
assert_eq!(
durability(528, 528, Durability::Number).as_deref(),
Some("0")
);
assert_eq!(
durability(0, 0, Durability::Number),
None,
"a pumpkin on the head"
);
assert_eq!(durability_colour(0, 100), 0xFF00_FF00);
assert_eq!(durability_colour(100, 100), 0xFFFF_0000);
assert_eq!(durability_colour(50, 100), 0xFFFF_FF00);
}
#[test]
fn facing_follows_direction_from_y_rot() {
// Yaw 0 looks toward +Z (south); turning right (yaw up) goes west, north, east.
let names: Vec<_> = [0.0, 44.9, 45.0, 90.0, 180.0, -90.0, 270.0, -180.0, 720.0]
.map(facing)
.to_vec();
assert_eq!(
names,
[
"south", "south", "west", "west", "north", "east", "east", "north", "south"
]
);
}
#[test]
fn combos_count_confirmed_hits_until_this_player_is_hurt() {
let mut c = Combo::default();
c.attacked(7, 0.0);
c.hurt(7, None, 0.1);
c.attacked(7, 0.6);
c.hurt(7, None, 0.7);
assert_eq!(c.count(0.8), 2);
// Another entity hurt, or a report too late after the swing, adds nothing.
c.attacked(7, 1.0);
c.hurt(9, None, 1.1);
c.hurt(7, None, 2.5);
assert_eq!(c.count(2.5), 2);
// One report per swing: a second one (thorns, fire) for the same attack doesn't count.
c.attacked(7, 3.0);
c.hurt(7, None, 3.1);
c.hurt(7, None, 3.2);
assert_eq!(c.count(3.2), 3);
c.broken();
assert_eq!(c.count(3.3), 0);
c.attacked(7, 4.0);
c.hurt(7, None, 4.1);
assert_eq!(c.count(7.0), 1);
assert_eq!(c.count(7.2), 0, "more than 3 s since the last hit");
// A report that names who hit decides alone: a spear jab (no swing
// remembered) counts, a teammate's hit right after a swing doesn't.
c.hurt(7, Some(true), 8.0);
c.attacked(7, 8.5);
c.hurt(7, Some(false), 8.6);
assert_eq!(c.count(8.7), 1);
}
#[test]
fn a_widget_rebuilds_only_when_what_it_shows_changes() {
let mut w: Widget<Vec<u32>> = Widget::default();
let mut builds = 0;
for frame in 0..10 {
w.next.clear();
w.next.push(if frame < 5 { 7 } else { 8 });
w.refresh(|k, lines| {
builds += 1;
lines.push(Line::new(Icon::None, k[0].to_string(), &width));
});
}
assert_eq!(builds, 2);
assert_eq!(w.lines[0].text.plain(), "8");
w.clear();
w.next.clear();
w.next.push(8);
assert!(w.refresh(|_, lines| lines.push(Line::new(Icon::None, "x".into(), &width))));
}
#[test]
fn the_box_fits_its_widest_line_and_icons_line_up() {
let lines = [
Line::new(Icon::Armour(3), "428".into(), &width),
Line::new(Icon::None, "12345".into(), &width),
Line::new(
Icon::Sprite("mob_effect/speed".into()),
"Speed II".into(),
&width,
)
.with_detail("01:30".into(), &width),
];
assert_eq!(icon_column(&lines), 18.0);
// Texts start after the 18-pixel column and a 2-pixel gap: the widest is "Speed II"
// (48 pixels).
let (w, h) = size(&lines);
assert_eq!(w, 2.0 + 18.0 + 2.0 + 48.0 + 2.0);
// 18 + 2 for the icon lines (the effect's two texts take 18), 8 + 2 for the text one.
assert_eq!(h, 18.0 + 10.0 + 20.0 + 2.0 * PAD - 2.0);
assert!(size(&[]) == (0.0, 0.0));
}
}
@@ -61,6 +61,9 @@ pub struct ScreenInputs<'a> {
/// Eye in water: (lightmap brightness at the eye, yaw, pitch).
pub water: Option<(f32, f32, f32)>,
pub on_fire: bool,
/// How far the fire overlay moves down, in its own heights (PvP & HUD's lower fire; 0 is
/// vanilla's place).
pub fire_lower: f32,
/// `Hud.extractCameraOverlays` draws: the full-screen blits become
/// screen quads (the spyglass, its fills and the nausea swirl do not).
pub gui_overlays: &'a [GuiDraw],
@@ -201,7 +204,7 @@ impl ScreenEffects {
for (dx, angle) in [(0.24f32, -10.0f32), (-0.24, 10.0)] {
let (s, c) = angle.to_radians().sin_cos();
// rotateY about the quad then translate; z = -0.5 feeds into x.
let corner = |x: f32, y: f32| [x * c + -0.5 * s + dx, y - 0.3];
let corner = |x: f32, y: f32| [x * c + -0.5 * s + dx, y - 0.3 - input.fire_lower];
let corners = [
corner(-0.5, -0.5),
corner(0.5, -0.5),
@@ -347,6 +350,7 @@ mod tests {
block_overlay: None,
water: Some((1.0, 0.0, 0.0)),
on_fire: false,
fire_lower: 0.0,
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
};
let quads = e.build(&input);
@@ -401,6 +405,7 @@ mod tests {
block_overlay: Some("block/stone".to_owned()),
water: Some((1.0, 0.0, 0.0)),
on_fire: false,
fire_lower: 0.0,
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
});
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
@@ -11,7 +11,10 @@
//! backend through `stopped`.
use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
use horizon_core::random::LegacyRandom;
use std::collections::HashMap;
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
/// `SoundSource` indices (vanilla order).
pub const MASTER: usize = 0;
@@ -41,11 +44,45 @@ pub enum Attenuation {
None,
Linear,
}
/// The object an entity-bound sound follows. Clones retain the same object,
/// even after the world replaces another entity with the same numeric id.
#[derive(Clone, Debug)]
pub struct SoundEntity {
pub id: u32,
state: Arc<RwLock<SoundEntityState>>,
}
#[derive(Clone, Copy, Debug)]
pub struct SoundEntityState {
pub position: [f64; 3],
pub silent: bool,
pub removed: bool,
}
impl SoundEntity {
pub fn new(id: u32, position: [f64; 3], silent: bool) -> Self {
Self {
id,
state: Arc::new(RwLock::new(SoundEntityState {
position,
silent,
removed: false,
})),
}
}
pub fn state(&self) -> SoundEntityState {
*self.state.read().unwrap()
}
pub fn update(&self, state: SoundEntityState) {
*self.state.write().unwrap() = state;
}
}
/// The tickable instance kinds Horizon plays (`TickableSoundInstance`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ticking {
/// Not tickable.
No,
/// `EntityBoundSoundInstance`.
EntityBound,
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
BiomeLoop { fade: i32, direction: i32 },
/// `UnderLiquidAmbientSoundInstance`.
@@ -145,6 +182,8 @@ pub struct SoundInstance {
pub random: LegacyRandom,
pub can_start_silent: bool,
pub ticking: Ticking,
/// A stable entity reference, separate from its reusable network id.
pub bound_entity: Option<SoundEntity>,
/// `AbstractTickableSoundInstance.stopped`.
pub stopped: bool,
/// `sound` after `getOrResolve`, or the sound a caller chose for the
@@ -184,6 +223,7 @@ impl SoundInstance {
random,
can_start_silent: false,
ticking: Ticking::No,
bound_entity: None,
stopped: false,
sound: None,
event: None,
@@ -212,6 +252,27 @@ impl SoundInstance {
false,
)
}
/// `EntityBoundSoundInstance`: initial coordinates are widened floats too.
pub fn entity_bound(
id: &str,
source: usize,
volume: f32,
pitch: f32,
random: LegacyRandom,
entity: SoundEntity,
) -> Self {
let position = entity.state().position.map(|v| f64::from(v as f32));
let mut instance = Self::at(id, source, volume, pitch, random, position);
instance.ticking = Ticking::EntityBound;
instance.bound_entity = Some(entity);
instance
}
/// `EntityBoundSoundInstance.canPlaySound`, also checked on replay.
pub fn can_play_sound(&self) -> bool {
self.bound_entity
.as_ref()
.is_none_or(|entity| !entity.state().silent)
}
/// `SimpleSoundInstance.forUI(sound, pitch, volume)`.
pub fn for_ui(id: &str, pitch: f32, volume: f32, random: LegacyRandom) -> Self {
Self::new(
@@ -551,6 +612,18 @@ impl SoundInstance {
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No | Ticking::MusicLayer => {}
Ticking::EntityBound => {
let entity = self
.bound_entity
.as_ref()
.expect("entity-bound sound target")
.state();
if entity.removed {
self.stop();
} else {
self.position = entity.position.map(|v| f64::from(v as f32));
}
}
Ticking::BiomeLoop { fade, direction } => {
let stop = *fade < 0;
*fade += *direction;
@@ -966,6 +1039,13 @@ impl SoundEngine {
pub fn category_gain(&self, source: usize) -> f32 {
self.gain_by_source[source]
}
/// Live object references used by entity-bound instances, including queued ones.
pub fn bound_entities(&self) -> Vec<SoundEntity> {
self.instances
.values()
.filter_map(|s| s.bound_entity.clone())
.collect()
}
/// `stop(SoundInstance)`.
pub fn stop(&mut self, id: InstanceId, backend: &mut impl Backend) {
if !self.loaded {
@@ -1111,6 +1191,9 @@ impl SoundEngine {
if !self.loaded || !self.instances.contains_key(&id) {
return PlayResult::NotStarted;
}
if !self.instances[&id].can_play_sound() {
return PlayResult::NotStarted;
}
self.resolve(id);
let instance = &self.instances[&id];
let Some(subtitle) = instance.event.clone() else {
@@ -1250,9 +1333,14 @@ impl SoundEngine {
self.play(id, backend);
}
for id in self.ticking.clone() {
let Some(instance) = self.instances.get_mut(&id) else {
let Some(instance) = self.instances.get(&id) else {
continue;
};
if !instance.can_play_sound() {
// SoundEngine still ticks the instance after stopping its channel.
self.stop(id, backend);
}
let instance = self.instances.get_mut(&id).expect("present");
instance.tick(ctx);
if instance.stopped {
self.stop(id, backend);
@@ -1764,3 +1852,66 @@ mod tests {
assert!(underwater.stopped);
}
}
#[cfg(test)]
mod entity_bound_tests {
use super::*;
fn instance(entity: SoundEntity) -> SoundInstance {
SoundInstance::entity_bound(
"minecraft:entity.allay.item_given",
NEUTRAL,
0.7,
1.2,
LegacyRandom::new(42),
entity,
)
}
#[test]
fn an_entity_bound_sound_tracks_float_positions_and_stops_before_the_removed_position() {
let entity = SoundEntity::new(7, [0.123456789, 64.123456789, -3.123456789], false);
let mut sound = instance(entity.clone());
assert_eq!(sound.position[0].to_bits(), 0x3fbf9add40000000);
entity.update(SoundEntityState {
position: [0.248456789, 65.25, -3.623456789],
silent: false,
removed: false,
});
sound.tick(&TickContext::default());
assert_eq!(sound.position[0].to_bits(), 0x3fcfcd6ea0000000);
let last = sound.position;
entity.update(SoundEntityState {
position: [100.0; 3],
silent: false,
removed: true,
});
sound.tick(&TickContext::default());
assert!(sound.stopped);
assert_eq!(sound.position, last);
}
#[test]
fn silence_is_read_from_the_entity_including_before_a_replay() {
let entity = SoundEntity::new(7, [0.0; 3], true);
let sound = instance(entity.clone());
assert!(!sound.can_play_sound());
entity.update(SoundEntityState {
position: [0.0; 3],
silent: false,
removed: false,
});
assert!(sound.can_play_sound());
}
#[test]
fn reusing_a_network_id_does_not_retarget_an_old_sound() {
let old = SoundEntity::new(7, [0.0; 3], false);
let mut sound = instance(old.clone());
old.update(SoundEntityState {
position: [0.0; 3],
silent: false,
removed: true,
});
let replacement = SoundEntity::new(7, [10.0; 3], false);
sound.tick(&TickContext::default());
assert!(sound.stopped);
assert_ne!(sound.position, replacement.state().position);
}
}
+20 -4
View File
@@ -144,8 +144,15 @@ pub fn set_labpbr_maps(on: bool) {
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Budget for either complete normal or specular image set.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Budget for either complete normal or specular image set: a quarter of the block textures'
/// budget, so raising `textureBudgetMiB` sharpens a pack's LabPBR maps along with its colours.
fn map_bytes(texture_budget: u64) -> u64 {
texture_budget / 4
}
/// [`map_bytes`] for the budget the next resource load keeps to.
fn max_map_bytes() -> u64 {
map_bytes(TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed))
}
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
const LAYERS_AT_LEAST: u64 = 1024;
@@ -275,7 +282,7 @@ impl Sprites {
self.specular.clear();
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
side_within(l.min(size), layers, MAX_MAP_BYTES)
side_within(l.min(size), layers, max_map_bytes())
});
if largest_map.is_some() {
let pbr_size = self.pbr_size;
@@ -783,7 +790,7 @@ fn pbr_map(
return None;
}
let side = w.max(h);
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
let output_side = side_within(side, LAYERS_AT_LEAST, max_map_bytes()).min(side);
let frames = (0..columns * rows)
.map(|index| {
let (x, y) = (index % columns * w, index / columns * h);
@@ -856,6 +863,15 @@ mod tests {
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn labpbr_maps_follow_the_texture_budget() {
// 1300 layers of 1024 px maps: 64 px at the default budget, 256 px at 1536 MiB.
assert_eq!(
side_within(1024, 1300, map_bytes(DEFAULT_TEXTURE_BUDGET)),
64
);
assert_eq!(side_within(1024, 1300, map_bytes(1536 << 20)), 256);
}
#[test]
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
// Selecting a texel preserves encoded channels, including categorical values.
let frame = [
+49 -7
View File
@@ -502,6 +502,10 @@ pub struct Streaming {
pub radius: i32,
/// `/forceload`ed chunks: kept loaded whatever the distance.
pub forced: HashSet<(i32, i32)>,
/// Chunks a native mod's tickets hold (NeoForge's `TicketController`):
/// kept loaded and ticking as forced ones are, but neither saved with
/// them nor listed by `/forceload`.
pub mod_forced: HashSet<(i32, i32)>,
/// No work is scheduled while paused (title screen).
pub paused: bool,
/// Dimension flags: sky light, Nether face shading.
@@ -569,6 +573,7 @@ struct GenerationScan {
radius: i32,
extra: Vec<((i32, i32), i32)>,
forced: HashSet<(i32, i32)>,
mod_forced: HashSet<(i32, i32)>,
band: Option<Band>,
chunk_revision: u64,
plan: Vec<(i64, i32, i32)>,
@@ -584,6 +589,7 @@ impl GenerationScan {
&& self.radius == ranges.radius
&& self.extra == ranges.extra
&& self.forced == *ranges.forced
&& self.mod_forced == *ranges.mod_forced
&& self.band.as_ref() == ranges.band
}
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
@@ -622,6 +628,7 @@ impl GenerationScan {
self.extra.clear();
self.extra.extend_from_slice(ranges.extra);
self.forced.clone_from(ranges.forced);
self.mod_forced.clone_from(ranges.mod_forced);
self.band = ranges.band.copied();
self.chunk_revision = world.chunks.membership_revision();
}
@@ -719,7 +726,7 @@ impl GenerationScan {
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for &(fx, fz) in input.ranges.forced_chunks() {
for dz in -reach..=reach {
for dx in -reach..=reach {
let (x, z) = (fx + dx, fz + dz);
@@ -739,6 +746,7 @@ impl GenerationScan {
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
mod_forced: input.ranges.mod_forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
@@ -1063,6 +1071,7 @@ impl Streaming {
center: (0, 0),
radius: 8,
forced: HashSet::new(),
mod_forced: HashSet::new(),
paused: false,
sky_light: true,
nether_shading: false,
@@ -1737,7 +1746,7 @@ impl Streaming {
> r4 * r4)
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges.forced.iter().any(|f| {
&& !ranges.forced_chunks().any(|f| {
(x - f.0).abs() <= FORCED_TERRAIN_RING
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
})
@@ -1808,6 +1817,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
@@ -1974,7 +1984,11 @@ impl Streaming {
// vanilla draws). Sections set aside past it come back
// when the player moves: a column that loads with its light gets no
// other request (#228).
let area = (self.center, self.radius, self.forced.len());
let area = (
self.center,
self.radius,
self.forced.len() + self.mod_forced.len(),
);
if self.waiting_area != area {
self.waiting_area = area;
let ranges = Ranges {
@@ -1982,6 +1996,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
@@ -2077,6 +2092,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
},
@@ -2210,6 +2226,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
}
@@ -2486,6 +2503,8 @@ struct Ranges<'a> {
radius: i32,
extra: &'a [((i32, i32), i32)],
forced: &'a HashSet<(i32, i32)>,
/// The chunks native mods' tickets hold, kept as forced ones are.
mod_forced: &'a HashSet<(i32, i32)>,
/// `extra` indexed by a grid or buckets: coverage queries without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
@@ -2830,10 +2849,20 @@ impl Ranges<'_> {
.iter()
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
}
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// The columns meshed for drawing: those vanilla draws around the
/// player (`in_view_distance`), and forced ones.
fn in_view(&self, x: i32, z: i32) -> bool {
in_view_distance(self.center, self.radius, x, z) || self.forced.contains(&(x, z))
in_view_distance(self.center, self.radius, x, z) || self.is_forced(x, z)
}
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
let r = i64::from(self.radius + margin);
@@ -2851,8 +2880,7 @@ impl Ranges<'_> {
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced
.iter()
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
@@ -3075,6 +3103,10 @@ fn forget_pending_sections(
}
}
/// No chunk held by a native mod's ticket, for the tests' ranges.
#[cfg(test)]
static NO_CHUNKS: std::sync::LazyLock<HashSet<(i32, i32)>> = std::sync::LazyLock::new(HashSet::new);
#[cfg(test)]
mod tests {
use super::*;
@@ -3291,6 +3323,7 @@ mod tests {
radius: 32,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -3561,6 +3594,7 @@ mod tests {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
},
@@ -3637,7 +3671,7 @@ mod tests {
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for &(fx, fz) in input.ranges.forced_chunks() {
for dz in -forced_reach..=forced_reach {
for dx in -forced_reach..=forced_reach {
let (x, z) = (fx + dx, fz + dz);
@@ -3943,6 +3977,7 @@ mod tests {
radius,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -3999,6 +4034,7 @@ mod tests {
radius,
extra: &[((radius * 2 + 9, 3), 4)],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4035,6 +4071,7 @@ mod tests {
radius: -1,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4068,6 +4105,7 @@ mod tests {
radius: 2,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4122,6 +4160,7 @@ mod tests {
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4193,6 +4232,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -4235,6 +4275,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -4300,6 +4341,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -429,10 +429,10 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let step = (start + select(corner, 4u - corner, dir == 1u)) & 3u;
let du = select(0u, 1u, step == 1u || step == 2u);
let dw = select(0u, 1u, step >= 2u);
var pos = block;
pos[axis] = block[axis] + side;
pos[(axis + 1u) % 3u] = block[(axis + 1u) % 3u] + du;
pos[(axis + 2u) % 3u] = block[(axis + 2u) % 3u] + dw;
// No dynamic component stores: FXC (DirectX 12) rejects them (X3500). A component's offset
// from the axis (0: the axis, 1: the next, 2: the one after) picks what it gets.
let rel = (vec3(0u, 1u, 2u) + vec3(3u - axis)) % vec3(3u);
let pos = block + select(select(vec3(dw), vec3(du), rel == vec3(1u)), vec3(side), rel == vec3(0u));
let sel = lo.z >> 24u;
let us = (sel >> (2u * corner)) & 1u;
let vs = (sel >> (2u * corner + 1u)) & 1u;
@@ -512,7 +512,8 @@ fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
}
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
return corner_vertex(s.c, slot_origins[block_slots[s.slot >> 8u]]);
let origin = slot_origins[block_slots[s.slot >> 8u]];
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
@@ -101,6 +101,9 @@ pub struct World {
/// Every block position written since the log was last drained, kept
/// only while the world is open to the network (see `lan`).
pub block_log: Option<Vec<(i32, i32, i32)>>,
/// Writes of this tick's block events and block entities, held out of
/// the log while it is sent: they go out with the next tick's.
pub block_log_held: Vec<(i32, i32, i32)>,
/// Every write since the mobs last ticked (position, old and new state),
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
/// the mobs of this world tick (`game/creatures.rs`).
@@ -217,6 +220,7 @@ impl World {
day_time: 1000,
weather: Weather::default(),
block_log: None,
block_log_held: Vec::new(),
path_writes: None,
bulk_log: Default::default(),
light_log: None,
@@ -0,0 +1,209 @@
//! `textureMatrix` in a gbuffers program (#9080), through the translated program on a host GPU.
//! Expected values: our probe packs K-texmat3 and K-texmat2 in Iris 1.11.7 read the identity in
//! the terrain vertex stage and zero in the terrain fragment stage.
use horizon_shaders::{GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers};
/// Draws one pixel with `vertex` and `fragment` as `gbuffers_terrain`; returns its RGBA8.
fn pixel(device: &wgpu::Device, queue: &wgpu::Queue, vertex: &str, fragment: &str) -> [u8; 4] {
use std::io::Write;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (path, source) in [
("shaders/gbuffers_terrain.vsh", vertex),
("shaders/gbuffers_terrain.fsh", fragment),
] {
zip.start_file(path, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack =
Pack::from_zip_bytes("texture-matrix.zip", zip.finish().unwrap().into_inner()).unwrap();
let macros = Macros::standard();
let program = translate_gbuffers(
&preprocess(&pack, "gbuffers_terrain.vsh", &macros),
&preprocess(&pack, "gbuffers_terrain.fsh", &macros),
Target::Buffers,
&GbuffersInputs::default(),
)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(!program.interface.draw, "no per-draw block in this probe");
use wgpu::util::DeviceExt;
// The frame's uniforms are all zero: `textureMatrix` must not come from them.
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &vec![0; program.interface.uniform_size as usize],
usage: wgpu::BufferUsages::UNIFORM,
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let (vs, fs) = (module(program.vertex), module(program.fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
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 view = texture.create_view(&Default::default());
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = 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(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
[mapped[0], mapped[1], mapped[2], mapped[3]]
}
/// A full-screen triangle; `extra` declares and sets more.
fn vertex(extra: &str, set: &str) -> String {
format!(
"#version 460 core\n{extra}\nvoid main() {{\n\
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n{set}\n}}\n"
)
}
/// Four values `v` as v / 4 + 0.5 (K-texmat3's encoding): 0 is 128, 1 is 191.
const ENCODE: &str = "color = vec4(e) / 4.0 + 0.5;";
fn assert_close(actual: [u8; 4], expected: [u8; 4], what: &str) {
assert!(
actual.iter().zip(expected).all(|(a, e)| a.abs_diff(e) <= 1),
"{what}: {actual:?}, expected {expected:?}"
);
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn texture_matrix_is_the_identity_in_the_vertex_stage_and_zero_in_the_fragment_stage() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Texture matrix GPU: {:?}", adapter.get_info().name);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let fragment_in = "#version 460 core\nflat in vec4 e;\nlayout(location = 0) out vec4 color;\n\
void main() { ";
// The vertex stage hands elements [0][0], [1][1], [3][0], [3][3] on (K-texmat3).
let elements = pixel(
&device,
&queue,
&vertex(
"uniform mat4 textureMatrix;\nflat out vec4 e;",
"e = vec4(textureMatrix[0][0], textureMatrix[1][1], textureMatrix[3][0], textureMatrix[3][3]);",
),
&format!("{fragment_in}{ENCODE} }}\n"),
);
assert_close(elements, [191, 191, 128, 191], "vertex-stage elements");
// A UV through it is the UV (K-texmat's `mat2(m) * uv + m[3].xy`).
let uv = pixel(
&device,
&queue,
&vertex(
"uniform mat4 textureMatrix;\nflat out vec4 e;",
"vec2 uv = vec2(0.25, 0.75);\n\
e = vec4(mat2(textureMatrix) * uv + textureMatrix[3].xy, (textureMatrix * vec4(uv, 0.0, 1.0)).xy);",
),
"#version 460 core\nflat in vec4 e;\nlayout(location = 0) out vec4 color;\n\
void main() { color = e; }\n",
);
assert_close(uv, [64, 191, 64, 191], "transformed UV");
// The fragment stage's own declaration reads zero, with or without the vertex stage's (K-texmat2).
for vertex_declares in [false, true] {
let v = if vertex_declares {
vertex(
"uniform mat4 textureMatrix;\nflat out vec4 e;",
"e = textureMatrix[0];",
)
} else {
vertex("flat out vec4 e;", "e = vec4(0.0);")
};
let f = "#version 460 core\nuniform mat4 textureMatrix;\nflat in vec4 e;\n\
layout(location = 0) out vec4 color;\nvoid main() {\n\
vec4 m = vec4(textureMatrix[0][0], textureMatrix[1][1], textureMatrix[3][0], textureMatrix[3][3]);\n\
color = m / 4.0 + 0.5 + e * 0.0;\n}\n";
assert_close(
pixel(&device, &queue, &v, f),
[128, 128, 128, 128],
&format!("fragment stage (vertex declares: {vertex_declares})"),
);
}
}
+4
View File
@@ -142,6 +142,10 @@ path = "tests/launch_api.rs"
name = "mod_api_hooks"
path = "tests/mod_api_hooks.rs"
[[example]]
name = "native_gui_overlay_probe"
path = "examples/native_gui_overlay_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
@@ -343,5 +343,34 @@
"horizon.extras.cosmetics_auras.tooltip": "Auren und Spuren, die andere Spieler tragen. Standardmäßig aus.",
"horizon.extras.cosmetics.shown": "Angezeigt",
"horizon.extras.cosmetics.hidden": "Ausgeblendet",
"horizon.extras.cosmetics.name_tags": "Nur Namensschilder"
"horizon.extras.cosmetics.name_tags": "Nur Namensschilder",
"horizon.moderation.duration.minute": "%s Minute",
"horizon.moderation.duration.minutes": "%s Minuten",
"horizon.moderation.duration.hour": "%s Stunde",
"horizon.moderation.duration.hours": "%s Stunden",
"horizon.moderation.duration.day": "%s Tag",
"horizon.moderation.duration.days": "%s Tage",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Keine Spieler stummgeschaltet",
"horizon.moderation.mutelist.count": "Stummgeschaltete Spieler: %s",
"horizon.moderation.chat_filter": "dem Chatfilter",
"horizon.moderation.until_unmuted": "bis zur Aufhebung der Stummschaltung",
"horizon.moderation.remaining": "für weitere %s",
"horizon.moderation.mutelist.entry": "%1$s wurde von %2$s %3$s stummgeschaltet",
"horizon.moderation.invalid_duration": "%s ist keine Zeitdauer: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <Spieler> [Dauer] [Grund]",
"horizon.moderation.usage.tempmute": "/tempmute <Spieler> <Dauer> [Grund]",
"horizon.moderation.usage.unmute": "/unmute <Spieler>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "für %s",
"horizon.moderation.muted": "%1$s wurde %2$s stummgeschaltet",
"horizon.moderation.unmuted": "%s ist nicht mehr stummgeschaltet",
"horizon.moderation.not_muted": "%s ist nicht stummgeschaltet",
"horizon.tempban.usage": "/tempban <Spieler> <Dauer> [Grund]",
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
}
@@ -111,6 +111,8 @@
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
"UI Brightness": "UI Brightness",
"HDR Lighting": "HDR Lighting",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Light sources, the sun and sunlight shine past white. Off: just as SDR.",
"Smooth Gradients": "Smooth Gradients",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
"Battery Mode": "Battery Mode",
@@ -220,6 +222,7 @@
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.realms.not_signed_in": "Sign in with your Microsoft account in the Horizon launcher to play on Realms.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
@@ -514,15 +517,189 @@
"horizon.extras.cosmetics_mine": "My cosmetics",
"horizon.extras.cosmetics_mine.tooltip": "Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe.",
"horizon.extras.cosmetics_others": "Other players' cosmetics",
"horizon.extras.cosmetics_others.tooltip": "What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden, the game never asks what they wear.",
"horizon.extras.cosmetics_others.tooltip": "What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear.",
"horizon.extras.cosmetics_auras": "Others' auras and trails",
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Off by default.",
"horizon.extras.cosmetics.shown": "Shown",
"horizon.extras.cosmetics.hidden": "Hidden",
"horizon.extras.cosmetics.name_tags": "Name tags only",
"horizon.moderation.duration.minute": "%s minute",
"horizon.moderation.duration.minutes": "%s minutes",
"horizon.moderation.duration.hour": "%s hour",
"horizon.moderation.duration.hours": "%s hours",
"horizon.moderation.duration.day": "%s day",
"horizon.moderation.duration.days": "%s days",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "There are no mutes",
"horizon.moderation.mutelist.count": "There are %s mute(s):",
"horizon.moderation.chat_filter": "the chat filter",
"horizon.moderation.until_unmuted": "until unmuted",
"horizon.moderation.remaining": "for another %s",
"horizon.moderation.mutelist.entry": "%1$s was muted by %2$s %3$s",
"horizon.moderation.invalid_duration": "%s isn't a duration: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "for %s",
"horizon.moderation.muted": "Muted %1$s %2$s",
"horizon.moderation.unmuted": "Unmuted %s",
"horizon.moderation.not_muted": "%s isn't muted",
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
"horizon.tempban.invalid_duration": "%s isn't a duration: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporary bans need a dedicated server",
"horizon.tempban.success": "%1$s for %2$s, until %3$s",
"horizon.tempban.list.empty": "There are no temporary bans",
"horizon.tempban.list.count": "There are %s temporary ban(s):",
"horizon.tempban.list.entry": "%1$s was banned by %2$s %3$s (until %4$s)",
"horizon.options.pointer_speed": "Menu Pointer Speed",
"horizon.options.scroll_speed": "Menu Scroll Speed",
"horizon.options.repeat_delay": "Navigation Repeat Delay",
"horizon.options.repeat_interval": "Navigation Repeat Interval",
"horizon.options.milliseconds": "%s ms"
"horizon.options.milliseconds": "%s ms",
"horizon.pages.pvp": "PvP & HUD",
"horizon.pages.pvp.tooltip": "Crosshair, HUD and PvP settings, all off by default. Nothing here shows what vanilla doesn't.",
"horizon.pages.open.pvp": "PvP & HUD...",
"horizon.pvp.header.crosshair": "Crosshair",
"horizon.pvp.crosshair": "Custom crosshair",
"horizon.pvp.crosshair_shape": "Shape",
"horizon.pvp.crosshair_size": "Size",
"horizon.pvp.crosshair_gap": "Gap",
"horizon.pvp.crosshair_thickness": "Thickness",
"horizon.pvp.crosshair_colour": "Colour",
"horizon.pvp.crosshair_outline": "Outline",
"horizon.pvp.crosshair_dot": "Centre dot",
"horizon.pvp.crosshair_hover": "Colour on entities",
"horizon.pvp.crosshair.tooltip": "Draws your own crosshair instead of vanilla's. Its first settings look like vanilla's.",
"horizon.pvp.crosshair_shape.tooltip": "A cross, a T, an X, a circle, a square or only a dot.",
"horizon.pvp.crosshair_size.tooltip": "The length of each arm, in GUI pixels.",
"horizon.pvp.crosshair_gap.tooltip": "The empty space in the middle.",
"horizon.pvp.crosshair_thickness.tooltip": "How wide each line is.",
"horizon.pvp.crosshair_colour.tooltip": "Inverted shows the colour under it inverted, as vanilla's crosshair does.",
"horizon.pvp.crosshair_outline.tooltip": "A thin black edge, so it shows on any background.",
"horizon.pvp.crosshair_dot.tooltip": "A dot in the middle of the crosshair.",
"horizon.pvp.crosshair_hover.tooltip": "Another colour while the crosshair is over a mob or a player you could hit, as vanilla's attack indicator knows it.",
"horizon.pvp.colour.inverted": "Inverted",
"horizon.pvp.colour.white": "White",
"horizon.pvp.colour.red": "Red",
"horizon.pvp.colour.orange": "Orange",
"horizon.pvp.colour.yellow": "Yellow",
"horizon.pvp.colour.green": "Green",
"horizon.pvp.colour.cyan": "Cyan",
"horizon.pvp.colour.blue": "Blue",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Black",
"horizon.pvp.shape.cross": "Cross",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Circle",
"horizon.pvp.shape.square": "Square",
"horizon.pvp.shape.dot": "Dot",
"horizon.pvp.outline": "Custom block outline",
"horizon.pvp.outline.tooltip": "Changes the outline around the block you look at.",
"horizon.pvp.outline_colour": "Colour",
"horizon.pvp.outline_colour.tooltip": "The outline's colour. Black at 40% is vanilla's.",
"horizon.pvp.outline_opacity": "Opacity",
"horizon.pvp.outline_opacity.tooltip": "How solid the outline is.",
"horizon.pvp.outline_thickness": "Thickness",
"horizon.pvp.outline_thickness.tooltip": "The outline's width, in times vanilla's.",
"horizon.pvp.outline_fill": "Fill",
"horizon.pvp.outline_fill.tooltip": "Tints the faces of the block you look at, lightly.",
"horizon.pvp.hit": "Custom hit colour",
"horizon.pvp.hit.tooltip": "Changes the colour mobs and players flash when they are hurt. Red at 30% is vanilla's.",
"horizon.pvp.hit_colour": "Colour",
"horizon.pvp.hit_colour.tooltip": "The colour hurt mobs and players flash.",
"horizon.pvp.hit_opacity": "Opacity",
"horizon.pvp.hit_opacity.tooltip": "How much of the colour covers them.",
"horizon.pvp.header.outline": "Block outline",
"horizon.pvp.header.hit": "Hit colour",
"horizon.pvp.hud_armour": "Armour",
"horizon.pvp.hud_armour.tooltip": "The armour you wear, head first, with how many uses each piece has left.",
"horizon.pvp.hud_armour_corner": "Armour corner",
"horizon.pvp.hud_armour_corner.tooltip": "Where the armour shows.",
"horizon.pvp.hud_armour_percent": "Durability in %",
"horizon.pvp.hud_armour_percent.tooltip": "Uses left as a percent of the piece's maximum instead of a number.",
"horizon.pvp.hud_effects": "Effects",
"horizon.pvp.hud_effects.tooltip": "Your effects with their names, levels and time left, in place of the icons at the top right.",
"horizon.pvp.hud_effects_corner": "Effects corner",
"horizon.pvp.hud_effects_corner.tooltip": "Where the effects show.",
"horizon.pvp.hud_held": "Held item count",
"horizon.pvp.hud_held.tooltip": "How many of the item in your hand your inventory holds; arrows with a bow or crossbow.",
"horizon.pvp.hud_held_corner": "Held count corner",
"horizon.pvp.hud_held_corner.tooltip": "Where the count shows.",
"horizon.pvp.hud_background": "Widget backgrounds",
"horizon.pvp.hud_background.tooltip": "A dark box behind each widget.",
"horizon.pvp.header.hud": "HUD widgets",
"horizon.pvp.corner.top_left": "Top left",
"horizon.pvp.corner.top_right": "Top right",
"horizon.pvp.corner.bottom_left": "Bottom left",
"horizon.pvp.corner.bottom_right": "Bottom right",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "The latency the server reports for you, in milliseconds. Multiplayer only.",
"horizon.pvp.hud_ping_corner": "Ping corner",
"horizon.pvp.hud_ping_corner.tooltip": "Where the ping shows.",
"horizon.pvp.hud_coords": "Coordinates",
"horizon.pvp.hud_coords.tooltip": "Your block position and the direction you face, as F3 shows them. Hidden when the world or server reduces F3.",
"horizon.pvp.hud_coords_corner": "Coordinates corner",
"horizon.pvp.hud_coords_corner.tooltip": "Where the coordinates show.",
"horizon.pvp.hud_server": "Server address",
"horizon.pvp.hud_server.tooltip": "The address of the server you play on. Multiplayer only.",
"horizon.pvp.hud_server_corner": "Server corner",
"horizon.pvp.hud_server_corner.tooltip": "Where the address shows.",
"horizon.pvp.hud_combo": "Combo counter",
"horizon.pvp.hud_combo.tooltip": "Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.",
"horizon.pvp.hud_combo_corner": "Combo corner",
"horizon.pvp.hud_combo_corner.tooltip": "Where the combo shows.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Facing: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "North",
"horizon.pvp.hud.direction.south": "South",
"horizon.pvp.hud.direction.east": "East",
"horizon.pvp.hud.direction.west": "West",
"horizon.pvp.numeric_enchants": "Enchantment levels as numbers",
"horizon.pvp.numeric_enchants.tooltip": "Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.",
"horizon.pvp.header.items": "Items",
"horizon.pvp.fire_lower": "Lower fire",
"horizon.pvp.fire_lower.tooltip": "Moves the flames you see while burning down, so they cover less of the screen.",
"horizon.pvp.shield_size": "Shield size",
"horizon.pvp.shield_size.tooltip": "The size of the shield in your hand, in first person.",
"horizon.pvp.totem_size": "Totem animation size",
"horizon.pvp.totem_size.tooltip": "The size of the totem that fills the screen when it saves you.",
"horizon.pvp.glint_colour": "Enchantment glint colour",
"horizon.pvp.glint_colour.tooltip": "The colour of the shimmer on enchanted items and armour. Off is vanilla's purple.",
"horizon.pvp.clear_water": "Clear water",
"horizon.pvp.clear_water.tooltip": "Under water, see as far as vanilla lets you after 30 seconds there, from the start.",
"horizon.pvp.clear_lava": "Clear lava",
"horizon.pvp.clear_lava.tooltip": "In lava, see as far as vanilla's Fire Resistance lets you, without the effect.",
"horizon.pvp.header.visual": "Visual tweaks",
"horizon.pvp.view_time": "Time of day",
"horizon.pvp.view_time.tooltip": "The time of day you see: the sky and its light only. The world's time goes on and nothing is sent to the server.",
"horizon.pvp.view_weather": "Weather",
"horizon.pvp.view_weather.tooltip": "The weather you see: rain, its light and fog only. The world's weather goes on and nothing is sent to the server.",
"horizon.pvp.header.view": "Your view",
"horizon.pvp.view.world": "World's",
"horizon.pvp.time.morning": "Morning",
"horizon.pvp.time.noon": "Noon",
"horizon.pvp.time.sunset": "Sunset",
"horizon.pvp.time.midnight": "Midnight",
"horizon.pvp.weather.clear": "Clear",
"horizon.pvp.weather.rain": "Rain",
"horizon.pvp.weather.thunder": "Thunder",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Chat",
"horizon.chat.copied": "Message copied",
"horizon.chat.search": "Search chat:",
"horizon.pvp.chat_timestamps": "Timestamps",
"horizon.pvp.chat_timestamps.tooltip": "The time each message came in, before it.",
"horizon.pvp.chat_compact": "Compact chat",
"horizon.pvp.chat_compact.tooltip": "A message repeated in a row shows once, with how many times it came: (x3).",
"horizon.pvp.chat_copy": "Copy on click",
"horizon.pvp.chat_copy.tooltip": "Clicking a message in the open chat copies its text, unless the click does something else.",
"horizon.pvp.chat_search": "Chat search",
"horizon.pvp.chat_search.tooltip": "Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.",
"horizon.pvp.preset": "PvP preset",
"horizon.pvp.preset.tooltip": "Turns on the custom crosshair, the armour and effects widgets, toggle sprint and sneak, lower fire, and the FPS counter and keystrokes mods if installed. Your settings for each stay."
}
@@ -225,5 +225,34 @@
"How frames reach the screen, and the performance overlay.": "Cómo llegan los fotogramas a la pantalla, y la superposición de rendimiento.",
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
"Results for \"%s\"": "Resultados de \"%s\"",
"No settings match.": "Ningún ajuste coincide."
"No settings match.": "Ningún ajuste coincide.",
"horizon.moderation.duration.minute": "%s minuto",
"horizon.moderation.duration.minutes": "%s minutos",
"horizon.moderation.duration.hour": "%s hora",
"horizon.moderation.duration.hours": "%s horas",
"horizon.moderation.duration.day": "%s día",
"horizon.moderation.duration.days": "%s días",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "No hay jugadores silenciados",
"horizon.moderation.mutelist.count": "Jugadores silenciados: %s",
"horizon.moderation.chat_filter": "el filtro del chat",
"horizon.moderation.until_unmuted": "hasta que se retire el silencio",
"horizon.moderation.remaining": "durante %s más",
"horizon.moderation.mutelist.entry": "%1$s fue silenciado por %2$s %3$s",
"horizon.moderation.invalid_duration": "%s no es una duración: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <jugador> [duración] [motivo]",
"horizon.moderation.usage.tempmute": "/tempmute <jugador> <duración> [motivo]",
"horizon.moderation.usage.unmute": "/unmute <jugador>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "durante %s",
"horizon.moderation.muted": "Se ha silenciado a %1$s %2$s",
"horizon.moderation.unmuted": "%s ya no está silenciado",
"horizon.moderation.not_muted": "%s no está silenciado",
"horizon.tempban.usage": "/tempban <jugador> <duración> [motivo]",
"horizon.tempban.invalid_duration": "%s no es una duración: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Los bloqueos temporales necesitan un servidor dedicado",
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s",
"horizon.tempban.list.empty": "No hay baneos temporales",
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)"
}
@@ -382,7 +382,7 @@
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
"horizon.extras.cosmetics_mine.tooltip": "Masqués : tous les joueurs Horizon vous voient sans vos cosmétiques, vous compris. Le même réglage que la garde-robe du launcher.",
"horizon.extras.cosmetics_others": "Cosmétiques des autres",
"horizon.extras.cosmetics_others.tooltip": "Ce que portent les autres joueurs Horizon : tout, seulement leur style d'étiquette de nom, ou rien. Masqués, le jeu ne demande jamais ce qu'ils portent.",
"horizon.extras.cosmetics_others.tooltip": "Ce que portent les autres joueurs Horizon : tout, seulement leur style d'étiquette de nom, ou rien. Masqués si vous n'avez pas de cosmétiques, tant que vous n'avez pas choisi. Masqués, le jeu ne demande jamais ce qu'ils portent.",
"horizon.extras.cosmetics_auras": "Auras et traînées des autres",
"horizon.extras.cosmetics_auras.tooltip": "Les auras et traînées que portent les autres joueurs. Désactivé par défaut.",
"horizon.extras.cosmetics.shown": "Affichés",
@@ -442,6 +442,35 @@
"horizon.repro.error": "Reproduction : %s",
"horizon.repro.no_recording": "Il n'y a pas encore d'enregistrement à exporter.",
"horizon.repro.replay_started": "Lecture démarrée : %s",
"horizon.moderation.duration.minute": "%s minute",
"horizon.moderation.duration.minutes": "%s minutes",
"horizon.moderation.duration.hour": "%s heure",
"horizon.moderation.duration.hours": "%s heures",
"horizon.moderation.duration.day": "%s jour",
"horizon.moderation.duration.days": "%s jours",
"horizon.moderation.reason": "%1$s : %2$s",
"horizon.moderation.mutelist.empty": "Aucun joueur réduit au silence",
"horizon.moderation.mutelist.count": "Nombre de joueurs réduits au silence : %s",
"horizon.moderation.chat_filter": "le filtre de discussion",
"horizon.moderation.until_unmuted": "jusqu'au rétablissement de la parole",
"horizon.moderation.remaining": "pour encore %s",
"horizon.moderation.mutelist.entry": "%1$s a été réduit au silence par %2$s %3$s",
"horizon.moderation.invalid_duration": "%s n'est pas une durée : 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <joueur> [durée] [raison]",
"horizon.moderation.usage.tempmute": "/tempmute <joueur> <durée> [raison]",
"horizon.moderation.usage.unmute": "/unmute <joueur>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "pour %s",
"horizon.moderation.muted": "%1$s a été réduit au silence %2$s",
"horizon.moderation.unmuted": "%s peut de nouveau parler",
"horizon.moderation.not_muted": "%s n'est pas réduit au silence",
"horizon.tempban.usage": "/tempban <joueur> <durée> [raison]",
"horizon.tempban.invalid_duration": "%s n'est pas une durée : 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Les bannissements temporaires nécessitent un serveur dédié",
"horizon.tempban.success": "%1$s pour %2$s, jusqu'au %3$s",
"horizon.tempban.list.empty": "Il n'y a aucun bannissement temporaire",
"horizon.tempban.list.count": "Il y a %s bannissement(s) temporaire(s) :",
"horizon.tempban.list.entry": "%1$s a été banni par %2$s %3$s (jusqu'au %4$s)",
"horizon.options.pointer_speed": "Vitesse du pointeur des menus",
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
"horizon.options.repeat_delay": "Délai de répétition de navigation",
@@ -186,6 +186,7 @@
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
@@ -0,0 +1,81 @@
//! A real-renderer consumer of the public GUI API. Run in a disposable game home:
//! `cargo run -p horizon-client --example native_gui_overlay_probe --
//! --resources PATH --world OverlayProbe --creative --world-frames 120`.
//! Every world frame turns the camera 90 degrees; GUI callbacks assert that
//! Host::camera already sees that frame. A magenta square stays above menus/F1.
use horizon_client::mod_api::{
CameraPoseEvent, GuiRenderPostEvent, Hooks, Host, HudEvent, ScreenRenderPostEvent,
};
use std::sync::atomic::{AtomicU32, Ordering};
struct Probe {
frame: AtomicU32,
yaw: AtomicU32,
}
impl Probe {
fn observe(&self, host: &dyn Host, phase: &str, flags: Option<(bool, bool)>) {
let camera = host.camera();
if let Some(camera) = &camera {
assert_eq!(
camera.yaw.to_bits(),
self.yaw.load(Ordering::SeqCst),
"previous frame's camera in {phase}"
);
assert!(
camera
.view
.iter()
.chain(camera.projection.iter())
.all(|v| v.is_finite())
);
}
eprintln!(
"native_gui_probe {}",
serde_json::json!({
"frame": self.frame.load(Ordering::SeqCst), "phase": phase,
"yaw": camera.map(|c| c.yaw), "flags": flags
})
);
}
}
impl Hooks for Probe {
fn camera_pose(&self, _: &dyn Host, event: &mut CameraPoseEvent<'_>) {
let frame = self.frame.fetch_add(1, Ordering::SeqCst);
event.yaw = [0.0_f32, 90.0, 180.0, -90.0][frame as usize % 4];
self.yaw.store(event.yaw.to_bits(), Ordering::SeqCst);
}
fn hud(&self, host: &mut dyn Host, _: &mut HudEvent<'_>) {
self.observe(host, "hud", None);
}
fn screen_render_post(&self, host: &dyn Host, event: &mut ScreenRenderPostEvent<'_>) {
self.observe(host, event.screen.id, None);
}
fn gui_render_post(&self, host: &dyn Host, event: &mut GuiRenderPostEvent<'_>) {
let (w, h) = (event.batch.width, event.batch.height);
host.fill(
event.batch,
[w * 0.5 - 8.0, h - 24.0, 16.0, 16.0],
0xffff00ff,
);
self.observe(
host,
"gui_render_post",
Some((event.gui_hidden, event.focused)),
);
}
}
fn main() {
horizon_client::entry::main_with(
vec![Box::new(Probe {
frame: AtomicU32::new(0),
yaw: AtomicU32::new(0),
}) as Box<dyn Hooks>]
.into(),
);
}
@@ -0,0 +1,141 @@
//! Client interaction state through the production Allay predictor; no prepared resources.
use horizon_client::{
Result,
items::ItemStack,
mobs::{
Anim, Brain, Flags, Mob, MobType, Part, Species,
allay::interaction::{Interaction, predict},
},
};
use horizon_core::geometry::Vec3;
use std::{io::Write, sync::Arc};
fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:allay".into(),
species: Species::Allay,
width: 0.6,
height: 1.95,
eye_height: 0.36,
category: "monster".into(),
max_health: 20.0,
speed: 0.23,
flying_speed: 0.4,
attack_damage: 3.0,
attack_knockback: 0.0,
follow_range: 35.0,
step_height: 0.6,
armor: 2.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
item_stack_limits: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags::default(),
model: Arc::new(Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: Vec::new(),
}),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Allay,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
fn stack(s: Option<&ItemStack>) -> String {
s.filter(|s| s.count > 0)
.map_or_else(|| "empty".into(), |s| format!("{}x{}", s.item, s.count))
}
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[1])?);
for tag in 0..3 {
for main in [true, false] {
for item in [
"minecraft:air",
"minecraft:stone",
"minecraft:amethyst_shard",
] {
for holding in [false, true] {
for dancing in [false, true] {
for ready in [false, true] {
for creative in [false, true] {
let mut mob = Mob::new(37, kind(), Vec3::ZERO, 0.0);
mob.extra.flag = dancing;
mob.extra.egg_time = i32::from(!ready);
if holding {
mob.extra.set_main_hand_stack(Some(ItemStack::new(
"minecraft:stone",
1,
)));
}
let mut offer =
(item != "minecraft:air").then(|| ItemStack::new(item, 1));
for step in 0..3 {
let duplicates = offer.as_ref().is_some_and(|s| {
(tag != 2 && &*s.item == "minecraft:amethyst_shard")
|| (tag == 1 && &*s.item == "minecraft:stone")
});
let result = predict(
&mut mob,
offer.as_ref(),
main,
duplicates,
[0, 0, 0, 11],
);
let sounds = result.as_ref().map_or_else(String::new, |r| {
format!("{}:6:2.0:1.0:true", r.sound())
});
let mut returned = String::new();
match result {
Some(Interaction::Take(s)) => returned = stack(Some(&s)),
Some(Interaction::Duplicate | Interaction::Give)
if !creative =>
{
if let Some(s) = offer.as_mut() {
s.count -= 1;
}
if offer.as_ref().is_some_and(|s| s.count == 0) {
offer = None;
}
}
_ => {}
}
let hand = if main { "MAIN_HAND" } else { "OFF_HAND" };
writeln!(
out,
"{tag} {hand} {item} {holding} {dancing} {ready} {creative} {step} => held={} offer={} can={} liked={} returned=[{returned}] sounds=[{sounds}]",
stack(mob.extra.main_hand_stack()),
stack(offer.as_ref()),
mob.extra.egg_time == 0,
mob.extra.owner.is_some()
)?;
}
}
}
}
}
}
}
}
Ok(())
}
@@ -119,6 +119,7 @@ pub fn main() -> Result<()> {
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
}
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
}
Loaded 100 of 649 files, more files were not shown because too many files have changed in this diff. Show more