Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 17a058bb2a launcher: a new Horizon instance is "My Horizon", its versions dated "Nov 1"
The mockup's instance dialog names a new instance before the player types (defaultName): "My
Horizon" for Horizon, as the launcher already did with "Vanilla 26.3" or "Fabric 26.3". A new
Horizon instance opened with an empty name and "New instance" as its title. The dialog now
starts with the default name for every kind, and it follows the kind until the player types
one. The Horizon version rows say "Nov 1" as the mockup's, not "2026-11-01" (the Versions
page's day format). One new string, "My Horizon", in the 16 languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:05:02 +01:00
1573 changed files with 17806 additions and 297597 deletions

No files matched your search

+1 -2
View File
@@ -1,4 +1,3 @@
self-hosted-runner:
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
# 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]
labels: [horizon-game, ludovics-mac-mini]
+9 -15
View File
@@ -1,16 +1,10 @@
# Trusted reviewers: one list for three things (docs/release-flow.md "Trusted reviewers").
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py);
# - `/unclaim @login` releases someone else's claim, and listed reviewers have no claim limit (tools/ci/claim_command.py).
# One reviewer per line: `<numeric GitHub ID> <login>`, with who added it and when. The ID decides; the login is only
# for people reading this file and for messages. A login is a name anyone can claim once its owner renames or deletes
# the account; an ID is never reused. Get it with `gh api users/<login> --jq .id`, or run
# `python3 tools/ci/resolve_reviewer_ids.py` to fill in the IDs and `--check` to catch renames.
# A line in any other form (a bare login included) grants nothing. Ludovic is always allowed (OWNER_ID in
# tools/ci/nightly_set.py) and isn't listed. Removing a line revokes that account at once. A line with only `paused`
# stops all three for every reviewer (Ludovic's still work).
54669067 kacpherek # added by Ludovic, 2026-10-27
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
+2 -2
View File
@@ -9,9 +9,9 @@ permissions:
actions: write
jobs:
cancel:
# One of our small runners (horizon-light), out of GitHub's pool: no checkout, no code of the pull request
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
steps:
- name: Cancel the pull request's unfinished runs
env:
-107
View File
@@ -1,107 +0,0 @@
name: cargo-horizon-mod
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120): binstall reads
# [package.metadata.binstall] from crates/cargo-horizon-mod/Cargo.toml and downloads the archive this workflow
# attaches to the tag `cargo-horizon-mod-v<version>`, whose version must be the manifest's. The targets come from
# tools/horizon_mod_binstall.py, the same file the manifest's template is checked against, so a platform added there
# is built here and one nobody publishes is caught before it ships. A pull request builds the archives and publishes
# nothing.
on:
push:
tags: ['cargo-horizon-mod-v*']
pull_request:
paths:
- 'crates/cargo-horizon-mod/**'
- 'tools/horizon_mod_binstall.py'
- '.github/workflows/cargo-horizon-mod.yml'
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
platforms: ${{ steps.platforms.outputs.platforms }}
steps:
- 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:
needs: plan
name: build ${{ matrix.target }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.platforms) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: The aarch64 Linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
- name: Build
run: cargo build --release --locked -p cargo-horizon-mod --target ${{ matrix.target }}
- name: It starts
if: matrix.run
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
out=$("$binary" 2>&1 || true)
case "$out" in
*"usage: cargo horizon-mod"*) ;;
*) echo "$out"; echo "::error::$binary does not start"; exit 1 ;;
esac
- name: Package
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -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@v7
with:
name: cargo-horizon-mod-${{ matrix.target }}
path: dist/*.tar.gz
if-no-files-found: error
retention-days: 3
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
pattern: cargo-horizon-mod-*
path: dist
merge-multiple: true
- name: The tag and the manifest name the same release and archives
run: python3 tools/horizon_mod_binstall.py dist "$GITHUB_REF_NAME"
- name: Checksums
run: |
set -euo pipefail
cd dist
for file in *.tar.gz; do sha256sum "$file" | cut -d' ' -f1 > "$file.sha256"; done
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
notes="\`cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod\`"
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
else
gh release create "$GITHUB_REF_NAME" dist/* --latest=false --title "cargo-horizon-mod ${GITHUB_REF_NAME#cargo-horizon-mod-v}" --notes "$notes"
fi
+21 -136
View File
@@ -27,24 +27,19 @@ jobs:
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
#
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's, an isolated user, so a fork's
# pull request runs there too) take the pull requests whose number ends in 0 or 1, about what their three runners
# can do; GitHub's runners take the others and main's pushes (which also save the Rust cache GitHub's pull request
# runs read), so both pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to
# ours, `light` (the desktop paused) or `off` every job to GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
# Where: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
# Python and the Rust cache steps are GitHub's only.
verify:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v7
- uses: actions/setup-python@v5
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
@@ -68,16 +63,6 @@ jobs:
else
python tools/check_public_tree.py --history
fi
# Our runners as GitHub's: some Python tests compile Rust with a bare `rustc` (GitHub's image has a default
# toolchain, our runners' own rustup homes may not), and the GPU tests find no adapter there, so they return
# early. Ours have Mesa's software Vulkan for parity; a backend Linux lacks keeps the tests from it, so a pull
# request's verify doesn't depend on which pool took it (#8627: a GPU test that flakes on lavapipe).
- name: Our runners as GitHub's (a default Rust toolchain, no GPU)
if: runner.environment != 'github-hosted'
run: |
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
@@ -119,14 +104,10 @@ jobs:
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request change the launcher or a crate it builds?
@@ -158,113 +139,18 @@ jobs:
run: |
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib --bins
# 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")
- name: Build a native-mod game with the launcher's managed toolchain
working-directory: launcher
shell: pwsh
env:
HORIZON_LAUNCHER_HOME: ${{ runner.temp }}\horizon-native-arm64-toolchain
HORIZON_NATIVE_TARGET_DIR: ${{ runner.temp }}\horizon-native-arm64-toolchain\target
run: |
$ErrorActionPreference = 'Stop'
cargo test --release --locked --target aarch64-pc-windows-msvc --lib rust_toolchain::tests::managed_arm64_toolchain_builds_the_game -- --ignored --exact --nocapture
$client = Join-Path $env:HORIZON_NATIVE_TARGET_DIR 'fast/horizon-client.exe'
python ../tools/check_pe_arch.py arm64 $client
python ../tools/check_imports.py $client
cargo +stable test --locked --lib
# 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).
parity-scope:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
# A git diff of this repository's own branch: one of our small runners (horizon-light, docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
@@ -276,7 +162,7 @@ jobs:
run: |
changed=$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)
harness=$(grep -E '^(tools/test_[a-z0-9_]+_parity\.py|tools/(parity[a-z_]*|lab)\.py|tools/parity_hosts\.json|mods/horizon/src/test/|crates/[^/]+/examples/|crates/horizon-client/src/game/[a-z_]*(fixture|harness)\.rs|\.github/workflows/parity[a-z-]*\.yml)' <<< "$changed" || true)
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]] || [[ " $LABELS " == *" frame-identity "* ]]; then
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "The pull request runs its harnesses: ${harness:-label run-parity}"
else
@@ -291,7 +177,6 @@ jobs:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
frame_identity: ${{ contains(github.event.pull_request.labels.*.name, 'frame-identity') }}
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
@@ -300,13 +185,13 @@ jobs:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- uses: actions/setup-java@v6
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
@@ -648,11 +533,11 @@ jobs:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/setup-java@v6
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
+6 -8
View File
@@ -29,14 +29,14 @@ jobs:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
# One of our small runners (horizon-light), out of GitHub's pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
@@ -50,22 +50,20 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
# One of our small runners (horizon-light), out of GitHub's pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
+12 -49
View File
@@ -1,6 +1,6 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server, 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
# 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
# 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@v7
- uses: actions/checkout@v4
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@v7
- uses: actions/upload-artifact@v4
with:
name: original-exporters
path: exporters/
@@ -83,10 +83,6 @@ jobs:
build:
needs: exporters
name: build ${{ matrix.name }}
# Windows ARM64 and musl are new: a 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
# optional archives out with a warning.
continue-on-error: ${{ matrix.name == 'windows-arm64' || matrix.name == 'linux-musl' }}
strategy:
fail-fast: false
matrix:
@@ -98,28 +94,13 @@ jobs:
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: linux-musl
os: ubuntu-22.04
musl: true
dir: horizon-linux-musl-x86_64
asset: horizon-linux-musl-x86_64.tar.gz
- name: windows
os: windows-latest
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
@@ -147,7 +128,7 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -160,29 +141,21 @@ jobs:
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted' && !matrix.musl
if: runner.environment == 'github-hosted'
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- name: ALSA and udev headers
if: runner.os == 'Linux' && !matrix.musl
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -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: Every program the release folder takes was built
if: ${{ !matrix.musl }}
run: |
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:
@@ -200,7 +173,6 @@ jobs:
codesign --verify --strict --verbose=2 "$BIN/$binary"
done
- name: Only system libraries
if: ${{ !matrix.musl }}
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
@@ -216,14 +188,13 @@ 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' && matrix.pe_arch != 'arm64' && !matrix.musl
if: runner.os != 'macOS'
run: |
set -euo pipefail
rustup component add llvm-tools
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
- name: Every program starts
if: ${{ !matrix.musl }}
run: |
set -euo pipefail
bin=$BIN
@@ -268,26 +239,18 @@ 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@v7
- uses: actions/upload-artifact@v4
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@v8
- uses: actions/download-artifact@v4
with:
name: original-exporters
path: exporters
- name: Build, check and package for musl in Alpine
if: matrix.musl
# Run Docker on the Ubuntu host: JavaScript actions themselves require glibc.
run: |
docker run --rm -v "$PWD:/work" -w /work \
-e HORIZON_VERSION -e HORIZON_NIGHTLY \
rust:alpine3.22 sh tools/ci/build-musl.sh
- name: Package
if: ${{ !matrix.musl }}
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
@@ -314,7 +277,7 @@ jobs:
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
@@ -336,7 +299,7 @@ jobs:
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
+15 -47
View File
@@ -1,5 +1,5 @@
name: launcher
# Builds the launcher on Linux, macOS, Windows x64 and ARM64, and releases it by itself (docs/release-flow.md): after a
# Builds the launcher on Linux, macOS and Windows, 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,9 +15,8 @@ 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 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).
# 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).
# 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
@@ -74,7 +73,7 @@ jobs:
version: ${{ steps.decide.outputs.version }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: mode
@@ -103,10 +102,6 @@ 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:
@@ -130,20 +125,9 @@ 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 }}
@@ -166,7 +150,7 @@ jobs:
working-directory: launcher
shell: bash
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
@@ -198,9 +182,6 @@ 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)
@@ -231,8 +212,7 @@ 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. The Windows ARM64 launcher always has
# one: it installs the x64 build where a release has no ARM64 one (backend.rs, GAME_FALLBACK).
# (exit 3); a launcher or a script that fails still fails the step.
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)'
@@ -256,11 +236,6 @@ 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"
@@ -320,8 +295,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/${{ matrix.setup_asset }}"
- uses: actions/upload-artifact@v7
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: launcher/out/*
@@ -339,12 +314,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@v7
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-launcher-*
@@ -356,13 +331,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@v7
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
@@ -376,7 +351,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
@@ -386,7 +361,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@v8
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
@@ -398,13 +373,6 @@ 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:
@@ -419,7 +387,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@v7
- uses: actions/upload-artifact@v4
with:
name: launcher-release-notes
path: notes/
+2 -8
View File
@@ -15,23 +15,17 @@ on:
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
concurrency:
group: mod-registry-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: crates/horizon-mod-registry
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-mod-registry
save-if: ${{ github.ref == 'refs/heads/main' }}
+2 -3
View File
@@ -21,11 +21,10 @@ concurrency:
jobs:
check:
# One of our small runners (label horizon-light, docs/ci-runners.md): main's script, Mojang's public manifest.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
+2 -6
View File
@@ -24,7 +24,7 @@ jobs:
pull-requests: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
@@ -52,8 +52,4 @@ jobs:
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "$branch"
# 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
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.'
+12 -47
View File
@@ -92,7 +92,7 @@ jobs:
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
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@v7
- uses: actions/upload-artifact@v4
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@v7
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
path: nightly-notes.md
@@ -155,42 +155,32 @@ 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@v7
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums for the nightly channel (SHA256SUMS.sig) and for this build (SHA256SUMS.v2.sig)
# SHA256SUMS.sig is for launchers from before v2 (signed for "nightly"); SHA256SUMS.v2.sig is signed for
# nightly-<date>-<commit>, the version the release title names, which current launchers and servers
# require (docs/security.md, "Nightly signatures"). The publish step titles the release from the same values.
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
ALSO_FOR: nightly-${{ needs.pick.outputs.date }}-${{ needs.pick.outputs.short }}
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
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
else echo "::warning::horizon-linux-musl-x86_64.tar.gz 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[@]}" "${musl[@]}" \
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@v7
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
if: needs.pick.outputs.dry_run == 'true'
with:
name: ephemeral.pub
@@ -205,24 +195,19 @@ jobs:
contents: write
issues: read # the open release-blocker issues (gate)
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signatures, checked as launchers check them
- name: The signature, checked as launchers check it
env:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
DATE: ${{ needs.pick.outputs.date }}
SHORT: ${{ needs.pick.outputs.short }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
# The version's own signature, which current launchers require: the title below names the same version.
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "nightly-${DATE}-${SHORT}" \
--signature SHA256SUMS.v2.sig --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
# only reports what a real run would do (that is how a blocker's fix is tried).
@@ -239,37 +224,18 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
SHORT: ${{ needs.pick.outputs.short }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
[[ -f dist/SHA256SUMS.v2.sig ]] && signature+=(dist/SHA256SUMS.v2.sig)
# The title is what launchers parse (strictly) into the version SHA256SUMS.v2.sig is signed for.
[[ "${SHA:0:7}" == "$SHORT" ]] || { echo "::error::the build's commit isn't the one signed"; exit 1; }
# 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
musl=()
if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then
musl=("dist/horizon-linux-musl-x86_64.tar.gz#Game files for musl Linux (horizon-linux-musl-x86_64.tar.gz)")
else
echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: the Nightly goes out without it"
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)" \
"${musl[@]}" \
"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)" \
@@ -287,8 +253,7 @@ jobs:
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
needs: [pick, build, sign, publish]
if: always() && inputs.issue != '' && needs.pick.result == 'success'
# One comment, no checkout and no secret: one of our small runners (label horizon-light, docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
+1 -3
View File
@@ -27,7 +27,7 @@ jobs:
issues: write # answers in the thread
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
@@ -41,8 +41,6 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/nightly_set.py command
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v7
- uses: actions/checkout@v4
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@v7
uses: actions/upload-artifact@v4
with:
name: nightly-parity-logs
path: .local/parity-runner/logs/
+3 -3
View File
@@ -19,15 +19,15 @@ jobs:
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
+4 -15
View File
@@ -22,24 +22,17 @@ env:
jobs:
bench:
# Our checks runners (label horizon-checks, docs/ci-runners.md): the branch and the base on the same machine, as
# the comparison needs. SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: pack-bench
- name: Bench the branch
@@ -53,15 +46,11 @@ jobs:
SCALE: ${{ inputs.scale || '1' }}
run: |
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
# Our runners keep the folder beside the checkout between jobs.
git worktree remove --force ../base 2> /dev/null || rm -rf ../base
git worktree prune
git worktree add ../base FETCH_HEAD
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
# A bench row that times a function the base lacks keeps it from building there: then
# the branch's own rows compare the two ways.
target=${CARGO_TARGET_DIR:-$GITHUB_WORKSPACE/target}
(cd ../base && CARGO_TARGET_DIR="$target" cargo run --profile fast -p horizon-client-render \
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
--example pack_load_bench -- --scale "$SCALE") > base.md \
|| echo "The bench does not build on the base." > base.md
- name: Summary
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
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@v7
uses: actions/upload-artifact@v4
with:
name: parity-main-logs
path: .local/parity-main-logs/
+2 -14
View File
@@ -20,12 +20,6 @@ on:
busy the job waits for it.
type: boolean
default: false
frame_identity:
description: >-
The pull request carries the frame-identity label: it must leave the picture alone (HDR, shader packs), so
the frame_identity harness, which no automatic selection picks, runs too.
type: boolean
default: false
permissions:
contents: read
env:
@@ -39,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@v7
- uses: actions/checkout@v4
with:
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
# main into it.
@@ -93,15 +87,9 @@ jobs:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.merge.outputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: The pictures this change must leave alone
if: ${{ !cancelled() && inputs.frame_identity }}
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.merge.outputs.base }}
run: python3 tools/parity.py frame_identity --timeout 2400
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: parity-logs
path: .local/parity-runner/logs/
+3 -3
View File
@@ -16,15 +16,15 @@ jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
urgent: ${{ steps.decide.outputs.urgent }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: mode
-1
View File
@@ -27,4 +27,3 @@ jobs:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
frame_identity: ${{ contains(github.event.pull_request.labels.*.name, 'frame-identity') }}
+2 -8
View File
@@ -15,23 +15,17 @@ on:
- '.github/workflows/telemetry-server.yml'
permissions:
contents: read
concurrency:
group: telemetry-server-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: crates/horizon-telemetry-server
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-telemetry-server
save-if: ${{ github.ref == 'refs/heads/main' }}
+2 -5
View File
@@ -21,15 +21,14 @@ jobs:
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
# One of our small runners (label horizon-light, docs/ci-runners.md): main's script only, as above.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
@@ -43,8 +42,6 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
+2 -4
View File
@@ -9,10 +9,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (logs, crash reports, telemetry consent, recordings; not saves), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Saves, `options.txt` and
caches go in the game folder, by default the working directory: start the game from a scratch folder with
`--game-dir` pointing at it, or they land in the repo root, hidden by `.gitignore`. For the
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
+33 -34
View File
@@ -5,8 +5,7 @@ everything is that **it plays exactly like vanilla**. This page is the short ver
welcome on [Discord](https://discord.gg/playhorizon).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything
else (videos, Discord and other community material) lives elsewhere.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
@@ -15,25 +14,23 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
## Build it
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
(`libasound2-dev libudev-dev`).
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
described in [Build from source](README.md#build-from-source).
- **Commands:**
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
[Build from source](README.md#build-from-source).
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
and compares their outputs. There are about 290 of them.
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
probe against Horizon, and compares their outputs. There are about 290 of them.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
@@ -44,10 +41,11 @@ and compares their outputs. There are about 290 of them.
off by default wherever they would change the vanilla look.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
`tools/check_public_tree.py` rejects them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code, or code whose license allows it
@@ -56,23 +54,24 @@ another project's source may also be read and used, **only when that project's l
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
at your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
another project's source; they come back only as a clean-room rewrite from a written description of the pack
format.
## Before asking for review
@@ -88,8 +87,8 @@ your change can affect. Without the vanilla data on your machine it lists those
`--quick` skips the harnesses. It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
including examples. The dependency scope includes optional, platform, build and development dependencies.
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
## Claim before you start
@@ -124,8 +123,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
`label:parity` is the list of real progress toward vanilla.
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix. `release-now` is for crash
fixes that shouldn't wait. pr_ready suggests one.
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
Generated
-17
View File
@@ -1318,7 +1318,6 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"dunce",
"flate2",
"fs4",
"gilrs",
@@ -1401,7 +1400,6 @@ dependencies = [
"chrono",
"flate2",
"fs4",
"getrandom 0.3.4",
"gilrs",
"glam",
"horizon-anticheat",
@@ -1431,7 +1429,6 @@ dependencies = [
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"mio",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -1448,7 +1445,6 @@ dependencies = [
"ureq",
"wgpu",
"winit",
"xxhash-rust",
"zip",
]
@@ -1717,7 +1713,6 @@ dependencies = [
"horizon-mcp",
"horizon-release-sign",
"horizon-server-files",
"horizon-world",
"libc",
"p12-keystore",
"png",
@@ -2320,18 +2315,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
+4 -4
View File
@@ -7,7 +7,7 @@
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -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&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-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.
**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).
@@ -122,13 +122,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
## Project status
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
### Java parity: 100%
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
+33 -14
View File
@@ -206,7 +206,7 @@ Manage a server from a console, ban and pardon players, and drive it from other
<details><summary>Technical</summary>
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The last pieces merged: bans edited while the server is stopped ([#9767](https://github.com/horizon-rust/horizon/issues/9767)) and the MCP tools' contracts pinned by a test ([#9748](https://github.com/horizon-rust/horizon/issues/9748)). The panel's later features and vanilla's management protocol are their own item (server-panel-management). [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
@@ -396,16 +396,6 @@ A Datapacks tab beside Mods and Resource packs: install into a chosen world's da
### Servers and multiplayer
#### Server panel extras and vanilla's management protocol
More of a server managed from its web panel (bans, worlds, logs and crash reports, modpacks), and vanilla's own server-management tools working with a Horizon server.
<details><summary>Technical</summary>
The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. Split from server-admin. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
@@ -610,6 +600,16 @@ An output device list in Music & Sounds, falling back to the system default. [#1
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn.
</details>
### Rendering
#### A light default shader pack
@@ -860,11 +860,30 @@ More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/pe
</details>
### Gameplay parity
#### End city banners keep their pattern
Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
</details>
#### Enchanted loot in End cities
Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
</details>
## Recently shipped
- **Enchanted loot in End cities** (v0.2.73): Armour and tools in End city chests should come enchanted, as in vanilla. [#1437](https://github.com/horizon-rust/horizon/issues/1437) · [#1660](https://github.com/horizon-rust/horizon/issues/1660) · [#1911](https://github.com/horizon-rust/horizon/issues/1911) · [#2037](https://github.com/horizon-rust/horizon/issues/2037)
- **End city banners keep their pattern** (v0.2.67): Banners in End cities show as plain magenta instead of with their pattern. [#1438](https://github.com/horizon-rust/horizon/issues/1438) · [#526](https://github.com/horizon-rust/horizon/issues/526) · [#1928](https://github.com/horizon-rust/horizon/issues/1928)
- **Play in your language** (v0.2.65): Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English. [#1806](https://github.com/horizon-rust/horizon/issues/1806) · [#1594](https://github.com/horizon-rust/horizon/issues/1594) · [#1672](https://github.com/horizon-rust/horizon/issues/1672)
- **Chickens flap while they fall** (v0.2.67): Chickens should flap their wings while they fall, not stand still in the air. [#1706](https://github.com/horizon-rust/horizon/issues/1706) · [#1892](https://github.com/horizon-rust/horizon/issues/1892)
- **Ctrl+Backspace and scroll bars in menus** (v0.2.67): Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. [#1764](https://github.com/horizon-rust/horizon/issues/1764) · [#1782](https://github.com/horizon-rust/horizon/issues/1782) · [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
- **A pointing hand over buttons** (v0.2.67): The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. [#1347](https://github.com/horizon-rust/horizon/issues/1347) · [#1897](https://github.com/horizon-rust/horizon/issues/1897)
-47
View File
@@ -14,11 +14,6 @@ Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
The GLSL frontend also resets the common type for each comma-separated declarator so one
variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
@@ -170,45 +165,3 @@ BlueMap is licensed under the MIT license:
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
> THE SOFTWARE.
## JOML
`crates/horizon-client-render/src/camera.rs` (`joml::Mat4::rotation`, `invert_orthonormal`) and
`crates/horizon-client-render/src/gfx/pack_uniforms.rs` (`invert_generic`, `invert_affine`, the
formulas of `pack_projection` and `shadow_ortho`) adapt the float arithmetic of
`Matrix4f.rotation(Quaternionfc)`, `invertOrthonormal`, `invertGenericNonThis`, `invertAffine`,
`setPerspective` and `setOrtho` from
[JOML](https://github.com/JOML-CI/JOML/tree/faf9eef2511242122beb6055fe3da92daed214c5) 1.10.9, the
library vanilla 26.3 uses, so that shader packs get the same matrices to the last bit. Changes:
ported to Rust; the pack_uniforms.rs functions drop JOML's property flags, and
`pack_projection` works the formula in doubles. The copyright line and license notice also head
both files.
`camera.rs` also adapts `Quaternionf.rotationX`, `rotationY` and `rotationZ`,
`Matrix4f.rotateAffine` and `Vector4f.mulAffine` (`joml::Quat::rotation_x`/`_y`/`_z`,
`joml::Mat4::rotate_affine`, `transform_affine`), for the sky's positions shader packs read.
Changes: ported to Rust; `rotate_affine` keeps only the affine case.
```
The MIT License
Copyright (c) 2015-2026 Richard Greenlees
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
+5 -9
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
python3 tools/bench.py --server horizon --exe target/release/horizon-server --bots 50
python3 tools/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
python3 tools/bench.py --server pumpkin --exe ./pumpkin --bots 50
python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50
python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50
Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the
server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds:
@@ -39,11 +39,6 @@ import tempfile
import threading
import time
try:
from . import lab
except ImportError:
import lab
ROOT = Path(__file__).resolve().parent.parent
EXE = '.exe' if os.name == 'nt' else ''
SERVERS = ('horizon', 'vanilla', 'pumpkin')
@@ -212,7 +207,8 @@ class Server:
self.lines = []
self.lock = threading.Lock()
self.log = log_path.open('w', encoding='utf-8')
env = lab.game_env(HORIZON_HOME=directory / 'horizon-home')
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'),
HORIZON_LAUNCHER_NO_SELF_UPDATE='1')
self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
encoding='utf-8', errors='replace', env=env)
@@ -1,42 +0,0 @@
# Entity occlusion split measurements — 2026-11-02
These measurements came from the pre-split integration branch for PR #7054. That build contained the entity-occlusion rewrite and particle light lookup cache; Particle Culling was Off. They are useful comparison evidence, but they are not a benchmark of this isolated entity-only commit. The cache-only run in PR #7391 measured effectively the same CPU time on the ordinary scene, but the combined result still cannot isolate interactions.
Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap. Two opposite-order passes measured for 8 seconds after warmup. Values are means. CPU is harness average CPU ms per frame; p99 is frame time. The driver supplied no usable GPU timing.
## Ordinary scene
The scene had 24 mobs, roughly 9,400–10,100 rain particles and campfire smoke.
| Build and mode | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, entity culling Off | 88.59 | 1.829 | 24.51 |
| Pre-split integration branch, all culling Off | 88.33 | 2.063 | 24.51 |
| Main, entity culling Synchronous | 89.34 | 1.965 | 23.99 |
| Pre-split integration branch, entity Synchronous | 89.13 | 2.113 | 24.15 |
This normal-scene run does not show a large FPS change; its CPU differences are small relative to frame time and not attributable to the entity-only change by themselves.
## Opaque-wall scene
A 32×32 platform at Y=300, a 7-block wall with a one-block vertical opening, and 192 pigs spawned behind the wall (216 mobs in the captured scene). The player looked toward the opening from the near side. The same world and camera path were used for the main and integration builds.
| Build and mode | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, Off | 89.08 | 2.423 | 13.52 |
| Integration branch, Off | 89.23 | 2.355 | 13.27 |
| Main, Synchronous | 92.81 | 2.566 | 14.60 |
| Integration branch, Synchronous | 93.21 | 2.550 | 12.84 |
| Main, Asynchronous | 92.73 | 2.364 | 13.04 |
| Integration branch, Asynchronous | 92.62 | 2.314 | 13.18 |
The asynchronous mode reduced measured CPU time slightly in this wall scene but did not improve FPS beyond run-to-run spread. This is a synthetic occlusion-heavy scene, not a broad performance claim.
## Captures
The HUD performance overlay is visible in each capture. The integration-build screenshots were recorded with the same wall and camera position in Off, Synchronous, and Asynchronous modes.
- Main, Off: `main-off.png`
- Integration branch, Off: `branch-off.png`
- Integration branch, Synchronous: `branch-sync.png`
- Integration branch, Asynchronous: `branch-async.png`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

@@ -1,42 +0,0 @@
# Entity occlusion pipeline — release/OpenGL run (2026-11-04)
This is the final local smoke benchmark for the entity-occlusion pipeline before opening the PR. It was run from the branch after the release build completed, with the OpenGL backend selected explicitly. The same process created each scene, switched the culling mode, flew the camera for two seconds, collected frame/CPU telemetry, and captured the screenshot after the scene settled.
**Host:** Intel Core i7-3770S, Intel HD Graphics 4000, Mesa OpenGL 4.2
**Window:** 1280×720, offscreen, VSync disabled
**World:** fresh temporary home, 32×32 platform at Y=300; wall scenes add a solid stone wall and an optional one-block slit
**Entities:** 24 pigs in the normal scene, 192 pigs in wall scenes, 512 pigs in stress scenes
**Settings:** render distance 8, `entityCullingPrecheck:64`, FPS overlay on, no resource-pack occlusion assumptions
**Build:** `cargo build --release -p horizon-client`
## Results
The values below are branch-only measurements. They are useful for comparing the three modes under identical geometry and for checking that the worker is doing useful work. A fresh main binary was not built in this short pass, so these numbers do not claim a controlled main-to-branch delta.
| Scene | Off FPS / CPU p50 | Sync FPS / CPU p50 | Async FPS / CPU p50 | Async entities culled |
| --- | ---: | ---: | ---: | ---: |
| `entity_normal` (24 visible) | 240.5 / 0.586 ms | 233.5 / 0.762 ms | 239.3 / 0.686 ms | 0 |
| `entity_wall` (192, solid) | 228.8 / 1.245 ms | 318.6 / 1.342 ms | **324.5 / 0.478 ms** | 124,608 |
| `entity_wall_slit` (192, one-block slit) | 231.4 / 1.102 ms | 265.0 / 1.387 ms | **281.2 / 0.716 ms** | 102,826 |
| `entity_stress` (512, solid) | 190.3 / 2.467 ms | 299.2 / 2.593 ms | **325.5 / 0.691 ms** | 290,234 |
| `entity_stress_slit` (512, one-block slit) | 190.4 / 2.476 ms | 262.2 / 2.573 ms | **262.1 / 1.297 ms** | 227,301 |
Async telemetry confirmed that the render thread stayed non-blocking (`try_recv`/`try_send`, `worker_blocking_wait_ms: 0`). The latest wall/slit run completed 562 worker jobs, queued 3,982 queries, avoided about 4.0 million entity bytes, and had 379 ns recent p50 worker-job time. The solid 512-pig run culled 15,552 generated vertices; the slit run culled 120,290 generated vertices. The normal scene recorded no pre-model hides and kept the opt-in path close to Off.
## Captures
Every image includes the FPS performance overlay. The names are `<mode>-<scene>.png`:
- [Off — normal](screenshots/off-entity_normal.png), [wall](screenshots/off-entity_wall.png), [wall slit](screenshots/off-entity_wall_slit.png), [stress](screenshots/off-entity_stress.png), [stress slit](screenshots/off-entity_stress_slit.png)
- [Sync — normal](screenshots/sync-entity_normal.png), [wall](screenshots/sync-entity_wall.png), [wall slit](screenshots/sync-entity_wall_slit.png), [stress](screenshots/sync-entity_stress.png), [stress slit](screenshots/sync-entity_stress_slit.png)
- [Async — normal](screenshots/async-entity_normal.png), [wall](screenshots/async-entity_wall.png), [wall slit](screenshots/async-entity_wall_slit.png), [stress](screenshots/async-entity_stress.png), [stress slit](screenshots/async-entity_stress_slit.png)
## Reproduction
From the repository root, with prepared 26.3 resources available:
```sh
python3 tools/entity_occlusion_bench.py
```
The script uses fresh temporary game data, the release binary in `target/release`, OpenGL, the FPS overlay, and writes JSON plus captures under `/tmp/horizon-entity-occlusion-release-quick/`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

@@ -1,27 +0,0 @@
# Particle culling review measurements — 2026-11-02
Ordinary-scene captures and measurements for PR #7054. The scene contains 24 mobs, about 9,400–10,100 rain particles, and about 50–100 campfire smoke particles. Two opposite-order passes were run with 2 s warmup and 8 s measurement per condition. Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap.
These are means of the two samples. CPU is the harness's average CPU time per frame; p99 is frame time. The normal scene is not expected to show large gains because most particles are visible.
| Condition | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, culling Off | 88.39 | 1.930 | 23.85 |
| Particle branch, culling Off | 88.55 | 1.828 | 24.88 |
| Main, Entity Culling Synchronous | 89.12 | 1.887 | 23.07 |
| Particle branch, Entity Culling Synchronous, Particle Culling Off | 89.06 | 1.897 | 25.18 |
| Particle Culling Synchronous | 88.87 | 1.774 | 24.94 |
| Particle Culling Asynchronous* | 89.00 | 1.754 | 24.90 |
*The asynchronous samples predate the final switch to a dedicated particle worker. They are retained only as historical context and are not a measurement of the final worker implementation. No FPS improvement is claimed from these ordinary-scene runs. The Off path delegates directly to `render`; `disabled_culled_render_matches_the_regular_particle_path` checks that it produces the same vertices.
The FPS and CPU differences are within run-to-run variability for this GPU-limited scene. The first four conditions are the reviewer's requested Main-versus-branch default and existing synchronous-mode comparisons. Particle Sync/Async rows are branch-internal mode comparisons.
## Captures
The screenshots show the same ordinary test world and FPS overlay:
- `main-off.png`
- `particle-off.png`
- `particle-sync.png`
- `particle-async.png`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

-870
View File
@@ -1,870 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:00:15Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "nvidia",
"driver": "NVIDIA 595.71.05",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.408,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 66658.183,
"stdev": 61839.502,
"min": 1827.42,
"max": 124993.694,
"ci95": 153630.237
},
"startup.world_ms": {
"n": 3,
"mean": 74754.411,
"stdev": 59189.587,
"min": 13600.651,
"max": 131761.773,
"ci95": 147046.952
},
"chunks.village.load_ms": {
"n": 3,
"mean": 5571.499,
"stdev": 1900.32,
"min": 3378.38,
"max": 6730.394,
"ci95": 4721.037
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 15287.0,
"stdev": 527.501,
"min": 14788.0,
"max": 15839.0,
"ci95": 1310.49
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2958.557,
"stdev": 376.543,
"min": 2526.453,
"max": 3216.434,
"ci95": 935.46
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 368.457,
"stdev": 90.668,
"min": 300.631,
"max": 471.438,
"ci95": 225.25
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 91.04,
"stdev": 40.506,
"min": 46.124,
"max": 124.796,
"ci95": 100.63
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 581.0,
"stdev": 48.0,
"min": 533.0,
"max": 629.0,
"ci95": 119.248
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1277.531,
"stdev": 25.546,
"min": 1253.879,
"max": 1304.623,
"ci95": 63.466
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.529,
"stdev": 0.127,
"min": 0.396,
"max": 0.649,
"ci95": 0.315
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.185,
"stdev": 0.034,
"min": 0.162,
"max": 0.224,
"ci95": 0.085
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3318.3,
"stdev": 911.608,
"min": 2266.627,
"max": 3883.075,
"ci95": 2264.743
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 16565.333,
"stdev": 172.755,
"min": 16367.0,
"max": 16683.0,
"ci95": 429.182
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2333.626,
"stdev": 569.124,
"min": 1745.365,
"max": 2881.457,
"ci95": 1413.897
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 336.615,
"stdev": 185.666,
"min": 208.133,
"max": 549.487,
"ci95": 461.257
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 160.734,
"stdev": 127.586,
"min": 39.777,
"max": 294.05,
"ci95": 316.967
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 666.333,
"stdev": 24.44,
"min": 645.0,
"max": 693.0,
"ci95": 60.718
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1346.816,
"stdev": 42.279,
"min": 1305.547,
"max": 1390.037,
"ci95": 105.035
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.669,
"stdev": 0.084,
"min": 0.598,
"max": 0.761,
"ci95": 0.208
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.222,
"stdev": 0.032,
"min": 0.197,
"max": 0.258,
"ci95": 0.079
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2463.434,
"stdev": 1056.0,
"min": 1527.516,
"max": 3608.283,
"ci95": 2623.462
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 14226.667,
"stdev": 307.077,
"min": 14038.0,
"max": 14581.0,
"ci95": 762.883
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2192.874,
"stdev": 1320.904,
"min": 673.829,
"max": 3071.415,
"ci95": 3281.573
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 345.406,
"stdev": 294.904,
"min": 8.977,
"max": 559.232,
"ci95": 732.642
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 172.573,
"stdev": 149.386,
"min": 1.038,
"max": 274.089,
"ci95": 371.125
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 629.0,
"stdev": 32.0,
"min": 597.0,
"max": 661.0,
"ci95": 79.499
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1352.223,
"stdev": 51.951,
"min": 1301.492,
"max": 1405.314,
"ci95": 129.065
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.512,
"stdev": 0.099,
"min": 0.406,
"max": 0.603,
"ci95": 0.246
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.178,
"stdev": 0.038,
"min": 0.135,
"max": 0.206,
"ci95": 0.094
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 6260.675,
"stdev": 3077.348,
"min": 2976.342,
"max": 9077.55,
"ci95": 7645.173
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 16015.333,
"stdev": 209.872,
"min": 15826.0,
"max": 16241.0,
"ci95": 521.393
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1412.095,
"stdev": 962.145,
"min": 714.773,
"max": 2509.775,
"ci95": 2390.293
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 182.479,
"stdev": 177.563,
"min": 13.364,
"max": 367.429,
"ci95": 441.126
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 88.451,
"stdev": 87.578,
"min": 1.526,
"max": 176.667,
"ci95": 217.572
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 799.667,
"stdev": 18.475,
"min": 789.0,
"max": 821.0,
"ci95": 45.899
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1491.572,
"stdev": 36.719,
"min": 1459.775,
"max": 1531.76,
"ci95": 91.221
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.697,
"stdev": 0.155,
"min": 0.602,
"max": 0.876,
"ci95": 0.386
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 0.325,
"stdev": 0.115,
"min": 0.239,
"max": 0.456,
"ci95": 0.286
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1545.896,
"stdev": 30.591,
"min": 1524.965,
"max": 1581.004,
"ci95": 75.999
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1348.389,
"stdev": 52.5,
"min": 1296.172,
"max": 1401.168,
"ci95": 130.428
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 37594,
"seconds": 12.0,
"avg_fps": 3132.783,
"low_1pct_fps": 471.438,
"low_01pct_fps": 124.796,
"p50_ms": 0.271,
"p95_ms": 0.494,
"p99_ms": 1.127,
"p999_ms": 2.15,
"max_ms": 155.204,
"cpu_p50_ms": 0.168,
"gpu_p50_ms": 0.104,
"histogram": [
37151,
394,
30,
8,
5,
2,
2,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 38598,
"seconds": 12.0,
"avg_fps": 3216.434,
"low_1pct_fps": 300.631,
"low_01pct_fps": 46.124,
"p50_ms": 0.267,
"p95_ms": 0.446,
"p99_ms": 1.034,
"p999_ms": 1.941,
"max_ms": 493.993,
"cpu_p50_ms": 0.162,
"gpu_p50_ms": 0.104,
"histogram": [
38177,
387,
16,
8,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
1,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 30318,
"seconds": 12.0,
"avg_fps": 2526.453,
"low_1pct_fps": 333.302,
"low_01pct_fps": 102.199,
"p50_ms": 0.305,
"p95_ms": 0.757,
"p99_ms": 1.69,
"p999_ms": 3.539,
"max_ms": 170.856,
"cpu_p50_ms": 0.224,
"gpu_p50_ms": 0.106,
"histogram": [
29368,
759,
139,
35,
11,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 34578,
"seconds": 12.0,
"avg_fps": 2881.457,
"low_1pct_fps": 549.487,
"low_01pct_fps": 294.05,
"p50_ms": 0.32,
"p95_ms": 0.483,
"p99_ms": 1.213,
"p999_ms": 2.5,
"max_ms": 5.358,
"cpu_p50_ms": 0.197,
"gpu_p50_ms": 0.154,
"histogram": [
34119,
379,
64,
9,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 28489,
"seconds": 12.0,
"avg_fps": 2374.055,
"low_1pct_fps": 208.133,
"low_01pct_fps": 39.777,
"p50_ms": 0.313,
"p95_ms": 0.937,
"p99_ms": 1.863,
"p999_ms": 4.318,
"max_ms": 571.48,
"cpu_p50_ms": 0.211,
"gpu_p50_ms": 0.151,
"histogram": [
27193,
1071,
140,
51,
19,
9,
1,
2,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 20945,
"seconds": 12.0,
"avg_fps": 1745.365,
"low_1pct_fps": 252.226,
"low_01pct_fps": 148.375,
"p50_ms": 0.343,
"p95_ms": 1.505,
"p99_ms": 2.758,
"p999_ms": 5.341,
"max_ms": 11.642,
"cpu_p50_ms": 0.258,
"gpu_p50_ms": 0.162,
"histogram": [
18145,
2293,
337,
106,
38,
13,
6,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 36856,
"seconds": 12.0,
"avg_fps": 3071.415,
"low_1pct_fps": 559.232,
"low_01pct_fps": 274.089,
"p50_ms": 0.286,
"p95_ms": 0.492,
"p99_ms": 1.179,
"p999_ms": 2.711,
"max_ms": 6.718,
"cpu_p50_ms": 0.135,
"gpu_p50_ms": 0.127,
"histogram": [
36366,
405,
60,
13,
10,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 34001,
"seconds": 12.0,
"avg_fps": 2833.377,
"low_1pct_fps": 468.008,
"low_01pct_fps": 242.591,
"p50_ms": 0.295,
"p95_ms": 0.589,
"p99_ms": 1.41,
"p999_ms": 3.048,
"max_ms": 7.7,
"cpu_p50_ms": 0.206,
"gpu_p50_ms": 0.126,
"histogram": [
33314,
544,
106,
23,
10,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 8086,
"seconds": 12.0,
"avg_fps": 673.829,
"low_1pct_fps": 8.977,
"low_01pct_fps": 1.038,
"p50_ms": 0.302,
"p95_ms": 0.665,
"p99_ms": 2.495,
"p999_ms": 15.716,
"max_ms": 4849.364,
"cpu_p50_ms": 0.194,
"gpu_p50_ms": 0.128,
"histogram": [
7808,
166,
50,
26,
8,
2,
3,
5,
2,
5,
1,
2,
2,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 12141,
"seconds": 12.0,
"avg_fps": 1011.738,
"low_1pct_fps": 166.643,
"low_01pct_fps": 87.16,
"p50_ms": 0.641,
"p95_ms": 2.642,
"p99_ms": 4.468,
"p999_ms": 7.821,
"max_ms": 22.536,
"cpu_p50_ms": 0.456,
"gpu_p50_ms": 0.138,
"histogram": [
7840,
2996,
866,
278,
87,
38,
16,
11,
2,
5,
0,
0,
0,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 30118,
"seconds": 12.0,
"avg_fps": 2509.775,
"low_1pct_fps": 367.429,
"low_01pct_fps": 176.667,
"p50_ms": 0.316,
"p95_ms": 0.771,
"p99_ms": 1.931,
"p999_ms": 4.2,
"max_ms": 14.633,
"cpu_p50_ms": 0.239,
"gpu_p50_ms": 0.127,
"histogram": [
29101,
775,
173,
38,
16,
8,
4,
1,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 8577,
"seconds": 12.0,
"avg_fps": 714.773,
"low_1pct_fps": 13.364,
"low_01pct_fps": 1.526,
"p50_ms": 0.416,
"p95_ms": 2.257,
"p99_ms": 4.36,
"p999_ms": 212.217,
"max_ms": 1051.428,
"cpu_p50_ms": 0.28,
"gpu_p50_ms": 0.132,
"histogram": [
7017,
978,
377,
111,
39,
24,
10,
6,
1,
0,
2,
2,
0,
0,
0,
0,
0,
0,
1,
0,
1,
2,
4,
2
]
}
],
"label": "horizon-desktop"
}
-870
View File
@@ -1,870 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T14:47:19Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "nvidia",
"driver": "NVIDIA 595.71.05",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 29.665,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 45386.16,
"stdev": 38970.921,
"min": 1077.276,
"max": 74343.156,
"ci95": 96816.948
},
"startup.world_ms": {
"n": 3,
"mean": 86585.608,
"stdev": 71331.593,
"min": 11201.581,
"max": 153020.143,
"ci95": 177211.803
},
"chunks.village.load_ms": {
"n": 3,
"mean": 11082.587,
"stdev": 5351.291,
"min": 7620.454,
"max": 17246.096,
"ci95": 13294.417
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 48440.333,
"stdev": 656.16,
"min": 48060.0,
"max": 49198.0,
"ci95": 1630.124
},
"fps.village.avg_fps": {
"n": 3,
"mean": 853.775,
"stdev": 345.646,
"min": 524.913,
"max": 1214.059,
"ci95": 858.701
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 130.235,
"stdev": 117.39,
"min": 9.024,
"max": 243.388,
"ci95": 291.635
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 70.65,
"stdev": 68.628,
"min": 1.125,
"max": 138.345,
"ci95": 170.496
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 2202.333,
"stdev": 33.307,
"min": 2165.0,
"max": 2229.0,
"ci95": 82.745
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1652.647,
"stdev": 73.452,
"min": 1568.063,
"max": 1700.344,
"ci95": 182.478
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.493,
"stdev": 0.169,
"min": 0.319,
"max": 0.657,
"ci95": 0.42
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.692,
"stdev": 0.207,
"min": 0.531,
"max": 0.925,
"ci95": 0.513
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 15019.921,
"stdev": 12855.4,
"min": 5613.942,
"max": 29668.11,
"ci95": 31937.162
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 80224.667,
"stdev": 678.709,
"min": 79626.0,
"max": 80962.0,
"ci95": 1686.142
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 898.208,
"stdev": 446.3,
"min": 382.944,
"max": 1163.61,
"ci95": 1108.759
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 178.513,
"stdev": 107.096,
"min": 54.933,
"max": 244.255,
"ci95": 266.064
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 103.369,
"stdev": 78.403,
"min": 12.974,
"max": 152.87,
"ci95": 194.779
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 2201.667,
"stdev": 34.429,
"min": 2163.0,
"max": 2229.0,
"ci95": 85.532
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1809.063,
"stdev": 93.103,
"min": 1707.98,
"max": 1891.305,
"ci95": 231.3
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.585,
"stdev": 0.147,
"min": 0.493,
"max": 0.754,
"ci95": 0.365
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.989,
"stdev": 0.725,
"min": 0.57,
"max": 1.826,
"ci95": 1.802
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 12399.047,
"stdev": 15052.073,
"min": 3174.115,
"max": 29768.486,
"ci95": 37394.441
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 64737.0,
"stdev": 16149.666,
"min": 46089.0,
"max": 74089.0,
"ci95": 40121.232
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 735.22,
"stdev": 601.75,
"min": 108.108,
"max": 1307.912,
"ci95": 1494.952
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 134.654,
"stdev": 147.391,
"min": 1.252,
"max": 292.881,
"ci95": 366.169
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 62.062,
"stdev": 66.294,
"min": 0.202,
"max": 132.042,
"ci95": 164.697
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 1942.333,
"stdev": 251.725,
"min": 1657.0,
"max": 2133.0,
"ci95": 625.369
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1812.661,
"stdev": 102.065,
"min": 1697.912,
"max": 1893.313,
"ci95": 253.564
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.421,
"stdev": 0.227,
"min": 0.193,
"max": 0.646,
"ci95": 0.563
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.513,
"stdev": 0.194,
"min": 0.303,
"max": 0.686,
"ci95": 0.483
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 31815.539,
"stdev": 36758.245,
"min": 9582.166,
"max": 74243.988,
"ci95": 91319.913
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 90876.333,
"stdev": 1525.588,
"min": 89793.0,
"max": 92621.0,
"ci95": 3790.075
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 712.657,
"stdev": 252.815,
"min": 450.565,
"max": 955.041,
"ci95": 628.077
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 117.93,
"stdev": 85.922,
"min": 24.389,
"max": 193.34,
"ci95": 213.46
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 72.407,
"stdev": 65.468,
"min": 3.577,
"max": 133.895,
"ci95": 162.646
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 2783.667,
"stdev": 48.881,
"min": 2741.0,
"max": 2837.0,
"ci95": 121.436
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1931.359,
"stdev": 106.681,
"min": 1808.184,
"max": 1994.242,
"ci95": 265.031
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.03,
"stdev": 0.685,
"min": 0.564,
"max": 1.817,
"ci95": 1.702
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 1.025,
"stdev": 0.315,
"min": 0.765,
"max": 1.376,
"ci95": 0.783
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2051.333,
"stdev": 147.151,
"min": 1884.176,
"max": 2161.316,
"ci95": 365.573
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1810.92,
"stdev": 94.623,
"min": 1707.342,
"max": 1892.828,
"ci95": 235.075
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 6301,
"seconds": 12.004,
"avg_fps": 524.913,
"low_1pct_fps": 9.024,
"low_01pct_fps": 1.125,
"p50_ms": 0.555,
"p95_ms": 2.072,
"p99_ms": 4.067,
"p999_ms": 282.455,
"max_ms": 3525.74,
"cpu_p50_ms": 0.531,
"gpu_p50_ms": 0.218,
"histogram": [
4878,
1060,
217,
86,
19,
15,
6,
2,
5,
0,
1,
2,
0,
0,
0,
0,
0,
0,
0,
2,
0,
4,
3,
1
]
},
{
"phase": "village",
"repeat": 1,
"frames": 9869,
"seconds": 12.001,
"avg_fps": 822.353,
"low_1pct_fps": 138.293,
"low_01pct_fps": 72.479,
"p50_ms": 0.976,
"p95_ms": 2.871,
"p99_ms": 5.065,
"p999_ms": 9.942,
"max_ms": 23.337,
"cpu_p50_ms": 0.925,
"gpu_p50_ms": 0.245,
"histogram": [
5262,
3404,
771,
248,
81,
49,
23,
11,
12,
3,
0,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 14570,
"seconds": 12.001,
"avg_fps": 1214.059,
"low_1pct_fps": 243.388,
"low_01pct_fps": 138.345,
"p50_ms": 0.655,
"p95_ms": 1.486,
"p99_ms": 2.883,
"p999_ms": 5.366,
"max_ms": 26.145,
"cpu_p50_ms": 0.62,
"gpu_p50_ms": 0.239,
"histogram": [
11502,
2683,
257,
77,
25,
23,
1,
1,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 4595,
"seconds": 11.999,
"avg_fps": 382.944,
"low_1pct_fps": 54.933,
"low_01pct_fps": 12.974,
"p50_ms": 2.269,
"p95_ms": 5.193,
"p99_ms": 8.719,
"p999_ms": 21.933,
"max_ms": 243.247,
"cpu_p50_ms": 1.826,
"gpu_p50_ms": 0.502,
"histogram": [
273,
1654,
1395,
836,
172,
122,
52,
38,
26,
10,
7,
5,
0,
2,
1,
0,
0,
0,
1,
0,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 13964,
"seconds": 12.001,
"avg_fps": 1163.61,
"low_1pct_fps": 244.255,
"low_01pct_fps": 152.87,
"p50_ms": 0.682,
"p95_ms": 1.524,
"p99_ms": 2.877,
"p999_ms": 5.801,
"max_ms": 7.977,
"cpu_p50_ms": 0.57,
"gpu_p50_ms": 0.467,
"histogram": [
11067,
2532,
235,
82,
23,
16,
6,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 13777,
"seconds": 12.0,
"avg_fps": 1148.071,
"low_1pct_fps": 236.351,
"low_01pct_fps": 144.264,
"p50_ms": 0.691,
"p95_ms": 1.637,
"p99_ms": 3.131,
"p999_ms": 5.794,
"max_ms": 9.301,
"cpu_p50_ms": 0.57,
"gpu_p50_ms": 0.465,
"histogram": [
10826,
2511,
284,
106,
23,
16,
5,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 1224,
"seconds": 11.322,
"avg_fps": 108.108,
"low_1pct_fps": 1.252,
"low_01pct_fps": 0.202,
"p50_ms": 0.439,
"p95_ms": 3.015,
"p99_ms": 7.359,
"p999_ms": 2880.23,
"max_ms": 7005.436,
"cpu_p50_ms": 0.303,
"gpu_p50_ms": 0.258,
"histogram": [
1081,
31,
49,
10,
3,
7,
23,
12,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
2
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 15695,
"seconds": 12.0,
"avg_fps": 1307.912,
"low_1pct_fps": 292.881,
"low_01pct_fps": 132.042,
"p50_ms": 0.624,
"p95_ms": 1.219,
"p99_ms": 2.173,
"p999_ms": 4.841,
"max_ms": 16.764,
"cpu_p50_ms": 0.551,
"gpu_p50_ms": 0.401,
"histogram": [
13558,
1932,
140,
37,
14,
6,
3,
1,
1,
1,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 9476,
"seconds": 12.0,
"avg_fps": 789.641,
"low_1pct_fps": 109.83,
"low_01pct_fps": 53.941,
"p50_ms": 0.802,
"p95_ms": 3.406,
"p99_ms": 5.844,
"p999_ms": 14.977,
"max_ms": 22.481,
"cpu_p50_ms": 0.686,
"gpu_p50_ms": 0.403,
"histogram": [
5819,
2151,
787,
455,
112,
65,
36,
18,
7,
7,
7,
5,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 5352,
"seconds": 11.878,
"avg_fps": 450.565,
"low_1pct_fps": 24.389,
"low_01pct_fps": 3.577,
"p50_ms": 1.431,
"p95_ms": 4.548,
"p99_ms": 7.601,
"p999_ms": 96.086,
"max_ms": 714.027,
"cpu_p50_ms": 1.376,
"gpu_p50_ms": 0.398,
"histogram": [
1412,
2323,
811,
460,
138,
84,
49,
34,
13,
9,
8,
2,
1,
1,
0,
1,
0,
0,
1,
2,
1,
1,
1,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8789,
"seconds": 12.001,
"avg_fps": 732.365,
"low_1pct_fps": 136.06,
"low_01pct_fps": 79.749,
"p50_ms": 0.972,
"p95_ms": 3.329,
"p99_ms": 5.68,
"p999_ms": 9.7,
"max_ms": 18.289,
"cpu_p50_ms": 0.935,
"gpu_p50_ms": 0.383,
"histogram": [
4529,
3007,
715,
287,
120,
61,
37,
16,
12,
1,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 11461,
"seconds": 12.001,
"avg_fps": 955.041,
"low_1pct_fps": 193.34,
"low_01pct_fps": 133.895,
"p50_ms": 0.787,
"p95_ms": 2.026,
"p99_ms": 3.953,
"p999_ms": 6.511,
"max_ms": 9.761,
"cpu_p50_ms": 0.765,
"gpu_p50_ms": 0.379,
"histogram": [
7754,
3114,
328,
165,
46,
33,
14,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-desktop"
}
-854
View File
@@ -1,854 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T12:44:40Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.39,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1516.155,
"stdev": 34.227,
"min": 1476.782,
"max": 1538.813,
"ci95": 85.032
},
"startup.world_ms": {
"n": 3,
"mean": 4080.9,
"stdev": 748.712,
"min": 3273.516,
"max": 4752.301,
"ci95": 1860.054
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3363.749,
"stdev": 224.552,
"min": 3105.622,
"max": 3514.059,
"ci95": 557.864
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 15473.667,
"stdev": 339.602,
"min": 15123.0,
"max": 15801.0,
"ci95": 843.686
},
"fps.village.avg_fps": {
"n": 3,
"mean": 999.753,
"stdev": 41.901,
"min": 970.527,
"max": 1047.758,
"ci95": 104.095
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 336.821,
"stdev": 17.834,
"min": 318.068,
"max": 353.566,
"ci95": 44.306
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 263.782,
"stdev": 31.556,
"min": 227.767,
"max": 286.582,
"ci95": 78.395
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1147.654,
"stdev": 31.82,
"min": 1116.875,
"max": 1180.422,
"ci95": 79.052
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.414,
"stdev": 0.036,
"min": 0.373,
"max": 0.44,
"ci95": 0.089
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 0.52,
"stdev": 0.071,
"min": 0.44,
"max": 0.577,
"ci95": 0.177
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3685.368,
"stdev": 844.43,
"min": 2932.117,
"max": 4598.204,
"ci95": 2097.85
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 16795.333,
"stdev": 437.802,
"min": 16325.0,
"max": 17191.0,
"ci95": 1087.648
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 592.615,
"stdev": 20.339,
"min": 580.401,
"max": 616.094,
"ci95": 50.529
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 250.069,
"stdev": 31.227,
"min": 217.065,
"max": 279.149,
"ci95": 77.58
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 151.684,
"stdev": 77.064,
"min": 64.033,
"max": 208.811,
"ci95": 191.454
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1064.344,
"stdev": 148.185,
"min": 901.203,
"max": 1190.609,
"ci95": 368.142
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.424,
"stdev": 0.003,
"min": 0.422,
"max": 0.427,
"ci95": 0.007
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 0.617,
"stdev": 0.016,
"min": 0.599,
"max": 0.629,
"ci95": 0.039
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 3594.311,
"stdev": 1381.026,
"min": 2011.153,
"max": 4551.541,
"ci95": 3430.936
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 14527.0,
"stdev": 319.629,
"min": 14166.0,
"max": 14774.0,
"ci95": 794.068
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 789.003,
"stdev": 25.076,
"min": 766.83,
"max": 816.216,
"ci95": 62.297
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 276.887,
"stdev": 51.785,
"min": 217.373,
"max": 311.665,
"ci95": 128.651
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 169.278,
"stdev": 29.119,
"min": 137.156,
"max": 193.944,
"ci95": 72.341
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 982.995,
"stdev": 287.819,
"min": 651.109,
"max": 1164.063,
"ci95": 715.039
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.397,
"stdev": 0.028,
"min": 0.38,
"max": 0.429,
"ci95": 0.069
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 0.569,
"stdev": 0.045,
"min": 0.533,
"max": 0.62,
"ci95": 0.112
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 5682.993,
"stdev": 1272.063,
"min": 4476.873,
"max": 7012.069,
"ci95": 3160.234
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 15640.0,
"stdev": 204.105,
"min": 15507.0,
"max": 15875.0,
"ci95": 507.067
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 716.475,
"stdev": 8.139,
"min": 707.613,
"max": 723.617,
"ci95": 20.221
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 313.736,
"stdev": 3.759,
"min": 311.007,
"max": 318.023,
"ci95": 9.338
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 270.042,
"stdev": 19.572,
"min": 247.802,
"max": 284.642,
"ci95": 48.624
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 997.474,
"stdev": 231.733,
"min": 790.266,
"max": 1247.703,
"ci95": 575.704
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.385,
"stdev": 0.023,
"min": 0.359,
"max": 0.401,
"ci95": 0.057
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 0.609,
"stdev": 0.044,
"min": 0.56,
"max": 0.645,
"ci95": 0.109
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1213.255,
"stdev": 81.897,
"min": 1136.078,
"max": 1299.172,
"ci95": 203.461
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1034.279,
"stdev": 188.802,
"min": 816.453,
"max": 1150.961,
"ci95": 469.049
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2167.747,
"stdev": 14.039,
"min": 2153.721,
"max": 2181.799,
"ci95": 34.878
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 1889.468,
"stdev": 24.444,
"min": 1862.799,
"max": 1910.807,
"ci95": 60.727
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 12573,
"seconds": 12.0,
"avg_fps": 1047.758,
"low_1pct_fps": 353.566,
"low_01pct_fps": 286.582,
"p50_ms": 0.921,
"p95_ms": 1.077,
"p99_ms": 2.675,
"p999_ms": 2.946,
"max_ms": 5.36,
"cpu_p50_ms": 0.44,
"gpu_p50_ms": 0.914,
"histogram": [
11056,
1291,
218,
6,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 11646,
"seconds": 12.0,
"avg_fps": 970.527,
"low_1pct_fps": 318.068,
"low_01pct_fps": 227.767,
"p50_ms": 0.973,
"p95_ms": 1.264,
"p99_ms": 2.805,
"p999_ms": 3.808,
"max_ms": 5.305,
"cpu_p50_ms": 0.577,
"gpu_p50_ms": 0.969,
"histogram": [
7035,
4334,
230,
39,
6,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 11771,
"seconds": 11.999,
"avg_fps": 980.973,
"low_1pct_fps": 338.828,
"low_01pct_fps": 276.996,
"p50_ms": 0.985,
"p95_ms": 1.157,
"p99_ms": 2.779,
"p999_ms": 3.068,
"max_ms": 4.308,
"cpu_p50_ms": 0.542,
"gpu_p50_ms": 0.98,
"histogram": [
6880,
4663,
208,
18,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 7394,
"seconds": 12.001,
"avg_fps": 616.094,
"low_1pct_fps": 253.993,
"low_01pct_fps": 182.207,
"p50_ms": 1.565,
"p95_ms": 1.915,
"p99_ms": 3.329,
"p999_ms": 4.661,
"max_ms": 7.32,
"cpu_p50_ms": 0.599,
"gpu_p50_ms": 1.551,
"histogram": [
82,
6988,
144,
162,
14,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 6965,
"seconds": 12.0,
"avg_fps": 580.401,
"low_1pct_fps": 217.065,
"low_01pct_fps": 64.033,
"p50_ms": 1.654,
"p95_ms": 1.983,
"p99_ms": 3.233,
"p999_ms": 3.893,
"max_ms": 81.863,
"cpu_p50_ms": 0.629,
"gpu_p50_ms": 1.645,
"histogram": [
11,
6622,
153,
174,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 6977,
"seconds": 12.001,
"avg_fps": 581.35,
"low_1pct_fps": 279.149,
"low_01pct_fps": 208.811,
"p50_ms": 1.659,
"p95_ms": 2.006,
"p99_ms": 3.273,
"p999_ms": 3.982,
"max_ms": 5.889,
"cpu_p50_ms": 0.623,
"gpu_p50_ms": 1.653,
"histogram": [
17,
6606,
164,
186,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 9793,
"seconds": 11.998,
"avg_fps": 816.216,
"low_1pct_fps": 301.624,
"low_01pct_fps": 176.734,
"p50_ms": 1.171,
"p95_ms": 1.499,
"p99_ms": 2.928,
"p999_ms": 3.333,
"max_ms": 23.472,
"cpu_p50_ms": 0.533,
"gpu_p50_ms": 1.163,
"histogram": [
259,
9293,
182,
56,
2,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 9202,
"seconds": 12.0,
"avg_fps": 766.83,
"low_1pct_fps": 217.373,
"low_01pct_fps": 137.156,
"p50_ms": 1.226,
"p95_ms": 1.912,
"p99_ms": 3.396,
"p999_ms": 6.003,
"max_ms": 13.562,
"cpu_p50_ms": 0.62,
"gpu_p50_ms": 1.219,
"histogram": [
635,
8130,
261,
124,
27,
15,
7,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 9408,
"seconds": 12.001,
"avg_fps": 783.964,
"low_1pct_fps": 311.665,
"low_01pct_fps": 193.944,
"p50_ms": 1.22,
"p95_ms": 1.541,
"p99_ms": 2.865,
"p999_ms": 3.298,
"max_ms": 13.861,
"cpu_p50_ms": 0.555,
"gpu_p50_ms": 1.214,
"histogram": [
100,
9066,
204,
34,
2,
0,
1,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 8618,
"seconds": 12.0,
"avg_fps": 718.194,
"low_1pct_fps": 312.177,
"low_01pct_fps": 277.681,
"p50_ms": 1.338,
"p95_ms": 1.664,
"p99_ms": 3.068,
"p999_ms": 3.364,
"max_ms": 4.008,
"cpu_p50_ms": 0.622,
"gpu_p50_ms": 1.329,
"histogram": [
62,
8301,
130,
125,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8492,
"seconds": 12.001,
"avg_fps": 707.613,
"low_1pct_fps": 311.007,
"low_01pct_fps": 284.642,
"p50_ms": 1.35,
"p95_ms": 1.707,
"p99_ms": 3.08,
"p999_ms": 3.329,
"max_ms": 3.796,
"cpu_p50_ms": 0.645,
"gpu_p50_ms": 1.342,
"histogram": [
32,
8216,
111,
133,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 8684,
"seconds": 12.001,
"avg_fps": 723.617,
"low_1pct_fps": 318.023,
"low_01pct_fps": 247.802,
"p50_ms": 1.318,
"p95_ms": 1.699,
"p99_ms": 2.875,
"p999_ms": 3.573,
"max_ms": 5.186,
"cpu_p50_ms": 0.56,
"gpu_p50_ms": 1.306,
"histogram": [
46,
8389,
199,
49,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-macmini"
}
-854
View File
@@ -1,854 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:17:26Z",
"app": {
"name": "horizon-client",
"version": "nightly-2026-11-04-67dad0d",
"commit": "67dad0d"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.712,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1528.444,
"stdev": 181.21,
"min": 1319.58,
"max": 1643.783,
"ci95": 450.188
},
"startup.world_ms": {
"n": 3,
"mean": 12368.716,
"stdev": 6475.37,
"min": 7168.429,
"max": 19621.62,
"ci95": 16087.01
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6926.208,
"stdev": 3712.691,
"min": 4208.555,
"max": 11156.419,
"ci95": 9223.581
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 46790.333,
"stdev": 1381.89,
"min": 45236.0,
"max": 47880.0,
"ci95": 3433.082
},
"fps.village.avg_fps": {
"n": 3,
"mean": 453.505,
"stdev": 14.384,
"min": 441.517,
"max": 469.455,
"ci95": 35.735
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 222.897,
"stdev": 10.449,
"min": 212.173,
"max": 233.048,
"ci95": 25.96
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 173.55,
"stdev": 31.798,
"min": 142.967,
"max": 206.437,
"ci95": 78.996
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1544.565,
"stdev": 26.422,
"min": 1516.75,
"max": 1569.328,
"ci95": 65.64
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.528,
"stdev": 0.018,
"min": 0.511,
"max": 0.548,
"ci95": 0.045
},
"cpu.village.p50_ms": {
"n": 3,
"mean": 1.496,
"stdev": 0.049,
"min": 1.453,
"max": 1.549,
"ci95": 0.121
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8297.116,
"stdev": 689.909,
"min": 7716.447,
"max": 9059.776,
"ci95": 1713.966
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 78617.667,
"stdev": 494.532,
"min": 78228.0,
"max": 79174.0,
"ci95": 1228.586
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 216.265,
"stdev": 6.372,
"min": 209.501,
"max": 222.156,
"ci95": 15.831
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 137.027,
"stdev": 3.787,
"min": 133.07,
"max": 140.617,
"ci95": 9.408
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 114.683,
"stdev": 7.985,
"min": 106.585,
"max": 122.551,
"ci95": 19.839
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1420.638,
"stdev": 247.812,
"min": 1266.047,
"max": 1706.469,
"ci95": 615.649
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.423,
"stdev": 0.059,
"min": 0.355,
"max": 0.458,
"ci95": 0.148
},
"cpu.forest.p50_ms": {
"n": 3,
"mean": 1.605,
"stdev": 0.269,
"min": 1.295,
"max": 1.779,
"ci95": 0.668
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 8354.659,
"stdev": 2877.749,
"min": 5454.947,
"max": 11209.93,
"ci95": 7149.301
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 74315.667,
"stdev": 63.106,
"min": 74246.0,
"max": 74369.0,
"ci95": 156.776
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 274.828,
"stdev": 4.993,
"min": 270.041,
"max": 280.004,
"ci95": 12.404
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 165.765,
"stdev": 7.21,
"min": 157.538,
"max": 170.985,
"ci95": 17.912
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 140.185,
"stdev": 9.74,
"min": 129.976,
"max": 149.377,
"ci95": 24.198
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1306.578,
"stdev": 97.011,
"min": 1199.094,
"max": 1387.641,
"ci95": 241.007
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.416,
"stdev": 0.029,
"min": 0.392,
"max": 0.448,
"ci95": 0.071
},
"cpu.ocean.p50_ms": {
"n": 3,
"mean": 1.636,
"stdev": 0.121,
"min": 1.496,
"max": 1.708,
"ci95": 0.301
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17124.873,
"stdev": 3461.08,
"min": 13599.217,
"max": 20517.544,
"ci95": 8598.494
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 91909.0,
"stdev": 815.603,
"min": 91157.0,
"max": 92776.0,
"ci95": 2026.235
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 242.657,
"stdev": 3.405,
"min": 240.66,
"max": 246.588,
"ci95": 8.459
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 110.95,
"stdev": 14.622,
"min": 99.907,
"max": 127.532,
"ci95": 36.325
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 51.329,
"stdev": 28.253,
"min": 30.391,
"max": 83.465,
"ci95": 70.191
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1451.688,
"stdev": 127.471,
"min": 1310.969,
"max": 1559.43,
"ci95": 316.681
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.614,
"stdev": 0.017,
"min": 0.594,
"max": 0.627,
"ci95": 0.043
},
"cpu.mountain.p50_ms": {
"n": 3,
"mean": 2.233,
"stdev": 0.05,
"min": 2.194,
"max": 2.289,
"ci95": 0.124
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1697.984,
"stdev": 29.735,
"min": 1667.406,
"max": 1726.797,
"ci95": 73.871
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1428.19,
"stdev": 120.658,
"min": 1333.0,
"max": 1563.891,
"ci95": 299.756
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4921.753,
"stdev": 30.436,
"min": 4890.971,
"max": 4951.831,
"ci95": 75.613
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4079.43,
"stdev": 10.978,
"min": 4068.049,
"max": 4089.956,
"ci95": 27.273
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 5634,
"seconds": 12.001,
"avg_fps": 469.455,
"low_1pct_fps": 223.469,
"low_01pct_fps": 171.246,
"p50_ms": 2.054,
"p95_ms": 2.492,
"p99_ms": 3.993,
"p999_ms": 5.425,
"max_ms": 6.645,
"cpu_p50_ms": 1.453,
"gpu_p50_ms": 1.965,
"histogram": [
0,
2082,
3324,
193,
24,
9,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 5395,
"seconds": 12.001,
"avg_fps": 449.543,
"low_1pct_fps": 233.048,
"low_01pct_fps": 206.437,
"p50_ms": 2.155,
"p95_ms": 2.611,
"p99_ms": 4.068,
"p999_ms": 4.456,
"max_ms": 5.305,
"cpu_p50_ms": 1.549,
"gpu_p50_ms": 2.043,
"histogram": [
0,
902,
4255,
206,
29,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 5298,
"seconds": 12.0,
"avg_fps": 441.517,
"low_1pct_fps": 212.173,
"low_01pct_fps": 142.967,
"p50_ms": 2.171,
"p95_ms": 3.249,
"p99_ms": 4.138,
"p999_ms": 5.591,
"max_ms": 9.676,
"cpu_p50_ms": 1.485,
"gpu_p50_ms": 2.092,
"histogram": [
0,
709,
4314,
229,
36,
6,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 2606,
"seconds": 12.002,
"avg_fps": 217.137,
"low_1pct_fps": 140.617,
"low_01pct_fps": 106.585,
"p50_ms": 4.471,
"p95_ms": 5.926,
"p99_ms": 6.575,
"p999_ms": 7.735,
"max_ms": 12.187,
"cpu_p50_ms": 1.74,
"gpu_p50_ms": 4.238,
"histogram": [
0,
0,
0,
221,
2094,
172,
110,
8,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 2514,
"seconds": 12.0,
"avg_fps": 209.501,
"low_1pct_fps": 137.393,
"low_01pct_fps": 122.551,
"p50_ms": 4.627,
"p95_ms": 6.304,
"p99_ms": 6.93,
"p999_ms": 7.971,
"max_ms": 8.506,
"cpu_p50_ms": 1.779,
"gpu_p50_ms": 4.409,
"histogram": [
0,
0,
2,
35,
2097,
200,
162,
17,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 2667,
"seconds": 12.005,
"avg_fps": 222.156,
"low_1pct_fps": 133.07,
"low_01pct_fps": 114.913,
"p50_ms": 4.37,
"p95_ms": 5.756,
"p99_ms": 6.742,
"p999_ms": 8.444,
"max_ms": 8.984,
"cpu_p50_ms": 1.295,
"gpu_p50_ms": 4.152,
"histogram": [
0,
0,
0,
440,
1978,
144,
85,
16,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 3294,
"seconds": 12.003,
"avg_fps": 274.44,
"low_1pct_fps": 157.538,
"low_01pct_fps": 129.976,
"p50_ms": 3.521,
"p95_ms": 4.814,
"p99_ms": 5.592,
"p999_ms": 7.491,
"max_ms": 7.868,
"cpu_p50_ms": 1.703,
"gpu_p50_ms": 3.277,
"histogram": [
0,
4,
55,
2967,
130,
122,
10,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 3241,
"seconds": 12.002,
"avg_fps": 270.041,
"low_1pct_fps": 168.771,
"low_01pct_fps": 141.201,
"p50_ms": 3.578,
"p95_ms": 4.942,
"p99_ms": 5.631,
"p999_ms": 6.498,
"max_ms": 7.601,
"cpu_p50_ms": 1.708,
"gpu_p50_ms": 3.323,
"histogram": [
0,
2,
8,
2970,
110,
144,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 3360,
"seconds": 12.0,
"avg_fps": 280.004,
"low_1pct_fps": 170.985,
"low_01pct_fps": 149.377,
"p50_ms": 3.477,
"p95_ms": 4.678,
"p99_ms": 5.462,
"p999_ms": 6.626,
"max_ms": 6.788,
"cpu_p50_ms": 1.496,
"gpu_p50_ms": 3.23,
"histogram": [
0,
1,
5,
3125,
129,
90,
10,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 2888,
"seconds": 12.0,
"avg_fps": 240.66,
"low_1pct_fps": 127.532,
"low_01pct_fps": 83.465,
"p50_ms": 3.984,
"p95_ms": 5.171,
"p99_ms": 6.823,
"p999_ms": 9.168,
"max_ms": 15.341,
"cpu_p50_ms": 2.194,
"gpu_p50_ms": 3.786,
"histogram": [
0,
0,
4,
2048,
638,
144,
33,
16,
3,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 2889,
"seconds": 12.001,
"avg_fps": 240.722,
"low_1pct_fps": 99.907,
"low_01pct_fps": 40.132,
"p50_ms": 3.961,
"p95_ms": 5.706,
"p99_ms": 7.364,
"p999_ms": 12.597,
"max_ms": 49.156,
"cpu_p50_ms": 2.289,
"gpu_p50_ms": 3.802,
"histogram": [
0,
5,
126,
1859,
599,
194,
72,
22,
8,
1,
2,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 2959,
"seconds": 12.0,
"avg_fps": 246.588,
"low_1pct_fps": 105.411,
"low_01pct_fps": 30.391,
"p50_ms": 3.851,
"p95_ms": 5.353,
"p99_ms": 6.248,
"p999_ms": 9.05,
"max_ms": 54.378,
"cpu_p50_ms": 2.216,
"gpu_p50_ms": 3.667,
"histogram": [
0,
2,
58,
2196,
462,
196,
32,
10,
1,
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-macmini"
}
-42
View File
@@ -1,42 +0,0 @@
# Horizon Nightly 2026-11-04 vs vanilla 26.3 on the Mac mini and the desktop (2026-11-04)
> **The Mac mini reports (`*-macmini-rd*.json`) are offscreen, not comparable.** Horizon rendered offscreen (no presenting)
> while vanilla drew into a window, so the Mac rows below are not a Horizon-vs-vanilla comparison, and the website does not
> publish them: it keeps the Mac mini's 2026-10-22 runs. They are kept here only as a record of that day's offscreen run.
The desktop runs are the ones behind playhorizon.dev/benchmarks as of 2026-11-04 (horizon-rust/horizon-site#58): `tools/perf_bench.py`
reports, render distance 12 and 32, three games each, every game from a fresh world (seed 1234), 1280x720, VSync off. Files:
`{horizon,vanilla}-macmini-rd{12,32}.json` and `{horizon,vanilla}-desktop-rd{12,32}.json` next to this page.
## Builds
- **Horizon:** the Nightly 2026-11-04 release bundle (67dad0d), as the launcher installs it: macOS arm64 on the Mac,
Linux x86_64 on the desktop. Its resources come from the bundle's own `horizon-prepare`. perf_bench ran from main
a55b9a2e52 and wrote that checkout's crate version and commit; `app.version` and `app.commit` were set to the bundle's by hand.
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25, the official launcher's JVM flags.
## Machines and conditions
- **Mac mini, Apple M4 Pro (14 cores, 20-core GPU), macOS: offscreen, not comparable.** The session was locked, so Horizon ran `--offscreen`: a locked
session gives a window no frames, and windowed Horizon measured 0 FPS. Vanilla drew into its window. Offscreen skips
presenting, which favours Horizon, so its averages aren't held at the 144 Hz display as they were on 2026-10-22. Each game
started at a 1-minute load of 17-18 (the run waited for < 20); during the runs the load rose to 30-50.
- **Desktop, RTX 4080 + i7-13700KF, NixOS, NVIDIA 595.** Both games drew into a headless sway rendering on the RTX 4080 (never
on its screen), both through Wayland on Vulkan. Vanilla went through SDL's Wayland driver: through Xwayland each of its
frames took about 2 s on this day. The machine also runs CI and agents' builds: each game started at a load of 28-30 on
24 threads (the run waited for < 30) and the load rose to 40-70 during the runs. Vanilla at RD 32 crashed twice under that
load (`GPU timeout attempting to acquire next frame`) before a third series finished. Start-up times are inflated by the
load and by a cold disk on each series' first start (start → world up to 130-235 s).
- **Order:** RD 12 ran Horizon then vanilla; RD 32 ran vanilla then Horizon.
## Results (average over the flights of the three games; FPS average / 1% low)
| Machine | RD | Horizon | Vanilla | Horizon ÷ vanilla (avg, 1% low) |
|---|---|---|---|---|
| Mac mini (**offscreen, not comparable**) | 12 | 774 / 294 | 132 / 27 | — |
| Mac mini (**offscreen, not comparable**) | 32 | 297 / 159 | 68 / 16 | — |
| Desktop | 12 | 2,224 / 308 | 1,824 / 201 | 1.2x, 1.5x |
| Desktop | 32 | 800 / 140 | 696 / 64 | 1.15x, 2.2x |
Neither machine was idle, so these say how both games behave on busy machines. They are not clean ratios; for those, see
`windows-9800x3d-5080-2026-10-29.md`. The scripts and logs are in horizon-parity `shots/site-bench-2026-11-04/`.
-787
View File
@@ -1,787 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T13:28:55Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "NVIDIA",
"driver": "1.4.329 NVIDIA 595.71.05",
"backend": "vulkan",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.788,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 121075.467,
"stdev": 30646.429,
"min": 93272.034,
"max": 153935.834,
"ci95": 76136.094
},
"startup.world_ms": {
"n": 3,
"mean": 191063.8,
"stdev": 34368.16,
"min": 153895.533,
"max": 221692.034,
"ci95": 85382.134
},
"chunks.village.load_ms": {
"n": 3,
"mean": 18724.322,
"stdev": 9639.048,
"min": 13107.549,
"max": 29854.376,
"ci95": 23946.655
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2161.532,
"stdev": 287.109,
"min": 1830.069,
"max": 2332.81,
"ci95": 713.276
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 162.575,
"stdev": 29.799,
"min": 136.023,
"max": 194.804,
"ci95": 74.03
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 27.752,
"stdev": 3.186,
"min": 24.403,
"max": 30.746,
"ci95": 7.916
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.village.rss_mb": {
"n": 3,
"mean": 2902.054,
"stdev": 158.708,
"min": 2747.244,
"max": 3064.393,
"ci95": 394.285
},
"tick.village.client_ms": {
"n": 3,
"mean": 0.691,
"stdev": 0.097,
"min": 0.601,
"max": 0.794,
"ci95": 0.241
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8852.332,
"stdev": 530.809,
"min": 8350.811,
"max": 9408.241,
"ci95": 1318.709
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1841.581,
"stdev": 1186.781,
"min": 471.209,
"max": 2529.847,
"ci95": 2948.365
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 255.087,
"stdev": 217.128,
"min": 5.798,
"max": 402.877,
"ci95": 539.419
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 92.882,
"stdev": 81.722,
"min": 0.61,
"max": 156.13,
"ci95": 203.024
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 3008.178,
"stdev": 149.07,
"min": 2875.082,
"max": 3169.256,
"ci95": 370.34
},
"tick.forest.client_ms": {
"n": 3,
"mean": 0.687,
"stdev": 0.132,
"min": 0.562,
"max": 0.825,
"ci95": 0.327
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 21215.745,
"stdev": 22821.547,
"min": 8034.077,
"max": 47567.797,
"ci95": 56696.441
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1525.483,
"stdev": 1472.651,
"min": 633.984,
"max": 3225.275,
"ci95": 3658.563
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 191.398,
"stdev": 272.942,
"min": 10.346,
"max": 505.335,
"ci95": 678.08
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 49.506,
"stdev": 66.145,
"min": 1.104,
"max": 124.874,
"ci95": 164.326
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 3062.136,
"stdev": 128.594,
"min": 2962.541,
"max": 3207.313,
"ci95": 319.472
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 1.029,
"stdev": 0.573,
"min": 0.595,
"max": 1.678,
"ci95": 1.423
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 33255.15,
"stdev": 20451.99,
"min": 16655.144,
"max": 56102.104,
"ci95": 50809.661
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1769.12,
"stdev": 996.863,
"min": 818.512,
"max": 2806.557,
"ci95": 2476.544
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 195.916,
"stdev": 195.285,
"min": 11.586,
"max": 400.567,
"ci95": 485.154
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 52.202,
"stdev": 51.208,
"min": 1.461,
"max": 103.864,
"ci95": 127.217
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 13.0,
"stdev": 0.0,
"min": 13.0,
"max": 13.0,
"ci95": 0.0
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 2957.948,
"stdev": 340.935,
"min": 2586.504,
"max": 3256.625,
"ci95": 846.998
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.436,
"stdev": 1.406,
"min": 0.462,
"max": 3.048,
"ci95": 3.494
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 3107.953,
"stdev": 141.888,
"min": 3020.105,
"max": 3271.645,
"ci95": 352.498
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2985.317,
"stdev": 210.327,
"min": 2761.164,
"max": 3178.35,
"ci95": 522.523
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 21963,
"seconds": 12.001,
"avg_fps": 1830.069,
"low_1pct_fps": 136.023,
"low_01pct_fps": 30.746,
"p50_ms": 0.331,
"p95_ms": 1.153,
"p99_ms": 3.001,
"p999_ms": 9.997,
"max_ms": 326.36,
"histogram": [
20336,
1238,
167,
108,
40,
23,
12,
10,
8,
7,
5,
1,
2,
1,
2,
0,
1,
1,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 27994,
"seconds": 12.0,
"avg_fps": 2332.81,
"low_1pct_fps": 194.804,
"low_01pct_fps": 28.107,
"p50_ms": 0.321,
"p95_ms": 0.771,
"p99_ms": 1.356,
"p999_ms": 3.836,
"max_ms": 331.931,
"histogram": [
27270,
652,
34,
13,
5,
2,
3,
4,
3,
2,
0,
1,
0,
0,
0,
0,
1,
0,
1,
1,
0,
2,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 27861,
"seconds": 12.0,
"avg_fps": 2321.717,
"low_1pct_fps": 156.898,
"low_01pct_fps": 24.403,
"p50_ms": 0.281,
"p95_ms": 0.852,
"p99_ms": 1.73,
"p999_ms": 6.331,
"max_ms": 301.991,
"histogram": [
26932,
726,
124,
42,
4,
4,
3,
2,
9,
2,
3,
1,
1,
1,
0,
1,
1,
0,
2,
0,
2,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 30359,
"seconds": 12.0,
"avg_fps": 2529.847,
"low_1pct_fps": 356.586,
"low_01pct_fps": 121.906,
"p50_ms": 0.353,
"p95_ms": 0.563,
"p99_ms": 1.405,
"p999_ms": 4.444,
"max_ms": 20.302,
"histogram": [
29756,
460,
57,
52,
11,
5,
3,
5,
2,
4,
1,
0,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 5781,
"seconds": 12.268,
"avg_fps": 471.209,
"low_1pct_fps": 5.798,
"low_01pct_fps": 0.61,
"p50_ms": 0.359,
"p95_ms": 0.77,
"p99_ms": 1.78,
"p999_ms": 20.826,
"max_ms": 5834.37,
"histogram": [
5586,
158,
12,
11,
0,
1,
3,
2,
0,
0,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
2,
3
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 30285,
"seconds": 12.0,
"avg_fps": 2523.687,
"low_1pct_fps": 402.877,
"low_01pct_fps": 156.13,
"p50_ms": 0.351,
"p95_ms": 0.539,
"p99_ms": 1.574,
"p999_ms": 3.683,
"max_ms": 16.022,
"histogram": [
29666,
490,
74,
35,
7,
2,
2,
1,
1,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 11685,
"seconds": 16.293,
"avg_fps": 717.19,
"low_1pct_fps": 10.346,
"low_01pct_fps": 1.104,
"p50_ms": 0.299,
"p95_ms": 1.119,
"p99_ms": 2.568,
"p999_ms": 12.054,
"max_ms": 5250.537,
"histogram": [
10993,
502,
102,
39,
14,
8,
6,
4,
4,
1,
1,
2,
0,
2,
0,
0,
0,
0,
1,
0,
0,
2,
0,
4
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 38704,
"seconds": 12.0,
"avg_fps": 3225.275,
"low_1pct_fps": 505.335,
"low_01pct_fps": 124.874,
"p50_ms": 0.283,
"p95_ms": 0.36,
"p99_ms": 0.909,
"p999_ms": 2.015,
"max_ms": 132.801,
"histogram": [
38385,
279,
25,
3,
2,
0,
1,
0,
2,
4,
1,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 7609,
"seconds": 12.002,
"avg_fps": 633.984,
"low_1pct_fps": 58.513,
"low_01pct_fps": 22.539,
"p50_ms": 1.141,
"p95_ms": 4.438,
"p99_ms": 9.113,
"p999_ms": 27.496,
"max_ms": 64.424,
"histogram": [
3452,
2398,
956,
368,
143,
96,
61,
46,
20,
29,
6,
10,
6,
7,
7,
0,
1,
3,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 10168,
"seconds": 12.423,
"avg_fps": 818.512,
"low_1pct_fps": 11.586,
"low_01pct_fps": 1.461,
"p50_ms": 0.267,
"p95_ms": 0.892,
"p99_ms": 3.176,
"p999_ms": 198.757,
"max_ms": 1882.64,
"histogram": [
9733,
205,
121,
49,
12,
9,
8,
2,
2,
0,
1,
2,
1,
3,
0,
3,
2,
0,
0,
2,
4,
5,
1,
3
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 33679,
"seconds": 12.0,
"avg_fps": 2806.557,
"low_1pct_fps": 400.567,
"low_01pct_fps": 103.864,
"p50_ms": 0.324,
"p95_ms": 0.411,
"p99_ms": 1.167,
"p999_ms": 3.508,
"max_ms": 83.572,
"histogram": [
33272,
315,
46,
22,
2,
1,
4,
2,
9,
1,
2,
2,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 20188,
"seconds": 12.0,
"avg_fps": 1682.292,
"low_1pct_fps": 175.594,
"low_01pct_fps": 51.28,
"p50_ms": 0.374,
"p95_ms": 1.37,
"p99_ms": 2.821,
"p999_ms": 8.947,
"max_ms": 53.494,
"histogram": [
18109,
1592,
314,
83,
36,
19,
7,
6,
6,
1,
3,
1,
4,
2,
3,
0,
1,
1,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-desktop"
}
-787
View File
@@ -1,787 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T15:46:25Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "linux",
"arch": "x86_64",
"cpu_threads": 24,
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
"ram_mb": 31904,
"gpu": {
"name": "NVIDIA GeForce RTX 4080",
"vendor": "NVIDIA",
"driver": "1.4.329 NVIDIA 595.71.05",
"backend": "vulkan",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.799,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 112742.179,
"stdev": 27885.804,
"min": 81110.692,
"max": 133773.748,
"ci95": 69277.769
},
"startup.world_ms": {
"n": 3,
"mean": 235049.179,
"stdev": 31461.958,
"min": 203486.748,
"max": 266409.692,
"ci95": 78162.144
},
"chunks.village.load_ms": {
"n": 3,
"mean": 79828.05,
"stdev": 109107.365,
"min": 16685.972,
"max": 205814.266,
"ci95": 271059.597
},
"fps.village.avg_fps": {
"n": 3,
"mean": 525.667,
"stdev": 390.505,
"min": 101.073,
"max": 869.433,
"ci95": 970.146
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 44.209,
"stdev": 39.491,
"min": 1.644,
"max": 79.658,
"ci95": 98.109
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 10.635,
"stdev": 9.759,
"min": 0.686,
"max": 20.192,
"ci95": 24.244
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.village.rss_mb": {
"n": 3,
"mean": 3296.229,
"stdev": 296.464,
"min": 2956.234,
"max": 3500.773,
"ci95": 736.517
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.23,
"stdev": 0.449,
"min": 0.897,
"max": 1.74,
"ci95": 1.114
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 61172.527,
"stdev": 17434.762,
"min": 46779.931,
"max": 80559.429,
"ci95": 43313.846
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 599.408,
"stdev": 492.96,
"min": 64.173,
"max": 1034.813,
"ci95": 1224.68
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 61.923,
"stdev": 56.534,
"min": 0.737,
"max": 112.221,
"ci95": 140.449
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 15.771,
"stdev": 14.11,
"min": 0.098,
"max": 27.463,
"ci95": 35.054
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 3278.964,
"stdev": 301.465,
"min": 2947.268,
"max": 3536.27,
"ci95": 748.941
},
"tick.forest.client_ms": {
"n": 3,
"mean": 5.908,
"stdev": 8.813,
"min": 0.693,
"max": 16.084,
"ci95": 21.895
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 61762.098,
"stdev": 3569.17,
"min": 57919.097,
"max": 64972.973,
"ci95": 8867.026
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1175.044,
"stdev": 131.94,
"min": 1093.801,
"max": 1327.281,
"ci95": 327.785
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 119.758,
"stdev": 68.419,
"min": 77.252,
"max": 198.683,
"ci95": 169.976
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 24.224,
"stdev": 18.982,
"min": 12.308,
"max": 46.114,
"ci95": 47.159
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 3275.349,
"stdev": 317.864,
"min": 2924.113,
"max": 3543.23,
"ci95": 789.681
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.654,
"stdev": 0.066,
"min": 0.579,
"max": 0.699,
"ci95": 0.163
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 113439.529,
"stdev": 64746.162,
"min": 55046.887,
"max": 183068.386,
"ci95": 160851.363
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 485.832,
"stdev": 317.254,
"min": 121.312,
"max": 699.617,
"ci95": 788.165
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 28.612,
"stdev": 23.334,
"min": 2.229,
"max": 46.542,
"ci95": 57.97
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 4.56,
"stdev": 3.408,
"min": 0.75,
"max": 7.318,
"ci95": 8.467
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 34.333,
"stdev": 36.95,
"min": 13.0,
"max": 77.0,
"ci95": 91.797
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 3248.708,
"stdev": 480.962,
"min": 2700.645,
"max": 3600.484,
"ci95": 1194.873
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 1.832,
"stdev": 2.076,
"min": 0.618,
"max": 4.229,
"ci95": 5.157
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 3371.958,
"stdev": 280.347,
"min": 3059.926,
"max": 3602.613,
"ci95": 696.476
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 3291.249,
"stdev": 316.441,
"min": 2935.975,
"max": 3542.848,
"ci95": 786.147
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1248,
"seconds": 12.348,
"avg_fps": 101.073,
"low_1pct_fps": 1.644,
"low_01pct_fps": 0.686,
"p50_ms": 1.225,
"p95_ms": 11.161,
"p99_ms": 226.597,
"p999_ms": 1457.605,
"max_ms": 1457.874,
"histogram": [
548,
232,
97,
118,
51,
49,
24,
39,
16,
17,
13,
6,
3,
2,
5,
3,
2,
2,
4,
2,
3,
8,
1,
3
]
},
{
"phase": "village",
"repeat": 1,
"frames": 10435,
"seconds": 12.002,
"avg_fps": 869.433,
"low_1pct_fps": 79.658,
"low_01pct_fps": 20.192,
"p50_ms": 0.813,
"p95_ms": 2.537,
"p99_ms": 5.685,
"p999_ms": 13.292,
"max_ms": 351.251,
"histogram": [
7598,
2071,
393,
171,
55,
53,
22,
24,
20,
10,
9,
2,
2,
1,
3,
0,
0,
0,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 7278,
"seconds": 12.0,
"avg_fps": 606.495,
"low_1pct_fps": 51.326,
"low_01pct_fps": 11.026,
"p50_ms": 1.105,
"p95_ms": 3.854,
"p99_ms": 7.067,
"p999_ms": 24.484,
"max_ms": 443.95,
"histogram": [
3206,
2621,
800,
360,
112,
71,
34,
26,
12,
13,
3,
5,
2,
7,
2,
1,
1,
0,
1,
0,
0,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 975,
"seconds": 15.193,
"avg_fps": 64.173,
"low_1pct_fps": 0.737,
"low_01pct_fps": 0.098,
"p50_ms": 0.864,
"p95_ms": 5.083,
"p99_ms": 77.689,
"p999_ms": 10225.237,
"max_ms": 10225.237,
"histogram": [
680,
99,
104,
12,
30,
13,
8,
4,
2,
3,
2,
1,
1,
3,
0,
1,
0,
1,
2,
3,
1,
2,
1,
2
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 8391,
"seconds": 12.0,
"avg_fps": 699.237,
"low_1pct_fps": 72.811,
"low_01pct_fps": 19.752,
"p50_ms": 1.087,
"p95_ms": 2.927,
"p99_ms": 5.909,
"p999_ms": 15.268,
"max_ms": 252.557,
"histogram": [
3342,
3998,
656,
231,
56,
26,
15,
19,
12,
12,
11,
6,
1,
3,
1,
0,
0,
1,
0,
0,
0,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 12418,
"seconds": 12.0,
"avg_fps": 1034.813,
"low_1pct_fps": 112.221,
"low_01pct_fps": 27.463,
"p50_ms": 0.837,
"p95_ms": 1.394,
"p99_ms": 3.491,
"p999_ms": 13.217,
"max_ms": 245.59,
"histogram": [
11155,
1004,
100,
64,
29,
16,
16,
10,
2,
6,
5,
3,
4,
2,
0,
1,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 13126,
"seconds": 12.0,
"avg_fps": 1093.801,
"low_1pct_fps": 77.252,
"low_01pct_fps": 12.308,
"p50_ms": 0.714,
"p95_ms": 1.402,
"p99_ms": 2.875,
"p999_ms": 12.067,
"max_ms": 238.158,
"histogram": [
11605,
1207,
191,
54,
17,
11,
7,
12,
4,
4,
2,
3,
2,
1,
0,
0,
0,
0,
1,
2,
3,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 15928,
"seconds": 12.0,
"avg_fps": 1327.281,
"low_1pct_fps": 198.683,
"low_01pct_fps": 46.114,
"p50_ms": 0.67,
"p95_ms": 1.172,
"p99_ms": 2.111,
"p999_ms": 7.067,
"max_ms": 193.743,
"histogram": [
14891,
856,
102,
42,
7,
6,
8,
6,
0,
5,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 13249,
"seconds": 12.0,
"avg_fps": 1104.051,
"low_1pct_fps": 83.338,
"low_01pct_fps": 14.249,
"p50_ms": 0.711,
"p95_ms": 1.403,
"p99_ms": 3.046,
"p999_ms": 10.625,
"max_ms": 451.869,
"histogram": [
11824,
1165,
124,
58,
13,
16,
11,
14,
7,
7,
3,
2,
1,
0,
0,
0,
1,
0,
0,
1,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 1469,
"seconds": 12.109,
"avg_fps": 121.312,
"low_1pct_fps": 2.229,
"low_01pct_fps": 0.75,
"p50_ms": 2.12,
"p95_ms": 13.173,
"p99_ms": 125.189,
"p999_ms": 727.045,
"max_ms": 1939.723,
"histogram": [
196,
440,
438,
136,
50,
36,
24,
25,
22,
17,
17,
13,
6,
11,
8,
4,
1,
4,
4,
4,
3,
7,
2,
1
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 8396,
"seconds": 12.001,
"avg_fps": 699.617,
"low_1pct_fps": 46.542,
"low_01pct_fps": 7.318,
"p50_ms": 1.022,
"p95_ms": 2.433,
"p99_ms": 5.528,
"p999_ms": 13.993,
"max_ms": 305.464,
"histogram": [
3248,
4509,
361,
113,
52,
37,
21,
26,
14,
4,
3,
2,
0,
0,
0,
0,
0,
0,
1,
1,
2,
2,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 7639,
"seconds": 12.0,
"avg_fps": 636.566,
"low_1pct_fps": 37.064,
"low_01pct_fps": 5.612,
"p50_ms": 1.023,
"p95_ms": 3.0,
"p99_ms": 7.11,
"p999_ms": 17.521,
"max_ms": 268.877,
"histogram": [
3051,
3794,
413,
158,
66,
43,
35,
21,
31,
12,
3,
4,
1,
1,
0,
0,
0,
0,
0,
0,
4,
2,
0,
0
]
}
],
"label": "vanilla-desktop"
}
-771
View File
@@ -1,771 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-04T12:54:29Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 12,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 17.628,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 12652.182,
"stdev": 3231.342,
"min": 9526.004,
"max": 15979.309,
"ci95": 8027.746
},
"startup.world_ms": {
"n": 3,
"mean": 31329.516,
"stdev": 10016.129,
"min": 20777.004,
"max": 40705.309,
"ci95": 24883.451
},
"chunks.village.load_ms": {
"n": 3,
"mean": 12623.084,
"stdev": 4958.724,
"min": 7251.986,
"max": 17026.894,
"ci95": 12319.147
},
"fps.village.avg_fps": {
"n": 3,
"mean": 92.729,
"stdev": 58.527,
"min": 28.713,
"max": 143.496,
"ci95": 145.402
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 16.52,
"stdev": 10.949,
"min": 4.001,
"max": 24.308,
"ci95": 27.2
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 11.515,
"stdev": 7.155,
"min": 3.549,
"max": 17.394,
"ci95": 17.774
},
"memory.village.rss_mb": {
"n": 3,
"mean": 1834.469,
"stdev": 329.198,
"min": 1455.688,
"max": 2051.516,
"ci95": 817.839
},
"tick.village.client_ms": {
"n": 3,
"mean": 1.903,
"stdev": 1.114,
"min": 1.131,
"max": 3.18,
"ci95": 2.767
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 14590.694,
"stdev": 3260.615,
"min": 10942.613,
"max": 17221.108,
"ci95": 8100.47
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 106.817,
"stdev": 104.558,
"min": 43.857,
"max": 227.513,
"ci95": 259.759
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 23.0,
"stdev": 26.394,
"min": 7.338,
"max": 53.473,
"ci95": 65.57
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 14.841,
"stdev": 15.773,
"min": 5.157,
"max": 33.042,
"ci95": 39.186
},
"memory.forest.rss_mb": {
"n": 3,
"mean": 1814.245,
"stdev": 154.974,
"min": 1635.453,
"max": 1910.125,
"ci95": 385.007
},
"tick.forest.client_ms": {
"n": 3,
"mean": 1.452,
"stdev": 0.542,
"min": 0.826,
"max": 1.777,
"ci95": 1.346
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 13309.049,
"stdev": 8088.157,
"min": 6394.403,
"max": 22203.194,
"ci95": 20093.716
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 164.405,
"stdev": 141.4,
"min": 62.162,
"max": 325.77,
"ci95": 351.285
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 36.242,
"stdev": 37.051,
"min": 14.736,
"max": 79.024,
"ci95": 92.047
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 20.898,
"stdev": 17.899,
"min": 8.855,
"max": 41.466,
"ci95": 44.468
},
"memory.ocean.rss_mb": {
"n": 3,
"mean": 1724.453,
"stdev": 347.692,
"min": 1323.5,
"max": 1942.734,
"ci95": 863.784
},
"tick.ocean.client_ms": {
"n": 3,
"mean": 0.866,
"stdev": 0.248,
"min": 0.58,
"max": 1.03,
"ci95": 0.617
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17989.43,
"stdev": 3969.295,
"min": 13444.423,
"max": 20774.261,
"ci95": 9861.07
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 162.084,
"stdev": 160.057,
"min": 68.44,
"max": 346.896,
"ci95": 397.635
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 33.74,
"stdev": 36.146,
"min": 11.158,
"max": 75.429,
"ci95": 89.798
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 17.194,
"stdev": 14.232,
"min": 7.903,
"max": 33.579,
"ci95": 35.357
},
"memory.mountain.rss_mb": {
"n": 3,
"mean": 1977.602,
"stdev": 108.627,
"min": 1872.516,
"max": 2089.453,
"ci95": 269.866
},
"tick.mountain.client_ms": {
"n": 3,
"mean": 0.772,
"stdev": 0.332,
"min": 0.463,
"max": 1.123,
"ci95": 0.825
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2135.313,
"stdev": 54.617,
"min": 2072.344,
"max": 2169.828,
"ci95": 135.686
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1836.44,
"stdev": 223.694,
"min": 1578.141,
"max": 1965.781,
"ci95": 555.732
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3253.456,
"stdev": 68.323,
"min": 3178.409,
"max": 3312.05,
"ci95": 169.737
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3144.363,
"stdev": 69.868,
"min": 3069.394,
"max": 3207.66,
"ci95": 173.576
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1272,
"seconds": 12.002,
"avg_fps": 105.979,
"low_1pct_fps": 21.25,
"low_01pct_fps": 17.394,
"p50_ms": 7.527,
"p95_ms": 22.34,
"p99_ms": 40.461,
"p999_ms": 54.28,
"max_ms": 60.7,
"histogram": [
0,
0,
0,
56,
161,
170,
180,
167,
184,
118,
71,
61,
26,
25,
28,
12,
10,
3,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1723,
"seconds": 12.007,
"avg_fps": 143.496,
"low_1pct_fps": 24.308,
"low_01pct_fps": 13.602,
"p50_ms": 5.317,
"p95_ms": 16.181,
"p99_ms": 29.771,
"p999_ms": 56.832,
"max_ms": 90.209,
"histogram": [
0,
0,
63,
413,
271,
273,
185,
148,
112,
89,
47,
43,
28,
22,
18,
4,
5,
1,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 346,
"seconds": 12.05,
"avg_fps": 28.713,
"low_1pct_fps": 4.001,
"low_01pct_fps": 3.549,
"p50_ms": 23.684,
"p95_ms": 110.057,
"p99_ms": 225.236,
"p999_ms": 281.776,
"max_ms": 281.776,
"histogram": [
0,
0,
0,
4,
8,
6,
6,
12,
21,
20,
23,
25,
24,
32,
50,
28,
24,
27,
17,
11,
6,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 589,
"seconds": 12.001,
"avg_fps": 49.081,
"low_1pct_fps": 8.19,
"low_01pct_fps": 6.325,
"p50_ms": 15.443,
"p95_ms": 53.004,
"p99_ms": 94.69,
"p999_ms": 158.114,
"max_ms": 158.114,
"histogram": [
0,
0,
0,
2,
7,
20,
30,
46,
53,
48,
56,
62,
61,
66,
63,
18,
22,
20,
10,
4,
1,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 2731,
"seconds": 12.004,
"avg_fps": 227.513,
"low_1pct_fps": 53.473,
"low_01pct_fps": 33.042,
"p50_ms": 3.801,
"p95_ms": 7.955,
"p99_ms": 14.177,
"p999_ms": 24.479,
"max_ms": 38.218,
"histogram": [
0,
0,
526,
1157,
432,
257,
151,
88,
46,
32,
13,
15,
6,
6,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 527,
"seconds": 12.016,
"avg_fps": 43.857,
"low_1pct_fps": 7.338,
"low_01pct_fps": 5.157,
"p50_ms": 16.784,
"p95_ms": 57.353,
"p99_ms": 111.984,
"p999_ms": 193.905,
"max_ms": 193.905,
"histogram": [
0,
0,
0,
1,
2,
9,
16,
29,
40,
61,
50,
54,
52,
62,
57,
35,
28,
11,
13,
5,
2,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 746,
"seconds": 12.001,
"avg_fps": 62.162,
"low_1pct_fps": 14.965,
"low_01pct_fps": 12.372,
"p50_ms": 13.76,
"p95_ms": 35.144,
"p99_ms": 57.63,
"p999_ms": 80.827,
"max_ms": 80.827,
"histogram": [
0,
0,
2,
13,
12,
41,
37,
53,
67,
82,
80,
99,
82,
76,
58,
17,
11,
12,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 3910,
"seconds": 12.002,
"avg_fps": 325.77,
"low_1pct_fps": 79.024,
"low_01pct_fps": 41.466,
"p50_ms": 2.685,
"p95_ms": 5.125,
"p99_ms": 8.342,
"p999_ms": 20.782,
"max_ms": 30.571,
"histogram": [
0,
78,
2526,
892,
203,
95,
49,
27,
13,
11,
7,
3,
2,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 1268,
"seconds": 12.044,
"avg_fps": 105.282,
"low_1pct_fps": 14.736,
"low_01pct_fps": 8.855,
"p50_ms": 6.542,
"p95_ms": 25.245,
"p99_ms": 50.465,
"p999_ms": 79.065,
"max_ms": 146.809,
"histogram": [
0,
0,
4,
171,
190,
169,
167,
133,
105,
93,
53,
43,
35,
41,
26,
13,
11,
10,
3,
1,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 822,
"seconds": 12.011,
"avg_fps": 68.44,
"low_1pct_fps": 14.632,
"low_01pct_fps": 10.101,
"p50_ms": 11.396,
"p95_ms": 35.387,
"p99_ms": 55.24,
"p999_ms": 99.005,
"max_ms": 99.005,
"histogram": [
0,
0,
0,
10,
42,
62,
55,
86,
89,
97,
64,
73,
74,
72,
48,
21,
17,
8,
4,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 4165,
"seconds": 12.006,
"avg_fps": 346.896,
"low_1pct_fps": 75.429,
"low_01pct_fps": 33.579,
"p50_ms": 2.541,
"p95_ms": 4.838,
"p99_ms": 7.998,
"p999_ms": 20.209,
"max_ms": 53.577,
"histogram": [
0,
640,
2307,
855,
178,
93,
34,
22,
15,
3,
3,
8,
2,
2,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 851,
"seconds": 12.0,
"avg_fps": 70.916,
"low_1pct_fps": 11.158,
"low_01pct_fps": 7.903,
"p50_ms": 10.128,
"p95_ms": 36.498,
"p99_ms": 68.434,
"p999_ms": 126.541,
"max_ms": 126.541,
"histogram": [
0,
0,
0,
38,
61,
87,
76,
69,
84,
100,
63,
53,
68,
53,
48,
15,
9,
16,
9,
2,
0,
0,
0,
0
]
}
],
"label": "vanilla-macmini"
}
-771
View File
@@ -1,771 +0,0 @@
{
"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"
}
-8
View File
@@ -6,14 +6,6 @@ license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
# `cargo-horizon-mod`'s workflow attaches to the `cargo-horizon-mod-v<version>` release for the runner's target,
# holding the binary at its root. tools/horizon_mod_binstall.py expands it for the workflow's targets.
[package.metadata.binstall]
pkg-url = "https://github.com/horizon-rust/horizon/releases/download/cargo-horizon-mod-v{ version }/{ name }-{ target }.tar.gz"
bin-dir = "{ bin }{ binary-ext }"
pkg-fmt = "tgz"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
+1 -1
View File
@@ -93,7 +93,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(horizon_mods::index::zip_kind(path)),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
_ => None,
}
+8 -29
View File
@@ -489,36 +489,15 @@ pub fn parse_duration(text: &str) -> Option<i64> {
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// 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 {
(60 * MINUTE, DurationUnit::Hour)
} else {
(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.
/// `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 (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
} else {
(up(24 * 60 * MINUTE), "day")
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
-16
View File
@@ -327,19 +327,3 @@ 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,9 +143,7 @@ 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")).and_then(|id| crate::identifier::normalize_id(&id))
else {
let Some(table) = text(self.input.get("LootTable")) else {
return false;
};
self.put("LootTable", string(&table));
@@ -907,16 +905,6 @@ 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(
+3 -5
View File
@@ -207,11 +207,9 @@ fn style_flag(v: Option<&Value>) -> Option<bool> {
}
}
/// 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 {
fn untrusted_http_url(url: &str) -> bool {
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
// https only, scheme case-insensitive.
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
+3 -408
View File
@@ -100,222 +100,6 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/ur_pk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ur_pk.json")
}
"assets/horizon/lang/fa_ir.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fa_ir.json")
}
"assets/horizon/lang/he_il.json" => {
include_str!("../../horizon-client/assets/horizon/lang/he_il.json")
}
"assets/horizon/lang/hu_hu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hu_hu.json")
}
"assets/horizon/lang/ro_ro.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ro_ro.json")
}
"assets/horizon/lang/da_dk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/da_dk.json")
}
"assets/horizon/lang/fi_fi.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fi_fi.json")
}
"assets/horizon/lang/no_no.json" => {
include_str!("../../horizon-client/assets/horizon/lang/no_no.json")
}
"assets/horizon/lang/bg_bg.json" => {
include_str!("../../horizon-client/assets/horizon/lang/bg_bg.json")
}
"assets/horizon/lang/sk_sk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sk_sk.json")
}
"assets/horizon/lang/lt_lt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lt_lt.json")
}
"assets/horizon/lang/it_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/it_it.json")
}
"assets/horizon/lang/nl_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/nl_nl.json")
}
"assets/horizon/lang/vi_vn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vi_vn.json")
}
"assets/horizon/lang/th_th.json" => {
include_str!("../../horizon-client/assets/horizon/lang/th_th.json")
}
"assets/horizon/lang/ja_jp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ja_jp.json")
}
"assets/horizon/lang/ar_sa.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ar_sa.json")
}
"assets/horizon/lang/pt_br.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pt_br.json")
}
"assets/horizon/lang/cs_cz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cs_cz.json")
}
"assets/horizon/lang/sv_se.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sv_se.json")
}
"assets/horizon/lang/el_gr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/el_gr.json")
}
"assets/horizon/lang/ca_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ca_es.json")
}
"assets/horizon/lang/et_ee.json" => {
include_str!("../../horizon-client/assets/horizon/lang/et_ee.json")
}
"assets/horizon/lang/lv_lv.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lv_lv.json")
}
"assets/horizon/lang/sl_si.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sl_si.json")
}
"assets/horizon/lang/sr_cs.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_cs.json")
}
"assets/horizon/lang/is_is.json" => {
include_str!("../../horizon-client/assets/horizon/lang/is_is.json")
}
"assets/horizon/lang/af_za.json" => {
include_str!("../../horizon-client/assets/horizon/lang/af_za.json")
}
"assets/horizon/lang/ms_my.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ms_my.json")
}
"assets/horizon/lang/be_by.json" => {
include_str!("../../horizon-client/assets/horizon/lang/be_by.json")
}
"assets/horizon/lang/sr_sp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_sp.json")
}
"assets/horizon/lang/az_az.json" => {
include_str!("../../horizon-client/assets/horizon/lang/az_az.json")
}
"assets/horizon/lang/uz_uz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uz_uz.json")
}
"assets/horizon/lang/kk_kz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kk_kz.json")
}
"assets/horizon/lang/ka_ge.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ka_ge.json")
}
"assets/horizon/lang/hy_am.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hy_am.json")
}
"assets/horizon/lang/bs_ba.json" => {
include_str!("../../horizon-client/assets/horizon/lang/bs_ba.json")
}
"assets/horizon/lang/mk_mk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mk_mk.json")
}
"assets/horizon/lang/sq_al.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sq_al.json")
}
"assets/horizon/lang/cy_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cy_gb.json")
}
"assets/horizon/lang/ga_ie.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ga_ie.json")
}
"assets/horizon/lang/eu_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eu_es.json")
}
"assets/horizon/lang/gl_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gl_es.json")
}
"assets/horizon/lang/fil_ph.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fil_ph.json")
}
"assets/horizon/lang/ta_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ta_in.json")
}
"assets/horizon/lang/mn_mn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mn_mn.json")
}
"assets/horizon/lang/lo_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lo_la.json")
}
"assets/horizon/lang/so_so.json" => {
include_str!("../../horizon-client/assets/horizon/lang/so_so.json")
}
"assets/horizon/lang/fy_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fy_nl.json")
}
"assets/horizon/lang/gd_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gd_gb.json")
}
"assets/horizon/lang/lb_lu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lb_lu.json")
}
"assets/horizon/lang/ky_kg.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ky_kg.json")
}
"assets/horizon/lang/kn_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kn_in.json")
}
"assets/horizon/lang/mt_mt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mt_mt.json")
}
"assets/horizon/lang/eo_uy.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eo_uy.json")
}
"assets/horizon/lang/haw_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/haw_us.json")
}
"assets/horizon/lang/ig_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ig_ng.json")
}
"assets/horizon/lang/la_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/la_la.json")
}
"assets/horizon/lang/tt_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tt_ru.json")
}
"assets/horizon/lang/yi_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yi_de.json")
}
"assets/horizon/lang/yo_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yo_ng.json")
}
"assets/horizon/lang/ba_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ba_ru.json")
}
"assets/horizon/lang/br_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/br_fr.json")
}
"assets/horizon/lang/cv_cu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cv_cu.json")
}
"assets/horizon/lang/fo_fo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fo_fo.json")
}
"assets/horizon/lang/fur_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fur_it.json")
}
"assets/horizon/lang/li_li.json" => {
include_str!("../../horizon-client/assets/horizon/lang/li_li.json")
}
"assets/horizon/lang/lmo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lmo.json")
}
"assets/horizon/lang/oc_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/oc_fr.json")
}
"assets/horizon/lang/sah_sah.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sah_sah.json")
}
"assets/horizon/lang/se_no.json" => {
include_str!("../../horizon-client/assets/horizon/lang/se_no.json")
}
"assets/horizon/lang/szl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/szl.json")
}
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
@@ -388,36 +172,6 @@ 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
@@ -443,7 +197,9 @@ impl Language {
else {
continue;
};
read_lang_file(&bytes, &mut map);
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(&bytes) {
map.extend(entries);
}
}
}
}
@@ -903,165 +659,4 @@ 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:?}");
}
}
+34 -215
View File
@@ -79,65 +79,28 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
_ => return None,
})
}
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
fn int_value(t: &Tag) -> Option<i32> {
Some(match t {
Tag::Byte(v) => i32::from(*v),
Tag::Short(v) => i32::from(*v),
Tag::Int(v) => *v,
Tag::Long(v) => *v as i32,
Tag::Float(v) => f32::from_bits(*v).floor() as i32,
Tag::Double(v) => f64::from_bits(*v).floor() as i32,
_ => return None,
})
}
/// `FloatTag.longValue` truncates; `DoubleTag.longValue` floors before narrowing.
fn long_value(t: &Tag) -> Option<i64> {
Some(match t {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
// FloatTag.longValue truncates; DoubleTag.longValue floors first.
Tag::Float(v) => f32::from_bits(*v) as i64,
Tag::Double(v) => f64::from_bits(*v).floor() as i64,
_ => return None,
})
}
#[derive(Clone, Copy)]
enum ElementKind {
List,
NumericArray,
}
impl ElementKind {
// Admission only: arrays require NumericTag; lists accept every tag type.
fn accepts(self, value: &Tag) -> bool {
matches!(self, Self::List)
|| matches!(
value,
Tag::Byte(_)
| Tag::Short(_)
| Tag::Int(_)
| Tag::Long(_)
| Tag::Float(_)
| Tag::Double(_)
)
fn number(t: &Tag) -> Option<i64> {
match t {
Tag::Byte(v) => Some(i64::from(*v)),
Tag::Short(v) => Some(i64::from(*v)),
Tag::Int(v) => Some(i64::from(*v)),
Tag::Long(v) => Some(*v),
_ => None,
}
}
/// Rust view adapter: run `f` over the elements, then write arrays using their numeric accessor.
/// Run `f` over the elements of a list/array, writing array results back.
/// None when `tag` is not list-like.
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>, ElementKind) -> R) -> Option<R> {
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>) -> R) -> Option<R> {
match tag {
Tag::List(l) => Some(f(l, ElementKind::List)),
Tag::List(l) => Some(f(l)),
Tag::ByteArray(_) | Tag::IntArray(_) | Tag::LongArray(_) => {
let mut v = elements(tag)?;
let r = f(&mut v, ElementKind::NumericArray);
let r = f(&mut v);
let nums: Vec<i64> = v.iter().filter_map(number).collect();
match tag {
// Float/double byteValue uses intValue's floor, then narrows.
Tag::ByteArray(a) => *a = v.iter().filter_map(int_value).map(|n| n as u8).collect(),
Tag::IntArray(a) => *a = v.iter().filter_map(int_value).collect(),
Tag::LongArray(a) => *a = v.iter().filter_map(long_value).collect(),
Tag::ByteArray(a) => *a = nums.iter().map(|n| *n as u8).collect(),
Tag::IntArray(a) => *a = nums.iter().map(|n| *n as i32).collect(),
Tag::LongArray(a) => *a = nums,
_ => {}
}
Some(r)
@@ -194,20 +157,6 @@ 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(
@@ -400,7 +349,7 @@ impl NbtPath {
});
removed
}
/// `NbtPathArgument.NbtPath.insert`: insert accepted tags into every reached collection.
/// `insert` (also append and prepend): into every list the path reaches.
pub fn insert(
&self,
index: i32,
@@ -423,23 +372,19 @@ impl NbtPath {
return;
}
let shown = crate::snbt::to_snbt(target);
let result = with_elements(target, |list, kind| {
let result = with_elements(target, |list| {
let start = if index < 0 {
list.len() as i64 + i64::from(index) + 1
} else {
i64::from(index)
};
let mut any = false;
let mut at = start;
for v in values {
if !kind.accepts(v) {
continue;
}
for (n, v) in values.iter().enumerate() {
let at = start + n as i64;
if at < 0 || at as usize > list.len() {
return Err(CollectionError::InvalidIndex(at as i32));
}
list.insert(at as usize, v.clone());
at += 1;
any = true;
}
Ok(any)
@@ -483,8 +428,7 @@ fn preferred_parent(node: &Node) -> Tag {
_ => Tag::Compound(Compound::new()),
}
}
/// Children of `parent`; the array creation arm follows
/// `NbtPathArgument.AllElementsNode.getOrCreateTag` admission.
/// `getOrCreateTag` (or `getTag` without `create`): the children of `parent`.
fn step_mut(
node: &Node,
parent: &mut Tag,
@@ -524,14 +468,11 @@ fn step_mut(
}
Node::AllElements => {
let make = create;
let _ = with_elements(parent, |list, kind| {
let _ = with_elements(parent, |list| {
if list.is_empty() {
if let Some(make) = make {
let value = make();
if kind.accepts(&value) {
list.push(value);
out(&mut list[0]);
}
list.push(make());
out(&mut list[0]);
}
} else {
for t in list.iter_mut() {
@@ -541,7 +482,7 @@ fn step_mut(
});
}
Node::Index(i) => {
let _ = with_elements(parent, |list, _kind| {
let _ = with_elements(parent, |list| {
let n = list.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
@@ -577,8 +518,7 @@ fn step_mut(
}
}
}
/// Element setters follow `NbtPathArgument.AllElementsNode.setTag` and
/// `NbtPathArgument.IndexedElementNode.setTag`; object nodes replace their children.
/// `Node.setTag`: replace the child(ren) of `parent` with `value`.
fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
match node {
Node::Child(name) => {
@@ -598,36 +538,29 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
}
0
}
Node::AllElements => with_elements(parent, |list, kind| {
let len = list.len();
if len == 0 {
if kind.accepts(value) {
list.push(value.clone());
}
// AllElementsNode returns 1 here even when addTag rejects.
Node::AllElements => with_elements(parent, |list| {
if list.is_empty() {
list.push(value.clone());
return 1;
}
// Compare the original tags, before numeric conversion.
let changed = list.iter().filter(|t| *t != value).count();
if changed == 0 {
return 0;
}
list.clear();
if !kind.accepts(value) {
return 0;
for t in list.iter_mut() {
*t = value.clone();
}
list.resize(len, value.clone());
changed
})
.unwrap_or(0),
Node::Index(i) => with_elements(parent, |list, kind| {
Node::Index(i) => with_elements(parent, |list| {
let n = list.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
} else {
i64::from(*i)
};
if (0..n).contains(&idx) && list[idx as usize] != *value && kind.accepts(value) {
if (0..n).contains(&idx) && list[idx as usize] != *value {
list[idx as usize] = value.clone();
1
} else {
@@ -653,7 +586,6 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
Node::MatchRoot(_) => 0,
}
}
/// Removal arms, including `NbtPathArgument.AllElementsNode.removeTag`.
fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
match node {
Node::Child(name) => match parent {
@@ -671,13 +603,13 @@ fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
}
_ => 0,
},
Node::AllElements => with_elements(parent, |l, _kind| {
Node::AllElements => with_elements(parent, |l| {
let n = l.len();
l.clear();
n
})
.unwrap_or(0),
Node::Index(i) => with_elements(parent, |l, _kind| {
Node::Index(i) => with_elements(parent, |l| {
let n = l.len() as i64;
let idx = if *i < 0 {
n + i64::from(*i)
@@ -840,25 +772,6 @@ 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
@@ -974,100 +887,6 @@ mod tests {
));
}
#[test]
fn numeric_array_edits_use_the_destination_numeric_accessor() {
for (initial, value, inserted, indexed, all) in [
(
"{v:[B;1b,2b]}",
"-3.5f",
"{v:[B;1b,-4b,2b]}",
"{v:[B;-4b,2b]}",
"{v:[B;-4b,-4b]}",
),
(
"{v:[B;1b,2b]}",
"-3.5d",
"{v:[B;1b,-4b,2b]}",
"{v:[B;-4b,2b]}",
"{v:[B;-4b,-4b]}",
),
(
"{v:[I;1,2]}",
"-3.5f",
"{v:[I;1,-4,2]}",
"{v:[I;-4,2]}",
"{v:[I;-4,-4]}",
),
(
"{v:[I;1,2]}",
"-3.5d",
"{v:[I;1,-4,2]}",
"{v:[I;-4,2]}",
"{v:[I;-4,-4]}",
),
(
"{v:[L;1L,2L]}",
"-3.5f",
"{v:[L;1L,-3L,2L]}",
"{v:[L;-3L,2L]}",
"{v:[L;-3L,-3L]}",
),
(
"{v:[L;1L,2L]}",
"-3.5d",
"{v:[L;1L,-4L,2L]}",
"{v:[L;-4L,2L]}",
"{v:[L;-4L,-4L]}",
),
] {
let value = tag(value);
let mut root = tag(initial);
assert_eq!(
path("v")
.insert(1, &mut root, std::slice::from_ref(&value))
.unwrap(),
1
);
assert_eq!(root, tag(inserted));
let mut root = tag(initial);
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 1);
assert_eq!(root, tag(indexed));
let mut root = tag(initial);
assert_eq!(path("v[]").set(&mut root, &value).unwrap(), 2);
assert_eq!(root, tag(all));
}
}
#[test]
fn numeric_arrays_reject_nonnumeric_insert_and_indexed_set() {
for initial in ["{v:[B;1b,2b]}", "{v:[I;1,2]}", "{v:[L;1L,2L]}"] {
let original = tag(initial);
let value = tag("\"bad\"");
for index in [0, 99, -99] {
let mut root = original.clone();
assert_eq!(
path("v")
.insert(index, &mut root, std::slice::from_ref(&value))
.unwrap(),
0
);
assert_eq!(root, original);
}
let mut root = original.clone();
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 0);
assert_eq!(root, original);
}
}
#[test]
fn empty_array_wildcard_set_preserves_vanillas_count_on_rejection() {
for initial in ["{v:[B;]}", "{v:[I;]}", "{v:[L;]}"] {
let mut root = tag(initial);
assert_eq!(path("v[]").set(&mut root, &tag("\"bad\"")).unwrap(), 1);
assert_eq!(root, tag(initial));
}
}
#[test]
fn partial_comparison() {
let actual = tag("{a:1,b:{c:[1,2,3],d:\"x\"},e:2}");
@@ -173,16 +173,6 @@ impl Registries {
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
if self.summonable.is_empty() {
self.summonable = strings(&r["summonable"]);
self.summonable.sort();
self.summonable.dedup();
}
if self.entity_types.is_empty() {
self.entity_types = strings(&r["entity_types"]);
self.entity_types.sort();
self.entity_types.dedup();
}
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
@@ -246,16 +236,6 @@ impl Registries {
fill(&mut self.templates, &e.templates);
fill(&mut self.persistent_components, &e.persistent_components);
fill(&mut self.learnable_recipes, &e.learnable_recipes);
if self.summonable.is_empty() {
self.summonable.clone_from(&e.summonable);
self.summonable.sort();
self.summonable.dedup();
}
if self.entity_types.is_empty() {
self.entity_types.clone_from(&e.entity_types);
self.entity_types.sort();
self.entity_types.dedup();
}
for (name, ids) in &e.by_registry {
if !self.by_registry.contains_key(name) {
self.by_registry.insert(name.clone(), ids.clone());
@@ -964,10 +964,6 @@ impl Scoreboard {
pub fn tracked(&self) -> Vec<String> {
self.holder_order.stream().map(str::to_owned).collect()
}
/// Every holder on a team (`teamsByPlayer.keySet()`).
pub fn team_members(&self) -> impl Iterator<Item = &str> {
self.team_of.keys().map(String::as_str)
}
// teams
pub fn team(&self, name: &str) -> Option<&Team> {
+13 -115
View File
@@ -8,20 +8,11 @@ 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!(
@@ -67,10 +58,6 @@ 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;
}
@@ -80,7 +67,6 @@ impl Pretty {
self.indent_depth -= 1;
}
self.depth -= 1;
self.piston = previous;
}
fn visit(&mut self, tag: &Tag) {
match tag {
@@ -171,11 +157,7 @@ impl Pretty {
"\n"
};
let n = c.len();
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() {
for (i, (k, v)) in java_hash_order(c).into_iter().enumerate() {
self.lit(&entry, None);
let key = string_of(k);
if !key.is_empty()
@@ -189,12 +171,7 @@ impl Pretty {
}
self.lit(":", None);
self.lit(" ", None);
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);
self.sub(v, true);
if i + 1 < n {
self.lit(",", None);
self.lit(spacing, None);
@@ -209,61 +186,22 @@ 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] = &[
@@ -330,7 +268,7 @@ pub fn java_hash_order(
.unwrap_or(WRITE_ORDER.len())
};
let mut entries: Vec<_> = c.iter().collect();
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), rank(k)));
entries.sort_by_key(|(k, _)| (bucket(k), rank(k)));
entries
}
/// `NbtUtils.toPrettyComponent`: the coloured, unindented form.
@@ -343,19 +281,6 @@ 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
@@ -370,33 +295,6 @@ 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}"),
+46 -663
View File
@@ -119,19 +119,6 @@ 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
@@ -155,14 +142,6 @@ 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 {
@@ -200,13 +179,12 @@ impl PadState {
#[derive(Clone, Debug, Default)]
pub struct Edges {
held: u32,
suppressed: u32,
}
impl Edges {
pub fn update(&mut self, state: &PadState, threshold: f32) -> Vec<(Button, bool)> {
let mut now = state.buttons;
for (trigger, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
let was = (self.held | self.suppressed) & button.bit() != 0;
let was = self.held & button.bit() != 0;
let value = state.triggers[trigger];
let on = if was {
value >= (threshold - 0.1).max(0.05)
@@ -219,8 +197,6 @@ impl Edges {
now &= !button.bit();
}
}
self.suppressed &= now;
now &= !self.suppressed;
let changed = now ^ self.held;
self.held = now;
Button::ALL
@@ -241,50 +217,6 @@ impl Edges {
pub fn is_held(&self, button: Button) -> bool {
self.held & button.bit() != 0
}
/// Cancel a context without turning its still-held buttons into new presses.
pub fn cancel(&mut self) -> Vec<(Button, bool)> {
self.suppressed |= self.held;
self.release_all()
}
}
/// Intentional activity may take input ownership; an unchanged hold may not.
#[derive(Default)]
pub struct Activity {
buttons: u32,
sticks: [[f32; 2]; 2],
}
impl Activity {
pub fn observe(&mut self, state: &PadState, settings: &PadSettings) -> bool {
let mut buttons = state.buttons;
for (i, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
let threshold = if self.buttons & button.bit() != 0 {
(settings.trigger_threshold - 0.1).max(0.05)
} else {
settings.trigger_threshold
};
if state.triggers[i] >= threshold {
buttons |= button.bit();
}
}
let mut activity = buttons & !self.buttons != 0;
self.buttons = buttons;
for (i, (stick, deadzone)) in [
(state.left, settings.move_deadzone),
(state.right, settings.look_deadzone),
]
.into_iter()
.enumerate()
{
if length(stick) <= deadzone {
self.sticks[i] = [0.0; 2];
} else if length([stick[0] - self.sticks[i][0], stick[1] - self.sticks[i][1]]) > 0.08 {
activity = true;
self.sticks[i] = stick;
}
}
activity
}
}
/// Horizon's pad-only actions (no vanilla key mapping).
@@ -316,39 +248,6 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.smoothCamera", None),
];
/// How a bindable action reaches the game: one of its own handlers, shared
/// with the keyboard where the keyboard has one, or else the vanilla default
/// key or button of its mapping, as a rebound keyboard key would.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
HotbarStep {
next: bool,
},
/// Held, or flipped when the pad's toggle sneak is on.
Sneak,
Screenshot,
SmoothCamera,
Key(winit::keyboard::KeyCode),
Mouse(winit::event::MouseButton),
}
/// The route of a bindable action; `None` for one the pad can't reach.
pub fn route(action: &str) -> Option<Route> {
match action {
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
crate::keybinds::mouse_button(default)
.map(Route::Mouse)
.or_else(|| crate::keybinds::key_code(default).map(Route::Key))
}
}
}
/// An action's name in the binds list: vanilla's translation key, or
/// Horizon's own English for the pad-only actions.
pub fn action_caption(action: &str) -> &str {
@@ -414,6 +313,31 @@ impl GlyphStyle {
}
}
/// How a controller drives menus (docs/controllers.md, "Menus and
/// inventories").
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum MenuNav {
/// A console-style highlight moved between widgets and slots.
#[default]
Focus,
/// A virtual mouse pointer.
Pointer,
}
impl MenuNav {
pub fn name(self) -> &'static str {
match self {
MenuNav::Focus => "focus",
MenuNav::Pointer => "pointer",
}
}
pub fn parse(name: &str) -> Self {
match name {
"pointer" => MenuNav::Pointer,
_ => MenuNav::Focus,
}
}
}
/// The on-screen keyboard A opens on a text field in focus mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextKeyboard {
@@ -475,16 +399,9 @@ 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,
/// How the pad drives menus: a focus highlight or a virtual pointer.
pub menu_nav: MenuNav,
pub keyboard: TextKeyboard,
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
pub pointer_speed: f32,
pub scroll_speed: f32,
pub repeat_delay: f32,
pub repeat_interval: f32,
/// Action name -> button, `None` for unbound; missing means the default.
bindings: BTreeMap<&'static str, Option<Button>>,
}
@@ -503,12 +420,8 @@ impl Default for PadSettings {
trigger_threshold: 0.4,
toggle_sneak: true,
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
menu_nav: MenuNav::Focus,
keyboard: TextKeyboard::Auto,
pointer_speed: 1.0,
scroll_speed: 12.0,
repeat_delay: 0.35,
repeat_interval: 0.09,
bindings: BTreeMap::new(),
}
}
@@ -549,13 +462,6 @@ impl PadSettings {
.filter(|v| v.is_finite())
.map_or(d, |v| v.clamp(0.0, 1.0))
};
let bounded = |default: f32, min: f32, max: f32| {
value
.parse::<f32>()
.ok()
.filter(|v| v.is_finite())
.map_or(default, |v| v.clamp(min, max))
};
match key.trim() {
"enabled" => s.enabled = flag(s.enabled),
"vibration" => s.vibration = flag(s.vibration),
@@ -571,12 +477,8 @@ impl PadSettings {
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"menuNavigation" => s.menu_nav = MenuNav::parse(value),
"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),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
other => {
if let Some(action) = other.strip_prefix("bind_") {
let button = if value == "none" {
@@ -595,7 +497,7 @@ impl PadSettings {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nnintendoLetters:{}\nkeyboard:{}\n",
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nmenuNavigation:{}\nkeyboard:{}\n",
bool_text(self.enabled),
bool_text(self.vibration),
bool_text(self.adaptive_triggers),
@@ -608,13 +510,9 @@ impl PadSettings {
self.trigger_threshold,
bool_text(self.toggle_sneak),
self.glyphs.name(),
bool_text(self.nintendo_letters),
self.menu_nav.name(),
self.keyboard.name(),
);
text.push_str(&format!(
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
for (action, button) in &self.bindings {
text.push_str(&format!(
"bind_{action}:{}\n",
@@ -815,98 +713,6 @@ pub fn stick_direction(stick: [f32; 2], threshold: f32) -> Option<Button> {
})
}
/// How far a stick already stepping must fall back to let go: between this and
/// the step threshold it holds its direction, so a stick resting on the
/// threshold doesn't step again.
pub const STICK_RELEASE: f32 = 0.35;
/// How much more a stick must lean along another axis to change direction, so
/// a stick resting on a diagonal doesn't flip between the two.
const STICK_TURN: f32 = 1.5;
/// [`stick_direction`] for a stick that may already be stepping `held`: it stays
/// that way until the stick falls back past [`STICK_RELEASE`] or clearly turns.
pub fn stick_direction_held(
stick: [f32; 2],
threshold: f32,
held: Option<Button>,
) -> Option<Button> {
let fresh = stick_direction(stick, threshold);
let Some((axis, positive)) = held.and_then(direction) else {
return fresh;
};
if length(stick) < STICK_RELEASE {
return None;
}
// `direction`'s y runs down the screen, a stick's up.
let toward = stick[axis] * if positive == (axis == 0) { 1.0 } else { -1.0 };
if toward > 0.0 && stick[1 - axis].abs() <= toward * STICK_TURN {
held
} else {
fresh
}
}
/// A list drawn with a scrollbar (`ui::scrollbar`), how far it is scrolled (a list
/// moving a whole row a notch draws the same rectangles, so they can't tell) and
/// the ways it can still scroll.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollArea {
pub rect: Rect,
pub amount: f32,
pub up: bool,
pub down: bool,
}
impl ScrollArea {
/// A row counts while any of it is in view: the stick can leave the focus on one nearly gone.
pub fn holds(&self, r: &Rect) -> bool {
let x = r[0] + r[2] / 2.0;
let [ax, ay, aw, ah] = self.rect;
(ax..ax + aw).contains(&x) && r[1] < ay + ah && r[1] + r[3] > ay
}
/// All of a row is in view.
fn shows(&self, r: &Rect) -> bool {
let [_, ay, _, ah] = self.rect;
self.holds(r) && r[1] + 0.5 >= ay && r[1] + r[3] <= ay + ah + 0.5
}
}
/// A vertical focus step, with lists: the strict step, unless it would land
/// outside the view of the list `from` is in, or only partly in it, while that
/// list can still scroll that way, when there is none (`None`) and the list
/// scrolls instead: its hidden rows come first and whole, as vanilla's arrow
/// keys walk a selection list's entries (`ensureVisible`).
pub fn list_step(
from: Rect,
button: Button,
targets: &[Rect],
areas: &[ScrollArea],
) -> Option<usize> {
let step = focus_step_strict(from, button, targets)?;
let can_scroll = |a: &ScrollArea| {
if button == Button::DPadUp {
a.up
} else {
a.down
}
};
let leaves = areas
.iter()
.find(|a| a.holds(&from))
.is_some_and(|a| can_scroll(a) && !a.shows(&targets[step]));
(!leaves).then_some(step)
}
/// The farthest centres of two neighbouring slots: 18 apart, 22 over the hotbar's gap.
const SLOT_NEIGHBOUR: f32 = 24.0;
/// A held direction's repeat stops at the edge of the slots it is walking: it
/// goes on only from a slot to a slot next to it (the hotbar's small gap
/// included). A fresh press may go anywhere.
pub fn repeat_crosses_slots(from: (f32, f32), to: (f32, f32), to_slot: bool) -> bool {
!to_slot || (to.0 - from.0).abs().max((to.1 - from.1).abs()) > SLOT_NEIGHBOUR
}
/// Arrow-key focus navigation as vanilla does it
/// (`ContainerEventHandler.nextFocusPathInDirection`), over every target of
/// the screen at once: from a strip one pixel past the focused edge, the
@@ -1002,25 +808,12 @@ pub fn focus_step_vague(from: Rect, button: Button, targets: &[Rect]) -> Option<
/// How far a list scrolled between two frames' targets: the vertical shift
/// most targets of the same position and size agree on (targets that didn't
/// move, outside the list, don't count), for a list scrolled towards its end
/// (`down`: the rows moved up) or its start. `None` when nothing moved that way.
/// It takes the direction because half a row's scroll votes the same both ways.
pub fn scroll_shift(before: &[Rect], after: &[Rect], down: bool) -> Option<f32> {
/// move, outside the list, don't count). `None` when nothing moved.
pub fn scroll_shift(before: &[Rect], after: &[Rect]) -> Option<f32> {
let same = |a: &Rect, b: &Rect| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5);
if before.len() == after.len() && before.iter().zip(after).all(|(a, b)| same(a, b)) {
return None;
}
// Most votes; among equals the smallest move (the next row, not a far one).
shift_votes(before, after)
.into_iter()
.filter(|v| (v.0 < 0.0) == down)
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))
.map(|v| v.0)
}
/// Every vertical shift that would carry a `before` target onto an `after`
/// target of the same position and size, with how many it would.
fn shift_votes(before: &[Rect], after: &[Rect]) -> Vec<(f32, usize)> {
let mut votes: Vec<(f32, usize)> = Vec::new();
for b in before {
for a in after {
@@ -1037,94 +830,11 @@ fn shift_votes(before: &[Rect], after: &[Rect]) -> Vec<(f32, usize)> {
}
}
}
// Most votes; among equals the smallest move (the next row, not a far one).
votes
}
/// Where the focus goes when the right stick scrolled a list (`down`: towards
/// its end, so the rows moved up): the rectangle moved by the list's shift;
/// the same rectangle, for a widget outside the list; or, its row out of view,
/// the nearest target of its kind (`after`'s flag), the list's rows before
/// anything fixed. `None` when nothing moved that way, or none of its kind is
/// left. It takes the direction because half a row's scroll votes the same
/// both ways once a new row comes into view.
pub fn follow_scroll(
focus: Rect,
down: bool,
before: &[Rect],
after: &[(Rect, bool)],
) -> Option<usize> {
let rects: Vec<Rect> = after.iter().map(|t| t.0).collect();
// A column that stayed put isn't the list, even spaced like its shift.
let column = |of: &[Rect], r: &Rect| -> Vec<f32> {
let same = |o: &&Rect| o[0] == r[0] && o[2] == r[2] && o[3] == r[3];
of.iter().filter(same).map(|o| o[1]).collect()
};
let moving = |r: &&Rect| column(before, r) != column(&rects, r);
let was: Vec<Rect> = before.iter().filter(moving).copied().collect();
let now: Vec<Rect> = rects.iter().filter(moving).copied().collect();
let dy = shift_votes(&was, &now)
.into_iter()
.filter(|v| (v.0 < 0.0) == down)
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))?
.0;
let same = |a: &Rect, b: &Rect| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5);
let moved = [focus[0], focus[1] + dy, focus[2], focus[3]];
let found = |at: &Rect| rects.iter().position(|r| same(r, at));
if !moving(&&focus) {
return found(&focus);
}
if let Some(i) = found(&moved).or_else(|| found(&focus)) {
return Some(i);
}
let in_list = |r: &Rect| {
let was = [r[0], r[1] - dy, r[2], r[3]];
before.iter().any(|b| same(b, &was))
};
let centre = (moved[0] + moved[2] / 2.0, moved[1] + moved[3] / 2.0);
let distance = |r: &Rect| (r[0] + r[2] / 2.0 - centre.0).hypot(r[1] + r[3] / 2.0 - centre.1);
after
.iter()
.enumerate()
.filter(|(_, t)| t.1)
.min_by(|(_, a), (_, b)| {
in_list(&b.0)
.cmp(&in_list(&a.0))
.then(distance(&a.0).total_cmp(&distance(&b.0)))
})
.map(|(i, _)| i)
}
/// The target a focus rectangle stands for once the screen's size changed:
/// the same widget, by its place in the draw order when the screen drew as
/// many; else the target nearest where its centre falls at the new size.
pub fn refind_resized(
focus: Rect,
before: &[Rect],
after: &[Rect],
old: (f32, f32),
new: (f32, f32),
) -> Option<usize> {
let same = |r: &Rect| r.iter().zip(&focus).all(|(a, b)| (a - b).abs() < 0.5);
if before.len() == after.len()
&& let Some(i) = before.iter().position(same)
{
return Some(i);
}
let at = rescaled(
(focus[0] + focus[2] / 2.0, focus[1] + focus[3] / 2.0),
old,
new,
);
let centres: Vec<(f32, f32)> = after
.iter()
.map(|r| (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
.collect();
nearest(at, &centres, f32::INFINITY)
}
/// A point at the same place, proportionally, on a screen of another size.
pub fn rescaled(at: (f32, f32), old: (f32, f32), new: (f32, f32)) -> (f32, f32) {
(at.0 * new.0 / old.0.max(1.0), at.1 * new.1 / old.1.max(1.0))
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))
.map(|v| v.0)
}
/// The target a remembered focus rectangle stands for in a new frame: the
@@ -1453,131 +1163,6 @@ pub fn bow_power(ticks: u32) -> f32 {
mod tests {
use super::*;
#[test]
fn every_bindable_action_reaches_the_game() {
for (action, _) in ACTIONS {
assert!(route(action).is_some(), "{action} has nowhere to go");
}
}
#[test]
fn navigation_preferences_migrate_and_stay_with_each_player() {
let dir = std::env::temp_dir().join(format!(
"horizon-pad-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let p2 = dir.join("P2");
fs::create_dir_all(&p2).unwrap();
let fresh = PadSettings::load(&dir);
assert_eq!(fresh, PadSettings::default());
// An older file, with the removed menuNavigation key: bindings kept.
let legacy =
PadSettings::parse("menuNavigation:pointer\nbind_key.jump:east\nlookSensitivity:0.7\n");
assert_eq!(legacy.binding("key.jump"), Some(Button::East));
assert_eq!(legacy.look_sensitivity, 0.7);
fresh.save(&dir);
assert!(!dir.join(FILE).exists(), "defaults write no file");
let mut other = legacy;
other.pointer_speed = 2.5;
other.scroll_speed = 24.0;
other.repeat_delay = 0.6;
other.repeat_interval = 0.12;
other.save(&p2);
assert_eq!(PadSettings::load(&p2), other);
assert_eq!(PadSettings::load(&dir), fresh, "P2 did not write P1");
let invalid = PadSettings::parse(
"pointerSpeed:NaN\nscrollSpeed:inf\nrepeatDelay:-10\nrepeatInterval:20\n",
);
assert_eq!((invalid.pointer_speed, invalid.scroll_speed), (1.0, 12.0));
assert_eq!((invalid.repeat_delay, invalid.repeat_interval), (0.1, 0.3));
let bounded = PadSettings::parse(
"pointerSpeed:10\nscrollSpeed:-1\nrepeatDelay:NaN\nrepeatInterval:inf\n",
);
assert_eq!((bounded.pointer_speed, bounded.scroll_speed), (3.0, 1.0));
assert_eq!(
(bounded.repeat_delay, bounded.repeat_interval),
(0.35, 0.09)
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn ownership_requires_new_activity_and_ignores_drift() {
let settings = PadSettings::default();
let mut p1 = Activity::default();
let mut p2 = Activity::default();
let mut state = PadState {
left: [0.04, 0.03],
..PadState::default()
};
assert!(!p1.observe(&state, &settings));
state.set(Button::South, true);
assert!(p1.observe(&state, &settings));
assert!(
!p1.observe(&state, &settings),
"held pad cannot reclaim mouse ownership"
);
assert!(
p2.observe(&state, &settings),
"another player's tracker is independent"
);
state.set(Button::South, false);
assert!(!p1.observe(&state, &settings));
state.left = [0.5, 0.0];
assert!(p1.observe(&state, &settings));
state.left = [0.52, 0.0];
assert!(!p1.observe(&state, &settings));
for x in [0.54, 0.56] {
state.left = [x, 0.0];
assert!(!p1.observe(&state, &settings));
}
state.left = [0.6, 0.0];
assert!(
p1.observe(&state, &settings),
"slow intentional movement accumulates"
);
state.left = [0.0; 2];
state.triggers[0] = 0.5;
assert!(p1.observe(&state, &settings));
state.triggers[0] = 0.35;
assert!(!p1.observe(&state, &settings));
state.triggers[0] = 0.45;
assert!(
!p1.observe(&state, &settings),
"trigger threshold noise is not new activity"
);
}
#[test]
fn cancelled_holds_stay_released_until_neutral_then_press_again() {
let mut edges = Edges::default();
let mut state = PadState::default();
state.set(Button::South, true);
state.triggers[1] = 0.8;
assert_eq!(
edges.update(&state, 0.4),
vec![(Button::South, true), (Button::RightTrigger, true)]
);
assert_eq!(
edges.cancel(),
vec![(Button::South, false), (Button::RightTrigger, false)]
);
assert!(edges.update(&state, 0.4).is_empty());
state.triggers[1] = 0.35;
assert!(edges.update(&state, 0.4).is_empty());
assert!(edges.update(&PadState::default(), 0.4).is_empty());
assert_eq!(edges.update(&state, 0.4), vec![(Button::South, true)]);
state.triggers[1] = 0.7;
assert_eq!(
edges.update(&state, 0.4),
vec![(Button::RightTrigger, true)]
);
}
#[test]
fn defaults_follow_bedrock() {
let s = PadSettings::default();
@@ -1607,9 +1192,7 @@ 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"));
@@ -1655,27 +1238,6 @@ 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]);
@@ -1820,63 +1382,6 @@ mod tests {
assert_eq!(focus_step_rect(grid[0], Button::South, &grid), None);
}
#[test]
fn follow_scroll_keeps_rows_and_fixed_widgets() {
// Rows 36 tall scrolled down 18: a fifth row comes into view, so +18
// carries as many rows as the real -18. Back, below, stays.
let back = [296.0, 212.0, 71.0, 20.0];
let rows = |ys: &[f32]| -> Vec<(Rect, bool)> {
let rows = ys.iter().map(|y| ([78.0, *y, 270.0, 36.0], true));
rows.chain([(back, false)]).collect()
};
let rects = |t: &[(Rect, bool)]| -> Vec<Rect> { t.iter().map(|t| t.0).collect() };
let before = rects(&rows(&[33.0, 69.0, 105.0, 141.0]));
let after = rows(&[15.0, 51.0, 87.0, 123.0, 159.0]);
assert_eq!(follow_scroll(before[1], true, &before, &after), Some(1));
assert_eq!(follow_scroll(back, true, &before, &after), Some(5));
// And back up.
let up = rows(&[33.0, 69.0, 105.0, 141.0]);
let was = rects(&after);
assert_eq!(follow_scroll(was[2], false, &was, &up), Some(2));
// Nothing moved, as on a screen with no list.
let still = rows(&[33.0, 69.0, 105.0, 141.0]);
assert_eq!(follow_scroll(before[1], true, &before, &still), None);
// A sidebar's tabs spaced exactly like the list's shift stay put.
let tab = |y: f32| [8.0, y, 112.0, 16.0];
let with_tabs = |t: &[(Rect, bool)]| -> Vec<(Rect, bool)> {
t.iter()
.copied()
.chain([(tab(100.0), true), (tab(118.0), true)])
.collect()
};
let was = rects(&with_tabs(&rows(&[33.0, 69.0, 105.0, 141.0])));
let now = with_tabs(&after);
assert_eq!(follow_scroll(tab(118.0), true, &was, &now), Some(7));
}
#[test]
fn follow_scroll_lands_on_the_nearest_row_when_its_row_leaves() {
// The row at 33 left the top; the next one is at 51. Nothing fixed.
let row = |y: f32| [78.0, y, 270.0, 36.0];
let before = [row(33.0), row(69.0), row(105.0)];
let after = [(row(51.0), true), (row(87.0), true)];
assert_eq!(follow_scroll(row(33.0), true, &before, &after), Some(0));
// The last row left the bottom: the list's new last row, not a fixed
// widget of its kind (at 200) nearer where the row went.
let fixed = [78.0, 200.0, 270.0, 36.0];
let before = [row(15.0), row(51.0), row(87.0), row(159.0), fixed];
let after = [
(row(33.0), true),
(row(69.0), true),
(row(105.0), true),
(fixed, true),
];
assert_eq!(follow_scroll(row(159.0), false, &before, &after), Some(2));
// Nothing of its kind left.
let other = after.map(|t| (t.0, false));
assert_eq!(follow_scroll(row(159.0), false, &before, &other), None);
}
#[test]
fn refind_tabs_sliders_and_stick_steps() {
let rects: Vec<Rect> = vec![[0.0, 0.0, 10.0, 10.0], [0.0, 20.0, 10.0, 10.0]];
@@ -1893,10 +1398,8 @@ mod tests {
.map(|i| [10.0, 16.0 + 24.0 * i as f32, 100.0, 20.0])
.chain([[10.0, 200.0, 50.0, 20.0]])
.collect();
assert_eq!(scroll_shift(&before, &after, true), Some(-24.0));
// Rows this far apart line up the other way too; the direction is what tells them apart.
assert_eq!(scroll_shift(&before, &after, false), Some(24.0));
assert_eq!(scroll_shift(&before, &before, true), None);
assert_eq!(scroll_shift(&before, &after), Some(-24.0));
assert_eq!(scroll_shift(&before, &before), None);
let tabs = [
([60.0, 0.0, 26.0, 32.0], false),
([0.0, 0.0, 26.0, 32.0], true),
@@ -1911,138 +1414,18 @@ mod tests {
assert_eq!(stick_direction([0.9, 0.2], 0.5), Some(Button::DPadRight));
assert_eq!(stick_direction([-0.2, -0.8], 0.5), Some(Button::DPadDown));
assert_eq!(stick_direction([0.3, 0.3], 0.5), None);
let s = PadSettings {
keyboard: TextKeyboard::Horizon,
..PadSettings::default()
};
let mut s = PadSettings::default();
assert_eq!(s.menu_nav, MenuNav::Focus);
s.menu_nav = MenuNav::Pointer;
s.keyboard = TextKeyboard::Horizon;
let back = PadSettings::parse(&s.serialize());
assert_eq!(
PadSettings::parse(&s.serialize()).keyboard,
TextKeyboard::Horizon
(back.menu_nav, back.keyboard),
(MenuNav::Pointer, TextKeyboard::Horizon)
);
assert!(steam_keyboard(TextKeyboard::Steam) && !steam_keyboard(TextKeyboard::Horizon));
}
#[test]
fn a_list_step_scrolls_rather_than_leave_a_list_that_can() {
let rows = |n: usize| -> Vec<Rect> {
(0..n)
.map(|i| [100.0, 40.0 + i as f32 * 36.0, 270.0, 36.0])
.collect()
};
let back: Rect = [150.0, 220.0, 150.0, 20.0];
let search: Rect = [150.0, 10.0, 150.0, 20.0];
let mut targets = rows(4);
targets.extend([back, search]);
let area = |up, down| ScrollArea {
rect: [0.0, 40.0, 380.0, 144.0],
amount: 0.0,
up,
down,
};
let (down, up) = (Button::DPadDown, Button::DPadUp);
let step = |from, button, area: &[ScrollArea]| list_step(from, button, &targets, area);
// A row below in view comes first.
assert_eq!(step(targets[1], down, &[area(true, true)]), Some(2));
// The last row in view: more below scrolls; none leaves.
assert_eq!(step(targets[3], down, &[area(true, true)]), None);
assert_eq!(step(targets[3], down, &[area(true, false)]), Some(4));
// The first row in view, the same upwards.
assert_eq!(step(targets[0], up, &[area(true, false)]), None);
assert_eq!(step(targets[0], up, &[area(false, true)]), Some(5));
// From outside the list it enters it, and no list means a plain step.
assert_eq!(step(back, up, &[area(true, true)]), Some(3));
assert_eq!(step(targets[3], down, &[]), Some(4));
}
#[test]
fn a_list_step_shows_its_row_whole_and_starts_from_one_nearly_gone() {
let area = |up, down| ScrollArea {
rect: [0.0, 40.0, 380.0, 100.0],
amount: 50.0,
up,
down,
};
let (down, up) = (Button::DPadDown, Button::DPadUp);
// The next row is cut by the view's bottom: it scrolls first, unless that's the end.
let targets: Vec<Rect> = vec![[100.0, 100.0, 270.0, 25.0], [100.0, 125.0, 270.0, 25.0]];
assert_eq!(
list_step(targets[0], down, &targets, &[area(true, true)]),
None
);
assert_eq!(
list_step(targets[0], down, &targets, &[area(true, false)]),
Some(1)
);
// The same at its top.
let targets: Vec<Rect> = vec![[100.0, 30.0, 270.0, 25.0], [100.0, 55.0, 270.0, 25.0]];
assert_eq!(
list_step(targets[1], up, &targets, &[area(true, true)]),
None
);
// A focused row with its last pixels in view is still in the list: going up scrolls,
// even with something above the list to go to.
let row: Rect = [100.0, 18.0, 270.0, 25.0];
let field: Rect = [150.0, 0.0, 150.0, 15.0];
assert!(area(true, true).holds(&row));
assert_eq!(list_step(row, up, &[row, field], &[area(true, true)]), None);
// Above the view altogether, it isn't.
assert!(!area(true, true).holds(&[100.0, 10.0, 270.0, 25.0]));
}
#[test]
fn a_stick_holds_its_direction_until_it_lets_go_or_turns() {
let step = 0.55;
let (up, right) = (Some(Button::DPadUp), Some(Button::DPadRight));
// Only a push past the threshold starts a step.
assert_eq!(stick_direction_held([0.0, 0.5], step, None), None);
assert_eq!(stick_direction_held([0.0, 0.6], step, None), up);
// Wobbling on the threshold keeps it; falling back past the release lets go.
assert_eq!(stick_direction_held([0.0, 0.5], step, up), up);
assert_eq!(stick_direction_held([0.0, 0.3], step, up), None);
// A diagonal doesn't flip it, but a clear turn does.
assert_eq!(stick_direction_held([0.6, 0.55], step, up), up);
assert_eq!(stick_direction_held([0.55, 0.6], step, right), right);
assert_eq!(stick_direction_held([0.9, 0.3], step, up), right);
// Straight across to the other side.
assert_eq!(
stick_direction_held([0.0, -0.9], step, up),
Some(Button::DPadDown)
);
// Past the release on the far side, but not far enough for a step.
assert_eq!(stick_direction_held([0.0, -0.45], step, up), None);
}
#[test]
fn a_repeat_stops_where_the_slots_end() {
let at = (100.0, 100.0);
assert!(!repeat_crosses_slots(at, (118.0, 100.0), true));
// The hotbar, over its small gap, is the same run of slots.
assert!(!repeat_crosses_slots(at, (100.0, 122.0), true));
// Another block of slots, or a button.
assert!(repeat_crosses_slots(at, (100.0, 132.0), true));
assert!(repeat_crosses_slots(at, (118.0, 100.0), false));
}
#[test]
fn a_resize_keeps_the_same_widget() {
let before: Vec<Rect> = vec![[10.0, 10.0, 20.0, 20.0], [100.0, 10.0, 20.0, 20.0]];
let after: Vec<Rect> = vec![[300.0, 90.0, 20.0, 20.0], [400.0, 90.0, 20.0, 20.0]];
let (old, new) = ((200.0, 100.0), (800.0, 400.0));
// The same count: the same place in the draw order, however far it moved.
assert_eq!(
refind_resized(before[0], &before, &after, old, new),
Some(0)
);
// A list showing more rows: the nearest to where it would be.
let more: Vec<Rect> = vec![
[30.0, 30.0, 20.0, 20.0],
[395.0, 35.0, 20.0, 20.0],
[700.0, 300.0, 20.0, 20.0],
];
assert_eq!(refind_resized(before[1], &before, &more, old, new), Some(1));
assert_eq!(refind_resized(before[0], &before, &[], old, new), None);
}
#[test]
fn labels_and_conflicts() {
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
-155
View File
@@ -1,155 +0,0 @@
//! The keys a player's mappings hold, as their vanilla default keys, and which
//! devices hold each one. A key stays held while any device holds it, so one
//! device letting go (splitscreen moving the keyboard to another player, say)
//! can't release what another still presses.
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Where a press came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
/// The keyboard and mouse, and the scripting socket acting as them.
Keyboard,
Pad,
}
impl Source {
fn bit(self) -> u8 {
match self {
Source::Keyboard => 1,
Source::Pad => 2,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HeldKeys {
held: HashMap<KeyCode, u8>,
}
impl HeldKeys {
pub fn contains(&self, key: &KeyCode) -> bool {
self.held.contains_key(key)
}
/// `source` holds `key`. True when nothing held it before.
pub fn press(&mut self, key: KeyCode, source: Source) -> bool {
let sources = self.held.entry(key).or_insert(0);
let new = *sources == 0;
*sources |= source.bit();
new
}
/// `source` lets go of `key`. True when nothing holds it any more.
pub fn release(&mut self, key: &KeyCode, source: Source) -> bool {
let Some(sources) = self.held.get_mut(key) else {
return true;
};
*sources &= !source.bit();
if *sources == 0 {
self.held.remove(key);
true
} else {
false
}
}
pub fn held_by(&self, key: &KeyCode, source: Source) -> bool {
self.held
.get(key)
.is_some_and(|sources| sources & source.bit() != 0)
}
/// `source` lets go of everything: the keys nothing holds any more.
pub fn release_source(&mut self, source: Source) -> Vec<KeyCode> {
let keys: Vec<KeyCode> = self
.held
.iter()
.filter(|(_, sources)| *sources & source.bit() != 0)
.map(|(key, _)| *key)
.collect();
keys.into_iter()
.filter(|key| self.release(key, source))
.collect()
}
/// Held, by the keyboard unless a device already holds it: for callers
/// that don't say where the press came from.
pub fn insert(&mut self, key: KeyCode) -> bool {
if self.contains(&key) {
return false;
}
self.press(key, Source::Keyboard)
}
/// Let go of `key` whoever holds it.
pub fn remove(&mut self, key: &KeyCode) -> bool {
self.held.remove(key).is_some()
}
pub fn clear(&mut self) {
self.held.clear();
}
pub fn is_empty(&self) -> bool {
self.held.is_empty()
}
pub fn len(&self) -> usize {
self.held.len()
}
pub fn iter(&self) -> impl Iterator<Item = &KeyCode> {
self.held.keys()
}
}
impl IntoIterator for HeldKeys {
type Item = KeyCode;
type IntoIter = std::collections::hash_map::IntoKeys<KeyCode, u8>;
fn into_iter(self) -> Self::IntoIter {
self.held.into_keys()
}
}
impl<'a> IntoIterator for &'a HeldKeys {
type Item = &'a KeyCode;
type IntoIter = std::collections::hash_map::Keys<'a, KeyCode, u8>;
fn into_iter(self) -> Self::IntoIter {
self.held.keys()
}
}
impl FromIterator<KeyCode> for HeldKeys {
fn from_iter<I: IntoIterator<Item = KeyCode>>(keys: I) -> Self {
let mut held = HeldKeys::default();
for key in keys {
held.insert(key);
}
held
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_key_stays_held_while_any_device_holds_it() {
let mut keys = HeldKeys::default();
assert!(keys.press(KeyCode::Space, Source::Pad));
assert!(!keys.press(KeyCode::Space, Source::Keyboard));
assert!(keys.press(KeyCode::KeyW, Source::Keyboard));
assert_eq!(keys.release_source(Source::Keyboard), vec![KeyCode::KeyW]);
assert!(keys.contains(&KeyCode::Space) && !keys.contains(&KeyCode::KeyW));
assert!(keys.held_by(&KeyCode::Space, Source::Pad));
assert!(!keys.held_by(&KeyCode::Space, Source::Keyboard));
assert!(keys.release(&KeyCode::Space, Source::Pad));
assert!(keys.is_empty());
}
#[test]
fn untagged_inserts_and_removes_act_as_before() {
let mut keys = HeldKeys::default();
keys.press(KeyCode::ShiftLeft, Source::Pad);
assert!(!keys.insert(KeyCode::ShiftLeft), "already held");
assert!(!keys.held_by(&KeyCode::ShiftLeft, Source::Keyboard));
assert!(
keys.remove(&KeyCode::ShiftLeft),
"remove lets go for everyone"
);
assert!(!keys.contains(&KeyCode::ShiftLeft));
assert!(keys.release(&KeyCode::KeyA, Source::Keyboard), "never held");
}
}
-2
View File
@@ -5,7 +5,6 @@
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod held;
pub mod input;
pub mod interaction_input;
pub mod key_names;
@@ -13,5 +12,4 @@ pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod snap;
pub mod window;
-202
View File
@@ -1,202 +0,0 @@
//! Where the controller's menu cursor settles once the stick is let go, and
//! how it gets there. Every rule's number lives in [`Tuning`], so the feel can
//! be adjusted in one place.
/// The snapping engine's knobs, in GUI pixels and seconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tuning {
/// A let-go cursor settles on a target this close to it.
pub radius: f32,
/// How long the glide onto a target takes.
pub glide_seconds: f32,
/// Movement shorter than this is a wobble: the cursor stays where it was.
pub min_travel: f32,
/// A flick that ends still nearest its starting target commits to the
/// neighbour it was heading for once it covered this fraction of the way.
pub commit: f32,
/// How far off the flick's direction a neighbour may lie (cosine of the
/// angle: 0.7 is about 45 degrees).
pub cone: f32,
/// The pointer's speed over a slot or control, as a fraction of its
/// speed elsewhere (1 leaves it unchanged).
pub over_target_speed: f32,
}
pub const TUNING: Tuning = Tuning {
radius: 16.0,
glide_seconds: 0.025,
min_travel: 1.5,
commit: 0.15,
cone: 0.7,
over_target_speed: 1.0,
};
type Point = (f32, f32);
fn distance(a: Point, b: Point) -> f32 {
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
}
/// The target nearest `at`, if one lies within `radius`.
pub fn nearest(at: Point, targets: &[Point], radius: f32) -> Option<usize> {
targets
.iter()
.enumerate()
.map(|(i, t)| (i, distance(at, *t)))
.filter(|(_, d)| *d <= radius)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
/// Where a stick movement from `start` to `end` settles among `targets`
/// (their centres), or `None` to leave the cursor where it is.
pub fn settle(start: Point, end: Point, targets: &[Point], tuning: &Tuning) -> Option<usize> {
let near = nearest(end, targets, tuning.radius);
let travel = (end.0 - start.0, end.1 - start.1);
let length = distance(start, end);
if length < tuning.min_travel {
return near;
}
let origin = nearest(start, targets, tuning.radius);
if near != origin {
return near;
}
// Still nearest where it started: a quick flick toward a neighbour.
let Some(o) = origin else {
return near;
};
let from = targets[o];
let heading = (travel.0 / length, travel.1 / length);
let next = targets
.iter()
.enumerate()
.filter(|(i, _)| *i != o)
.filter_map(|(i, t)| {
let to = (t.0 - from.0, t.1 - from.1);
let gap = distance(from, *t);
let aligned = (to.0 * heading.0 + to.1 * heading.1) / gap;
(gap > 0.0 && aligned >= tuning.cone).then_some((i, gap, to))
})
.min_by(|a, b| a.1.total_cmp(&b.1));
match next {
Some((i, gap, to)) => {
let progress = ((end.0 - from.0) * to.0 + (end.1 - from.1) * to.1) / (gap * gap);
if progress >= tuning.commit {
Some(i)
} else {
near
}
}
None => near,
}
}
/// Where a cursor left on a target goes once the targets moved: with most of
/// them if they moved together (a recipe book opened), else to the nearest
/// one (a creative tab with other slots), so it never rests between targets.
/// `None` when it wasn't on a target, or one is still there.
pub fn follow(at: Point, before: &[Point], after: &[Point]) -> Option<usize> {
const SAME: f32 = 0.5;
let on = before.iter().find(|t| distance(at, **t) <= SAME)?;
if after.iter().any(|t| distance(at, *t) <= SAME) {
return None;
}
let carried = |by: Point| {
before
.iter()
.filter(|b| {
let moved = (b.0 + by.0, b.1 + by.1);
after.iter().any(|a| distance(moved, *a) <= SAME)
})
.count()
};
let together = after
.iter()
.enumerate()
.map(|(i, a)| (i, carried((a.0 - on.0, a.1 - on.1))))
.max_by_key(|&(_, count)| count)
.filter(|&(_, count)| count * 2 > before.len());
together
.map(|(i, _)| i)
.or_else(|| nearest(at, after, f32::INFINITY))
}
/// The cursor `seconds` into a glide from `from` to `to`: eased out, so it
/// slows as it lands, and exactly `to` once the glide is over.
pub fn glide(from: Point, to: Point, seconds: f32, tuning: &Tuning) -> Point {
let t = (seconds / tuning.glide_seconds).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t).powi(3);
(
from.0 + (to.0 - from.0) * eased,
from.1 + (to.1 - from.1) * eased,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// A row of inventory slots, 18 pixels apart, and the slot below the first.
const SLOTS: [Point; 4] = [(8.0, 8.0), (26.0, 8.0), (44.0, 8.0), (8.0, 26.0)];
#[test]
fn follow_goes_with_targets_that_moved_together() {
let moved = SLOTS.map(|s| (s.0 + 77.0, s.1));
assert_eq!(follow(SLOTS[1], &SLOTS, &moved), Some(1));
}
#[test]
fn follow_takes_the_nearest_of_other_targets() {
let other = [(30.0, 50.0), (200.0, 200.0)];
assert_eq!(follow(SLOTS[1], &SLOTS, &other), Some(0));
}
#[test]
fn follow_leaves_a_free_cursor_and_a_target_still_there() {
let other = [(30.0, 50.0)];
assert_eq!(follow((100.0, 100.0), &SLOTS, &other), None);
assert_eq!(follow(SLOTS[0], &SLOTS, &SLOTS), None);
assert_eq!(follow(SLOTS[0], &SLOTS, &[]), None);
}
#[test]
fn a_short_flick_moves_to_the_neighbour_it_heads_for() {
// 4 pixels right of slot 0: still nearest slot 0, but heading for slot 1.
assert_eq!(settle((8.0, 8.0), (12.0, 8.0), &SLOTS, &TUNING), Some(1));
assert_eq!(settle((8.0, 8.0), (8.0, 12.0), &SLOTS, &TUNING), Some(3));
assert_eq!(settle((26.0, 8.0), (22.0, 8.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn a_wobble_stays_put() {
assert_eq!(settle((8.0, 8.0), (8.5, 8.4), &SLOTS, &TUNING), Some(0));
// Barely started toward the neighbour: not committed.
assert_eq!(settle((8.0, 8.0), (9.6, 8.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn a_long_move_settles_on_the_nearest_target() {
assert_eq!(settle((8.0, 8.0), (42.0, 9.0), &SLOTS, &TUNING), Some(2));
// Far from everything: left where it is.
assert_eq!(settle((8.0, 8.0), (200.0, 200.0), &SLOTS, &TUNING), None);
}
#[test]
fn a_flick_off_any_row_does_not_jump_sideways() {
// Up from slot 0: nothing lies that way, so it stays on slot 0.
assert_eq!(settle((8.0, 8.0), (8.0, 4.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn the_glide_eases_onto_its_target_and_stops_there() {
let (from, to) = ((0.0, 0.0), (20.0, -10.0));
assert_eq!(glide(from, to, 0.0, &TUNING), from);
let half = glide(from, to, TUNING.glide_seconds / 2.0, &TUNING);
assert!(
half.0 > 10.0 && half.0 < 20.0,
"eased out: past halfway at half time"
);
assert_eq!(glide(from, to, TUNING.glide_seconds, &TUNING), to);
assert_eq!(glide(from, to, 1.0, &TUNING), to);
}
}
-6
View File
@@ -30,8 +30,6 @@ horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The control endpoint's token (control.rs), as horizon-server's and the relay's.
getrandom = "0.3"
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
@@ -65,10 +63,6 @@ ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
# The network threads of a server's players: epoll, kqueue or Windows' AFD (lan/io_pool.rs).
mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
@@ -64,11 +64,10 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]).unwrap();
renderer.set_shader_pack(0, None, &[]);
assert!(
!renderer.pack_entity_shadows(),
"selected pack without a compiled post"
@@ -165,7 +164,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
}
let (_, notes) = renderer
.set_shader_pack(dimension, Some(post), &[])
.unwrap()
.unwrap();
if break_pipeline {
assert!(
@@ -1,831 +0,0 @@
//! Draws a scene `horizon-ctl render-capture DIR` wrote, offscreen, from its camera, with the
//! real `Renderer`, and prints GPU and CPU milliseconds per frame and Horizon's own passes'
//! GPU times as JSON (docs/graphics.md, "Render capture and replay").
//! `cargo run --release -p horizon-client-render --example scene_replay -- DIR [--frames 300]
//! [--warmup 60] [--yaw-offsets 0,90,180,270] [--texture-filtering 0|1|2] [--anisotropy-bit N]
//! [--mipmaps N] [--lod-distance CHUNKS] [--render-distance CHUNKS] [--size WxH]
//! [--section-draws flat|indirect] [--section-cull on|off] [--section-sides on|off]
//! [--gpu-culling on|off] [--root RESOURCES] [--screenshot PNG]
//! [--world SAVE --leaf-culling MODE] [--memory on]`
//! `--screenshot` saves the first view's last frame, after the timed frames.
//! `--world` meshes the captured sections again from that save's region files
//! (blocks, biomes and saved light; `ao`, `biomeBlendRadius` and `cutoutLeaves`
//! from `./options.txt`) with `--leaf-culling` (default `off`), so a mesher
//! change can be measured on the captured view. A section whose 3x3 columns are
//! not all saved keeps its captured mesh.
//! `--memory on` adds the renderer's largest GPU resources and, on macOS, the process's
//! physical footprint by category (`footprint`), taken after the timed frames.
use horizon_client_render::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{BIOME_STRATEGY, Chunk, ColumnLayout, Light, block_strategy},
fast_hash::{FxHashMap, FxHashSet},
gfx::{
PreparedTextures, Renderer, ScreenshotRequest,
capture::{self, AtlasCheck, Scene},
panorama::Faces,
},
graphics::LeafCullingMode,
hud::HudBatch,
lod::TileKey,
mesher::{MeshScratch, MeshTables, Mesher, SectionMesh, packed},
models,
};
use horizon_core::{
biome::{BiomeZoom, obfuscate_seed},
paletted::PalettedContainer,
};
use horizon_world::{
chunk::BlockState,
nbt::{Limits, NamedTag, Tag},
region::RegionFile,
registry::BlockRegistry,
};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
// The game's allocator, so that `--memory`'s footprint is the game's: the system heap kept
// gigabytes of the meshes `--world` frees dirty.
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
#[derive(Default)]
struct Options {
dir: PathBuf,
frames: usize,
warmup: usize,
yaw_offsets: Vec<f32>,
root: Option<PathBuf>,
texture_filtering: Option<i32>,
anisotropy_bit: Option<i32>,
mipmaps: Option<u32>,
lod_distance: Option<u32>,
render_distance: Option<i32>,
size: Option<[u32; 2]>,
flat: Option<bool>,
cull: Option<bool>,
sides: Option<bool>,
gpu_culling: Option<bool>,
screenshot: Option<PathBuf>,
world: Option<PathBuf>,
leaf_culling: LeafCullingMode,
memory: bool,
}
fn on_off(value: &str) -> Result<bool, String> {
match value {
"on" | "flat" => Ok(true),
"off" | "indirect" => Ok(false),
other => Err(format!("expected on or off, got {other}")),
}
}
fn options() -> Result<Options, String> {
let mut o = Options {
frames: 300,
warmup: 60,
yaw_offsets: vec![0.0],
..Default::default()
};
let mut args = std::env::args().skip(1);
let mut dir = None;
while let Some(arg) = args.next() {
if !arg.starts_with("--") {
dir = Some(PathBuf::from(arg));
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |v: &str| v.parse::<i64>().map_err(|e| format!("{arg}: {e}"));
match arg.as_str() {
"--frames" => o.frames = number(&value)?.max(1) as usize,
"--warmup" => o.warmup = number(&value)?.max(0) as usize,
"--yaw-offsets" => {
o.yaw_offsets = value
.split(',')
.map(|v| v.trim().parse::<f32>().map_err(|e| format!("{arg}: {e}")))
.collect::<Result<_, _>>()?
}
"--root" => o.root = Some(value.into()),
"--texture-filtering" => o.texture_filtering = Some(number(&value)? as i32),
"--anisotropy-bit" => o.anisotropy_bit = Some(number(&value)? as i32),
"--mipmaps" => o.mipmaps = Some(number(&value)?.max(0) as u32),
"--lod-distance" => o.lod_distance = Some(number(&value)?.max(0) as u32),
"--render-distance" => o.render_distance = Some(number(&value)?.max(0) as i32),
"--size" => {
let (w, h) = value.split_once('x').ok_or("--size takes WxH")?;
o.size = Some([number(w)? as u32, number(h)? as u32]);
}
"--section-draws" => o.flat = Some(on_off(&value)?),
"--section-cull" => o.cull = Some(on_off(&value)?),
"--section-sides" => o.sides = Some(on_off(&value)?),
"--gpu-culling" => o.gpu_culling = Some(on_off(&value)?),
"--screenshot" => o.screenshot = Some(value.into()),
"--world" => o.world = Some(value.into()),
"--memory" => o.memory = on_off(&value)?,
"--leaf-culling" => {
o.leaf_culling = LeafCullingMode::ALL
.into_iter()
.find(|mode| mode.name() == value)
.ok_or(format!("no leaf culling mode {value}"))?;
}
other => return Err(format!("unknown option {other}")),
}
}
o.dir = dir.ok_or("the capture directory is required")?;
Ok(o)
}
/// Milliseconds: average, median, 95th percentile and slowest, over `samples`.
fn summary(samples: &[f32]) -> Value {
if samples.is_empty() {
return Value::Null;
}
let mut sorted = samples.to_vec();
sorted.sort_by(f32::total_cmp);
let at = |q: f32| sorted[((sorted.len() - 1) as f32 * q).round() as usize];
json!({
"avg": samples.iter().sum::<f32>() / samples.len() as f32,
"min": sorted[0],
"p10": at(0.1),
"p50": at(0.5),
"p95": at(0.95),
"max": sorted[sorted.len() - 1],
"samples": samples.len(),
})
}
fn ms(d: Duration) -> f32 {
d.as_secs_f32() * 1000.0
}
fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let o = options()?;
let scene = Scene::read(&o.dir)?;
let settings = &scene.settings;
let root = o.root.clone().unwrap_or_else(|| settings.resources.clone());
let loading = Instant::now();
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
if AtlasCheck::of(&render) != settings.atlas {
return Err(format!(
"the block sprites at {} are not the ones the capture was made with (resource packs \
or mods?): pass --root with the game's resource root",
root.display()
)
.into());
}
let one = [255u8; 4];
let zero = [0u8; 4];
let pixels = render.pixels.get();
let prepared = PreparedTextures::new(
&pixels,
render.sprite_size,
render.texture_layers,
&render.sprite_mips,
(&zero[..], 1),
)
.with_pbr(
&render.pbr_normals,
&render.pbr_specular,
render.pbr_size,
render.texture_layers,
);
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&pixels,
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
&render.sprite_mips,
&render.animations,
(&one[..], 1, 1),
(&zero[..], 1, 1),
(&zero[..], 1, 1),
(&zero[..], 1),
Arc::new(Faces::colour([0, 0, 0, 255])),
Some(prepared),
None,
settings.graphics_api(),
settings.translucent_batching,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
let state = &scene.renderer;
let [width, height] = o.size.unwrap_or(state.size);
renderer.resize(width, height);
renderer.gamma = state.gamma;
renderer.fullbright = state.fullbright;
renderer.improved_transparency = state.improved_transparency;
renderer.animated_textures = state.animated_textures;
let filtering = o.texture_filtering.unwrap_or(settings.texture_filtering);
let anisotropy = o.anisotropy_bit.unwrap_or(settings.anisotropy_bit);
renderer.set_texture_filtering(filtering, anisotropy);
renderer.set_mipmap_levels(o.mipmaps.unwrap_or(settings.mipmap_levels));
renderer.set_world_post(
settings.resolution_scale,
settings.fxaa,
settings.taa,
settings.upscale,
);
let mut notes = Vec::new();
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
if let Err(e) = result {
notes.push(format!("{what}: {e}"));
}
};
note(
"section-draws",
renderer.set_flat_draws(o.flat.unwrap_or(state.flat_draws)),
);
note(
"section-cull",
renderer.set_section_cull(o.cull.unwrap_or(state.section_cull)),
);
note(
"gpu-culling",
renderer.set_gpu_culling(o.gpu_culling.unwrap_or(state.gpu_culling)),
);
renderer.set_section_sides(o.sides.unwrap_or(state.section_sides));
renderer.set_pass_timing(true);
let layout = ColumnLayout::of_level(state.min_y, state.sections as i32 * 16);
if layout != ColumnLayout::VANILLA {
renderer.clear_sections(layout);
}
// Near sections first: with no far tile resident yet, none fades in.
let camera = scene.frame.camera;
let column = |x: f64| (x.floor() as i32) >> 4;
let (cx, cz) = (column(camera[0]), column(camera[2]));
let near = |x: i32, z: i32| {
o.render_distance
.is_none_or(|r| (x - cx).pow(2) + (z - cz).pow(2) <= r * r)
};
let (mut remeshed, mut world_report) = (FxHashMap::default(), Value::Null);
if let Some(world) = &o.world {
let keys: Vec<[i32; 3]> = section_keys(&o.dir)?
.into_iter()
.filter(|&[x, _, z]| near(x, z))
.collect();
let started = Instant::now();
remeshed = remesh(world, &root, &assets, &render, o.leaf_culling, &keys)?;
world_report = json!({
"save": world.display().to_string(),
"leaf_culling": o.leaf_culling.name(),
"remeshed": remeshed.len(),
"kept": keys.len() - remeshed.len(),
"remesh_ms": ms(started.elapsed()),
});
}
let leaf_layers = leaf_layers(&render);
let (mut sections, mut quads, mut leaf_quads) = (0, 0usize, 0usize);
let mut packed_quads = 0usize;
capture::read_meshes(&o.dir, "sections", |[x, y, z], captured| {
if near(x, z) {
let mesh = remeshed.remove(&[x, y, z]).unwrap_or(captured);
quads += mesh.solid.len() / 4;
packed_quads += packed::packed_quads(&mesh.packed, mesh.solid.len() / 4);
leaf_quads += mesh
.solid
.iter()
.filter(|v| leaf_layers.contains(&v.layer))
.count()
/ 4;
renderer.upload((x, y, z), &mesh);
sections += 1;
}
})?;
let mut tiles: Vec<(TileKey, SectionMesh)> = Vec::new();
capture::read_meshes(&o.dir, "tiles", |[x, z, level], mesh| {
let key = TileKey {
x,
z,
level: level as u8,
};
let [ox, _, oz] = key.origin();
let half = f64::from(key.size()) / 2.0;
let (dx, dz) = (
f64::from(ox) + half - camera[0],
f64::from(oz) + half - camera[2],
);
let near = o
.lod_distance
.is_none_or(|d| (dx * dx + dz * dz).sqrt() <= f64::from(d) * 16.0);
if near {
tiles.push((key, mesh));
}
})?;
let tile_count = tiles.len();
if !tiles.is_empty() {
let cutout: FxHashSet<(i32, i32)> = scene.lod_cutout.iter().copied().collect();
renderer.queue_lods(tiles, Vec::new(), Some(cutout));
renderer.upload_lods(Duration::from_secs(3600));
}
let loaded_ms = ms(loading.elapsed());
let hud = HudBatch::new([width, height]);
let mut views = Vec::new();
for &offset in &o.yaw_offsets {
let mut frame = scene.frame(&hud);
frame.yaw += offset;
frame.eye_rotation[0] += offset;
if let Some(r) = o.render_distance {
frame.distance = (r * 16) as f32;
frame.detail_distance = frame.distance;
}
let draw = |renderer: &mut Renderer| -> horizon_client_render::Result<Duration> {
if frame.cave_culling {
renderer.start_occlusion(frame.camera, frame.distance);
}
let started = Instant::now();
renderer.render(&frame)?;
Ok(started.elapsed())
};
for _ in 0..o.warmup {
draw(&mut renderer)?;
}
renderer.gpu_times.clear();
let (mut call, mut encode, mut gpu) = (Vec::new(), Vec::new(), Vec::new());
for _ in 0..o.frames {
call.push(ms(draw(&mut renderer)?));
encode.push(ms(renderer.last_encode));
gpu.append(&mut renderer.gpu_times);
}
// Timestamps arrive a few frames late: a few more of the same frame bring them in.
for _ in 0..16 {
if gpu.len() >= o.frames {
break;
}
draw(&mut renderer)?;
gpu.append(&mut renderer.gpu_times);
}
gpu.truncate(o.frames);
if let Some(path) = o.screenshot.as_ref().filter(|_| views.is_empty()) {
renderer.screenshot_result = None;
renderer.screenshot = Some(ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
if renderer.screenshot_result.is_some() {
break;
}
}
match renderer.screenshot_result.take() {
Some(Ok(_)) => {}
Some(Err(e)) => notes.push(format!("screenshot: {e}")),
None => notes.push("screenshot: not saved".into()),
}
}
views.push(json!({
"yaw_offset": offset,
"gpu_ms": summary(&gpu),
"render_call_ms": summary(&call),
"cpu_encode_ms": summary(&encode),
"gpu_passes_median_ms": renderer
.own_pass_times()
.into_iter()
.map(|(name, ms)| (name, json!(ms)))
.collect::<serde_json::Map<_, _>>(),
"drawn_sections": renderer.drawn_sections,
"section_draws": renderer.section_draws,
"section_draw_calls": renderer.section_draw_calls,
// Occlusion culling's last result: boxes hidden, boxes tested, share of solid
// indices hidden.
"culled": renderer.culled_sections(),
}));
}
let section_draws = if renderer.flat_draws() {
"flat"
} else {
"indirect"
};
let memory = o.memory.then(|| memory_report(&renderer));
let report = json!({
"capture": o.dir.display().to_string(),
"adapter": renderer.info.name,
"captured_on": state.adapter,
"size": [width, height],
"sections": sections,
"solid_quads": quads,
"leaf_quads": leaf_quads,
// Solid quads uploaded as one record each (`mesher::packed`).
"packed_quads": packed_quads,
"world": world_report,
"tiles": tile_count,
"load_ms": loaded_ms,
"frames": o.frames,
"warmup": o.warmup,
"settings": {
"texture_filtering": filtering,
"anisotropy_bit": anisotropy,
"mipmaps": o.mipmaps.unwrap_or(settings.mipmap_levels),
"lod_distance": o.lod_distance,
"render_distance": o.render_distance,
"section_draws": section_draws,
"section_cull": renderer.section_cull(),
"section_sides": renderer.section_sides(),
"gpu_culling": renderer.gpu_culling(),
"shader_pack_left_out": state.shader_pack,
},
"notes": notes,
"views": views,
"memory": memory,
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn mb(bytes: u64) -> f64 {
(bytes as f64 / 1048576.0 * 10.0).round() / 10.0
}
/// `--memory`: the renderer's census in MiB and, on macOS, `footprint`'s summary of this
/// process (GPU buffers and textures count as IOAccelerator memory there).
fn memory_report(renderer: &Renderer) -> Value {
let gpu: serde_json::Map<String, Value> = renderer
.gpu_census()
.into_iter()
.map(|(name, bytes)| (format!("{name}_mb"), json!(mb(bytes))))
.collect();
let footprint = std::process::Command::new("/usr/bin/footprint")
.args(["-f", "bytes", "-p", &std::process::id().to_string()])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).into_owned());
let footprint = footprint.map(|text| {
let mut total = None;
let mut categories = Vec::new();
for line in text.lines() {
if let Some(at) = line.find("Footprint: ") {
total = line[at + 11..]
.split_whitespace()
.next()
.and_then(|v| v.parse::<u64>().ok());
}
// " <dirty> B <clean> B <reclaimable> B <regions> <category>"
let words: Vec<&str> = line.split_whitespace().collect();
if words.len() >= 8
&& words[1] == "B"
&& let Ok(dirty) = words[0].parse::<u64>()
&& dirty >= 1 << 20
{
categories.push(json!([words[7..].join(" "), mb(dirty)]));
}
}
json!({ "total_mb": total.map(mb), "dirty_by_category_mb": categories })
});
json!({ "gpu": gpu, "footprint": footprint })
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = replay(window) {
eprintln!("scene_replay: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
/// The captured sections' keys, from the index alone.
fn section_keys(dir: &Path) -> Result<Vec<[i32; 3]>, Box<dyn std::error::Error>> {
#[derive(serde::Deserialize)]
struct Key {
key: [i32; 3],
}
let records: Vec<Key> = serde_json::from_reader(std::io::BufReader::new(std::fs::File::open(
dir.join("sections.json"),
)?))?;
Ok(records.into_iter().map(|r| r.key).collect())
}
/// Sprite layers of every leaves block, as terrain vertices carry them (with or
/// without `OPAQUE_LAYER`).
fn leaf_layers(render: &RenderAssets) -> FxHashSet<u16> {
render
.sprite_layers
.iter()
.filter(|(name, _)| name.ends_with("_leaves"))
.flat_map(|(_, &layer)| {
[
layer as u16,
layer as u16 | horizon_client_render::mesher::OPAQUE_LAYER,
]
})
.collect()
}
/// `--world`: the sections `keys` meshed again from `save`'s overworld region
/// files, on every core, as the game's workers mesh them.
fn remesh(
save: &Path,
root: &Path,
assets: &Assets,
render: &RenderAssets,
leaf_culling: LeafCullingMode,
keys: &[[i32; 3]],
) -> Result<FxHashMap<[i32; 3], SectionMesh>, Box<dyn std::error::Error>> {
let facts = BlockFacts::load(root, assets.registry)?;
let visibility = models::load_visibility(root, assets.registry)?;
let tables = MeshTables::new(assets, render, &facts, visibility)?;
let biomes = Biomes::load(root)?;
let seed = world_seed(save).unwrap_or_else(|| {
eprintln!(
"scene_replay: no seed in {}, biome blending uses 0",
save.display()
);
0
});
let options = std::fs::read_to_string("options.txt").unwrap_or_default();
let option = |key: &str| {
options
.lines()
.find_map(|l| l.strip_prefix(key)?.strip_prefix(':').map(str::to_owned))
};
let smooth_lighting = option("ao").is_none_or(|v| v == "true");
let cutout_leaves = option("cutoutLeaves").is_none_or(|v| v == "true");
let blend_radius = option("biomeBlendRadius")
.and_then(|v| v.parse().ok())
.unwrap_or(2);
let mut columns: Vec<(i32, i32)> = keys
.iter()
.flat_map(|&[x, _, z]| {
(-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)))
})
.collect();
columns.sort_unstable();
columns.dedup();
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let region_dir = save.join("dimensions/minecraft/overworld/region");
let next = AtomicUsize::new(0);
let chunks: FxHashMap<(i32, i32), Chunk> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut files: FxHashMap<(i32, i32), Option<RegionFile>> = FxHashMap::default();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&(x, z)) = columns.get(i) else {
break;
};
let file = files.entry((x >> 5, z >> 5)).or_insert_with(|| {
RegionFile::open(region_dir.join(format!(
"r.{}.{}.mca",
x >> 5,
z >> 5
)))
.ok()
});
let root = file
.as_mut()
.and_then(|f| {
f.read(horizon_core::position::ChunkPos::new(x, z))
.ok()
.flatten()
})
.and_then(|record| record.nbt(Limits::default()).ok());
if let Some(chunk) =
root.and_then(|root| decode(&root, assets.registry, &biomes))
{
out.push(((x, z), chunk));
}
}
out
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect()
});
let meshable: Vec<[i32; 3]> = keys
.iter()
.copied()
.filter(|&[x, _, z]| {
(-1..=1).all(|dz| (-1..=1).all(|dx| chunks.contains_key(&(x + dx, z + dz))))
})
.collect();
let next = AtomicUsize::new(0);
let meshes = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
shader_pack: false,
assets,
render,
tables: &tables,
biomes: &biomes,
visibility: tables.visibility.clone(),
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius,
smooth_lighting,
cutout_leaves,
translucent_layers: &[],
leaf_culling,
};
let mut scratch = MeshScratch::default();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&[x, y, z]) = meshable.get(i) else {
break;
};
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
})
});
let mut mesh =
mesher.mesh_reusing(&region, y, SectionMesh::default(), &mut scratch);
mesh.sort_solid();
out.push(([x, y, z], mesh));
}
out
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect::<FxHashMap<_, _>>()
});
Ok(meshes)
}
/// The world seed: `data/minecraft/world_gen_settings.dat`, or level.dat's
/// `Data.WorldGenSettings` in older saves.
fn world_seed(save: &Path) -> Option<i64> {
let read = |path: PathBuf| {
horizon_world::datafix::files::read_dat(&path)
.ok()
.flatten()
};
let seed = |tag: Option<&Tag>| match tag?.get("seed")? {
Tag::Long(seed) => Some(*seed),
_ => None,
};
seed(
read(save.join("data/minecraft/world_gen_settings.dat"))
.as_ref()
.and_then(|t| t.get("data")),
)
.or_else(|| {
seed(
read(save.join("level.dat"))?
.get("Data")?
.get("WorldGenSettings"),
)
})
}
/// A saved full chunk's blocks, biomes and light, as the client's own save
/// loader reads them (`save::decode_lit`, in horizon-client).
fn decode(root: &NamedTag, registry: &BlockRegistry, biomes: &Biomes) -> Option<Chunk> {
let tag = &root.tag;
let text = |t: Option<&Tag>| match t {
Some(Tag::String(s)) => Some(s.to_string_lossy().to_string()),
_ => None,
};
if text(tag.get("Status")).is_some_and(|s| s != "minecraft:full" && s != "full") {
return None;
}
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
return None;
}
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (tag.get("xPos"), tag.get("zPos")) else {
return None;
};
let mut chunk = Chunk::with_layout(*x, *z, ColumnLayout::VANILLA);
let air = registry.default_state("minecraft:air")?.0;
let Some(Tag::List(sections)) = tag.get("sections") else {
return Some(chunk);
};
let longs = |t: Option<&Tag>| match t {
Some(Tag::LongArray(data)) => Some(data.iter().map(|&w| w as u64).collect::<Vec<_>>()),
_ => None,
};
let mut layers = vec![(None, None); chunk.sections.len()];
for section in sections {
let Some(Tag::Byte(y)) = section.get("Y") else {
continue;
};
let Some(si) = chunk.section_index(i32::from(*y) * 16) else {
continue;
};
let target = Arc::make_mut(&mut chunk.sections[si]);
if let Some(states) = section.get("block_states")
&& let Some(Tag::List(palette)) = states.get("palette")
{
let palette: Vec<u32> = palette
.iter()
.map(|p| {
BlockState::from_nbt(p)
.ok()
.and_then(|s| {
registry.id(&s).or_else(|| {
let default = registry.default_state(&s.name)?;
let mut full = registry.state(default)?.clone();
for (k, v) in &s.properties {
if full.properties.contains_key(k) {
full.properties.insert(k.clone(), v.clone());
}
}
registry.id(&full).or(Some(default))
})
})
.map_or(air, |id| id.0)
})
.collect();
let data = longs(states.get("data"));
if let Ok(blocks) =
PalettedContainer::from_packed(block_strategy(), palette, data.as_deref())
{
target.blocks = blocks;
}
}
if let Some(b) = section.get("biomes")
&& let Some(Tag::List(palette)) = b.get("palette")
{
let palette: Vec<u32> = palette
.iter()
.map(|p| match p {
Tag::String(s) => u32::from(biomes.id(&s.to_string_lossy())),
_ => 0,
})
.collect();
let data = longs(b.get("data"));
if let Ok(container) =
PalettedContainer::from_packed(BIOME_STRATEGY, palette, data.as_deref())
{
target.biomes = container;
}
}
let layer = |name: &str| match section.get(name) {
Some(Tag::ByteArray(a)) if a.len() == 2048 => Some(a.clone()),
_ => None,
};
layers[si] = (layer("SkyLight"), layer("BlockLight"));
}
// `save::load_light`: a section without saved sky light takes the light
// of the blocks above it, from the top of the column down.
let mut above = [15u8; 256];
for (si, (sky, block)) in layers.into_iter().enumerate().rev() {
let mut data = Box::new([0u8; 4096]);
for (i, v) in data.iter_mut().enumerate() {
let nibble =
|layer: &Option<Vec<u8>>| layer.as_ref().map(|l| l[i >> 1] >> ((i & 1) << 2) & 15);
let s = nibble(&sky).unwrap_or(above[i & 255]);
let b = nibble(&block).unwrap_or(0);
*v = s << 4 | b;
}
if sky.is_some() {
for (i, a) in above.iter_mut().enumerate() {
*a = data[i] >> 4;
}
}
let section = Arc::make_mut(&mut chunk.sections[si]);
section.light = Light::Full(data);
section.compact();
}
chunk.lit = true;
chunk.light_loaded = true;
Some(chunk)
}
@@ -79,10 +79,7 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &renderer.pack_inputs());
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
let (_, notes) = renderer
.set_shader_pack(0, Some(post), &[])
.unwrap()
.unwrap();
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
assert!(
!valid || notes.iter().all(|n| !n.contains("skipped")),
"{notes:?}"
@@ -124,7 +121,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
renderer.resize(320, 180);
@@ -355,7 +351,6 @@ 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: &[],
@@ -378,12 +373,10 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
+7 -137
View File
@@ -84,9 +84,7 @@ pub struct Assets {
pub struct RenderAssets {
pub sprite_size: u32,
pub sprite_minimum_size: u32,
/// The block sprites' RGBA, layer by layer: as built until a renderer has
/// them, then kept compressed ([`SpritePixels`]).
pub pixels: SpritePixels,
pub pixels: Vec<u8>,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
/// Shared parsed resources; item-only validation errors surface at item preparation.
@@ -113,76 +111,6 @@ pub struct RenderAssets {
/// sprites are when it changes (`native_block_sprites_version`).
pub generation: u64,
}
/// The block sprites' pixels for a renderer to upload. Once a renderer has
/// them ([`Self::pack`]) they are kept lz4-compressed: the GPU holds the
/// texture array, and the copy only serves a renderer made again (another
/// window, a lost device, another graphics API), which waits for
/// [`Self::get`] to uncompress them. Shared by the window and the workers'
/// `RenderAssets`, so the compressed form replaces the raw one in place.
pub struct SpritePixels(Arc<PixelStore>);
struct PixelStore {
len: usize,
/// Set by the first `pack`: later calls cost one atomic read.
packing: std::sync::atomic::AtomicBool,
/// As built; dropped once `packed` holds them.
raw: std::sync::Mutex<Option<Arc<Vec<u8>>>>,
packed: std::sync::OnceLock<Vec<u8>>,
}
impl SpritePixels {
pub fn new(pixels: Vec<u8>) -> Self {
Self(Arc::new(PixelStore {
len: pixels.len(),
packing: std::sync::atomic::AtomicBool::new(false),
raw: std::sync::Mutex::new(Some(Arc::new(pixels))),
packed: std::sync::OnceLock::new(),
}))
}
/// Their size in bytes, uncompressed.
pub fn len(&self) -> usize {
self.0.len
}
pub fn is_empty(&self) -> bool {
self.0.len == 0
}
/// The pixels as built, uncompressed again once packed.
pub fn get(&self) -> Arc<Vec<u8>> {
let raw = self.0.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
raw.unwrap_or_else(|| {
let packed = self.0.packed.get().map(Vec::as_slice).unwrap_or_default();
Arc::new(
lz4_flex::decompress_size_prepended(packed)
.ok()
.filter(|p| p.len() == self.0.len)
.unwrap_or_else(|| vec![0; self.0.len]),
)
})
}
/// Keep them compressed from now on, once a renderer has uploaded them:
/// compressed on a thread of their own, the raw copy dropped after.
pub fn pack(&self) {
use std::sync::atomic::Ordering;
if self.0.len == 0 || self.0.packing.swap(true, Ordering::Relaxed) {
return;
}
let store = self.0.clone();
let job = move || {
let raw = store.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(raw) = raw {
let _ = store.packed.set(lz4_flex::compress_prepend_size(&raw));
// `get` reads the compressed copy from here on.
*store.raw.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
};
let fallback = job.clone();
if std::thread::Builder::new()
.name("Sprite packing".into())
.spawn(job)
.is_err()
{
fallback();
}
}
}
/// The last `RenderAssets::generation` handed out.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn invalid(message: &str) -> io::Error {
@@ -279,20 +207,6 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] with the model library already parsed.
///
/// Parsing it reads every model file under `assets/`, which needs no
/// block registry, so a caller that has the files to spare can start
/// it before `Assets::load` finishes and hand the result in here. On
/// a cold cache that is most of a second of reading moved off the
/// critical path.
pub fn reload_with_models(
&self,
root: &Path,
parsed: crate::model_library::ParsedModels,
) -> Result<RenderAssets> {
RenderAssets::load_with_parsed(root, self, true, Some(parsed))
}
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
/// layers, without the block or item models or the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
@@ -304,16 +218,6 @@ impl RenderAssets {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
Self::load_with_parsed(root, assets, draws, None)
}
/// `parsed`: the model library when a caller parsed it ahead of this
/// (see [`Assets::reload_with_models`]); otherwise it is parsed here.
fn load_with_parsed(
root: &Path,
assets: &Assets,
draws: bool,
parsed: Option<crate::model_library::ParsedModels>,
) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
@@ -321,24 +225,17 @@ impl RenderAssets {
}
// A server draws no block and no item: its block models (about 24 MB
// baked) and item model library (7 MB) stay empty.
let parsed = match parsed {
Some(parsed) => parsed,
None if draws => {
let _step = crate::spans::startup::step("core.models_parse");
crate::model_library::ParsedModels::load(root)
}
None => crate::model_library::ParsedModels::default(),
let parsed = if draws {
crate::model_library::ParsedModels::load(root)
} else {
crate::model_library::ParsedModels::default()
};
let mut models = if draws {
let _step = crate::spans::startup::step("core.models_bake");
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = {
let _step = crate::spans::startup::step("core.item_library");
parsed.into_item_library()
};
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
for name in [
@@ -352,10 +249,8 @@ 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");
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
} else {
Default::default()
@@ -406,7 +301,7 @@ impl RenderAssets {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.count())?,
pixels: SpritePixels::new(sprites.pixels),
pixels: sprites.pixels,
model_catalog: models.catalog,
model_library,
model_placements: models.placements,
@@ -504,29 +399,4 @@ mod tests {
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
#[test]
fn sprite_pixels_come_back_the_same_once_packed() {
// Two layers of 16x16 RGBA with some structure, as sprites have.
let pixels: Vec<u8> = (0..2 * 16 * 16 * 4).map(|i| (i * 7 % 251) as u8).collect();
let stored = super::SpritePixels::new(pixels.clone());
assert_eq!(*stored.get(), pixels);
stored.pack();
stored.pack();
// The packing thread drops the raw copy once the compressed one is set.
let started = std::time::Instant::now();
while stored.0.raw.lock().unwrap().is_some() {
assert!(started.elapsed().as_secs() < 10, "packing never finished");
std::thread::yield_now();
}
assert!(
stored
.0
.packed
.get()
.is_some_and(|p| p.len() < pixels.len())
);
assert_eq!(*stored.get(), pixels);
assert_eq!(stored.len(), pixels.len());
}
}
@@ -379,17 +379,14 @@ pub fn generate(
let (_, _, key) = load(palette_key, "textures/palettes")?;
let (_, _, target) = load(palette, "textures/palettes")?;
let mut mapped = argb(&pixels);
let key = argb(&key);
let target = argb(&target);
apply_palette(&mut mapped, &key, &target)?;
let out: Vec<u8> = mapped
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
let out = mapped
.iter()
.flat_map(|p| {
let [a, r, g, b] = p.to_be_bytes();
[r, g, b, a]
})
.collect();
drop(mapped);
Some((w, h, out))
}
}
+22 -227
View File
@@ -61,8 +61,6 @@ struct Mixer {
world_names: Vec<String>,
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -208,18 +206,6 @@ 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,
@@ -236,7 +222,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<CapturedSound>>,
pub capture: Option<Vec<(String, [f64; 3], f32, f32)>>,
/// 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>,
@@ -558,8 +544,9 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
(forward, up, right)
};
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
mixer.cameras.clear();
mixer.cameras.extend(
let mut cameras = std::mem::take(&mut mixer.cameras);
cameras.clear();
cameras.extend(
std::iter::once((
0,
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
@@ -578,11 +565,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
)
}),
);
let shared = mixer.cameras.len() > 1;
let shared = cameras.len() > 1;
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
// Gains and pans per source for this buffer.
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
continue;
@@ -600,8 +586,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = mixer
.cameras
let camera = cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
@@ -632,7 +617,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
for (id, [left, right, gain], step) in voices.iter().copied() {
mixer.cameras = cameras;
for (id, [left, right, gain], step) in voices {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
@@ -662,7 +648,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.position += step;
}
}
mixer.voices = voices;
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
@@ -705,27 +690,17 @@ fn open_device(
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
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()),
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()),
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();
@@ -806,35 +781,10 @@ 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;
@@ -842,7 +792,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(
stream_config,
&config.config(),
move |data: &mut [T], _| {
scratch.resize(data.len(), 0.0f32);
if let Ok(mut m) = mixer.lock() {
@@ -1309,17 +1259,7 @@ 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(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,
});
capture.push((event.to_owned(), at, volume, pitch));
}
*self.heard.entry(event.to_owned()).or_default() += 1;
self.sync_volumes();
@@ -1358,66 +1298,6 @@ 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,
@@ -1428,17 +1308,7 @@ impl Audio {
seed: i64,
) {
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
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,
});
capture.push((event.to_owned(), at, volume, pitch));
}
if at.is_some() {
*self.heard.entry(event.to_owned()).or_default() += 1;
@@ -1578,81 +1448,6 @@ 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"));
+11 -108
View File
@@ -49,10 +49,6 @@ 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 =
@@ -105,22 +101,6 @@ impl AuthSession {
let _ = std::fs::remove_file(path);
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
}
/// The access token's expiry (its JWT `exp`) has passed, or is a minute away: the session
/// service would refuse it. A token that doesn't read as a JWT counts as good: the server
/// says.
pub fn expired(&self) -> bool {
let exp = self
.access_token
.split('.')
.nth(1)
.and_then(|p| crate::skins::base64_decode(&p.replace('-', "+").replace('_', "/")))
.and_then(|json| serde_json::from_slice::<serde_json::Value>(&json).ok())
.and_then(|v| v.get("exp")?.as_u64());
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
exp.is_some_and(|exp| exp <= now + 60)
}
}
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
@@ -456,8 +436,11 @@ struct ChatKeys {
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: std::sync::LazyLock<Mutex<std::collections::HashMap<u128, ChatKeys>>> =
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
@@ -466,14 +449,7 @@ pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
prepare_chat_keys_for(session, now);
}
/// Prepare keys for this player, independently of the other local accounts.
pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
let uuid = session.uuid;
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
let mut keys = CHAT_KEYS.lock().unwrap();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
@@ -487,13 +463,12 @@ pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
return;
}
keys.fetching = true;
drop(accounts);
drop(keys);
let spawned = std::thread::Builder::new()
.name("Profile key fetch".into())
.spawn(move || {
let result = fetch_chat_keys(&session);
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
let mut keys = CHAT_KEYS.lock().unwrap();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
@@ -501,24 +476,19 @@ pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().entry(uuid).or_default().fetching = false;
CHAT_KEYS.lock().unwrap().fetching = false;
}
}
/// The chat key pair fetched so far, unless it has expired.
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
chat_keys_for(current()?.uuid)
}
/// This account's key pair; another local player's keys can never be returned.
pub fn chat_keys_for(uuid: u128) -> Option<Arc<ChatKeyPair>> {
let now = now_millis();
CHAT_KEYS
.lock()
.unwrap()
.get(&uuid)?
.current
.clone()
.filter(|k| !k.public.has_expired(now_millis()))
.filter(|k| !k.public.has_expired(now))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
@@ -662,42 +632,6 @@ mod tests {
}
}
/// base64url without padding, as a JWT's parts are.
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n =
chunk.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)) << (8 * (3 - chunk.len()));
for k in 0..=chunk.len() {
out.push(char::from(ALPHABET[(n >> (18 - 6 * k) & 63) as usize]));
}
}
out
}
#[test]
fn a_token_past_its_exp_is_expired() {
// header.payload.signature, the payload base64url without padding.
let token = |exp: u64| {
let json = format!(r#"{{"exp":{exp}}}"#);
let payload = base64url(json.as_bytes());
AuthSession::new("Ann", UUID, &format!("e30.{payload}.sig"))
};
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(token(now - 10).expired());
assert!(
token(now + 30).expired(),
"a minute from its end: as good as gone"
);
assert!(!token(now + 3600).expired());
assert!(!AuthSession::new("Ann", UUID, "not-a-jwt").expired());
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
@@ -861,37 +795,6 @@ mod tests {
.to_string()
}
#[test]
fn local_accounts_use_their_own_chat_keys() {
let pair = |signature: &[u8]| {
let mut keys = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
keys.public.expires_at = i64::MAX;
keys.refreshed_after = i64::MAX;
keys.public.signature = signature.to_vec();
Arc::new(keys)
};
let ann = pair(b"ann");
let bea = pair(b"bea");
{
let mut accounts = CHAT_KEYS.lock().unwrap();
for (uuid, keys) in [(101, ann.clone()), (102, bea.clone())] {
accounts.insert(
uuid,
ChatKeys {
current: Some(keys),
..Default::default()
},
);
}
}
prepare_chat_keys_for(Arc::new(AuthSession::new("Ann", 101, "unused")), true);
assert!(Arc::ptr_eq(&chat_keys_for(101).unwrap(), &ann));
assert!(Arc::ptr_eq(&chat_keys_for(102).unwrap(), &bea));
assert!(chat_keys_for(103).is_none());
CHAT_KEYS.lock().unwrap().remove(&101);
CHAT_KEYS.lock().unwrap().remove(&102);
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
@@ -89,7 +89,6 @@ 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.
@@ -230,7 +229,6 @@ 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);
}
@@ -299,25 +297,14 @@ 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,
@@ -333,7 +320,6 @@ 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);
@@ -360,7 +346,6 @@ 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);
@@ -373,7 +358,6 @@ 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(_))) {
@@ -385,7 +369,6 @@ 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);
@@ -395,7 +378,6 @@ 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);
}
@@ -407,16 +389,6 @@ impl BlockEntities {
self.add(pos);
}
}
/// [`Self::retain`] for unloading chunks: removes the block entities at
/// the positions `gone` names and changes none it keeps, so those that
/// sleep stay asleep (a moving player unloads chunks most ticks).
pub fn unload(&mut self, mut gone: impl FnMut(&Pos) -> bool) {
let asleep = std::mem::take(&mut self.asleep);
self.retain(|p, _| !gone(p));
self.asleep = asleep;
let map = &self.map;
self.asleep.retain(|p| map.contains_key(p));
}
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
@@ -427,7 +399,6 @@ 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 {
@@ -445,7 +416,6 @@ impl BlockEntities {
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.asleep.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -463,7 +433,6 @@ 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);
@@ -483,7 +452,6 @@ 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;
}
@@ -568,75 +536,12 @@ mod tests {
.collect()
}
/// Unloading a chunk removes its block entities and leaves the others'
/// sleep alone; `retain`, which may change what it keeps, wakes them all.
#[test]
fn unloading_a_chunk_keeps_the_others_asleep() {
let mut b = level();
let (kept, gone) = ((1, 64, 1), (17, 64, 1));
b.insert(kept, hopper());
b.insert(gone, hopper());
b.sleep(kept);
b.sleep(gone);
b.unload(|p| p.0 >> 4 == 1);
assert!(b.get(&gone).is_none());
assert!(!b.is_asleep(&gone));
assert!(b.is_asleep(&kept));
b.retain(|_, _| true);
assert!(!b.is_asleep(&kept));
}
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
found.sort();
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,14 +939,6 @@ 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)
@@ -956,9 +948,7 @@ impl BlockEntityAnims {
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
self.shulkers
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
if !keep_bells {
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
}
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
self.conduits
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
self.skulls
@@ -7,11 +7,10 @@
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{
Result,
bidi::visual,
bidi::visual_str,
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
font::Glyph,
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
@@ -271,10 +270,10 @@ pub const DYES: [(&str, u32); 16] = [
("black", 0x1D1D21),
];
fn dye_prefix(name: &str) -> Option<(usize, &str)> {
// Longest names first; this is the same stable order as sorting DYES by
// descending prefix length, without a Vec allocation or comparison sort.
const ORDER: [usize; 16] = [3, 8, 2, 1, 4, 10, 0, 12, 13, 15, 5, 6, 7, 9, 11, 14];
ORDER.into_iter().find_map(|i| {
// Longest names first so "light_gray" wins over "gray".
let mut order: Vec<usize> = (0..16).collect();
order.sort_by_key(|&i| std::cmp::Reverse(DYES[i].0.len()));
order.into_iter().find_map(|i| {
name.strip_prefix(DYES[i].0)
.and_then(|rest| rest.strip_prefix('_'))
.map(|rest| (i, rest))
@@ -639,9 +638,7 @@ fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
}
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour. The font colours each
/// glyph by its style (`Font.StringRenderOutput`): a line's own colour wins,
/// else the sign's. A line is one call per run of one colour.
/// full-bright with an 8x outline in the dark colour.
fn sign_text_calls(
scene: &Scene,
data: &SignData,
@@ -663,54 +660,26 @@ fn sign_text_calls(
} else {
(dark, 0, vertex_light(packed))
};
let advance = |c: char| (scene.glyph)(c).advance;
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
for i in 0..text.lines.len() {
// The line that fits, in the order it is drawn (getVisualOrder).
let line = text.line_text(i);
let line = line.resolved();
let flat: String = line.chars().map(|(c, _)| c).collect();
let fits = first_line(&flat, data.max_line_width(), &advance);
let shown = line.slice(0, fits.chars().count());
let shown = visual(&shown);
let width = shown.chars().map(|(c, _)| advance(c)).sum::<f32>().ceil();
let x = -((width as i32) / 2) as f32;
let y = i as f32 * line_height - midpoint;
let first = calls.len();
let mut pen = 0.0;
for span in shown.spans.iter().filter(|s| !s.text.is_empty()) {
let run = span
.style
.color
.map_or(color, |rgb| 0xFF00_0000 | (rgb & 0xFF_FFFF));
match calls[first..].last_mut() {
Some(last) if last.color == run => last.text.push_str(&span.text),
_ => calls.push(TextCall {
matrix: m,
x: x + pen,
y,
text: span.text.clone(),
color: run,
outline,
light,
}),
}
pen += span.text.chars().map(advance).sum::<f32>();
}
if calls.len() == first {
// A line with no text is still a call, of no glyphs.
calls.push(TextCall {
let advance = |c: char| (scene.glyph)(c).1;
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
text.lines
.iter()
.enumerate()
.map(|(i, line)| {
// The line that fits, in the order it is drawn (getVisualOrder).
let shown = first_line(line, data.max_line_width(), &advance);
let shown = visual_str(&shown).into_owned();
TextCall {
matrix: m,
x,
y,
text: String::new(),
x: -((width(&shown) as i32) / 2) as f32,
y: i as f32 * line_height - midpoint,
text: shown,
color,
outline,
light,
});
}
}
calls
}
})
.collect()
}
/// How far the text of an outlined call sits in front of its outline, in
@@ -726,31 +695,14 @@ 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 = 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).
let (x0, x1) = (x + g.left, x + g.right);
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let (cell, advance) = (scene.glyph)(ch);
if let Some(r) = cell {
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
let c =
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
@@ -769,7 +721,7 @@ pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut V
});
}
}
x += g.advance;
x += advance;
}
};
if call.outline != 0 {
@@ -866,14 +818,11 @@ pub struct Scene<'a> {
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
pub glyph: &'a dyn Fn(char) -> Glyph,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
/// Per-block visibility for dynamic, animated, or uncached models. Bounds
/// are world-space and conservatively include model animation extents.
pub dynamic_visible: Option<&'a dyn Fn([i32; 3], u32, DVec3, DVec3) -> bool>,
}
/// Empty by default (a dedicated server): no state has a block entity model.
@@ -1443,167 +1392,6 @@ impl BlockEntityRenderer {
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
fn dynamic_bounds(
&self,
scene: &Scene,
pos: [i32; 3],
kind: &Kind,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> (DVec3, DVec3) {
let [x, y, z] = pos.map(f64::from);
let mut lo = DVec3::new(x, y, z);
let mut hi = DVec3::new(x + 1.0, y + 1.0, z + 1.0);
match kind {
Kind::Chest { double_with, .. } => {
if let Some(offset) = double_with {
lo.x += f64::from(offset[0].min(0));
lo.z += f64::from(offset[2].min(0));
hi.x += f64::from(offset[0].max(0));
hi.z += f64::from(offset[2].max(0));
}
let is_open = chest_openness.map_or_else(
|| {
(scene.lid)(pos) != 0.0
|| double_with.is_some_and(|offset| {
let other = [
pos[0].saturating_add(offset[0]),
pos[1].saturating_add(offset[1]),
pos[2].saturating_add(offset[2]),
];
(scene.lid)(other) != 0.0
})
},
|(open, _)| open != 0.0,
);
if is_open {
// The lid pivots up from the rear edge. Its longest corner
// rises by less than one block in the fully open pose.
hi.y += 0.8;
}
}
Kind::Shulker { facing, .. } => {
let offset = step(facing);
lo.x += f64::from(offset[0].min(0)) * 0.52;
lo.y += f64::from(offset[1].min(0)) * 0.52;
lo.z += f64::from(offset[2].min(0)) * 0.52;
hi.x += f64::from(offset[0].max(0)) * 0.52;
hi.y += f64::from(offset[1].max(0)) * 0.52;
hi.z += f64::from(offset[2].max(0)) * 0.52;
}
Kind::Banner { wall: false, .. } => {
hi.y += 1.0;
lo.x -= 0.05;
lo.z -= 0.05;
hi.x += 0.05;
hi.z += 0.05;
}
Kind::Banner { wall: true, .. } => {
lo.y -= 1.0;
lo.x -= 0.05;
lo.z -= 0.05;
hi.x += 0.05;
hi.z += 0.05;
}
Kind::Sign { hanging: true, .. } => {
lo.y -= 1.0;
lo.x -= 0.15;
lo.z -= 0.15;
hi.x += 0.15;
hi.z += 0.15;
}
Kind::Sign { hanging: false, .. } => {
hi.y += 0.3;
lo.x -= 0.02;
lo.z -= 0.02;
hi.x += 0.02;
hi.z += 0.02;
}
Kind::EnchantingTable | Kind::Lectern { .. } => {
// The book and statue models rise above the block's collision
// cube; include their full rendered height and rotated corners.
hi.y += 1.0;
lo.x -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.z += 0.25;
}
Kind::CopperGolem { .. } => {
// The statue pose rotates around the root, so some variants
// extend below the block as well as above it.
lo.y -= 1.0;
hi.y += 1.0;
lo.x -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.z += 0.25;
}
Kind::Conduit => {
// Its animated cage is centered above the conduit block and
// the wind model spans multiple cells. The caller bypasses
// voxel occlusion for envelopes this tall, but retains frustum
// culling with this conservative volume.
lo.x -= 1.0;
lo.y -= 1.0;
lo.z -= 1.0;
hi.x += 1.0;
hi.y += 1.0;
hi.z += 1.0;
}
Kind::Bell => {
// The bell's pivoted swing moves its corners slightly outside
// the block even though the model starts centered in it.
lo.x -= 0.1;
lo.y -= 0.1;
lo.z -= 0.1;
hi.x += 0.1;
hi.y += 0.1;
hi.z += 0.1;
}
Kind::Skull { .. } => {
// Wall-mounted and dragon heads can protrude from their block.
lo.x -= 0.25;
lo.y -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.y += 0.25;
hi.z += 0.25;
}
Kind::EndGateway { .. } => {
let anim = (scene.anim)(pos);
let beam = gateway_beam(anim.gateway, scene.partial, scene.max_y, scene.game_time);
let height = f64::from(beam.height.max(0));
lo.y -= height;
hi.y += height;
}
_ => {}
}
(lo, hi)
}
fn is_dynamic_visible(
&self,
scene: &Scene,
shapes: &Arc<BlockEntityShapes>,
pos: [i32; 3],
state: u32,
kind: &Kind,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> bool {
let Some(visible) = scene.dynamic_visible else {
return true;
};
if matches!(kind, Kind::EndGateway { .. }) {
return true;
}
// A pack model may extend outside the vanilla envelope. Keep that
// geometry on the uncullable path unless its model bounds are known.
if !Arc::ptr_eq(shapes, &self.vanilla) {
return true;
}
let (lo, hi) = self.dynamic_bounds(scene, pos, kind, chest_openness);
visible(pos, state, lo, hi)
}
fn cached_entry_is_current(
&self,
scene: &Scene,
@@ -1613,7 +1401,6 @@ impl BlockEntityRenderer {
options: CacheOptions,
entry: &StaticEntry,
section_revision: StaticSectionRevision,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> bool {
if entry.key.state != state
|| entry.key.nether != scene.nether
@@ -1625,24 +1412,15 @@ impl BlockEntityRenderer {
}
match kind {
Kind::Chest { .. } => {
let (open, other) = chest_openness.unwrap_or_else(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if open != 0.0 {
return false;
}
let mut light = scene.world.light(pos[0], pos[1], pos[2]);
if let Some(other) = other {
let other_light = scene.world.light(other[0], other[1], other[2]);
light = (light >> 4).max(other_light >> 4) << 4
| (light & 15).max(other_light & 15);
}
entry.key.light == light
let (open, other) = self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
);
// Double chests depend on the neighboring half's light too;
// let those uncommon models take the full key path.
open == 0.0 && other.is_none()
}
Kind::Shulker { .. } => {
let (progress, old) = (scene.anim)(pos).shulker;
@@ -1905,17 +1683,6 @@ impl BlockEntityRenderer {
}
let local = static_local_index(pos, section_min_y);
let current_section_revision = active_section_revision.unwrap_or_default();
let chest_openness = (cache.is_some()
&& options.chests
&& matches!(kind, Kind::Chest { .. }))
.then(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if cache_only && section_revision.is_some() && active_section_revision.is_none() {
break 'draw;
}
@@ -1931,7 +1698,6 @@ impl BlockEntityRenderer {
options,
&active_entries[index].1,
current_section_revision,
chest_openness,
)
{
let entry = &mut active_entries[index].1;
@@ -1951,9 +1717,7 @@ impl BlockEntityRenderer {
..*vertex
}));
}
if matches!(kind, Kind::Banner { .. })
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
{
if matches!(kind, Kind::Banner { .. }) {
let packed = scene.world.light(pos[0], pos[1], pos[2]);
let origin =
(DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
@@ -1974,25 +1738,11 @@ impl BlockEntityRenderer {
}
break 'draw;
}
if cache_only && chest_openness.is_some_and(|(open, _)| open != 0.0) {
if let Some(cache) = &mut cache
&& let Ok(index) =
active_entries.binary_search_by_key(&local, |(index, _)| *index)
{
active_entries.remove(index);
cache.len -= 1;
cache.mark_section_dirty(section);
}
break 'draw;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let mut packed = scene.world.light(pos[0], pos[1], pos[2]);
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, None) {
break 'draw;
}
if options.signs {
self.sign_text(scene, pos, state, origin, packed, out);
} else {
@@ -2011,15 +1761,8 @@ impl BlockEntityRenderer {
break 'draw;
}
if let Some(cache) = &mut cache
&& let Some(key) = self.static_key(
scene,
pos,
state,
kind,
options,
&mut packed,
chest_openness,
)
&& let Some(key) =
self.static_key(scene, pos, state, kind, options, &mut packed)
{
let slot = active_entries.binary_search_by_key(&local, |(index, _)| *index);
let entry = match slot {
@@ -2104,9 +1847,7 @@ impl BlockEntityRenderer {
..*vertex
}));
}
if matches!(kind, Kind::Banner { .. })
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
{
if matches!(kind, Kind::Banner { .. }) {
self.render_banner(
&shapes,
scene,
@@ -2133,9 +1874,6 @@ impl BlockEntityRenderer {
if cache_only {
break 'draw;
}
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, chest_openness) {
break 'draw;
}
let base = Mat4::from_translation(origin);
self.render_one(scene, pos, kind, base, packed, lights, false, true, out);
}
@@ -2202,7 +1940,6 @@ impl BlockEntityRenderer {
kind: &Kind,
options: CacheOptions,
packed: &mut u8,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> Option<StaticKey> {
let mut sprite_rects = [None; 5];
let mut sprite_count = 0;
@@ -2220,14 +1957,12 @@ impl BlockEntityRenderer {
part,
..
} if options.chests => {
let (open, other) = chest_openness.unwrap_or_else(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
let (open, other) = self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
);
if open != 0.0 {
return None;
}
@@ -3497,7 +3232,6 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
mod tests {
use super::*;
use crate::assets::Assets;
use horizon_world::nbt::Tag;
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn entity_block_particles_and_breaking_use_exported_models() -> Result<()> {
@@ -3608,9 +3342,8 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
// Compare the cached world path with the original model renderer,
// including atlas replacement, current light and animated fallback.
@@ -3877,13 +3610,7 @@ mod tests {
w: 8,
h: 8,
};
Glyph {
rect: Some(rect),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
}
(Some(rect), 6.0)
};
let scene = Scene {
camera: DVec3::ZERO,
@@ -3905,7 +3632,6 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let mut call = TextCall {
matrix: Mat4::IDENTITY,
@@ -3934,202 +3660,6 @@ mod tests {
assert!(out.iter().all(|v| v.pos[2] == 0.0));
}
/// BakedSheetGlyph draws a glyph over its own box from the pen
/// (x + left .. x + right, y + up .. y + down), not a fixed 8x8 cell. With
/// Force Unicode Font a Latin letter is an 8x16 Unifont glyph at half
/// scale: 4 units wide and 8 tall, advancing 5 (#8699).
#[test]
fn sign_text_draws_each_glyph_over_its_own_box() {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.125,
w: 8,
h: 16,
}),
left: 0.0,
top: 0.0,
right: 4.0,
bottom: 8.0,
advance: 5.0,
offset: 0.5,
};
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
translucent_sprites: &HashSet::new(),
atlas_revision: 1,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let call = TextCall {
matrix: Mat4::IDENTITY,
x: 10.0,
y: 2.0,
text: "ab".into(),
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
let boxes: Vec<_> = out
.chunks(4)
.map(|q| {
let xs = q.iter().map(|v| v.pos[0]);
let ys = q.iter().map(|v| v.pos[1]);
[
xs.clone().fold(f32::MAX, f32::min),
xs.fold(f32::MIN, f32::max),
ys.clone().fold(f32::MAX, f32::min),
ys.fold(f32::MIN, f32::max),
]
})
.collect();
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
/// The front text calls of a sign whose every glyph advances 6.
fn front_calls(data: &SignData, near: bool) -> Vec<(String, f32, f32, u32, u32)> {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| Glyph {
advance: 6.0,
..Glyph::default()
};
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
translucent_sprites: &HashSet::new(),
atlas_revision: 1,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
sign_text_calls(&scene, data, &data.front, Mat4::IDENTITY, 0, near)
.into_iter()
.map(|c| (c.text, c.x, c.y, c.color, c.outline))
.collect()
}
fn sign_with(lines: [Tag; 4]) -> SignData {
let mut data = SignData::default();
data.decode(&Tag::compound([(
"front_text",
Tag::compound([("messages", Tag::List(lines.to_vec()))]),
)]));
data
}
fn colored(text: &str, color: &str) -> Tag {
Tag::compound([
("text", Tag::String(text.into())),
("color", Tag::String(color.into())),
])
}
/// `Font.StringRenderOutput`: a glyph takes its style's colour and the
/// sign's dark text colour only when the style has none (#9783). A line
/// of two colours is two calls, the second where the first ends, the
/// whole line centred.
#[test]
fn sign_text_takes_each_lines_own_colors() {
let mut two = colored("ab", "red");
let Tag::Compound(parts) = &mut two else {
unreachable!()
};
parts.insert("extra".into(), Tag::List(vec![colored("cd", "blue")]));
let data = sign_with([
Tag::String("plain".into()),
colored("Online", "green"),
two,
Tag::String("".into()),
]);
// Black dye: the dark text colour is black. Sign lines are 10 apart
// from 20 above the middle.
assert_eq!(
front_calls(&data, false),
[
("plain".to_owned(), -15.0, -20.0, 0xFF00_0000, 0),
("Online".to_owned(), -18.0, -10.0, 0xFF55_FF55, 0),
("ab".to_owned(), -12.0, 0.0, 0xFFFF_5555, 0),
("cd".to_owned(), 0.0, 0.0, 0xFF55_55FF, 0),
(String::new(), 0.0, 10.0, 0xFF00_0000, 0),
]
);
}
/// A glowing sign's own colour still wins over its dye, and the outline
/// stays the dye's dark colour. `§` codes in a line colour it too, and
/// `§r` goes back to the sign's colour.
#[test]
fn glowing_sign_text_keeps_its_outline_under_a_lines_own_colors() {
let mut data = sign_with([
Tag::String("plain".into()),
colored("Online", "green"),
Tag::String("§cRed§rX".into()),
Tag::String("".into()),
]);
data.front.color = 14;
data.front.glowing = true;
let (red, outline) = (0xFFFF_0000, 0xFF66_0000);
assert_eq!(
front_calls(&data, true),
[
("plain".to_owned(), -15.0, -20.0, red, outline),
("Online".to_owned(), -18.0, -10.0, 0xFF55_FF55, outline),
("Red".to_owned(), -12.0, 0.0, 0xFFFF_5555, outline),
("X".to_owned(), 6.0, 0.0, red, outline),
(String::new(), 0.0, 10.0, red, outline),
]
);
}
/// The text that fits the sign is drawn, in the colour it had.
#[test]
fn a_long_colored_line_is_cut_where_it_stops_fitting() {
// 90 wide: fifteen glyphs of 6.
let data = sign_with([
colored("abcdefghijklmnopqrst", "gold"),
Tag::String("".into()),
Tag::String("".into()),
Tag::String("".into()),
]);
assert_eq!(
front_calls(&data, false)[0],
("abcdefghijklmno".to_owned(), -45.0, -20.0, 0xFFFF_AA00, 0)
);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
@@ -4184,13 +3714,7 @@ mod tests {
w: 8,
h: 8,
};
Glyph {
rect: (c != ' ').then_some(rect),
right: 8.0,
bottom: 8.0,
advance: wide.get(),
..Glyph::default()
}
((c != ' ').then_some(rect), wide.get())
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
@@ -4217,7 +3741,6 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
let mut cached = Vec::new();
@@ -4356,9 +3879,8 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
@@ -4442,9 +3964,8 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
@@ -4,7 +4,6 @@ use crate::{
ModelPart, SIGN_TEXT_KEEP, Scene, StaticSectionRevision, classify,
},
chunk::Chunk,
font::Glyph,
hud::Rect,
mesher::Vertex,
signs::{SignData, SignText},
@@ -356,9 +355,8 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
// Reverse local-position order to check that cache indexing does not
// change the order of emitted entities.
@@ -445,9 +443,8 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
shulker_boxes: true,
@@ -500,9 +497,8 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([100, 70, -32], 0)];
let other_block = [([116, 70, -32], 0)];
@@ -655,19 +651,18 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
};
let sign_data = &data;
let sign = move |_: [i32; 3]| Some(sign_data);
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
let glyph = |_: char| {
(
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
6.0,
)
};
let scene = Scene {
camera: DVec3::new(2.25, 64.5, -3.75),
@@ -689,7 +684,6 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let enabled = CacheOptions {
@@ -741,9 +735,8 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -843,9 +836,8 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
shulker_boxes: true,
@@ -898,9 +890,8 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -960,9 +951,8 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1056,9 +1046,8 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let pos = [0, 64, 0];
let options = CacheOptions {
@@ -1145,9 +1134,8 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
let options = CacheOptions {
@@ -1221,9 +1209,8 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1300,9 +1287,8 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
sign: &|_| None,
banner: &banner_patterns,
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1367,9 +1353,8 @@ fn cached_wall_banner_poles_use_the_wall_model() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let pos = [0, 64, 0];
let options = CacheOptions {
@@ -1415,9 +1400,8 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
copper_golem_statues: true,
@@ -1521,9 +1505,8 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyph: &|_| (None, 0.0),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
copper_golem_statues: true,
+3 -114
View File
@@ -90,20 +90,9 @@ pub fn effect_lines<'a>(
);
}
let instant = effect(e).is_some_and(|d| d.instant);
if !instant {
if duration == -1 {
// MobEffectInstance.isInfiniteDuration: formatDuration says
// the infinite sign, not a time.
line = format!(
"{line} ({})",
translate("effect.duration.infinite").unwrap_or_else(|| "∞".to_owned())
);
} else {
let scaled = (duration as f32 * scale) as i32;
if scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
}
let scaled = (duration as f32 * scale) as i32;
if !instant && scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
(line, effect(e).map_or(Category::Neutral, |d| d.category))
})
@@ -351,17 +340,6 @@ 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;
@@ -477,84 +455,6 @@ 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());
}
/// The stand sleeps while `idle`, so its tick must change nothing then: run the real
/// tick on idle stands and compare, and check a stand with fuel to take wakes.
#[test]
fn an_idle_brewing_stand_tick_changes_nothing() {
let items = Items::for_tests(std::collections::HashMap::new());
let brewing = Brewing {
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
inputs: HashSet::new(),
recipes: vec![BrewingRecipe {
id: "awkward".into(),
input: PotionIngredient {
items: ["minecraft:potion".to_owned()].into_iter().collect(),
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
},
reagent: PotionIngredient {
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
potions: None,
},
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
}],
};
let providers = Providers::empty();
let water = || Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
let stand = |bottle: bool, fuel: i32, powder: bool| {
let mut s = BrewingStand::new();
s.fuel = fuel;
if bottle {
s.items[1] = water();
}
if powder {
s.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
}
s.last_bottles = Some([false, bottle, false]);
s
};
for before in [
stand(false, 0, false),
stand(true, 0, false),
stand(true, 5, false),
stand(true, 5, true),
] {
assert!(before.idle(), "{before:?}");
let mut after = before.clone();
assert_eq!(
after.tick(&brewing, &items, &providers),
BrewTick::default()
);
assert_eq!(format!("{after:?}"), format!("{before:?}"));
}
// A brewable ingredient starts the brew: not idle, and the tick changes it.
let mut brewing_now = stand(true, 5, false);
brewing_now.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
assert!(!brewing_now.idle());
brewing_now.tick(&brewing, &items, &providers);
assert_eq!(brewing_now.brew_time, 400);
}
/// #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.
@@ -573,15 +473,4 @@ mod tests {
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
}
#[test]
fn infinite_effects_say_the_infinite_sign() {
// MobEffectInstance.isInfiniteDuration: a -1 duration says the
// infinite sign, not a time; a short one still says nothing.
let translate = |key: &str| (key == "effect.duration.infinite").then(|| "∞".to_owned());
let lines = effect_lines([("minecraft:resistance", -1, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance (∞)");
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance");
}
}
-199
View File
@@ -1,27 +1,3 @@
// `joml::Mat4::rotation`, `rotate_affine`, `transform_affine` and `invert_orthonormal`, and
// `joml::Quat::rotation_x`/`_y`/`_z`, in this file are adapted from JOML 1.10.9's `Matrix4f`,
// `Quaternionf` and `Vector4f` (https://github.com/JOML-CI/JOML, MIT; see THIRD_PARTY_NOTICES.md).
//
// Copyright (c) 2015-2026 Richard Greenlees
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! The camera (`Camera.tick`, `tickFov`, `calculateFov`, `alignWithEntity`,
//! `getMaxZoom`, `setRotation`, `move`), view and hurt bobbing
//! (`GameRenderer.bobView`, `bobHurt`), the walk and bob history they read
@@ -143,40 +119,6 @@ pub mod joml {
w: w * cz + z * sz,
}
}
/// `Quaternionf.rotationX(angle)`.
pub fn rotation_x(angle: f32) -> Self {
let (x, w) = Self::half_turn(angle);
Self {
x,
y: 0.0,
z: 0.0,
w,
}
}
/// `Quaternionf.rotationY(angle)`.
pub fn rotation_y(angle: f32) -> Self {
let (y, w) = Self::half_turn(angle);
Self {
x: 0.0,
y,
z: 0.0,
w,
}
}
/// `Quaternionf.rotationZ(angle)`.
pub fn rotation_z(angle: f32) -> Self {
let (z, w) = Self::half_turn(angle);
Self {
x: 0.0,
y: 0.0,
z,
w,
}
}
fn half_turn(angle: f32) -> (f32, f32) {
let sin = sin(angle * 0.5);
(sin, cos_from_sin(sin, angle * 0.5))
}
/// `Quaternionf.transform(x, y, z, dest)` (`Vector3f.rotate`).
pub fn transform(&self, v: [f32; 3]) -> [f32; 3] {
let [x, y, z] = v;
@@ -200,10 +142,8 @@ pub mod joml {
}
const PERSPECTIVE: u8 = 1;
const AFFINE: u8 = 2;
const IDENTITY: u8 = 4;
const TRANSLATION: u8 = 8;
const ORTHONORMAL: u8 = 16;
/// A JOML `Matrix4f` with its property flags, which select the code
/// paths (and so the rounding) of later operations. `m[column * 4 + row]`.
@@ -344,149 +284,10 @@ pub mod joml {
// already taken that branch.
self.rotate_axis(2, ang);
}
/// `Matrix4f.rotation(Quaternionfc)`.
pub fn rotation(&mut self, q: Quat) {
let (w2, x2, y2, z2) = (q.w * q.w, q.x * q.x, q.y * q.y, q.z * q.z);
let zw = q.z * q.w;
let dzw = zw + zw;
let xy = q.x * q.y;
let dxy = xy + xy;
let xz = q.x * q.z;
let dxz = xz + xz;
let yw = q.y * q.w;
let dyw = yw + yw;
let yz = q.y * q.z;
let dyz = yz + yz;
let xw = q.x * q.w;
let dxw = xw + xw;
self.reset_unless_identity();
self.set(0, 0, w2 + x2 - z2 - y2);
self.set(0, 1, dxy + dzw);
self.set(0, 2, dxz - dyw);
self.set(1, 0, -dzw + dxy);
self.set(1, 1, y2 - z2 + w2 - x2);
self.set(1, 2, dyz + dxw);
self.set(2, 0, dyw + dxz);
self.set(2, 1, dyz - dxw);
self.set(2, 2, z2 - y2 - x2 + w2);
self.properties = AFFINE | ORTHONORMAL;
}
/// An affine matrix of no more specific kind, as JOML's property flags have it.
pub fn affine(m: glam::Mat4) -> Self {
Self {
m: m.to_cols_array(),
properties: AFFINE,
}
}
/// `Matrix4f.rotate(Quaternionfc)` of an affine matrix that is neither the identity nor
/// a pure translation (`rotateAffine`).
pub fn rotate_affine(&mut self, q: Quat) {
debug_assert!(
self.properties & AFFINE != 0 && self.properties & (IDENTITY | TRANSLATION) == 0
);
let (w2, x2, y2, z2) = (q.w * q.w, q.x * q.x, q.y * q.y, q.z * q.z);
let zw = q.z * q.w;
let dzw = zw + zw;
let xy = q.x * q.y;
let dxy = xy + xy;
let xz = q.x * q.z;
let dxz = xz + xz;
let yw = q.y * q.w;
let dyw = yw + yw;
let yz = q.y * q.z;
let dyz = yz + yz;
let xw = q.x * q.w;
let dxw = xw + xw;
let r = [
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
];
let s = *self;
for (column, r) in r.iter().enumerate() {
for row in 0..3 {
let v = s.get(0, row) * r[0] + s.get(1, row) * r[1] + s.get(2, row) * r[2];
self.set(column, row, v);
}
self.set(column, 3, 0.0);
}
self.properties &= !(PERSPECTIVE | IDENTITY | TRANSLATION);
}
/// `Vector4f.mul(Matrix4fc)` of an affine matrix (`mulAffine`, JOML's `Math.fma`
/// unfused): the matrix times `v`, `w` kept.
pub fn transform_affine(&self, v: [f32; 4]) -> [f32; 3] {
debug_assert!(self.properties & AFFINE != 0);
let [x, y, z, w] = v;
std::array::from_fn(|row| {
self.get(0, row) * x
+ (self.get(1, row) * y + (self.get(2, row) * z + self.get(3, row) * w))
})
}
/// `Matrix4f.invert()` of a rotation and translation (`invertOrthonormal`; with the
/// identity and pure-translation shortcuts). `None` for any other matrix, which JOML
/// inverts by other code paths.
pub fn invert_orthonormal(&self) -> Option<Self> {
if self.properties & IDENTITY != 0 {
return Some(Self::IDENTITY);
}
let mut out = *self;
if self.properties & TRANSLATION != 0 {
for row in 0..3 {
out.set(3, row, -self.get(3, row));
}
out.properties = AFFINE | TRANSLATION | ORTHONORMAL;
return Some(out);
}
if self.properties & ORTHONORMAL == 0 {
return None;
}
let g = |c, r| self.get(c, r);
let t = |c| -(g(c, 0) * g(3, 0) + g(c, 1) * g(3, 1) + g(c, 2) * g(3, 2));
out.m = [
g(0, 0),
g(1, 0),
g(2, 0),
0.0,
g(0, 1),
g(1, 1),
g(2, 1),
0.0,
g(0, 2),
g(1, 2),
g(2, 2),
0.0,
t(0),
t(1),
t(2),
1.0,
];
out.properties = AFFINE | ORTHONORMAL;
Some(out)
}
pub fn to_glam(self) -> glam::Mat4 {
glam::Mat4::from_cols_array(&self.m)
}
}
}
use joml::{Mat4, Quat};
/// `Camera.getViewRotationMatrix` to the last bit: `Matrix4f.rotation` of the conjugate of
/// `setRotation`'s quaternion, and its JOML `invert()`. `view_matrix` turns the same quaternion
/// into a matrix by glam's formula, which differs in the last bits (1.0 for 0.99999994, 6e-8 for
/// 0).
pub fn view_rotation_matrix(yaw: f32, pitch: f32) -> (glam::Mat4, glam::Mat4) {
let q = Quat::rotation_yxz(PI - yaw * DEG_TO_RAD, -pitch * DEG_TO_RAD, 0.0);
let mut m = Mat4::IDENTITY;
m.rotation(Quat {
x: -q.x,
y: -q.y,
z: -q.z,
w: q.w,
});
let inverse = m.invert_orthonormal().expect("a rotation is orthonormal");
(m.to_glam(), inverse.to_glam())
}
/// Camera-relative world view used by terrain, sky and the first-person hand.
pub fn view_matrix(yaw: f32, pitch: f32) -> glam::Mat4 {
// Vanilla uses the conjugate of Camera.rotation, not a world-up look-at.
Loaded 100 of 1573 files, more files were not shown because too many files have changed in this diff. Show more