mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
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:
commit
167f156560
649 files changed
+55029
-5841
No files matched your search
@@ -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]
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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)" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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/`.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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| §ion.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();
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
¯os,
|
||||
&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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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", ¯os),
|
||||
&preprocess(&pack, "gbuffers_terrain.fsh", ¯os),
|
||||
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})"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user