mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85ccb2bd2 |
No files matched your search
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
|
||||
That one line is what makes the next agent find it.
|
||||
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
|
||||
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
|
||||
animation) must be reached from `game/remote*.rs`.
|
||||
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
|
||||
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
|
||||
usually the same bug on the other path.
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -7,4 +7,3 @@ 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)
|
||||
@@ -9,9 +9,7 @@ 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
|
||||
# (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: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel the pull request's unfinished runs
|
||||
env:
|
||||
|
||||
@@ -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
|
||||
+82
-156
@@ -1,7 +1,7 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
|
||||
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
@@ -24,37 +24,19 @@ env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
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.
|
||||
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' }}
|
||||
timeout-minutes: 45
|
||||
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
|
||||
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
|
||||
# verify runs always() and fails unless all three passed.
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v7
|
||||
if: runner.environment == 'github-hosted'
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
@@ -68,32 +50,32 @@ 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
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
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 --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
shared-key: verify-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -102,7 +84,32 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# these 4-core runners it was no faster (#4978).
|
||||
@@ -111,22 +118,29 @@ jobs:
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
|
||||
# recompiling the workspace with a different dependency graph.
|
||||
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
verify:
|
||||
if: always()
|
||||
needs: [python, clippy, tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Did python, clippy and tests pass?
|
||||
env:
|
||||
RESULTS: ${{ join(needs.*.result, ' ') }}
|
||||
run: |
|
||||
echo "python, clippy, tests: $RESULTS"
|
||||
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
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' }}
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
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?
|
||||
@@ -138,16 +152,12 @@ jobs:
|
||||
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust and the headers GPUI links against
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
env:
|
||||
HOSTED: ${{ runner.environment == 'github-hosted' }}
|
||||
run: |
|
||||
if [ "$HOSTED" = true ]; then
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
fi
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher
|
||||
@@ -159,100 +169,16 @@ jobs:
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
|
||||
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
|
||||
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
|
||||
# 90-minute job. The Nightly and launcher releases build and check these binaries on every run.
|
||||
windows-arm64:
|
||||
name: Windows ARM64 game and launcher
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows-11-vs2026-arm
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Use every runner core for Cargo
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
||||
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: windows-arm64-game
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: windows-arm64-launcher
|
||||
- name: Check LLVM assembler used by ring
|
||||
shell: pwsh
|
||||
run: |
|
||||
$llvmBin = Join-Path $env:ProgramFiles 'LLVM\bin'
|
||||
if (-not (Get-Command clang -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $llvmBin 'clang.exe'))) {
|
||||
$env:PATH = "$llvmBin;$env:PATH"
|
||||
"PATH=$env:PATH" >> $env:GITHUB_ENV
|
||||
}
|
||||
$clang = Get-Command clang -ErrorAction Stop
|
||||
Write-Output "Using $($clang.Source)"
|
||||
& $clang.Source --version
|
||||
- name: Build the game for native Windows ARM64
|
||||
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Check the game's PE machine
|
||||
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Check game DLL imports
|
||||
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Start every game executable
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
|
||||
$output = & $client --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
|
||||
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
$path = "target/aarch64-pc-windows-msvc/release/$binary"
|
||||
$output = & $path --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --help failed" }
|
||||
}
|
||||
foreach ($binary in @('horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
$path = "target/aarch64-pc-windows-msvc/release/$binary"
|
||||
$output = & $path --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --version failed" }
|
||||
Write-Output ($output -join "`n")
|
||||
}
|
||||
- name: Test and build the native Windows ARM64 launcher
|
||||
working-directory: launcher
|
||||
run: |
|
||||
cargo test --release --locked --target aarch64-pc-windows-msvc --lib
|
||||
cargo build --release --locked --target aarch64-pc-windows-msvc
|
||||
- name: Check the launcher's PE machine
|
||||
working-directory: launcher
|
||||
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
- name: Check launcher DLL imports and command-line startup
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
|
||||
python ../tools/check_imports.py $launcher
|
||||
$output = & $launcher --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
|
||||
Write-Output ($output -join "`n")
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
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' }}
|
||||
runs-on: 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
|
||||
@@ -287,13 +213,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'
|
||||
@@ -635,11 +561,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'
|
||||
|
||||
@@ -29,14 +29,13 @@ 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' }}
|
||||
runs-on: 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
|
||||
@@ -56,14 +55,13 @@ jobs:
|
||||
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' }}
|
||||
runs-on: 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
|
||||
|
||||
@@ -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 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:
|
||||
@@ -103,18 +99,8 @@ jobs:
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
pe_arch: x64
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: windows-arm64
|
||||
os: windows-11-vs2026-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
dir: horizon-windows-arm64
|
||||
asset: horizon-windows-arm64.zip
|
||||
exe: .exe
|
||||
pe_arch: arm64
|
||||
# The ARM64 runner builds and runs native ARM64 executables; keep the C runtime in the archive.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
@@ -142,7 +128,7 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -164,18 +150,12 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
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
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
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:
|
||||
@@ -205,15 +185,6 @@ jobs:
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- 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'
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -259,14 +230,14 @@ jobs:
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
|
||||
- uses: actions/upload-artifact@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
|
||||
@@ -297,7 +268,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 }}
|
||||
@@ -319,7 +290,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 }}
|
||||
|
||||
@@ -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.
|
||||
@@ -8,16 +8,10 @@ name: launcher
|
||||
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
|
||||
# commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
|
||||
# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
|
||||
# itself instead (launcher/src/backend.rs).
|
||||
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
|
||||
# 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).
|
||||
# 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
|
||||
|
||||
@@ -25,19 +19,16 @@ on:
|
||||
push:
|
||||
branches: [main, launcher, 'launcher-*']
|
||||
paths:
|
||||
# Everything the launcher is built and packaged from, as tools/release_train.py's LAUNCHER_PATHS.
|
||||
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'crates/horizon-friends/**'
|
||||
- 'crates/horizon-mcp/**'
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-port-pack/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
- 'crates/horizon-release-sign/**'
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- 'crates/horizon-theme/**'
|
||||
- 'packaging/linux/**'
|
||||
- 'packaging/icons/**'
|
||||
- '.github/workflows/launcher.yml'
|
||||
schedule:
|
||||
- cron: '7-59/10 * * * *'
|
||||
@@ -74,7 +65,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 +94,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 +117,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 +142,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
|
||||
@@ -185,7 +161,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev patchelf
|
||||
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
|
||||
- name: Metal shaders compiled now, or at run time without the Metal toolchain
|
||||
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
|
||||
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
|
||||
@@ -193,14 +169,11 @@ jobs:
|
||||
run: |
|
||||
if ! xcrun metal --version > /dev/null 2>&1; then
|
||||
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
|
||||
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
# 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 +204,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 +228,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"
|
||||
@@ -268,18 +235,6 @@ jobs:
|
||||
# The client starts: --help prints before any window or GPU is needed.
|
||||
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
|
||||
soft help
|
||||
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
|
||||
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
setup="$RUNNER_TEMP/setup-smoke"
|
||||
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
|
||||
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
|
||||
installed="$setup/Horizon Launcher/horizon-launcher.exe"
|
||||
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
|
||||
"$installed" uninstall --quiet
|
||||
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
|
||||
rm -rf "$setup"
|
||||
fi
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
@@ -299,13 +254,6 @@ jobs:
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
if [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
# The .tar.gz below is what the launcher updates itself from; the AppImage is what a
|
||||
# player downloads and double-clicks, and it replaces itself instead
|
||||
# (packaging/linux/appimage.sh, launcher/src/backend.rs).
|
||||
../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
|
||||
fi
|
||||
mkdir -p dist
|
||||
cp "$BIN/${{ matrix.binary }}" dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
@@ -315,13 +263,7 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
ls -l out
|
||||
- name: Setup exe (Windows)
|
||||
# The launcher's binary under another name: started with no arguments it shows the per-user
|
||||
# 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
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: launcher/out/*
|
||||
@@ -339,12 +281,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 +298,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 +318,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
@@ -386,7 +328,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 +340,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 +354,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/
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
name: mojang republish
|
||||
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
|
||||
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
|
||||
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
|
||||
# gh workflow run "mojang republish" -f dry_run=true
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Only print what it would do with issues
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: mojang-republish
|
||||
cancel-in-progress: false
|
||||
|
||||
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' }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Check the manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
|
||||
@@ -1,59 +0,0 @@
|
||||
name: native hook stamp
|
||||
# Opens a PR that numbers pending fragments after they merge. Only pushes to
|
||||
# main run main's own tool; fragments are read as data, not PR code.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tools/native-hook-fragments/*.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: native-hook-stamp
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
stamp:
|
||||
if: github.repository == 'horizon-rust/horizon'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- name: Open a stamp pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
branch=automation/native-hook-stamp
|
||||
if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
|
||||
echo "A stamp pull request is already open."
|
||||
exit 0
|
||||
fi
|
||||
python3 tools/stamp_native_hooks.py
|
||||
if git diff --quiet; then
|
||||
echo "No fragments to stamp."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git checkout -b "$branch"
|
||||
git add -u
|
||||
git commit -m 'Stamp native hook API fragments'
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
|
||||
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
|
||||
@@ -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,12 +155,12 @@ jobs:
|
||||
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
|
||||
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@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-*
|
||||
@@ -171,20 +171,16 @@ jobs:
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
|
||||
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
|
||||
else echo "::warning::horizon-windows-arm64.zip is missing: SHA256SUMS goes out without it"; fi
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
"${arm64[@]}" \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@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
|
||||
@@ -199,10 +195,10 @@ 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
|
||||
@@ -234,20 +230,12 @@ jobs:
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
|
||||
arm64=()
|
||||
if [[ -f dist/horizon-windows-arm64.zip ]]; then
|
||||
arm64=("dist/horizon-windows-arm64.zip#Game files the launcher installs (Nightly): Windows ARM64 (horizon-windows-arm64.zip)")
|
||||
else
|
||||
echo "::warning::horizon-windows-arm64.zip is missing: the Nightly goes out without it (ARM64 launchers install the x64 build)"
|
||||
fi
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"${arm64[@]}" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
@@ -265,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -19,15 +19,13 @@ 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
|
||||
# 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: 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
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
name: pack load bench
|
||||
# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs
|
||||
# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull
|
||||
# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't
|
||||
# start a workflow its main lacks), against horizon-rust/horizon's main.
|
||||
on:
|
||||
push:
|
||||
branches: ['pack-bench/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base:
|
||||
description: Ref to compare against
|
||||
default: main
|
||||
scale:
|
||||
description: How many times the default pack sizes
|
||||
default: '1'
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
CARGO_TERM_COLOR: never
|
||||
|
||||
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' }}
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
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
|
||||
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
|
||||
env:
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md
|
||||
- name: Bench the base, with the branch's bench
|
||||
env:
|
||||
BASE: ${{ inputs.base || 'main' }}
|
||||
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 \
|
||||
--example pack_load_bench -- --scale "$SCALE") > base.md \
|
||||
|| echo "The bench does not build on the base." > base.md
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md
|
||||
echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -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/
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
# harnesses at a time) up to a few hours, and any pull request's job may land there.
|
||||
timeout-minutes: ${{ inputs.full && 360 || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@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.
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
|
||||
@@ -16,15 +16,13 @@ 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
|
||||
# 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: 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
|
||||
@@ -40,8 +39,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
|
||||
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
@@ -55,11 +53,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -72,23 +68,13 @@ unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
|
||||
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
|
||||
|
||||
## Write tests with signal
|
||||
|
||||
- Test observable behavior, not words in docs, comments or source. Keep checks for machine-consumed contracts.
|
||||
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
|
||||
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
|
||||
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
|
||||
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
|
||||
- Make slow tests faster without removing cases or weakening their coverage.
|
||||
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -104,16 +90,9 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
|
||||
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
|
||||
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
|
||||
([docs/native-mods.md](docs/native-mods.md)).
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
|
||||
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
|
||||
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
@@ -138,17 +117,14 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
|
||||
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
|
||||
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
|
||||
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
|
||||
state stays separate from the library tests. This compiles gameplay twice for executable tests.
|
||||
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
@@ -164,7 +140,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
@@ -173,30 +149,34 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
|
||||
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## Single player runs on a server thread
|
||||
## The two single-player paths
|
||||
|
||||
`game/integrated.rs` runs a second `Game` (`serving`) on a "Server thread" with one level per dimension;
|
||||
the window joins it as a client through memory (`remote`), exactly as a LAN guest or a dedicated server's
|
||||
player does (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
|
||||
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
|
||||
Open `[server-thread]` issues list the gaps left.
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
|
||||
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
|
||||
Test the server thread when you touch player-facing gameplay:
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
|
||||
+34
-40
@@ -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
|
||||
@@ -97,8 +96,7 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
|
||||
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
|
||||
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
|
||||
enough), then claim it. `/unclaim` gives it back. Issues labelled `tracker`, `proposal` or `not claimable` aren't
|
||||
claimed whole: claim one of the issues a tracker lists, or open one for the part you take.
|
||||
enough), then claim it. `/unclaim` gives it back.
|
||||
|
||||
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
|
||||
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
|
||||
@@ -115,17 +113,13 @@ it (edited on every push, never repeated): talk to its author in the thread befo
|
||||
can see.
|
||||
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
|
||||
ourselves instead of sending it back to you; only design questions come back.
|
||||
- **Once we've pushed to your branch, merge main into it; don't rebase or force-push.** A rebase or force-push
|
||||
replaces our commits with yours and erases the fixes we added (it happened on #5188 and #5203). Tell your agent
|
||||
too: `git merge origin/main`, never `git rebase` or `git push --force` on a branch a maintainer has touched. If
|
||||
our fixes get erased anyway, we finish the work in our own pull request that keeps your commits and credits you.
|
||||
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
|
||||
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
|
||||
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
|
||||
`label:parity` is the list of real progress toward vanilla.
|
||||
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
|
||||
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix. `release-now` is for crash
|
||||
fixes that shouldn't wait. pr_ready suggests one.
|
||||
`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
+16
-709
File diff suppressed because it is too large.
Load diff
+1
-16
@@ -1,25 +1,19 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = [
|
||||
"crates/horizon-core",
|
||||
"crates/horizon-dualsense",
|
||||
"crates/horizon-narrator",
|
||||
"crates/horizon-threads",
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-lib",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
"crates/horizon-cosmetics",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-anticheat",
|
||||
"crates/horizon-chat-filter",
|
||||
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
|
||||
"crates/horizon-mcp",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
@@ -31,12 +25,9 @@ members = [
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mod-api-gen",
|
||||
"crates/horizon-mod-api-gen/dispatch-tests",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"xtask",
|
||||
@@ -68,12 +59,6 @@ large_enum_variant = "allow"
|
||||
# Drop this and the folder once a wgpu release has these fixes.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
|
||||
naga = { path = "third_party/naga" }
|
||||
# libmimalloc-sys 0.1.49 whose mimalloc v3 counts bits without POPCNT when MSVC builds it: the
|
||||
# game died before main on CPUs without it (Core 2, #7264; THIRD_PARTY_NOTICES.md). Drop this and
|
||||
# the folder once a libmimalloc-sys release has microsoft/mimalloc#1291's fix.
|
||||
libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
|
||||
|
||||
# Line tables keep file:line in backtraces; full debug info made every
|
||||
# example and test binary hundreds of megabytes and slow to link.
|
||||
|
||||
@@ -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>
|
||||
@@ -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 |
|
||||
| --- | ---: | --- |
|
||||
|
||||
+18
-9
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -634,6 +634,16 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
|
||||
|
||||
</details>
|
||||
|
||||
#### HDR monitors
|
||||
|
||||
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
#### MSAA, anisotropic filtering, exclusive fullscreen
|
||||
|
||||
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
|
||||
@@ -732,13 +742,13 @@ Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayla
|
||||
|
||||
</details>
|
||||
|
||||
#### A Nix flake
|
||||
#### AppImage and a Nix flake
|
||||
|
||||
Install Horizon on NixOS through a Nix flake.
|
||||
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). The launcher's AppImage ships with each launcher release ([packaging/linux/appimage.sh](packaging/linux/appimage.sh)): one file with the libraries GPUI needs, which replaces itself when it updates. A flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA) is still missing, the way horizon-parity's NixOS setup runs them today.
|
||||
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -868,7 +878,7 @@ 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). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
|
||||
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>
|
||||
|
||||
@@ -878,7 +888,7 @@ 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). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
|
||||
`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>
|
||||
|
||||
@@ -894,7 +904,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
|
||||
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
|
||||
+16
-127
@@ -8,26 +8,6 @@ developers, with upstream fixes applied
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## naga
|
||||
|
||||
`third_party/naga` is [naga](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs developers, with
|
||||
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)
|
||||
0.1.49 by Octavian Oncescu, with the [mimalloc](https://github.com/microsoft/mimalloc) v3 sources it
|
||||
bundles (Copyright (c) 2018-2025 Microsoft Corporation, Daan Leijen), both under the MIT license
|
||||
(`LICENSE.txt` and `c_src/mimalloc/v3/LICENSE` in that folder). It holds only the v3 sources. One
|
||||
change: `mi_popcount` in `include/mimalloc/bits.h` uses mimalloc's portable bit count on MSVC
|
||||
x86/x64 instead of the POPCNT instruction, which CPUs before Nehalem lack (#7264).
|
||||
|
||||
## Pixelify Sans
|
||||
|
||||
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
|
||||
@@ -41,42 +21,32 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
|
||||
the same grid.
|
||||
|
||||
## Hugeicons
|
||||
|
||||
`crates/horizon-theme/icons/glass/*.svg` are icons from the free set of
|
||||
[Hugeicons](https://github.com/hugeicons/hugeicons) (`@hugeicons/core-free-icons` 4.4.0), under
|
||||
the MIT license (`crates/horizon-theme/icons/glass/LICENSE-hugeicons.txt`), exported with a 1.6
|
||||
stroke in `currentColor`.
|
||||
|
||||
`crates/horizon-theme/icons/glass/discord.svg` is Discord's logo as drawn by
|
||||
[Simple Icons](https://github.com/simple-icons/simple-icons) 13.21.0 (CC0 1.0). The logo is Discord's
|
||||
trademark, used only on the button that opens Horizon's Discord server.
|
||||
|
||||
## AMD FidelityFX SDK (FSR 3.1 frame generation)
|
||||
|
||||
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
|
||||
ports to WGSL of the optical flow and frame interpolation shaders of the
|
||||
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
|
||||
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
|
||||
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
|
||||
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
|
||||
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
|
||||
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
|
||||
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
|
||||
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
|
||||
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
|
||||
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
|
||||
trademarks; Horizon is not affiliated with AMD.
|
||||
|
||||
## Mod loader logos
|
||||
|
||||
The launcher shows the logo of the mod loader an instance plays with, and offers the logos as
|
||||
instance icons.
|
||||
The launcher shows the logo of the mod loader an instance plays with.
|
||||
|
||||
- `launcher/assets/logos/{fabric,quilt,neoforge,forge}.png` are the loaders' logos as the launcher
|
||||
mockup on [playhorizon.dev/launcher](https://playhorizon.dev/launcher) shows them (horizon-site
|
||||
`public/media/launcher/*.webp`, converted to PNG), each its project's own mark:
|
||||
[Fabric](https://fabricmc.net/), [Quilt](https://quiltmc.org/) (its art is
|
||||
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)),
|
||||
[NeoForged](https://neoforged.net/) (its art, by Ridanisaurus, is
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)) and
|
||||
[Minecraft Forge](https://minecraftforge.net/). They name the loader an instance runs.
|
||||
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
|
||||
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
|
||||
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
|
||||
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
|
||||
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
|
||||
unmodified (the launcher scales it up by whole pixels).
|
||||
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
|
||||
fetches them from their own sites when an instance needs them
|
||||
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
|
||||
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
|
||||
|
||||
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
|
||||
the project endorses Horizon.
|
||||
@@ -89,84 +59,3 @@ with Microsoft accounts
|
||||
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
|
||||
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
|
||||
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
|
||||
|
||||
## CIT Resewn
|
||||
|
||||
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
|
||||
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
|
||||
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
|
||||
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2021 SHsuperCM
|
||||
>
|
||||
> 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.
|
||||
|
||||
## FusionExamples
|
||||
|
||||
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
|
||||
texture templates (`templates/template <layout>.png`) of
|
||||
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2024 Martijn van den Brand
|
||||
>
|
||||
> 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.
|
||||
|
||||
## BlueMap
|
||||
|
||||
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
|
||||
[BlueMap](https://github.com/BlueMap-Minecraft/BlueMap) web map. It follows three of BlueMap's files:
|
||||
`core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java` (`saveTile`) for the
|
||||
tiles' pixel layout, `common/webapp/src/js/util/Utils.js` (`pathFromCoords`) for their paths, and
|
||||
`core/src/main/java/de/bluecolored/bluemap/core/map/MapSettingsSerializer.java` for a map's
|
||||
`settings.json`, at commit
|
||||
[96ae320d1d39](https://github.com/BlueMap-Minecraft/BlueMap/tree/96ae320d1d39581c10058d8222f2290f2fcb5333).
|
||||
BlueMap is licensed under the MIT license:
|
||||
|
||||
> Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
|
||||
> Copyright (c) contributors
|
||||
>
|
||||
> 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.
|
||||
@@ -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,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 |
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,758 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "1bd24e4042"
|
||||
},
|
||||
"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": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 113.978,
|
||||
"window": [
|
||||
1920,
|
||||
1080
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 1524.333,
|
||||
"stdev": 364.142,
|
||||
"min": 1112.127,
|
||||
"max": 1802.295,
|
||||
"ci95": 904.651
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 14001.774,
|
||||
"stdev": 8119.623,
|
||||
"min": 7466.904,
|
||||
"max": 23091.569,
|
||||
"ci95": 20171.889
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8159.696,
|
||||
"stdev": 3897.327,
|
||||
"min": 5313.528,
|
||||
"max": 12601.663,
|
||||
"ci95": 9682.279
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 25431.333,
|
||||
"stdev": 1389.728,
|
||||
"min": 24149.0,
|
||||
"max": 26908.0,
|
||||
"ci95": 3452.555
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 568.391,
|
||||
"stdev": 11.948,
|
||||
"min": 558.517,
|
||||
"max": 581.673,
|
||||
"ci95": 29.684
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 270.644,
|
||||
"stdev": 9.955,
|
||||
"min": 259.156,
|
||||
"max": 276.728,
|
||||
"ci95": 24.732
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 209.951,
|
||||
"stdev": 30.24,
|
||||
"min": 191.819,
|
||||
"max": 244.86,
|
||||
"ci95": 75.126
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8879.707,
|
||||
"stdev": 1758.259,
|
||||
"min": 7205.686,
|
||||
"max": 10711.562,
|
||||
"ci95": 4368.11
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 29366.667,
|
||||
"stdev": 273.35,
|
||||
"min": 29053.0,
|
||||
"max": 29554.0,
|
||||
"ci95": 679.094
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 333.026,
|
||||
"stdev": 1.469,
|
||||
"min": 331.478,
|
||||
"max": 334.4,
|
||||
"ci95": 3.649
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 193.014,
|
||||
"stdev": 11.504,
|
||||
"min": 185.57,
|
||||
"max": 206.264,
|
||||
"ci95": 28.58
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 137.067,
|
||||
"stdev": 28.701,
|
||||
"min": 108.337,
|
||||
"max": 165.738,
|
||||
"ci95": 71.302
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6399.435,
|
||||
"stdev": 2268.592,
|
||||
"min": 4346.111,
|
||||
"max": 8834.786,
|
||||
"ci95": 5635.949
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 25436.0,
|
||||
"stdev": 813.154,
|
||||
"min": 24718.0,
|
||||
"max": 26319.0,
|
||||
"ci95": 2020.149
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 411.986,
|
||||
"stdev": 1.42,
|
||||
"min": 410.981,
|
||||
"max": 413.61,
|
||||
"ci95": 3.527
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 209.307,
|
||||
"stdev": 10.427,
|
||||
"min": 197.391,
|
||||
"max": 216.759,
|
||||
"ci95": 25.905
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 126.88,
|
||||
"stdev": 17.642,
|
||||
"min": 106.534,
|
||||
"max": 137.93,
|
||||
"ci95": 43.829
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14672.976,
|
||||
"stdev": 9832.9,
|
||||
"min": 8230.723,
|
||||
"max": 25990.951,
|
||||
"ci95": 24428.25
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 27715.0,
|
||||
"stdev": 1706.033,
|
||||
"min": 26572.0,
|
||||
"max": 29676.0,
|
||||
"ci95": 4238.362
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 357.149,
|
||||
"stdev": 2.391,
|
||||
"min": 355.009,
|
||||
"max": 359.73,
|
||||
"ci95": 5.941
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 199.658,
|
||||
"stdev": 12.172,
|
||||
"min": 187.504,
|
||||
"max": 211.847,
|
||||
"ci95": 30.238
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 154.465,
|
||||
"stdev": 13.484,
|
||||
"min": 144.923,
|
||||
"max": 169.891,
|
||||
"ci95": 33.498
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1892.0,
|
||||
"stdev": 49.025,
|
||||
"min": 1835.406,
|
||||
"max": 1921.438,
|
||||
"ci95": 121.794
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1616.755,
|
||||
"stdev": 47.256,
|
||||
"min": 1564.734,
|
||||
"max": 1657.031,
|
||||
"ci95": 117.4
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3083.815,
|
||||
"stdev": 25.002,
|
||||
"min": 3062.456,
|
||||
"max": 3111.315,
|
||||
"ci95": 62.113
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2495.271,
|
||||
"stdev": 36.569,
|
||||
"min": 2453.284,
|
||||
"max": 2520.151,
|
||||
"ci95": 90.85
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 6981,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 581.673,
|
||||
"low_1pct_fps": 276.049,
|
||||
"low_01pct_fps": 191.819,
|
||||
"p50_ms": 1.655,
|
||||
"p95_ms": 2.267,
|
||||
"p99_ms": 3.277,
|
||||
"p999_ms": 4.174,
|
||||
"max_ms": 7.418,
|
||||
"cpu_p50_ms": 0.955,
|
||||
"gpu_p50_ms": 1.646,
|
||||
"histogram": [
|
||||
0,
|
||||
6445,
|
||||
353,
|
||||
176,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 6703,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 558.517,
|
||||
"low_1pct_fps": 276.728,
|
||||
"low_01pct_fps": 244.86,
|
||||
"p50_ms": 1.725,
|
||||
"p95_ms": 2.341,
|
||||
"p99_ms": 3.396,
|
||||
"p999_ms": 3.939,
|
||||
"max_ms": 4.185,
|
||||
"cpu_p50_ms": 0.767,
|
||||
"gpu_p50_ms": 1.708,
|
||||
"histogram": [
|
||||
17,
|
||||
6041,
|
||||
442,
|
||||
202,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 6780,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 564.982,
|
||||
"low_1pct_fps": 259.156,
|
||||
"low_01pct_fps": 193.174,
|
||||
"p50_ms": 1.707,
|
||||
"p95_ms": 2.311,
|
||||
"p99_ms": 3.407,
|
||||
"p999_ms": 4.984,
|
||||
"max_ms": 5.322,
|
||||
"cpu_p50_ms": 0.923,
|
||||
"gpu_p50_ms": 1.698,
|
||||
"histogram": [
|
||||
5,
|
||||
6118,
|
||||
480,
|
||||
163,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 3978,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 331.478,
|
||||
"low_1pct_fps": 187.208,
|
||||
"low_01pct_fps": 137.127,
|
||||
"p50_ms": 2.93,
|
||||
"p95_ms": 3.969,
|
||||
"p99_ms": 4.719,
|
||||
"p999_ms": 6.451,
|
||||
"max_ms": 8.835,
|
||||
"cpu_p50_ms": 0.857,
|
||||
"gpu_p50_ms": 2.92,
|
||||
"histogram": [
|
||||
0,
|
||||
29,
|
||||
2279,
|
||||
1541,
|
||||
109,
|
||||
14,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 3999,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 333.199,
|
||||
"low_1pct_fps": 185.57,
|
||||
"low_01pct_fps": 108.337,
|
||||
"p50_ms": 2.921,
|
||||
"p95_ms": 3.862,
|
||||
"p99_ms": 4.679,
|
||||
"p999_ms": 6.817,
|
||||
"max_ms": 11.683,
|
||||
"cpu_p50_ms": 0.828,
|
||||
"gpu_p50_ms": 2.903,
|
||||
"histogram": [
|
||||
3,
|
||||
16,
|
||||
2356,
|
||||
1511,
|
||||
97,
|
||||
12,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 4013,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 334.4,
|
||||
"low_1pct_fps": 206.264,
|
||||
"low_01pct_fps": 165.738,
|
||||
"p50_ms": 2.915,
|
||||
"p95_ms": 3.809,
|
||||
"p99_ms": 4.525,
|
||||
"p999_ms": 5.078,
|
||||
"max_ms": 8.228,
|
||||
"cpu_p50_ms": 0.861,
|
||||
"gpu_p50_ms": 2.899,
|
||||
"histogram": [
|
||||
1,
|
||||
18,
|
||||
2357,
|
||||
1553,
|
||||
77,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 4932,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 410.981,
|
||||
"low_1pct_fps": 216.759,
|
||||
"low_01pct_fps": 137.93,
|
||||
"p50_ms": 2.353,
|
||||
"p95_ms": 3.181,
|
||||
"p99_ms": 4.01,
|
||||
"p999_ms": 5.767,
|
||||
"max_ms": 9.713,
|
||||
"cpu_p50_ms": 0.888,
|
||||
"gpu_p50_ms": 2.343,
|
||||
"histogram": [
|
||||
0,
|
||||
163,
|
||||
4474,
|
||||
264,
|
||||
23,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 4937,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 411.366,
|
||||
"low_1pct_fps": 213.772,
|
||||
"low_01pct_fps": 136.177,
|
||||
"p50_ms": 2.349,
|
||||
"p95_ms": 3.258,
|
||||
"p99_ms": 4.015,
|
||||
"p999_ms": 5.985,
|
||||
"max_ms": 8.539,
|
||||
"cpu_p50_ms": 0.871,
|
||||
"gpu_p50_ms": 2.339,
|
||||
"histogram": [
|
||||
0,
|
||||
202,
|
||||
4406,
|
||||
295,
|
||||
25,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 4963,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 413.61,
|
||||
"low_1pct_fps": 197.391,
|
||||
"low_01pct_fps": 106.534,
|
||||
"p50_ms": 2.335,
|
||||
"p95_ms": 3.214,
|
||||
"p99_ms": 4.111,
|
||||
"p999_ms": 7.65,
|
||||
"max_ms": 11.399,
|
||||
"cpu_p50_ms": 0.878,
|
||||
"gpu_p50_ms": 2.323,
|
||||
"histogram": [
|
||||
2,
|
||||
228,
|
||||
4417,
|
||||
274,
|
||||
28,
|
||||
7,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 4317,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 359.73,
|
||||
"low_1pct_fps": 211.847,
|
||||
"low_01pct_fps": 169.891,
|
||||
"p50_ms": 2.7,
|
||||
"p95_ms": 3.753,
|
||||
"p99_ms": 4.366,
|
||||
"p999_ms": 5.041,
|
||||
"max_ms": 8.726,
|
||||
"cpu_p50_ms": 1.113,
|
||||
"gpu_p50_ms": 2.687,
|
||||
"histogram": [
|
||||
0,
|
||||
68,
|
||||
3445,
|
||||
718,
|
||||
80,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 4281,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 356.707,
|
||||
"low_1pct_fps": 199.624,
|
||||
"low_01pct_fps": 144.923,
|
||||
"p50_ms": 2.712,
|
||||
"p95_ms": 3.806,
|
||||
"p99_ms": 4.535,
|
||||
"p999_ms": 5.453,
|
||||
"max_ms": 8.354,
|
||||
"cpu_p50_ms": 1.111,
|
||||
"gpu_p50_ms": 2.694,
|
||||
"histogram": [
|
||||
0,
|
||||
64,
|
||||
3353,
|
||||
717,
|
||||
138,
|
||||
5,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 4261,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 355.009,
|
||||
"low_1pct_fps": 187.504,
|
||||
"low_01pct_fps": 148.582,
|
||||
"p50_ms": 2.727,
|
||||
"p95_ms": 4.083,
|
||||
"p99_ms": 4.744,
|
||||
"p999_ms": 6.091,
|
||||
"max_ms": 7.442,
|
||||
"cpu_p50_ms": 1.126,
|
||||
"gpu_p50_ms": 2.711,
|
||||
"histogram": [
|
||||
0,
|
||||
155,
|
||||
3198,
|
||||
719,
|
||||
165,
|
||||
17,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-m4pro-1080p"
|
||||
}
|
||||
@@ -1,758 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T23:17:23Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "1bd24e4042"
|
||||
},
|
||||
"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": 28.236,
|
||||
"window": [
|
||||
1920,
|
||||
1080
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 2190.186,
|
||||
"stdev": 1780.667,
|
||||
"min": 906.481,
|
||||
"max": 4223.028,
|
||||
"ci95": 4423.779
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 14254.027,
|
||||
"stdev": 4604.903,
|
||||
"min": 10130.899,
|
||||
"max": 19223.339,
|
||||
"ci95": 11440.138
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6509.117,
|
||||
"stdev": 1616.283,
|
||||
"min": 5457.214,
|
||||
"max": 8370.17,
|
||||
"ci95": 4015.394
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 48522.333,
|
||||
"stdev": 1713.943,
|
||||
"min": 46732.0,
|
||||
"max": 50148.0,
|
||||
"ci95": 4258.014
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 326.473,
|
||||
"stdev": 6.811,
|
||||
"min": 319.588,
|
||||
"max": 333.208,
|
||||
"ci95": 16.921
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 152.497,
|
||||
"stdev": 34.142,
|
||||
"min": 116.575,
|
||||
"max": 184.525,
|
||||
"ci95": 84.82
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 122.567,
|
||||
"stdev": 33.914,
|
||||
"min": 87.235,
|
||||
"max": 154.858,
|
||||
"ci95": 84.254
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 10363.339,
|
||||
"stdev": 3122.178,
|
||||
"min": 6760.012,
|
||||
"max": 12265.121,
|
||||
"ci95": 7756.547
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 82591.667,
|
||||
"stdev": 1489.69,
|
||||
"min": 81053.0,
|
||||
"max": 84027.0,
|
||||
"ci95": 3700.895
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 165.414,
|
||||
"stdev": 4.368,
|
||||
"min": 162.757,
|
||||
"max": 170.455,
|
||||
"ci95": 10.851
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 103.992,
|
||||
"stdev": 20.41,
|
||||
"min": 83.527,
|
||||
"max": 124.346,
|
||||
"ci95": 50.705
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 94.785,
|
||||
"stdev": 24.344,
|
||||
"min": 69.796,
|
||||
"max": 118.428,
|
||||
"ci95": 60.478
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 10450.642,
|
||||
"stdev": 2599.832,
|
||||
"min": 7510.454,
|
||||
"max": 12445.71,
|
||||
"ci95": 6458.863
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 76405.667,
|
||||
"stdev": 649.936,
|
||||
"min": 75681.0,
|
||||
"max": 76937.0,
|
||||
"ci95": 1614.662
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 210.179,
|
||||
"stdev": 5.279,
|
||||
"min": 205.559,
|
||||
"max": 215.932,
|
||||
"ci95": 13.114
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 128.777,
|
||||
"stdev": 30.982,
|
||||
"min": 93.017,
|
||||
"max": 147.571,
|
||||
"ci95": 76.97
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 115.916,
|
||||
"stdev": 39.398,
|
||||
"min": 70.443,
|
||||
"max": 139.838,
|
||||
"ci95": 97.879
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17168.619,
|
||||
"stdev": 3613.606,
|
||||
"min": 13749.902,
|
||||
"max": 20949.796,
|
||||
"ci95": 8977.42
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 91622.667,
|
||||
"stdev": 482.189,
|
||||
"min": 91089.0,
|
||||
"max": 92027.0,
|
||||
"ci95": 1197.921
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 190.088,
|
||||
"stdev": 2.175,
|
||||
"min": 187.78,
|
||||
"max": 192.099,
|
||||
"ci95": 5.403
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 108.92,
|
||||
"stdev": 14.244,
|
||||
"min": 92.641,
|
||||
"max": 119.094,
|
||||
"ci95": 35.388
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 91.132,
|
||||
"stdev": 12.82,
|
||||
"min": 76.343,
|
||||
"max": 99.09,
|
||||
"ci95": 31.849
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2536.536,
|
||||
"stdev": 40.31,
|
||||
"min": 2506.281,
|
||||
"max": 2582.297,
|
||||
"ci95": 100.144
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2195.922,
|
||||
"stdev": 37.757,
|
||||
"min": 2167.078,
|
||||
"max": 2238.656,
|
||||
"ci95": 93.8
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 5559.837,
|
||||
"stdev": 47.18,
|
||||
"min": 5505.457,
|
||||
"max": 5589.863,
|
||||
"ci95": 117.211
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4373.034,
|
||||
"stdev": 82.743,
|
||||
"min": 4311.909,
|
||||
"max": 4467.191,
|
||||
"ci95": 205.561
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 3920,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 326.623,
|
||||
"low_1pct_fps": 116.575,
|
||||
"low_01pct_fps": 87.235,
|
||||
"p50_ms": 2.938,
|
||||
"p95_ms": 4.356,
|
||||
"p99_ms": 7.397,
|
||||
"p999_ms": 9.497,
|
||||
"max_ms": 13.274,
|
||||
"cpu_p50_ms": 1.611,
|
||||
"gpu_p50_ms": 2.871,
|
||||
"histogram": [
|
||||
0,
|
||||
115,
|
||||
2297,
|
||||
1271,
|
||||
149,
|
||||
33,
|
||||
10,
|
||||
24,
|
||||
18,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 3835,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 319.588,
|
||||
"low_1pct_fps": 156.39,
|
||||
"low_01pct_fps": 125.608,
|
||||
"p50_ms": 3.04,
|
||||
"p95_ms": 4.322,
|
||||
"p99_ms": 5.265,
|
||||
"p999_ms": 7.732,
|
||||
"max_ms": 8.183,
|
||||
"cpu_p50_ms": 1.733,
|
||||
"gpu_p50_ms": 2.936,
|
||||
"histogram": [
|
||||
0,
|
||||
1,
|
||||
1610,
|
||||
2006,
|
||||
142,
|
||||
51,
|
||||
15,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 3999,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 333.208,
|
||||
"low_1pct_fps": 184.525,
|
||||
"low_01pct_fps": 154.858,
|
||||
"p50_ms": 2.9,
|
||||
"p95_ms": 4.01,
|
||||
"p99_ms": 4.955,
|
||||
"p999_ms": 6.193,
|
||||
"max_ms": 6.805,
|
||||
"cpu_p50_ms": 1.51,
|
||||
"gpu_p50_ms": 2.788,
|
||||
"histogram": [
|
||||
0,
|
||||
8,
|
||||
2499,
|
||||
1328,
|
||||
132,
|
||||
25,
|
||||
7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 1957,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 163.03,
|
||||
"low_1pct_fps": 83.527,
|
||||
"low_01pct_fps": 69.796,
|
||||
"p50_ms": 6.011,
|
||||
"p95_ms": 8.032,
|
||||
"p99_ms": 10.903,
|
||||
"p999_ms": 14.322,
|
||||
"max_ms": 14.333,
|
||||
"cpu_p50_ms": 1.555,
|
||||
"gpu_p50_ms": 5.857,
|
||||
"histogram": [
|
||||
0,
|
||||
5,
|
||||
26,
|
||||
16,
|
||||
36,
|
||||
883,
|
||||
739,
|
||||
194,
|
||||
29,
|
||||
22,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1954,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 162.757,
|
||||
"low_1pct_fps": 104.102,
|
||||
"low_01pct_fps": 96.13,
|
||||
"p50_ms": 5.98,
|
||||
"p95_ms": 7.665,
|
||||
"p99_ms": 9.049,
|
||||
"p999_ms": 10.323,
|
||||
"max_ms": 10.482,
|
||||
"cpu_p50_ms": 1.584,
|
||||
"gpu_p50_ms": 5.715,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
9,
|
||||
1028,
|
||||
737,
|
||||
130,
|
||||
46,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 2046,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 170.455,
|
||||
"low_1pct_fps": 124.346,
|
||||
"low_01pct_fps": 118.428,
|
||||
"p50_ms": 5.719,
|
||||
"p95_ms": 7.342,
|
||||
"p99_ms": 7.815,
|
||||
"p999_ms": 8.38,
|
||||
"max_ms": 8.506,
|
||||
"cpu_p50_ms": 1.384,
|
||||
"gpu_p50_ms": 5.572,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
26,
|
||||
1452,
|
||||
396,
|
||||
169,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 2467,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 205.559,
|
||||
"low_1pct_fps": 93.017,
|
||||
"low_01pct_fps": 70.443,
|
||||
"p50_ms": 4.708,
|
||||
"p95_ms": 6.6,
|
||||
"p99_ms": 9.432,
|
||||
"p999_ms": 13.566,
|
||||
"max_ms": 15.228,
|
||||
"cpu_p50_ms": 1.664,
|
||||
"gpu_p50_ms": 4.483,
|
||||
"histogram": [
|
||||
0,
|
||||
18,
|
||||
55,
|
||||
87,
|
||||
1811,
|
||||
290,
|
||||
143,
|
||||
27,
|
||||
20,
|
||||
13,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 2509,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 209.045,
|
||||
"low_1pct_fps": 145.742,
|
||||
"low_01pct_fps": 139.838,
|
||||
"p50_ms": 4.642,
|
||||
"p95_ms": 6.15,
|
||||
"p99_ms": 6.72,
|
||||
"p999_ms": 7.081,
|
||||
"max_ms": 7.254,
|
||||
"cpu_p50_ms": 1.74,
|
||||
"gpu_p50_ms": 4.376,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
78,
|
||||
2008,
|
||||
277,
|
||||
142,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 2591,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 215.932,
|
||||
"low_1pct_fps": 147.571,
|
||||
"low_01pct_fps": 137.466,
|
||||
"p50_ms": 4.466,
|
||||
"p95_ms": 5.947,
|
||||
"p99_ms": 6.512,
|
||||
"p999_ms": 7.262,
|
||||
"max_ms": 7.283,
|
||||
"cpu_p50_ms": 1.422,
|
||||
"gpu_p50_ms": 4.286,
|
||||
"histogram": [
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
111,
|
||||
2114,
|
||||
248,
|
||||
111,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 2285,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 190.386,
|
||||
"low_1pct_fps": 92.641,
|
||||
"low_01pct_fps": 76.343,
|
||||
"p50_ms": 5.038,
|
||||
"p95_ms": 7.005,
|
||||
"p99_ms": 9.383,
|
||||
"p999_ms": 11.731,
|
||||
"max_ms": 15.266,
|
||||
"cpu_p50_ms": 2.239,
|
||||
"gpu_p50_ms": 4.915,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
54,
|
||||
45,
|
||||
916,
|
||||
985,
|
||||
168,
|
||||
78,
|
||||
22,
|
||||
15,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 2306,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 192.099,
|
||||
"low_1pct_fps": 119.094,
|
||||
"low_01pct_fps": 99.09,
|
||||
"p50_ms": 5.017,
|
||||
"p95_ms": 6.58,
|
||||
"p99_ms": 7.586,
|
||||
"p999_ms": 9.195,
|
||||
"max_ms": 11.428,
|
||||
"cpu_p50_ms": 2.261,
|
||||
"gpu_p50_ms": 4.823,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
12,
|
||||
1070,
|
||||
1003,
|
||||
165,
|
||||
43,
|
||||
9,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 2254,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 187.78,
|
||||
"low_1pct_fps": 115.026,
|
||||
"low_01pct_fps": 97.962,
|
||||
"p50_ms": 5.175,
|
||||
"p95_ms": 6.791,
|
||||
"p99_ms": 7.916,
|
||||
"p999_ms": 9.284,
|
||||
"max_ms": 11.986,
|
||||
"cpu_p50_ms": 2.158,
|
||||
"gpu_p50_ms": 4.942,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
17,
|
||||
774,
|
||||
1199,
|
||||
182,
|
||||
69,
|
||||
12,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-m4pro-1080p"
|
||||
}
|
||||
@@ -1,758 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "1bd24e4042"
|
||||
},
|
||||
"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": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 73.292,
|
||||
"window": [
|
||||
1920,
|
||||
1080
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7204.239,
|
||||
"stdev": 1586.64,
|
||||
"min": 5409.683,
|
||||
"max": 8421.051,
|
||||
"ci95": 3941.749
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 11407.51,
|
||||
"stdev": 2085.692,
|
||||
"min": 9027.004,
|
||||
"max": 12913.993,
|
||||
"ci95": 5181.563
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5116.146,
|
||||
"stdev": 744.611,
|
||||
"min": 4548.096,
|
||||
"max": 5959.13,
|
||||
"ci95": 1849.865
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7220.0,
|
||||
"stdev": 137.292,
|
||||
"min": 7092.0,
|
||||
"max": 7365.0,
|
||||
"ci95": 341.079
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 540.213,
|
||||
"stdev": 182.263,
|
||||
"min": 432.911,
|
||||
"max": 750.659,
|
||||
"ci95": 452.803
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 195.561,
|
||||
"stdev": 75.888,
|
||||
"min": 141.859,
|
||||
"max": 282.38,
|
||||
"ci95": 188.533
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 134.559,
|
||||
"stdev": 74.125,
|
||||
"min": 87.663,
|
||||
"max": 220.016,
|
||||
"ci95": 184.153
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 7185.67,
|
||||
"stdev": 4093.192,
|
||||
"min": 4323.79,
|
||||
"max": 11874.133,
|
||||
"ci95": 10168.874
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 8302.667,
|
||||
"stdev": 538.894,
|
||||
"min": 7853.0,
|
||||
"max": 8900.0,
|
||||
"ci95": 1338.794
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 364.068,
|
||||
"stdev": 8.947,
|
||||
"min": 355.173,
|
||||
"max": 373.067,
|
||||
"ci95": 22.229
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 145.064,
|
||||
"stdev": 21.824,
|
||||
"min": 121.995,
|
||||
"max": 165.382,
|
||||
"ci95": 54.218
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 97.299,
|
||||
"stdev": 15.364,
|
||||
"min": 81.075,
|
||||
"max": 111.627,
|
||||
"ci95": 38.17
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4944.497,
|
||||
"stdev": 1598.757,
|
||||
"min": 3816.976,
|
||||
"max": 6774.175,
|
||||
"ci95": 3971.854
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7041.0,
|
||||
"stdev": 276.949,
|
||||
"min": 6821.0,
|
||||
"max": 7352.0,
|
||||
"ci95": 688.036
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 410.281,
|
||||
"stdev": 35.314,
|
||||
"min": 370.881,
|
||||
"max": 439.08,
|
||||
"ci95": 87.731
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 161.057,
|
||||
"stdev": 15.769,
|
||||
"min": 144.003,
|
||||
"max": 175.109,
|
||||
"ci95": 39.175
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 111.852,
|
||||
"stdev": 16.415,
|
||||
"min": 93.045,
|
||||
"max": 123.299,
|
||||
"ci95": 40.78
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8844.497,
|
||||
"stdev": 4291.678,
|
||||
"min": 5269.587,
|
||||
"max": 13604.075,
|
||||
"ci95": 10661.98
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7381.0,
|
||||
"stdev": 88.606,
|
||||
"min": 7290.0,
|
||||
"max": 7467.0,
|
||||
"ci95": 220.127
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 466.294,
|
||||
"stdev": 2.738,
|
||||
"min": 464.707,
|
||||
"max": 469.455,
|
||||
"ci95": 6.801
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.71,
|
||||
"stdev": 21.73,
|
||||
"min": 141.437,
|
||||
"max": 184.468,
|
||||
"ci95": 53.984
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 90.643,
|
||||
"stdev": 14.864,
|
||||
"min": 76.851,
|
||||
"max": 106.387,
|
||||
"ci95": 36.928
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1536.969,
|
||||
"stdev": 19.205,
|
||||
"min": 1516.188,
|
||||
"max": 1554.063,
|
||||
"ci95": 47.711
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1379.422,
|
||||
"stdev": 9.712,
|
||||
"min": 1371.438,
|
||||
"max": 1390.234,
|
||||
"ci95": 24.129
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2093.701,
|
||||
"stdev": 13.714,
|
||||
"min": 2078.862,
|
||||
"max": 2105.909,
|
||||
"ci95": 34.07
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1883.495,
|
||||
"stdev": 10.346,
|
||||
"min": 1871.987,
|
||||
"max": 1892.026,
|
||||
"ci95": 25.702
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 9008,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 750.659,
|
||||
"low_1pct_fps": 282.38,
|
||||
"low_01pct_fps": 220.016,
|
||||
"p50_ms": 1.047,
|
||||
"p95_ms": 2.169,
|
||||
"p99_ms": 3.163,
|
||||
"p999_ms": 4.077,
|
||||
"max_ms": 5.462,
|
||||
"cpu_p50_ms": 0.447,
|
||||
"gpu_p50_ms": 1.021,
|
||||
"histogram": [
|
||||
3979,
|
||||
3877,
|
||||
1033,
|
||||
112,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 5245,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 437.07,
|
||||
"low_1pct_fps": 141.859,
|
||||
"low_01pct_fps": 87.663,
|
||||
"p50_ms": 1.822,
|
||||
"p95_ms": 4.165,
|
||||
"p99_ms": 5.399,
|
||||
"p999_ms": 10.052,
|
||||
"max_ms": 12.712,
|
||||
"cpu_p50_ms": 0.652,
|
||||
"gpu_p50_ms": 1.943,
|
||||
"histogram": [
|
||||
831,
|
||||
1968,
|
||||
606,
|
||||
1582,
|
||||
154,
|
||||
82,
|
||||
3,
|
||||
3,
|
||||
10,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 5195,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 432.911,
|
||||
"low_1pct_fps": 162.445,
|
||||
"low_01pct_fps": 95.997,
|
||||
"p50_ms": 1.806,
|
||||
"p95_ms": 4.202,
|
||||
"p99_ms": 5.259,
|
||||
"p999_ms": 7.591,
|
||||
"max_ms": 17.328,
|
||||
"cpu_p50_ms": 0.636,
|
||||
"gpu_p50_ms": 1.876,
|
||||
"histogram": [
|
||||
297,
|
||||
2502,
|
||||
650,
|
||||
1469,
|
||||
192,
|
||||
73,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 4262,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 355.173,
|
||||
"low_1pct_fps": 165.382,
|
||||
"low_01pct_fps": 111.627,
|
||||
"p50_ms": 2.917,
|
||||
"p95_ms": 3.854,
|
||||
"p99_ms": 5.022,
|
||||
"p999_ms": 7.808,
|
||||
"max_ms": 9.769,
|
||||
"cpu_p50_ms": 0.59,
|
||||
"gpu_p50_ms": 2.91,
|
||||
"histogram": [
|
||||
98,
|
||||
416,
|
||||
2506,
|
||||
1080,
|
||||
118,
|
||||
29,
|
||||
10,
|
||||
1,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 4477,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 373.067,
|
||||
"low_1pct_fps": 121.995,
|
||||
"low_01pct_fps": 81.075,
|
||||
"p50_ms": 2.243,
|
||||
"p95_ms": 4.687,
|
||||
"p99_ms": 5.835,
|
||||
"p999_ms": 11.831,
|
||||
"max_ms": 13.367,
|
||||
"cpu_p50_ms": 0.541,
|
||||
"gpu_p50_ms": 2.346,
|
||||
"histogram": [
|
||||
183,
|
||||
1975,
|
||||
392,
|
||||
887,
|
||||
903,
|
||||
99,
|
||||
12,
|
||||
6,
|
||||
11,
|
||||
6,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 4368,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 363.964,
|
||||
"low_1pct_fps": 147.816,
|
||||
"low_01pct_fps": 99.196,
|
||||
"p50_ms": 2.264,
|
||||
"p95_ms": 4.763,
|
||||
"p99_ms": 5.86,
|
||||
"p999_ms": 7.641,
|
||||
"max_ms": 11.427,
|
||||
"cpu_p50_ms": 0.562,
|
||||
"gpu_p50_ms": 2.203,
|
||||
"histogram": [
|
||||
73,
|
||||
1978,
|
||||
472,
|
||||
695,
|
||||
1004,
|
||||
104,
|
||||
35,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 4451,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 370.881,
|
||||
"low_1pct_fps": 175.109,
|
||||
"low_01pct_fps": 119.212,
|
||||
"p50_ms": 2.858,
|
||||
"p95_ms": 4.279,
|
||||
"p99_ms": 5.045,
|
||||
"p999_ms": 6.057,
|
||||
"max_ms": 10.866,
|
||||
"cpu_p50_ms": 0.609,
|
||||
"gpu_p50_ms": 2.922,
|
||||
"histogram": [
|
||||
193,
|
||||
1245,
|
||||
964,
|
||||
1732,
|
||||
270,
|
||||
42,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 5269,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 439.08,
|
||||
"low_1pct_fps": 144.003,
|
||||
"low_01pct_fps": 93.045,
|
||||
"p50_ms": 1.767,
|
||||
"p95_ms": 4.188,
|
||||
"p99_ms": 5.191,
|
||||
"p999_ms": 10.375,
|
||||
"max_ms": 11.271,
|
||||
"cpu_p50_ms": 0.545,
|
||||
"gpu_p50_ms": 1.815,
|
||||
"histogram": [
|
||||
1239,
|
||||
1550,
|
||||
644,
|
||||
1555,
|
||||
201,
|
||||
59,
|
||||
1,
|
||||
4,
|
||||
10,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 5051,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 420.881,
|
||||
"low_1pct_fps": 164.058,
|
||||
"low_01pct_fps": 123.299,
|
||||
"p50_ms": 1.872,
|
||||
"p95_ms": 4.455,
|
||||
"p99_ms": 5.38,
|
||||
"p999_ms": 6.923,
|
||||
"max_ms": 11.189,
|
||||
"cpu_p50_ms": 0.548,
|
||||
"gpu_p50_ms": 1.874,
|
||||
"histogram": [
|
||||
519,
|
||||
2114,
|
||||
673,
|
||||
1086,
|
||||
577,
|
||||
65,
|
||||
13,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 5635,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 469.455,
|
||||
"low_1pct_fps": 184.468,
|
||||
"low_01pct_fps": 106.387,
|
||||
"p50_ms": 1.626,
|
||||
"p95_ms": 3.874,
|
||||
"p99_ms": 4.614,
|
||||
"p999_ms": 5.913,
|
||||
"max_ms": 18.185,
|
||||
"cpu_p50_ms": 0.564,
|
||||
"gpu_p50_ms": 1.604,
|
||||
"histogram": [
|
||||
1491,
|
||||
1599,
|
||||
704,
|
||||
1688,
|
||||
128,
|
||||
20,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 5578,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 464.72,
|
||||
"low_1pct_fps": 141.437,
|
||||
"low_01pct_fps": 76.851,
|
||||
"p50_ms": 1.587,
|
||||
"p95_ms": 4.127,
|
||||
"p99_ms": 5.085,
|
||||
"p999_ms": 10.138,
|
||||
"max_ms": 21.644,
|
||||
"cpu_p50_ms": 0.588,
|
||||
"gpu_p50_ms": 1.625,
|
||||
"histogram": [
|
||||
1321,
|
||||
1798,
|
||||
770,
|
||||
1453,
|
||||
173,
|
||||
41,
|
||||
2,
|
||||
5,
|
||||
7,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 5576,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 464.707,
|
||||
"low_1pct_fps": 168.226,
|
||||
"low_01pct_fps": 88.692,
|
||||
"p50_ms": 1.627,
|
||||
"p95_ms": 4.154,
|
||||
"p99_ms": 4.908,
|
||||
"p999_ms": 9.555,
|
||||
"max_ms": 14.784,
|
||||
"cpu_p50_ms": 0.595,
|
||||
"gpu_p50_ms": 1.64,
|
||||
"histogram": [
|
||||
1054,
|
||||
2049,
|
||||
837,
|
||||
1377,
|
||||
217,
|
||||
32,
|
||||
3,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-m4pro-1080p"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# Horizon main vs vanilla 26.3 on a loaded Mac mini M4 Pro (2026-11-01)
|
||||
|
||||
`tools/perf_bench.py` reports for Horizon main 1bd24e4042, vanilla 26.3 and vanilla with performance mods, render distances
|
||||
8, 16 and 32, three games each, every game from a fresh world. **These are not player numbers:** the Mac mini was running other
|
||||
work the whole time (1-minute load average 15-114 on 14 cores), and every game rendered without presenting. They are kept because
|
||||
they're the first Mac reports and show how each game behaves under heavy CPU contention. The reports are the
|
||||
`*-m4pro-1080p-rd*.json` files next to this page.
|
||||
|
||||
## Setup
|
||||
|
||||
- **Machine:** Mac mini, Apple M4 Pro (10 performance + 4 efficiency cores, 20-core GPU), 64 GB, macOS 27.0.1. Screen
|
||||
1920x1080; the session was locked, so every game ran `offscreen` (Horizon renders into a texture; vanilla draws into a
|
||||
window nobody sees).
|
||||
- **Horizon:** main 1bd24e4042, `cargo build --release -p horizon-client`, Metal.
|
||||
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25 (Temurin), `-Xmx2G` with the launcher's G1
|
||||
flags, OpenGL 4.1 (Apple's Metal-backed driver).
|
||||
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0, ImmediatelyFast 1.17.1 and
|
||||
Fabric API (`tools/benchmark-deps/mods.lock.json`).
|
||||
- **Order:** the games took turns inside each repeat (vanilla, mods, Horizon; then mods, Horizon, vanilla; then Horizon, vanilla,
|
||||
mods), one game at a time.
|
||||
|
||||
## Why these numbers aren't a fair comparison
|
||||
|
||||
- **Load.** Load before each game: RD 8 46-82, RD 16 43-114, RD 32 15-59 (the later RD 32 games ran at 15-30). Horizon raises its
|
||||
render and server threads to the user-interactive QoS class, and the JVM's threads stay at the default, so under this
|
||||
contention macOS runs Horizon's frame first. Vanilla's 1% lows of 2-15 FPS show that, more than how fast either game is.
|
||||
- **Resolution.** Vanilla's 1920x1080 window was shrunk to **1920x930** by the 1080p screen (menu and title bars); Horizon,
|
||||
offscreen, rendered 1920x1080. Vanilla drew 14% fewer pixels, which favours it in GPU-bound scenes. Because the recorded
|
||||
windows differ, `perf_bench.py --table` refuses to compare these reports. The tables below were made with the window check
|
||||
skipped. A rerun at a size that fits the screen (1600x900) would avoid it.
|
||||
|
||||
## Results
|
||||
|
||||
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight (average / 1% low / 0.1% low):
|
||||
|
||||
| RD | scene | Horizon main | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 8 | forest | 364 ±22 / 145 ±54 / 97 ±38 | 66 ±22 / 8 ±10 / 6 ±8 | 47 ±42 / 4 ±2 / 3 ±1 |
|
||||
| 8 | mountain | 466 ±7 / 165 ±54 / 91 ±37 | 46 ±37 / 5 ±7 / 4 ±6 | 67 ±35 / 8 ±5 / 4 ±4 |
|
||||
| 8 | ocean | 410 ±88 / 161 ±39 / 112 ±41 | 50 ±44 / 6 ±13 / 5 ±11 | 35 ±16 / 5 ±4 / 4 ±4 |
|
||||
| 8 | village | 540 ±453 / 196 ±189 / 135 ±184 | 42 ±16 / 4 ±3 / 3 ±0 | 33 ±46 / 4 ±6 / 3 ±4 |
|
||||
| 16 | forest | 333 ±4 / 193 ±29 / 137 ±71 | 23 ±9 / 2 ±1 / 2 ±1 | 62 ±94 / 10 ±21 / 7 ±14 |
|
||||
| 16 | mountain | 357 ±6 / 200 ±30 / 154 ±33 | 44 ±12 / 7 ±2 / 4 ±1 | 36 ±44 / 4 ±5 / 3 ±5 |
|
||||
| 16 | ocean | 412 ±4 / 209 ±26 / 127 ±44 | 51 ±16 / 6 ±8 / 4 ±7 | 75 ±159 / 13 ±33 / 7 ±16 |
|
||||
| 16 | village | 568 ±30 / 271 ±25 / 210 ±75 | 46 ±110 / 9 ±27 / 7 ±17 | 46 ±45 / 7 ±9 / 5 ±4 |
|
||||
| 32 | forest | 165 ±11 / 104 ±51 / 95 ±60 | 68 ±122 / 18 ±49 / 14 ±42 | 114 ±132 / 29 ±44 / 21 ±34 |
|
||||
| 32 | mountain | 190 ±5 / 109 ±35 / 91 ±32 | 29 ±19 / 3 ±2 / 2 ±3 | 57 ±49 / 7 ±2 / 3 ±3 |
|
||||
| 32 | ocean | 210 ±13 / 129 ±77 / 116 ±98 | 44 ±80 / 5 ±4 / 2 ±1 | 119 ±176 / 32 ±66 / 17 ±37 |
|
||||
| 32 | village | 326 ±17 / 152 ±85 / 123 ±84 | 81 ±99 / 15 ±20 / 11 ±11 | 92 ±28 / 13 ±6 / 9 ±3 |
|
||||
|
||||
Start to title / in-world, mean scene load, RSS steady / peak, physical footprint steady / peak (macOS `footprint`, which on Apple
|
||||
Silicon includes the game's GPU allocations), load average before the first game:
|
||||
|
||||
| RD | Horizon main | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 | 7.2 / 11.4, 6.5, 1379 / 1537, 1883 / 2094, 73 | 49.9 / 89.1, 18.2, 1811 / 1877, 2635 / 2709, 66 | 56.3 / 97.7, 17.9, 1927 / 2019, 2396 / 2441, 74 |
|
||||
| 16 | 1.5 / 14.0, 9.5, 1617 / 1892, 2495 / 3084, 114 | 19.4 / 78.9, 33.3, 2431 / 2555, 3450 / 3713, 76 | 23.8 / 76.9, 27.1, 2355 / 2566, 3212 / 3431, 70 |
|
||||
| 32 | 2.2 / 14.3, 11.1, 2196 / 2537, 4373 / 5560, 28 | 20.6 / 124.2, 44.2, 2838 / 2958, 4265 / 4618, 59 | 13.5 / 63.2, 29.9, 3705 / 4078, 4647 / 5250, 37 |
|
||||
|
||||
## What it shows
|
||||
|
||||
- Horizon is GPU-bound on the M4 Pro in every scene at every distance: its median GPU time per frame is 1.6-2.5 ms at RD 8 and
|
||||
2.9-5.7 ms at RD 32, its render thread's CPU time 0.6-2.2 ms.
|
||||
- In the least-loaded RD 32 games (load 15-22), vanilla + mods matched Horizon's average FPS over the forest (162 vs 163-170) and
|
||||
the ocean (187 vs 209-216); Horizon's 1% lows were 2.4 times the modded client's there, and 15-17 times over the village and the
|
||||
mountain.
|
||||
- Horizon's resident memory is 1.2-1.7 times smaller than both clients'. Its physical footprint, GPU memory included, is level
|
||||
with vanilla's at RD 32 (4373 vs 4265 MB steady) and above it at peak (5560 vs 4618 MB).
|
||||
@@ -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/`.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"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": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 75.854,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 19419.646,
|
||||
"stdev": 10653.755,
|
||||
"min": 8240.178,
|
||||
"max": 29455.403,
|
||||
"ci95": 26467.531
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 78944.646,
|
||||
"stdev": 36737.011,
|
||||
"min": 51495.357,
|
||||
"max": 120678.403,
|
||||
"ci95": 91267.16
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 21947.683,
|
||||
"stdev": 10432.774,
|
||||
"min": 10427.451,
|
||||
"max": 30758.368,
|
||||
"ci95": 25918.54
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 45.524,
|
||||
"stdev": 44.352,
|
||||
"min": 15.749,
|
||||
"max": 96.497,
|
||||
"ci95": 110.185
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.141,
|
||||
"stdev": 10.812,
|
||||
"min": 1.86,
|
||||
"max": 21.565,
|
||||
"ci95": 26.862
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.727,
|
||||
"stdev": 6.902,
|
||||
"min": 1.785,
|
||||
"max": 14.612,
|
||||
"ci95": 17.146
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 39062.839,
|
||||
"stdev": 21806.234,
|
||||
"min": 15037.908,
|
||||
"max": 57603.235,
|
||||
"ci95": 54174.061
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 23.347,
|
||||
"stdev": 3.693,
|
||||
"min": 20.154,
|
||||
"max": 27.392,
|
||||
"ci95": 9.175
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.419,
|
||||
"stdev": 0.269,
|
||||
"min": 2.196,
|
||||
"max": 2.718,
|
||||
"ci95": 0.669
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1.972,
|
||||
"stdev": 0.438,
|
||||
"min": 1.551,
|
||||
"max": 2.425,
|
||||
"ci95": 1.088
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 22097.713,
|
||||
"stdev": 4463.742,
|
||||
"min": 18053.319,
|
||||
"max": 26887.066,
|
||||
"ci95": 11089.445
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 51.279,
|
||||
"stdev": 6.309,
|
||||
"min": 44.786,
|
||||
"max": 57.386,
|
||||
"ci95": 15.673
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.15,
|
||||
"stdev": 3.128,
|
||||
"min": 3.444,
|
||||
"max": 9.574,
|
||||
"ci95": 7.77
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.161,
|
||||
"stdev": 2.739,
|
||||
"min": 2.487,
|
||||
"max": 7.321,
|
||||
"ci95": 6.803
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 50257.39,
|
||||
"stdev": 34798.619,
|
||||
"min": 25919.598,
|
||||
"max": 90115.595,
|
||||
"ci95": 86451.54
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 44.346,
|
||||
"stdev": 4.882,
|
||||
"min": 39.246,
|
||||
"max": 48.975,
|
||||
"ci95": 12.128
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.637,
|
||||
"stdev": 0.69,
|
||||
"min": 5.888,
|
||||
"max": 7.246,
|
||||
"ci95": 1.714
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.444,
|
||||
"stdev": 0.6,
|
||||
"min": 3.937,
|
||||
"max": 5.107,
|
||||
"ci95": 1.491
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2554.552,
|
||||
"stdev": 218.163,
|
||||
"min": 2328.859,
|
||||
"max": 2764.313,
|
||||
"ci95": 541.991
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2430.854,
|
||||
"stdev": 230.654,
|
||||
"min": 2186.328,
|
||||
"max": 2644.531,
|
||||
"ci95": 573.023
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3713.311,
|
||||
"stdev": 191.61,
|
||||
"min": 3512.331,
|
||||
"max": 3893.925,
|
||||
"ci95": 476.025
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3450.061,
|
||||
"stdev": 76.233,
|
||||
"min": 3370.253,
|
||||
"max": 3522.128,
|
||||
"ci95": 189.388
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 189,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 15.749,
|
||||
"low_1pct_fps": 1.86,
|
||||
"low_01pct_fps": 1.785,
|
||||
"p50_ms": 22.219,
|
||||
"p95_ms": 254.854,
|
||||
"p99_ms": 515.134,
|
||||
"p999_ms": 560.079,
|
||||
"max_ms": 560.079,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
7,
|
||||
9,
|
||||
10,
|
||||
16,
|
||||
12,
|
||||
11,
|
||||
14,
|
||||
16,
|
||||
22,
|
||||
9,
|
||||
6,
|
||||
5,
|
||||
11,
|
||||
10,
|
||||
15,
|
||||
9,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 292,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 24.325,
|
||||
"low_1pct_fps": 3.998,
|
||||
"low_01pct_fps": 3.783,
|
||||
"p50_ms": 15.477,
|
||||
"p95_ms": 165.164,
|
||||
"p99_ms": 242.898,
|
||||
"p999_ms": 264.363,
|
||||
"max_ms": 264.363,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
26,
|
||||
20,
|
||||
32,
|
||||
20,
|
||||
20,
|
||||
17,
|
||||
12,
|
||||
12,
|
||||
10,
|
||||
17,
|
||||
14,
|
||||
20,
|
||||
14,
|
||||
16,
|
||||
16,
|
||||
21,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 1158,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 96.497,
|
||||
"low_1pct_fps": 21.565,
|
||||
"low_01pct_fps": 14.612,
|
||||
"p50_ms": 7.567,
|
||||
"p95_ms": 22.849,
|
||||
"p99_ms": 34.806,
|
||||
"p999_ms": 62.86,
|
||||
"max_ms": 74.011,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
38,
|
||||
452,
|
||||
158,
|
||||
102,
|
||||
94,
|
||||
125,
|
||||
68,
|
||||
34,
|
||||
42,
|
||||
30,
|
||||
7,
|
||||
4,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 271,
|
||||
"seconds": 12.047,
|
||||
"avg_fps": 22.496,
|
||||
"low_1pct_fps": 2.196,
|
||||
"low_01pct_fps": 1.551,
|
||||
"p50_ms": 18.263,
|
||||
"p95_ms": 189.284,
|
||||
"p99_ms": 317.823,
|
||||
"p999_ms": 644.675,
|
||||
"max_ms": 644.675,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
22,
|
||||
23,
|
||||
13,
|
||||
22,
|
||||
22,
|
||||
19,
|
||||
24,
|
||||
22,
|
||||
14,
|
||||
17,
|
||||
12,
|
||||
13,
|
||||
9,
|
||||
16,
|
||||
15,
|
||||
4,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 242,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 20.154,
|
||||
"low_1pct_fps": 2.343,
|
||||
"low_01pct_fps": 1.939,
|
||||
"p50_ms": 19.762,
|
||||
"p95_ms": 157.949,
|
||||
"p99_ms": 376.292,
|
||||
"p999_ms": 515.678,
|
||||
"max_ms": 515.678,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
17,
|
||||
16,
|
||||
26,
|
||||
19,
|
||||
11,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
9,
|
||||
16,
|
||||
17,
|
||||
12,
|
||||
21,
|
||||
19,
|
||||
9,
|
||||
6,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 331,
|
||||
"seconds": 12.084,
|
||||
"avg_fps": 27.392,
|
||||
"low_1pct_fps": 2.718,
|
||||
"low_01pct_fps": 2.425,
|
||||
"p50_ms": 13.0,
|
||||
"p95_ms": 162.797,
|
||||
"p99_ms": 325.827,
|
||||
"p999_ms": 412.349,
|
||||
"max_ms": 412.349,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
5,
|
||||
26,
|
||||
34,
|
||||
42,
|
||||
41,
|
||||
24,
|
||||
27,
|
||||
15,
|
||||
21,
|
||||
22,
|
||||
8,
|
||||
10,
|
||||
11,
|
||||
10,
|
||||
14,
|
||||
10,
|
||||
8,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 620,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 51.664,
|
||||
"low_1pct_fps": 3.444,
|
||||
"low_01pct_fps": 2.487,
|
||||
"p50_ms": 10.358,
|
||||
"p95_ms": 54.808,
|
||||
"p99_ms": 181.112,
|
||||
"p999_ms": 402.152,
|
||||
"max_ms": 402.152,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
30,
|
||||
79,
|
||||
95,
|
||||
86,
|
||||
90,
|
||||
60,
|
||||
54,
|
||||
28,
|
||||
28,
|
||||
19,
|
||||
9,
|
||||
8,
|
||||
8,
|
||||
6,
|
||||
8,
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 689,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 57.386,
|
||||
"low_1pct_fps": 9.574,
|
||||
"low_01pct_fps": 7.321,
|
||||
"p50_ms": 12.76,
|
||||
"p95_ms": 43.737,
|
||||
"p99_ms": 69.357,
|
||||
"p999_ms": 136.592,
|
||||
"max_ms": 136.592,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
23,
|
||||
46,
|
||||
80,
|
||||
80,
|
||||
92,
|
||||
68,
|
||||
74,
|
||||
54,
|
||||
49,
|
||||
51,
|
||||
24,
|
||||
22,
|
||||
16,
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 538,
|
||||
"seconds": 12.013,
|
||||
"avg_fps": 44.786,
|
||||
"low_1pct_fps": 5.431,
|
||||
"low_01pct_fps": 2.674,
|
||||
"p50_ms": 14.011,
|
||||
"p95_ms": 71.23,
|
||||
"p99_ms": 141.423,
|
||||
"p999_ms": 373.913,
|
||||
"max_ms": 373.913,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
8,
|
||||
38,
|
||||
84,
|
||||
83,
|
||||
53,
|
||||
62,
|
||||
52,
|
||||
54,
|
||||
29,
|
||||
12,
|
||||
15,
|
||||
13,
|
||||
22,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 588,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 48.975,
|
||||
"low_1pct_fps": 6.777,
|
||||
"low_01pct_fps": 3.937,
|
||||
"p50_ms": 12.678,
|
||||
"p95_ms": 64.221,
|
||||
"p99_ms": 108.513,
|
||||
"p999_ms": 254.008,
|
||||
"max_ms": 254.008,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
34,
|
||||
21,
|
||||
41,
|
||||
108,
|
||||
62,
|
||||
55,
|
||||
55,
|
||||
40,
|
||||
45,
|
||||
41,
|
||||
21,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
9,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 471,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 39.246,
|
||||
"low_1pct_fps": 5.888,
|
||||
"low_01pct_fps": 4.289,
|
||||
"p50_ms": 17.65,
|
||||
"p95_ms": 73.314,
|
||||
"p99_ms": 135.3,
|
||||
"p999_ms": 233.136,
|
||||
"max_ms": 233.136,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
12,
|
||||
22,
|
||||
29,
|
||||
36,
|
||||
40,
|
||||
43,
|
||||
38,
|
||||
41,
|
||||
46,
|
||||
62,
|
||||
29,
|
||||
20,
|
||||
19,
|
||||
22,
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 538,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 44.818,
|
||||
"low_1pct_fps": 7.246,
|
||||
"low_01pct_fps": 5.107,
|
||||
"p50_ms": 15.959,
|
||||
"p95_ms": 63.485,
|
||||
"p99_ms": 109.639,
|
||||
"p999_ms": 195.815,
|
||||
"max_ms": 195.815,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
6,
|
||||
21,
|
||||
75,
|
||||
56,
|
||||
54,
|
||||
78,
|
||||
54,
|
||||
69,
|
||||
36,
|
||||
23,
|
||||
22,
|
||||
18,
|
||||
14,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-m4pro-1080p"
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T23:17:23Z",
|
||||
"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": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 58.672,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 20579.57,
|
||||
"stdev": 13060.035,
|
||||
"min": 10660.541,
|
||||
"max": 35376.471,
|
||||
"ci95": 32445.545
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 124180.237,
|
||||
"stdev": 102723.197,
|
||||
"min": 51510.541,
|
||||
"max": 241702.471,
|
||||
"ci95": 255199.163
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 23389.087,
|
||||
"stdev": 5416.249,
|
||||
"min": 18538.934,
|
||||
"max": 29233.632,
|
||||
"ci95": 13455.795
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 81.421,
|
||||
"stdev": 39.944,
|
||||
"min": 35.32,
|
||||
"max": 105.706,
|
||||
"ci95": 99.234
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 15.288,
|
||||
"stdev": 8.185,
|
||||
"min": 5.907,
|
||||
"max": 20.97,
|
||||
"ci95": 20.334
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 10.544,
|
||||
"stdev": 4.403,
|
||||
"min": 5.804,
|
||||
"max": 14.505,
|
||||
"ci95": 10.938
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 48382.741,
|
||||
"stdev": 32005.955,
|
||||
"min": 26161.003,
|
||||
"max": 85067.56,
|
||||
"ci95": 79513.616
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 67.82,
|
||||
"stdev": 49.16,
|
||||
"min": 12.722,
|
||||
"max": 107.193,
|
||||
"ci95": 122.129
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.769,
|
||||
"stdev": 19.765,
|
||||
"min": 2.609,
|
||||
"max": 40.124,
|
||||
"ci95": 49.103
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 13.876,
|
||||
"stdev": 16.851,
|
||||
"min": 2.308,
|
||||
"max": 33.21,
|
||||
"ci95": 41.864
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 37630.797,
|
||||
"stdev": 33094.177,
|
||||
"min": 14901.304,
|
||||
"max": 75599.155,
|
||||
"ci95": 82217.128
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 44.125,
|
||||
"stdev": 32.286,
|
||||
"min": 13.532,
|
||||
"max": 77.872,
|
||||
"ci95": 80.209
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.619,
|
||||
"stdev": 1.694,
|
||||
"min": 3.247,
|
||||
"max": 6.512,
|
||||
"ci95": 4.208
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.354,
|
||||
"stdev": 0.44,
|
||||
"min": 1.967,
|
||||
"max": 2.833,
|
||||
"ci95": 1.094
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 67242.203,
|
||||
"stdev": 17886.115,
|
||||
"min": 56354.348,
|
||||
"max": 87884.948,
|
||||
"ci95": 44435.159
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 28.745,
|
||||
"stdev": 7.77,
|
||||
"min": 20.237,
|
||||
"max": 35.466,
|
||||
"ci95": 19.304
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.013,
|
||||
"stdev": 0.849,
|
||||
"min": 2.273,
|
||||
"max": 3.94,
|
||||
"ci95": 2.109
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.377,
|
||||
"stdev": 1.031,
|
||||
"min": 1.246,
|
||||
"max": 3.263,
|
||||
"ci95": 2.561
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2958.083,
|
||||
"stdev": 22.425,
|
||||
"min": 2937.734,
|
||||
"max": 2982.125,
|
||||
"ci95": 55.71
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2837.568,
|
||||
"stdev": 165.159,
|
||||
"min": 2648.188,
|
||||
"max": 2951.719,
|
||||
"ci95": 410.31
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4618.337,
|
||||
"stdev": 95.349,
|
||||
"min": 4543.347,
|
||||
"max": 4725.644,
|
||||
"ci95": 236.878
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4264.652,
|
||||
"stdev": 87.422,
|
||||
"min": 4173.589,
|
||||
"max": 4347.91,
|
||||
"ci95": 217.185
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 424,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 35.32,
|
||||
"low_1pct_fps": 5.907,
|
||||
"low_01pct_fps": 5.804,
|
||||
"p50_ms": 17.195,
|
||||
"p95_ms": 95.694,
|
||||
"p99_ms": 164.6,
|
||||
"p999_ms": 172.292,
|
||||
"max_ms": 172.292,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
19,
|
||||
39,
|
||||
45,
|
||||
44,
|
||||
52,
|
||||
42,
|
||||
41,
|
||||
38,
|
||||
21,
|
||||
20,
|
||||
21,
|
||||
19,
|
||||
12,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1239,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 103.238,
|
||||
"low_1pct_fps": 20.97,
|
||||
"low_01pct_fps": 14.505,
|
||||
"p50_ms": 8.073,
|
||||
"p95_ms": 18.618,
|
||||
"p99_ms": 34.704,
|
||||
"p999_ms": 53.789,
|
||||
"max_ms": 84.091,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
275,
|
||||
412,
|
||||
260,
|
||||
117,
|
||||
60,
|
||||
35,
|
||||
25,
|
||||
23,
|
||||
14,
|
||||
7,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 1271,
|
||||
"seconds": 12.024,
|
||||
"avg_fps": 105.706,
|
||||
"low_1pct_fps": 18.988,
|
||||
"low_01pct_fps": 11.324,
|
||||
"p50_ms": 7.988,
|
||||
"p95_ms": 16.852,
|
||||
"p99_ms": 35.156,
|
||||
"p999_ms": 65.46,
|
||||
"max_ms": 111.155,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
220,
|
||||
500,
|
||||
318,
|
||||
101,
|
||||
43,
|
||||
21,
|
||||
15,
|
||||
19,
|
||||
17,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 154,
|
||||
"seconds": 12.105,
|
||||
"avg_fps": 12.722,
|
||||
"low_1pct_fps": 2.609,
|
||||
"low_01pct_fps": 2.308,
|
||||
"p50_ms": 54.156,
|
||||
"p95_ms": 216.738,
|
||||
"p99_ms": 333.238,
|
||||
"p999_ms": 433.205,
|
||||
"max_ms": 433.205,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
4,
|
||||
5,
|
||||
7,
|
||||
9,
|
||||
13,
|
||||
25,
|
||||
5,
|
||||
7,
|
||||
12,
|
||||
17,
|
||||
26,
|
||||
18,
|
||||
5,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1287,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 107.193,
|
||||
"low_1pct_fps": 40.124,
|
||||
"low_01pct_fps": 33.21,
|
||||
"p50_ms": 8.355,
|
||||
"p95_ms": 14.243,
|
||||
"p99_ms": 22.791,
|
||||
"p999_ms": 29.023,
|
||||
"max_ms": 31.2,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
627,
|
||||
416,
|
||||
130,
|
||||
43,
|
||||
33,
|
||||
16,
|
||||
18,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 1003,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 83.546,
|
||||
"low_1pct_fps": 10.575,
|
||||
"low_01pct_fps": 6.111,
|
||||
"p50_ms": 9.556,
|
||||
"p95_ms": 22.218,
|
||||
"p99_ms": 53.64,
|
||||
"p999_ms": 143.879,
|
||||
"max_ms": 183.38,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
57,
|
||||
515,
|
||||
239,
|
||||
64,
|
||||
45,
|
||||
21,
|
||||
30,
|
||||
12,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 163,
|
||||
"seconds": 12.045,
|
||||
"avg_fps": 13.532,
|
||||
"low_1pct_fps": 3.247,
|
||||
"low_01pct_fps": 2.833,
|
||||
"p50_ms": 52.758,
|
||||
"p95_ms": 208.496,
|
||||
"p99_ms": 263.074,
|
||||
"p999_ms": 352.963,
|
||||
"max_ms": 352.963,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
14,
|
||||
23,
|
||||
10,
|
||||
12,
|
||||
17,
|
||||
30,
|
||||
18,
|
||||
20,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 951,
|
||||
"seconds": 12.212,
|
||||
"avg_fps": 77.872,
|
||||
"low_1pct_fps": 6.512,
|
||||
"low_01pct_fps": 1.967,
|
||||
"p50_ms": 8.895,
|
||||
"p95_ms": 31.377,
|
||||
"p99_ms": 72.053,
|
||||
"p999_ms": 508.284,
|
||||
"max_ms": 508.284,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
359,
|
||||
306,
|
||||
104,
|
||||
47,
|
||||
29,
|
||||
31,
|
||||
16,
|
||||
18,
|
||||
10,
|
||||
9,
|
||||
10,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 495,
|
||||
"seconds": 12.082,
|
||||
"avg_fps": 40.97,
|
||||
"low_1pct_fps": 4.097,
|
||||
"low_01pct_fps": 2.261,
|
||||
"p50_ms": 11.995,
|
||||
"p95_ms": 80.625,
|
||||
"p99_ms": 146.758,
|
||||
"p999_ms": 442.316,
|
||||
"max_ms": 442.316,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
80,
|
||||
109,
|
||||
56,
|
||||
31,
|
||||
37,
|
||||
24,
|
||||
30,
|
||||
28,
|
||||
17,
|
||||
17,
|
||||
27,
|
||||
24,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 243,
|
||||
"seconds": 12.008,
|
||||
"avg_fps": 20.237,
|
||||
"low_1pct_fps": 2.827,
|
||||
"low_01pct_fps": 2.623,
|
||||
"p50_ms": 21.487,
|
||||
"p95_ms": 177.814,
|
||||
"p99_ms": 315.716,
|
||||
"p999_ms": 381.289,
|
||||
"max_ms": 381.289,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
20,
|
||||
26,
|
||||
24,
|
||||
32,
|
||||
25,
|
||||
21,
|
||||
8,
|
||||
19,
|
||||
13,
|
||||
18,
|
||||
15,
|
||||
10,
|
||||
7,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 431,
|
||||
"seconds": 12.153,
|
||||
"avg_fps": 35.466,
|
||||
"low_1pct_fps": 2.273,
|
||||
"low_01pct_fps": 1.246,
|
||||
"p50_ms": 12.232,
|
||||
"p95_ms": 106.955,
|
||||
"p99_ms": 249.209,
|
||||
"p999_ms": 802.438,
|
||||
"max_ms": 802.438,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
66,
|
||||
137,
|
||||
62,
|
||||
41,
|
||||
21,
|
||||
23,
|
||||
20,
|
||||
3,
|
||||
14,
|
||||
11,
|
||||
11,
|
||||
10,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 367,
|
||||
"seconds": 12.02,
|
||||
"avg_fps": 30.533,
|
||||
"low_1pct_fps": 3.94,
|
||||
"low_01pct_fps": 3.263,
|
||||
"p50_ms": 21.354,
|
||||
"p95_ms": 104.182,
|
||||
"p99_ms": 218.448,
|
||||
"p999_ms": 306.483,
|
||||
"max_ms": 306.483,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
52,
|
||||
38,
|
||||
32,
|
||||
44,
|
||||
53,
|
||||
47,
|
||||
20,
|
||||
21,
|
||||
19,
|
||||
16,
|
||||
15,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-m4pro-1080p"
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"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": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 66.477,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 49895.61,
|
||||
"stdev": 37830.08,
|
||||
"min": 27410.441,
|
||||
"max": 93571.631,
|
||||
"ci95": 93982.713
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 89080.61,
|
||||
"stdev": 49418.222,
|
||||
"min": 59243.441,
|
||||
"max": 146123.631,
|
||||
"ci95": 122771.577
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 29280.957,
|
||||
"stdev": 15620.91,
|
||||
"min": 18681.538,
|
||||
"max": 47219.974,
|
||||
"ci95": 38807.625
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 42.245,
|
||||
"stdev": 6.241,
|
||||
"min": 36.416,
|
||||
"max": 48.83,
|
||||
"ci95": 15.506
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.137,
|
||||
"stdev": 1.2,
|
||||
"min": 3.03,
|
||||
"max": 5.412,
|
||||
"ci95": 2.981
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.891,
|
||||
"stdev": 0.196,
|
||||
"min": 2.777,
|
||||
"max": 3.118,
|
||||
"ci95": 0.488
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 16212.425,
|
||||
"stdev": 1584.155,
|
||||
"min": 14800.702,
|
||||
"max": 17925.672,
|
||||
"ci95": 3935.576
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 66.392,
|
||||
"stdev": 8.735,
|
||||
"min": 57.65,
|
||||
"max": 75.119,
|
||||
"ci95": 21.699
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 8.327,
|
||||
"stdev": 3.862,
|
||||
"min": 6.012,
|
||||
"max": 12.786,
|
||||
"ci95": 9.595
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.154,
|
||||
"stdev": 3.399,
|
||||
"min": 3.873,
|
||||
"max": 10.06,
|
||||
"ci95": 8.444
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 9054.345,
|
||||
"stdev": 1795.805,
|
||||
"min": 7429.882,
|
||||
"max": 10982.71,
|
||||
"ci95": 4461.386
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 49.891,
|
||||
"stdev": 17.901,
|
||||
"min": 29.637,
|
||||
"max": 63.591,
|
||||
"ci95": 44.472
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.144,
|
||||
"stdev": 5.262,
|
||||
"min": 2.948,
|
||||
"max": 12.218,
|
||||
"ci95": 13.074
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 5.254,
|
||||
"stdev": 4.377,
|
||||
"min": 2.603,
|
||||
"max": 10.306,
|
||||
"ci95": 10.874
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18171.415,
|
||||
"stdev": 5234.921,
|
||||
"min": 12170.39,
|
||||
"max": 21800.551,
|
||||
"ci95": 13005.314
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 45.9,
|
||||
"stdev": 14.757,
|
||||
"min": 29.055,
|
||||
"max": 56.549,
|
||||
"ci95": 36.661
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.83,
|
||||
"stdev": 2.815,
|
||||
"min": 2.104,
|
||||
"max": 7.727,
|
||||
"ci95": 6.994
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.986,
|
||||
"stdev": 2.385,
|
||||
"min": 1.597,
|
||||
"max": 6.366,
|
||||
"ci95": 5.924
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1877.276,
|
||||
"stdev": 149.19,
|
||||
"min": 1724.094,
|
||||
"max": 2022.125,
|
||||
"ci95": 370.639
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1810.693,
|
||||
"stdev": 157.376,
|
||||
"min": 1671.984,
|
||||
"max": 1981.719,
|
||||
"ci95": 390.975
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2708.649,
|
||||
"stdev": 200.476,
|
||||
"min": 2538.472,
|
||||
"max": 2929.644,
|
||||
"ci95": 498.051
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2635.498,
|
||||
"stdev": 241.221,
|
||||
"min": 2402.909,
|
||||
"max": 2884.511,
|
||||
"ci95": 599.274
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 586,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 48.83,
|
||||
"low_1pct_fps": 3.03,
|
||||
"low_01pct_fps": 2.779,
|
||||
"p50_ms": 6.298,
|
||||
"p95_ms": 102.59,
|
||||
"p99_ms": 300.171,
|
||||
"p999_ms": 359.883,
|
||||
"max_ms": 359.883,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
30,
|
||||
105,
|
||||
67,
|
||||
75,
|
||||
58,
|
||||
68,
|
||||
43,
|
||||
24,
|
||||
14,
|
||||
15,
|
||||
8,
|
||||
4,
|
||||
8,
|
||||
4,
|
||||
7,
|
||||
8,
|
||||
18,
|
||||
11,
|
||||
11,
|
||||
8,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 498,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 41.489,
|
||||
"low_1pct_fps": 5.412,
|
||||
"low_01pct_fps": 3.118,
|
||||
"p50_ms": 14.331,
|
||||
"p95_ms": 81.666,
|
||||
"p99_ms": 133.706,
|
||||
"p999_ms": 320.73,
|
||||
"max_ms": 320.73,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
15,
|
||||
23,
|
||||
21,
|
||||
37,
|
||||
43,
|
||||
41,
|
||||
55,
|
||||
45,
|
||||
50,
|
||||
45,
|
||||
30,
|
||||
15,
|
||||
18,
|
||||
18,
|
||||
21,
|
||||
13,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 437,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 36.416,
|
||||
"low_1pct_fps": 3.97,
|
||||
"low_01pct_fps": 2.777,
|
||||
"p50_ms": 12.279,
|
||||
"p95_ms": 133.385,
|
||||
"p99_ms": 203.287,
|
||||
"p999_ms": 360.113,
|
||||
"max_ms": 360.113,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
28,
|
||||
21,
|
||||
19,
|
||||
41,
|
||||
52,
|
||||
41,
|
||||
31,
|
||||
33,
|
||||
27,
|
||||
25,
|
||||
27,
|
||||
13,
|
||||
13,
|
||||
17,
|
||||
12,
|
||||
14,
|
||||
12,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 797,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 66.407,
|
||||
"low_1pct_fps": 6.184,
|
||||
"low_01pct_fps": 4.528,
|
||||
"p50_ms": 7.208,
|
||||
"p95_ms": 64.408,
|
||||
"p99_ms": 126.005,
|
||||
"p999_ms": 220.846,
|
||||
"max_ms": 220.846,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
22,
|
||||
78,
|
||||
137,
|
||||
123,
|
||||
136,
|
||||
77,
|
||||
58,
|
||||
31,
|
||||
14,
|
||||
13,
|
||||
14,
|
||||
13,
|
||||
10,
|
||||
11,
|
||||
21,
|
||||
20,
|
||||
13,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 692,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 57.65,
|
||||
"low_1pct_fps": 6.012,
|
||||
"low_01pct_fps": 3.873,
|
||||
"p50_ms": 10.783,
|
||||
"p95_ms": 48.101,
|
||||
"p99_ms": 121.963,
|
||||
"p999_ms": 258.167,
|
||||
"max_ms": 258.167,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
52,
|
||||
52,
|
||||
41,
|
||||
62,
|
||||
83,
|
||||
88,
|
||||
50,
|
||||
46,
|
||||
40,
|
||||
50,
|
||||
43,
|
||||
20,
|
||||
23,
|
||||
9,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 902,
|
||||
"seconds": 12.008,
|
||||
"avg_fps": 75.119,
|
||||
"low_1pct_fps": 12.786,
|
||||
"low_01pct_fps": 10.06,
|
||||
"p50_ms": 9.096,
|
||||
"p95_ms": 38.051,
|
||||
"p99_ms": 67.18,
|
||||
"p999_ms": 99.405,
|
||||
"max_ms": 99.405,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
26,
|
||||
115,
|
||||
72,
|
||||
65,
|
||||
100,
|
||||
118,
|
||||
90,
|
||||
66,
|
||||
51,
|
||||
54,
|
||||
50,
|
||||
39,
|
||||
16,
|
||||
17,
|
||||
13,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 687,
|
||||
"seconds": 12.171,
|
||||
"avg_fps": 56.446,
|
||||
"low_1pct_fps": 3.267,
|
||||
"low_01pct_fps": 2.853,
|
||||
"p50_ms": 4.938,
|
||||
"p95_ms": 103.639,
|
||||
"p99_ms": 257.27,
|
||||
"p999_ms": 350.504,
|
||||
"max_ms": 350.504,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
28,
|
||||
199,
|
||||
125,
|
||||
99,
|
||||
51,
|
||||
35,
|
||||
32,
|
||||
18,
|
||||
5,
|
||||
7,
|
||||
11,
|
||||
10,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
14,
|
||||
15,
|
||||
8,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 764,
|
||||
"seconds": 12.014,
|
||||
"avg_fps": 63.591,
|
||||
"low_1pct_fps": 12.218,
|
||||
"low_01pct_fps": 10.306,
|
||||
"p50_ms": 11.549,
|
||||
"p95_ms": 45.214,
|
||||
"p99_ms": 67.493,
|
||||
"p999_ms": 97.034,
|
||||
"max_ms": 97.034,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
45,
|
||||
41,
|
||||
59,
|
||||
58,
|
||||
87,
|
||||
101,
|
||||
71,
|
||||
85,
|
||||
42,
|
||||
56,
|
||||
49,
|
||||
15,
|
||||
16,
|
||||
22,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 357,
|
||||
"seconds": 12.046,
|
||||
"avg_fps": 29.637,
|
||||
"low_1pct_fps": 2.948,
|
||||
"low_01pct_fps": 2.603,
|
||||
"p50_ms": 10.947,
|
||||
"p95_ms": 173.526,
|
||||
"p99_ms": 311.134,
|
||||
"p999_ms": 384.135,
|
||||
"max_ms": 384.135,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
11,
|
||||
47,
|
||||
31,
|
||||
12,
|
||||
34,
|
||||
32,
|
||||
23,
|
||||
15,
|
||||
26,
|
||||
21,
|
||||
14,
|
||||
15,
|
||||
7,
|
||||
6,
|
||||
16,
|
||||
18,
|
||||
6,
|
||||
15,
|
||||
8,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 355,
|
||||
"seconds": 12.218,
|
||||
"avg_fps": 29.055,
|
||||
"low_1pct_fps": 2.104,
|
||||
"low_01pct_fps": 1.597,
|
||||
"p50_ms": 7.766,
|
||||
"p95_ms": 145.251,
|
||||
"p99_ms": 368.51,
|
||||
"p999_ms": 626.205,
|
||||
"max_ms": 626.205,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
41,
|
||||
53,
|
||||
25,
|
||||
28,
|
||||
15,
|
||||
22,
|
||||
7,
|
||||
16,
|
||||
10,
|
||||
15,
|
||||
17,
|
||||
12,
|
||||
15,
|
||||
11,
|
||||
11,
|
||||
8,
|
||||
14,
|
||||
19,
|
||||
7,
|
||||
7,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 680,
|
||||
"seconds": 12.025,
|
||||
"avg_fps": 56.549,
|
||||
"low_1pct_fps": 7.727,
|
||||
"low_01pct_fps": 6.366,
|
||||
"p50_ms": 11.633,
|
||||
"p95_ms": 52.508,
|
||||
"p99_ms": 111.848,
|
||||
"p999_ms": 157.09,
|
||||
"max_ms": 157.09,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
32,
|
||||
38,
|
||||
43,
|
||||
49,
|
||||
71,
|
||||
55,
|
||||
55,
|
||||
54,
|
||||
57,
|
||||
53,
|
||||
41,
|
||||
54,
|
||||
21,
|
||||
19,
|
||||
17,
|
||||
12,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 632,
|
||||
"seconds": 12.132,
|
||||
"avg_fps": 52.095,
|
||||
"low_1pct_fps": 4.66,
|
||||
"low_01pct_fps": 3.995,
|
||||
"p50_ms": 8.601,
|
||||
"p95_ms": 78.611,
|
||||
"p99_ms": 172.405,
|
||||
"p999_ms": 250.342,
|
||||
"max_ms": 250.342,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
75,
|
||||
86,
|
||||
53,
|
||||
32,
|
||||
57,
|
||||
40,
|
||||
41,
|
||||
30,
|
||||
38,
|
||||
25,
|
||||
24,
|
||||
39,
|
||||
16,
|
||||
23,
|
||||
7,
|
||||
12,
|
||||
15,
|
||||
9,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-m4pro-1080p"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,723 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "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": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 69.878,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 23843.039,
|
||||
"stdev": 7524.513,
|
||||
"min": 16643.766,
|
||||
"max": 31655.294,
|
||||
"ci95": 18693.436
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 76908.706,
|
||||
"stdev": 36719.804,
|
||||
"min": 45051.766,
|
||||
"max": 117069.294,
|
||||
"ci95": 91224.412
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19608.485,
|
||||
"stdev": 9449.035,
|
||||
"min": 13510.149,
|
||||
"max": 30492.958,
|
||||
"ci95": 23474.6
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 46.159,
|
||||
"stdev": 17.954,
|
||||
"min": 31.413,
|
||||
"max": 66.152,
|
||||
"ci95": 44.604
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.544,
|
||||
"stdev": 3.452,
|
||||
"min": 3.225,
|
||||
"max": 10.116,
|
||||
"ci95": 8.577
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.689,
|
||||
"stdev": 1.808,
|
||||
"min": 2.647,
|
||||
"max": 6.085,
|
||||
"ci95": 4.491
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 23344.685,
|
||||
"stdev": 5493.145,
|
||||
"min": 17038.709,
|
||||
"max": 27089.821,
|
||||
"ci95": 13646.83
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 61.892,
|
||||
"stdev": 37.939,
|
||||
"min": 22.354,
|
||||
"max": 98.0,
|
||||
"ci95": 94.254
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.736,
|
||||
"stdev": 8.597,
|
||||
"min": 2.296,
|
||||
"max": 19.148,
|
||||
"ci95": 21.359
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.651,
|
||||
"stdev": 5.574,
|
||||
"min": 2.202,
|
||||
"max": 12.903,
|
||||
"ci95": 13.847
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 27267.052,
|
||||
"stdev": 21587.34,
|
||||
"min": 10640.097,
|
||||
"max": 51663.797,
|
||||
"ci95": 53630.253
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 75.493,
|
||||
"stdev": 63.885,
|
||||
"min": 25.975,
|
||||
"max": 147.604,
|
||||
"ci95": 158.711
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 13.396,
|
||||
"stdev": 13.366,
|
||||
"min": 4.634,
|
||||
"max": 28.78,
|
||||
"ci95": 33.205
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.356,
|
||||
"stdev": 6.609,
|
||||
"min": 3.26,
|
||||
"max": 14.98,
|
||||
"ci95": 16.418
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 38284.867,
|
||||
"stdev": 4237.4,
|
||||
"min": 33922.931,
|
||||
"max": 42385.65,
|
||||
"ci95": 10527.134
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 35.918,
|
||||
"stdev": 17.742,
|
||||
"min": 18.135,
|
||||
"max": 53.619,
|
||||
"ci95": 44.077
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.999,
|
||||
"stdev": 2.132,
|
||||
"min": 2.453,
|
||||
"max": 6.431,
|
||||
"ci95": 5.297
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.017,
|
||||
"stdev": 1.831,
|
||||
"min": 1.775,
|
||||
"max": 5.12,
|
||||
"ci95": 4.549
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2566.339,
|
||||
"stdev": 218.228,
|
||||
"min": 2339.094,
|
||||
"max": 2774.266,
|
||||
"ci95": 542.153
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2354.852,
|
||||
"stdev": 240.105,
|
||||
"min": 2082.984,
|
||||
"max": 2537.867,
|
||||
"ci95": 596.503
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3430.598,
|
||||
"stdev": 77.063,
|
||||
"min": 3345.395,
|
||||
"max": 3495.426,
|
||||
"ci95": 191.451
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3211.931,
|
||||
"stdev": 99.984,
|
||||
"min": 3124.098,
|
||||
"max": 3320.738,
|
||||
"ci95": 248.394
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 377,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 31.413,
|
||||
"low_1pct_fps": 3.225,
|
||||
"low_01pct_fps": 2.647,
|
||||
"p50_ms": 15.145,
|
||||
"p95_ms": 128.716,
|
||||
"p99_ms": 276.685,
|
||||
"p999_ms": 377.749,
|
||||
"max_ms": 377.749,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
17,
|
||||
26,
|
||||
21,
|
||||
30,
|
||||
23,
|
||||
43,
|
||||
16,
|
||||
25,
|
||||
28,
|
||||
28,
|
||||
35,
|
||||
16,
|
||||
14,
|
||||
12,
|
||||
15,
|
||||
13,
|
||||
11,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 794,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 66.152,
|
||||
"low_1pct_fps": 10.116,
|
||||
"low_01pct_fps": 6.085,
|
||||
"p50_ms": 11.137,
|
||||
"p95_ms": 41.078,
|
||||
"p99_ms": 73.531,
|
||||
"p999_ms": 164.347,
|
||||
"max_ms": 164.347,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
20,
|
||||
57,
|
||||
65,
|
||||
52,
|
||||
70,
|
||||
82,
|
||||
88,
|
||||
80,
|
||||
67,
|
||||
53,
|
||||
48,
|
||||
52,
|
||||
18,
|
||||
20,
|
||||
10,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 491,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 40.912,
|
||||
"low_1pct_fps": 6.291,
|
||||
"low_01pct_fps": 5.336,
|
||||
"p50_ms": 15.962,
|
||||
"p95_ms": 71.604,
|
||||
"p99_ms": 137.056,
|
||||
"p999_ms": 187.401,
|
||||
"max_ms": 187.401,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
10,
|
||||
32,
|
||||
54,
|
||||
60,
|
||||
45,
|
||||
49,
|
||||
44,
|
||||
49,
|
||||
50,
|
||||
22,
|
||||
21,
|
||||
20,
|
||||
18,
|
||||
8,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 784,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 65.322,
|
||||
"low_1pct_fps": 7.763,
|
||||
"low_01pct_fps": 4.848,
|
||||
"p50_ms": 10.024,
|
||||
"p95_ms": 43.46,
|
||||
"p99_ms": 94.19,
|
||||
"p999_ms": 206.286,
|
||||
"max_ms": 206.286,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
17,
|
||||
40,
|
||||
69,
|
||||
80,
|
||||
95,
|
||||
88,
|
||||
82,
|
||||
53,
|
||||
58,
|
||||
39,
|
||||
49,
|
||||
52,
|
||||
21,
|
||||
15,
|
||||
14,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1176,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 98.0,
|
||||
"low_1pct_fps": 19.148,
|
||||
"low_01pct_fps": 12.903,
|
||||
"p50_ms": 6.214,
|
||||
"p95_ms": 30.125,
|
||||
"p99_ms": 41.199,
|
||||
"p999_ms": 73.675,
|
||||
"max_ms": 81.328,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
179,
|
||||
205,
|
||||
175,
|
||||
115,
|
||||
111,
|
||||
79,
|
||||
39,
|
||||
21,
|
||||
25,
|
||||
34,
|
||||
79,
|
||||
77,
|
||||
20,
|
||||
10,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 271,
|
||||
"seconds": 12.123,
|
||||
"avg_fps": 22.354,
|
||||
"low_1pct_fps": 2.296,
|
||||
"low_01pct_fps": 2.202,
|
||||
"p50_ms": 14.989,
|
||||
"p95_ms": 214.282,
|
||||
"p99_ms": 404.5,
|
||||
"p999_ms": 454.134,
|
||||
"max_ms": 454.134,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
18,
|
||||
19,
|
||||
19,
|
||||
28,
|
||||
28,
|
||||
13,
|
||||
19,
|
||||
20,
|
||||
12,
|
||||
21,
|
||||
14,
|
||||
5,
|
||||
8,
|
||||
4,
|
||||
9,
|
||||
18,
|
||||
10,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 1772,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 147.604,
|
||||
"low_1pct_fps": 28.78,
|
||||
"low_01pct_fps": 14.98,
|
||||
"p50_ms": 5.328,
|
||||
"p95_ms": 15.261,
|
||||
"p99_ms": 25.961,
|
||||
"p999_ms": 58.586,
|
||||
"max_ms": 74.925,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
34,
|
||||
421,
|
||||
331,
|
||||
290,
|
||||
175,
|
||||
163,
|
||||
122,
|
||||
83,
|
||||
44,
|
||||
33,
|
||||
26,
|
||||
28,
|
||||
16,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 635,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 52.9,
|
||||
"low_1pct_fps": 6.774,
|
||||
"low_01pct_fps": 3.828,
|
||||
"p50_ms": 9.578,
|
||||
"p95_ms": 61.195,
|
||||
"p99_ms": 112.118,
|
||||
"p999_ms": 261.228,
|
||||
"max_ms": 261.228,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
38,
|
||||
49,
|
||||
64,
|
||||
63,
|
||||
63,
|
||||
51,
|
||||
60,
|
||||
27,
|
||||
29,
|
||||
27,
|
||||
17,
|
||||
37,
|
||||
28,
|
||||
30,
|
||||
27,
|
||||
15,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 312,
|
||||
"seconds": 12.011,
|
||||
"avg_fps": 25.975,
|
||||
"low_1pct_fps": 4.634,
|
||||
"low_01pct_fps": 3.26,
|
||||
"p50_ms": 24.629,
|
||||
"p95_ms": 115.493,
|
||||
"p99_ms": 169.827,
|
||||
"p999_ms": 306.737,
|
||||
"max_ms": 306.737,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
5,
|
||||
10,
|
||||
10,
|
||||
24,
|
||||
39,
|
||||
33,
|
||||
38,
|
||||
47,
|
||||
16,
|
||||
16,
|
||||
21,
|
||||
23,
|
||||
22,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 218,
|
||||
"seconds": 12.021,
|
||||
"avg_fps": 18.135,
|
||||
"low_1pct_fps": 2.453,
|
||||
"low_01pct_fps": 2.156,
|
||||
"p50_ms": 17.447,
|
||||
"p95_ms": 257.129,
|
||||
"p99_ms": 377.63,
|
||||
"p999_ms": 463.802,
|
||||
"max_ms": 463.802,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
7,
|
||||
13,
|
||||
15,
|
||||
18,
|
||||
11,
|
||||
9,
|
||||
7,
|
||||
16,
|
||||
8,
|
||||
3,
|
||||
11,
|
||||
10,
|
||||
14,
|
||||
7,
|
||||
5,
|
||||
16,
|
||||
8,
|
||||
15,
|
||||
12,
|
||||
13,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 653,
|
||||
"seconds": 12.179,
|
||||
"avg_fps": 53.619,
|
||||
"low_1pct_fps": 6.431,
|
||||
"low_01pct_fps": 5.12,
|
||||
"p50_ms": 8.427,
|
||||
"p95_ms": 58.968,
|
||||
"p99_ms": 141.423,
|
||||
"p999_ms": 195.302,
|
||||
"max_ms": 195.302,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
38,
|
||||
66,
|
||||
76,
|
||||
79,
|
||||
64,
|
||||
45,
|
||||
38,
|
||||
20,
|
||||
29,
|
||||
22,
|
||||
24,
|
||||
46,
|
||||
25,
|
||||
36,
|
||||
17,
|
||||
14,
|
||||
10,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 432,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 35.999,
|
||||
"low_1pct_fps": 3.112,
|
||||
"low_01pct_fps": 1.775,
|
||||
"p50_ms": 16.724,
|
||||
"p95_ms": 83.13,
|
||||
"p99_ms": 220.496,
|
||||
"p999_ms": 563.493,
|
||||
"max_ms": 563.493,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
7,
|
||||
14,
|
||||
17,
|
||||
23,
|
||||
35,
|
||||
35,
|
||||
26,
|
||||
24,
|
||||
33,
|
||||
34,
|
||||
43,
|
||||
43,
|
||||
23,
|
||||
27,
|
||||
20,
|
||||
15,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-m4pro-1080p"
|
||||
}
|
||||
@@ -1,723 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T23:17:23Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "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": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 37.185,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 13521.661,
|
||||
"stdev": 573.908,
|
||||
"min": 13085.942,
|
||||
"max": 14171.935,
|
||||
"ci95": 1425.783
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 63229.994,
|
||||
"stdev": 6185.388,
|
||||
"min": 56237.942,
|
||||
"max": 67987.935,
|
||||
"ci95": 15366.595
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14807.746,
|
||||
"stdev": 2020.358,
|
||||
"min": 13414.414,
|
||||
"max": 17124.849,
|
||||
"ci95": 5019.253
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 91.753,
|
||||
"stdev": 11.452,
|
||||
"min": 78.555,
|
||||
"max": 99.067,
|
||||
"ci95": 28.451
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 13.105,
|
||||
"stdev": 2.514,
|
||||
"min": 10.354,
|
||||
"max": 15.282,
|
||||
"ci95": 6.244
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.203,
|
||||
"stdev": 1.382,
|
||||
"min": 7.689,
|
||||
"max": 10.396,
|
||||
"ci95": 3.432
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 24180.167,
|
||||
"stdev": 777.711,
|
||||
"min": 23290.973,
|
||||
"max": 24733.555,
|
||||
"ci95": 1932.098
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 113.871,
|
||||
"stdev": 53.305,
|
||||
"min": 56.542,
|
||||
"max": 161.938,
|
||||
"ci95": 132.428
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 28.748,
|
||||
"stdev": 17.633,
|
||||
"min": 12.823,
|
||||
"max": 47.698,
|
||||
"ci95": 43.807
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 20.551,
|
||||
"stdev": 13.547,
|
||||
"min": 8.937,
|
||||
"max": 35.433,
|
||||
"ci95": 33.655
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19314.547,
|
||||
"stdev": 5853.258,
|
||||
"min": 15068.272,
|
||||
"max": 25991.528,
|
||||
"ci95": 14541.473
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 119.116,
|
||||
"stdev": 71.027,
|
||||
"min": 45.498,
|
||||
"max": 187.231,
|
||||
"ci95": 176.454
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 31.721,
|
||||
"stdev": 26.624,
|
||||
"min": 8.585,
|
||||
"max": 60.822,
|
||||
"ci95": 66.144
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.8,
|
||||
"stdev": 14.937,
|
||||
"min": 5.876,
|
||||
"max": 33.821,
|
||||
"ci95": 37.109
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 61290.415,
|
||||
"stdev": 15063.343,
|
||||
"min": 49453.256,
|
||||
"max": 78246.003,
|
||||
"ci95": 37422.439
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 56.905,
|
||||
"stdev": 19.674,
|
||||
"min": 37.807,
|
||||
"max": 77.109,
|
||||
"ci95": 48.878
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.384,
|
||||
"stdev": 0.62,
|
||||
"min": 6.67,
|
||||
"max": 7.784,
|
||||
"ci95": 1.539
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.48,
|
||||
"stdev": 1.306,
|
||||
"min": 2.526,
|
||||
"max": 4.968,
|
||||
"ci95": 3.244
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4078.01,
|
||||
"stdev": 79.593,
|
||||
"min": 4020.391,
|
||||
"max": 4168.828,
|
||||
"ci95": 197.735
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3705.104,
|
||||
"stdev": 149.415,
|
||||
"min": 3541.375,
|
||||
"max": 3834.078,
|
||||
"ci95": 371.196
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 5250.386,
|
||||
"stdev": 54.229,
|
||||
"min": 5200.662,
|
||||
"max": 5308.208,
|
||||
"ci95": 134.723
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4647.172,
|
||||
"stdev": 29.179,
|
||||
"min": 4613.552,
|
||||
"max": 4665.911,
|
||||
"ci95": 72.492
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 1172,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 97.637,
|
||||
"low_1pct_fps": 15.282,
|
||||
"low_01pct_fps": 10.396,
|
||||
"p50_ms": 7.394,
|
||||
"p95_ms": 26.126,
|
||||
"p99_ms": 50.505,
|
||||
"p999_ms": 79.614,
|
||||
"max_ms": 112.764,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
24,
|
||||
111,
|
||||
198,
|
||||
193,
|
||||
170,
|
||||
129,
|
||||
109,
|
||||
59,
|
||||
42,
|
||||
43,
|
||||
23,
|
||||
38,
|
||||
10,
|
||||
10,
|
||||
7,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1189,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 99.067,
|
||||
"low_1pct_fps": 13.678,
|
||||
"low_01pct_fps": 9.523,
|
||||
"p50_ms": 7.061,
|
||||
"p95_ms": 28.029,
|
||||
"p99_ms": 58.166,
|
||||
"p999_ms": 103.326,
|
||||
"max_ms": 106.682,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
59,
|
||||
132,
|
||||
223,
|
||||
172,
|
||||
171,
|
||||
107,
|
||||
90,
|
||||
59,
|
||||
39,
|
||||
36,
|
||||
28,
|
||||
35,
|
||||
14,
|
||||
9,
|
||||
9,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 943,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 78.555,
|
||||
"low_1pct_fps": 10.354,
|
||||
"low_01pct_fps": 7.689,
|
||||
"p50_ms": 8.817,
|
||||
"p95_ms": 31.62,
|
||||
"p99_ms": 70.58,
|
||||
"p999_ms": 130.049,
|
||||
"max_ms": 130.049,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
8,
|
||||
34,
|
||||
97,
|
||||
118,
|
||||
168,
|
||||
126,
|
||||
106,
|
||||
69,
|
||||
58,
|
||||
49,
|
||||
36,
|
||||
32,
|
||||
8,
|
||||
9,
|
||||
13,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 679,
|
||||
"seconds": 12.009,
|
||||
"avg_fps": 56.542,
|
||||
"low_1pct_fps": 12.823,
|
||||
"low_01pct_fps": 8.937,
|
||||
"p50_ms": 14.344,
|
||||
"p95_ms": 42.09,
|
||||
"p99_ms": 65.756,
|
||||
"p999_ms": 111.897,
|
||||
"max_ms": 111.897,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
33,
|
||||
50,
|
||||
62,
|
||||
68,
|
||||
60,
|
||||
48,
|
||||
67,
|
||||
84,
|
||||
76,
|
||||
64,
|
||||
27,
|
||||
18,
|
||||
14,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1478,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 123.133,
|
||||
"low_1pct_fps": 25.723,
|
||||
"low_01pct_fps": 17.282,
|
||||
"p50_ms": 6.388,
|
||||
"p95_ms": 17.972,
|
||||
"p99_ms": 29.029,
|
||||
"p999_ms": 57.659,
|
||||
"max_ms": 58.069,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
336,
|
||||
298,
|
||||
222,
|
||||
186,
|
||||
144,
|
||||
77,
|
||||
70,
|
||||
57,
|
||||
39,
|
||||
23,
|
||||
13,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 1944,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 161.938,
|
||||
"low_1pct_fps": 47.698,
|
||||
"low_01pct_fps": 35.433,
|
||||
"p50_ms": 5.168,
|
||||
"p95_ms": 12.12,
|
||||
"p99_ms": 17.337,
|
||||
"p999_ms": 26.48,
|
||||
"max_ms": 29.965,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
18,
|
||||
793,
|
||||
527,
|
||||
243,
|
||||
125,
|
||||
91,
|
||||
47,
|
||||
41,
|
||||
34,
|
||||
15,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 548,
|
||||
"seconds": 12.044,
|
||||
"avg_fps": 45.498,
|
||||
"low_1pct_fps": 8.585,
|
||||
"low_01pct_fps": 5.876,
|
||||
"p50_ms": 15.416,
|
||||
"p95_ms": 62.523,
|
||||
"p99_ms": 98.757,
|
||||
"p999_ms": 170.17,
|
||||
"max_ms": 170.17,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
15,
|
||||
26,
|
||||
52,
|
||||
53,
|
||||
58,
|
||||
37,
|
||||
56,
|
||||
39,
|
||||
58,
|
||||
60,
|
||||
27,
|
||||
24,
|
||||
18,
|
||||
21,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 1496,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 124.619,
|
||||
"low_1pct_fps": 25.756,
|
||||
"low_01pct_fps": 10.702,
|
||||
"p50_ms": 6.649,
|
||||
"p95_ms": 15.751,
|
||||
"p99_ms": 26.208,
|
||||
"p999_ms": 76.546,
|
||||
"max_ms": 110.33,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
15,
|
||||
250,
|
||||
299,
|
||||
266,
|
||||
216,
|
||||
163,
|
||||
115,
|
||||
68,
|
||||
40,
|
||||
32,
|
||||
13,
|
||||
14,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 2247,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 187.231,
|
||||
"low_1pct_fps": 60.822,
|
||||
"low_01pct_fps": 33.821,
|
||||
"p50_ms": 4.73,
|
||||
"p95_ms": 8.497,
|
||||
"p99_ms": 12.315,
|
||||
"p999_ms": 21.724,
|
||||
"max_ms": 42.26,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
347,
|
||||
987,
|
||||
453,
|
||||
211,
|
||||
122,
|
||||
67,
|
||||
32,
|
||||
15,
|
||||
6,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 670,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 55.799,
|
||||
"low_1pct_fps": 7.784,
|
||||
"low_01pct_fps": 2.526,
|
||||
"p50_ms": 12.887,
|
||||
"p95_ms": 45.129,
|
||||
"p99_ms": 76.315,
|
||||
"p999_ms": 395.821,
|
||||
"max_ms": 395.821,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
54,
|
||||
79,
|
||||
75,
|
||||
81,
|
||||
68,
|
||||
60,
|
||||
53,
|
||||
70,
|
||||
54,
|
||||
17,
|
||||
16,
|
||||
9,
|
||||
17,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 455,
|
||||
"seconds": 12.035,
|
||||
"avg_fps": 37.807,
|
||||
"low_1pct_fps": 6.67,
|
||||
"low_01pct_fps": 4.968,
|
||||
"p50_ms": 15.986,
|
||||
"p95_ms": 82.012,
|
||||
"p99_ms": 117.987,
|
||||
"p999_ms": 201.279,
|
||||
"max_ms": 201.279,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
27,
|
||||
37,
|
||||
47,
|
||||
49,
|
||||
37,
|
||||
39,
|
||||
31,
|
||||
39,
|
||||
34,
|
||||
20,
|
||||
24,
|
||||
30,
|
||||
26,
|
||||
10,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 933,
|
||||
"seconds": 12.1,
|
||||
"avg_fps": 77.109,
|
||||
"low_1pct_fps": 7.697,
|
||||
"low_01pct_fps": 2.946,
|
||||
"p50_ms": 8.593,
|
||||
"p95_ms": 34.696,
|
||||
"p99_ms": 79.266,
|
||||
"p999_ms": 339.486,
|
||||
"max_ms": 339.486,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
45,
|
||||
176,
|
||||
209,
|
||||
196,
|
||||
95,
|
||||
45,
|
||||
43,
|
||||
26,
|
||||
19,
|
||||
30,
|
||||
9,
|
||||
9,
|
||||
14,
|
||||
13,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-m4pro-1080p"
|
||||
}
|
||||
@@ -1,723 +0,0 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "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": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 74.321,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 56311.461,
|
||||
"stdev": 26982.658,
|
||||
"min": 36031.835,
|
||||
"max": 86935.815,
|
||||
"ci95": 67034.048
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 97731.794,
|
||||
"stdev": 21691.925,
|
||||
"min": 72951.835,
|
||||
"max": 113284.815,
|
||||
"ci95": 53890.078
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13954.335,
|
||||
"stdev": 10146.301,
|
||||
"min": 7679.006,
|
||||
"max": 25660.115,
|
||||
"ci95": 25206.843
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 33.239,
|
||||
"stdev": 18.526,
|
||||
"min": 12.03,
|
||||
"max": 46.264,
|
||||
"ci95": 46.026
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.772,
|
||||
"stdev": 2.399,
|
||||
"min": 1.734,
|
||||
"max": 6.416,
|
||||
"ci95": 5.96
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.866,
|
||||
"stdev": 1.762,
|
||||
"min": 1.433,
|
||||
"max": 4.834,
|
||||
"ci95": 4.379
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19803.961,
|
||||
"stdev": 14910.114,
|
||||
"min": 8220.752,
|
||||
"max": 36626.588,
|
||||
"ci95": 37041.766
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 47.303,
|
||||
"stdev": 17.044,
|
||||
"min": 32.698,
|
||||
"max": 66.03,
|
||||
"ci95": 42.343
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.365,
|
||||
"stdev": 0.709,
|
||||
"min": 3.658,
|
||||
"max": 5.076,
|
||||
"ci95": 1.761
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.128,
|
||||
"stdev": 0.564,
|
||||
"min": 2.651,
|
||||
"max": 3.751,
|
||||
"ci95": 1.402
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18639.188,
|
||||
"stdev": 8912.103,
|
||||
"min": 11960.198,
|
||||
"max": 28758.72,
|
||||
"ci95": 22140.678
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 34.908,
|
||||
"stdev": 6.372,
|
||||
"min": 29.271,
|
||||
"max": 41.822,
|
||||
"ci95": 15.831
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.614,
|
||||
"stdev": 1.558,
|
||||
"min": 2.986,
|
||||
"max": 6.09,
|
||||
"ci95": 3.869
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.06,
|
||||
"stdev": 1.662,
|
||||
"min": 2.224,
|
||||
"max": 5.463,
|
||||
"ci95": 4.13
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19191.558,
|
||||
"stdev": 2902.338,
|
||||
"min": 15845.729,
|
||||
"max": 21030.7,
|
||||
"ci95": 7210.388
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 67.004,
|
||||
"stdev": 13.923,
|
||||
"min": 51.98,
|
||||
"max": 79.471,
|
||||
"ci95": 34.589
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.862,
|
||||
"stdev": 1.987,
|
||||
"min": 6.339,
|
||||
"max": 10.109,
|
||||
"ci95": 4.936
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.993,
|
||||
"stdev": 1.679,
|
||||
"min": 2.06,
|
||||
"max": 5.088,
|
||||
"ci95": 4.172
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2019.089,
|
||||
"stdev": 53.398,
|
||||
"min": 1960.188,
|
||||
"max": 2064.328,
|
||||
"ci95": 132.657
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1927.195,
|
||||
"stdev": 108.077,
|
||||
"min": 1813.609,
|
||||
"max": 2028.758,
|
||||
"ci95": 268.5
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2440.727,
|
||||
"stdev": 72.508,
|
||||
"min": 2375.394,
|
||||
"max": 2518.738,
|
||||
"ci95": 180.134
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2395.53,
|
||||
"stdev": 71.597,
|
||||
"min": 2318.504,
|
||||
"max": 2460.05,
|
||||
"ci95": 177.872
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 146,
|
||||
"seconds": 12.136,
|
||||
"avg_fps": 12.03,
|
||||
"low_1pct_fps": 1.734,
|
||||
"low_01pct_fps": 1.433,
|
||||
"p50_ms": 28.465,
|
||||
"p95_ms": 339.625,
|
||||
"p99_ms": 455.663,
|
||||
"p999_ms": 697.859,
|
||||
"max_ms": 697.859,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
4,
|
||||
9,
|
||||
9,
|
||||
5,
|
||||
5,
|
||||
8,
|
||||
6,
|
||||
5,
|
||||
5,
|
||||
1,
|
||||
10,
|
||||
9,
|
||||
6,
|
||||
11,
|
||||
10,
|
||||
5,
|
||||
8,
|
||||
15,
|
||||
13,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 556,
|
||||
"seconds": 12.018,
|
||||
"avg_fps": 46.264,
|
||||
"low_1pct_fps": 6.416,
|
||||
"low_01pct_fps": 4.834,
|
||||
"p50_ms": 13.72,
|
||||
"p95_ms": 71.774,
|
||||
"p99_ms": 120.078,
|
||||
"p999_ms": 206.87,
|
||||
"max_ms": 206.87,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
5,
|
||||
16,
|
||||
22,
|
||||
57,
|
||||
55,
|
||||
64,
|
||||
62,
|
||||
62,
|
||||
50,
|
||||
38,
|
||||
29,
|
||||
19,
|
||||
23,
|
||||
16,
|
||||
21,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 498,
|
||||
"seconds": 12.022,
|
||||
"avg_fps": 41.423,
|
||||
"low_1pct_fps": 3.166,
|
||||
"low_01pct_fps": 2.331,
|
||||
"p50_ms": 7.7,
|
||||
"p95_ms": 114.025,
|
||||
"p99_ms": 246.232,
|
||||
"p999_ms": 428.98,
|
||||
"max_ms": 428.98,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
15,
|
||||
63,
|
||||
55,
|
||||
58,
|
||||
33,
|
||||
38,
|
||||
31,
|
||||
22,
|
||||
23,
|
||||
17,
|
||||
24,
|
||||
24,
|
||||
20,
|
||||
11,
|
||||
9,
|
||||
10,
|
||||
14,
|
||||
15,
|
||||
12,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 521,
|
||||
"seconds": 12.065,
|
||||
"avg_fps": 43.182,
|
||||
"low_1pct_fps": 4.362,
|
||||
"low_01pct_fps": 3.751,
|
||||
"p50_ms": 8.121,
|
||||
"p95_ms": 110.621,
|
||||
"p99_ms": 191.925,
|
||||
"p999_ms": 266.575,
|
||||
"max_ms": 266.575,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
63,
|
||||
27,
|
||||
49,
|
||||
61,
|
||||
67,
|
||||
39,
|
||||
45,
|
||||
27,
|
||||
17,
|
||||
13,
|
||||
17,
|
||||
7,
|
||||
11,
|
||||
13,
|
||||
11,
|
||||
18,
|
||||
22,
|
||||
11,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 793,
|
||||
"seconds": 12.01,
|
||||
"avg_fps": 66.03,
|
||||
"low_1pct_fps": 5.076,
|
||||
"low_01pct_fps": 2.651,
|
||||
"p50_ms": 8.218,
|
||||
"p95_ms": 44.767,
|
||||
"p99_ms": 143.139,
|
||||
"p999_ms": 377.207,
|
||||
"max_ms": 377.207,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
60,
|
||||
106,
|
||||
55,
|
||||
58,
|
||||
127,
|
||||
97,
|
||||
64,
|
||||
41,
|
||||
28,
|
||||
32,
|
||||
31,
|
||||
39,
|
||||
12,
|
||||
7,
|
||||
8,
|
||||
14,
|
||||
9,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 395,
|
||||
"seconds": 12.08,
|
||||
"avg_fps": 32.698,
|
||||
"low_1pct_fps": 3.658,
|
||||
"low_01pct_fps": 2.983,
|
||||
"p50_ms": 10.283,
|
||||
"p95_ms": 171.818,
|
||||
"p99_ms": 231.998,
|
||||
"p999_ms": 335.242,
|
||||
"max_ms": 335.242,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
19,
|
||||
13,
|
||||
27,
|
||||
42,
|
||||
31,
|
||||
53,
|
||||
36,
|
||||
22,
|
||||
25,
|
||||
20,
|
||||
19,
|
||||
10,
|
||||
8,
|
||||
7,
|
||||
6,
|
||||
11,
|
||||
15,
|
||||
23,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 405,
|
||||
"seconds": 12.043,
|
||||
"avg_fps": 33.63,
|
||||
"low_1pct_fps": 4.765,
|
||||
"low_01pct_fps": 4.492,
|
||||
"p50_ms": 14.686,
|
||||
"p95_ms": 115.893,
|
||||
"p99_ms": 196.822,
|
||||
"p999_ms": 222.615,
|
||||
"max_ms": 222.615,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
6,
|
||||
16,
|
||||
18,
|
||||
49,
|
||||
42,
|
||||
36,
|
||||
22,
|
||||
30,
|
||||
35,
|
||||
32,
|
||||
23,
|
||||
10,
|
||||
10,
|
||||
22,
|
||||
22,
|
||||
16,
|
||||
11,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 502,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 41.822,
|
||||
"low_1pct_fps": 6.09,
|
||||
"low_01pct_fps": 5.463,
|
||||
"p50_ms": 15.264,
|
||||
"p95_ms": 76.822,
|
||||
"p99_ms": 144.7,
|
||||
"p999_ms": 183.045,
|
||||
"max_ms": 183.045,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
14,
|
||||
12,
|
||||
13,
|
||||
47,
|
||||
43,
|
||||
52,
|
||||
41,
|
||||
40,
|
||||
52,
|
||||
40,
|
||||
50,
|
||||
15,
|
||||
26,
|
||||
17,
|
||||
17,
|
||||
9,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 353,
|
||||
"seconds": 12.06,
|
||||
"avg_fps": 29.271,
|
||||
"low_1pct_fps": 2.986,
|
||||
"low_01pct_fps": 2.224,
|
||||
"p50_ms": 10.806,
|
||||
"p95_ms": 150.281,
|
||||
"p99_ms": 268.607,
|
||||
"p999_ms": 449.56,
|
||||
"max_ms": 449.56,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
29,
|
||||
16,
|
||||
24,
|
||||
36,
|
||||
35,
|
||||
23,
|
||||
31,
|
||||
13,
|
||||
16,
|
||||
12,
|
||||
18,
|
||||
14,
|
||||
9,
|
||||
8,
|
||||
16,
|
||||
18,
|
||||
17,
|
||||
12,
|
||||
6,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 624,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 51.98,
|
||||
"low_1pct_fps": 7.137,
|
||||
"low_01pct_fps": 5.088,
|
||||
"p50_ms": 12.033,
|
||||
"p95_ms": 75.22,
|
||||
"p99_ms": 115.345,
|
||||
"p999_ms": 196.553,
|
||||
"max_ms": 196.553,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
29,
|
||||
48,
|
||||
34,
|
||||
38,
|
||||
40,
|
||||
61,
|
||||
56,
|
||||
50,
|
||||
57,
|
||||
54,
|
||||
41,
|
||||
33,
|
||||
13,
|
||||
15,
|
||||
15,
|
||||
28,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 835,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 69.562,
|
||||
"low_1pct_fps": 6.339,
|
||||
"low_01pct_fps": 2.06,
|
||||
"p50_ms": 9.818,
|
||||
"p95_ms": 37.917,
|
||||
"p99_ms": 81.491,
|
||||
"p999_ms": 485.481,
|
||||
"max_ms": 485.481,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
62,
|
||||
63,
|
||||
59,
|
||||
75,
|
||||
84,
|
||||
75,
|
||||
94,
|
||||
59,
|
||||
77,
|
||||
55,
|
||||
31,
|
||||
36,
|
||||
27,
|
||||
10,
|
||||
10,
|
||||
5,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 954,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 79.471,
|
||||
"low_1pct_fps": 10.109,
|
||||
"low_01pct_fps": 4.832,
|
||||
"p50_ms": 7.91,
|
||||
"p95_ms": 38.672,
|
||||
"p99_ms": 79.104,
|
||||
"p999_ms": 206.935,
|
||||
"max_ms": 206.935,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
55,
|
||||
70,
|
||||
136,
|
||||
119,
|
||||
109,
|
||||
93,
|
||||
98,
|
||||
52,
|
||||
49,
|
||||
48,
|
||||
21,
|
||||
30,
|
||||
17,
|
||||
14,
|
||||
11,
|
||||
17,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-m4pro-1080p"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,20 +6,9 @@ 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"
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,8 +1,7 @@
|
||||
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
|
||||
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
|
||||
//!
|
||||
//! - `new <path> [--name "Name"]`: a content mod from the template; with
|
||||
//! `--template native [--horizon <commit>]`, a native trait mod's bundle.
|
||||
//! - `new <path> [--name "Name"]`: a content mod from the template.
|
||||
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
|
||||
//! and `data/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
@@ -33,7 +32,7 @@ fn main() -> ExitCode {
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] [--template content|native [--horizon <commit>]] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -75,11 +74,6 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
|
||||
match flag(args, "--template") {
|
||||
None | Some("content") => {}
|
||||
Some("native") => return new_native(dir, &id, flag(args, "--horizon"), fill),
|
||||
Some(_) => return Err("--template is content or native".into()),
|
||||
}
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let files = [
|
||||
(
|
||||
@@ -120,6 +114,11 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
if flag(args, "--template").is_some_and(|t| t != "content") {
|
||||
return Err(
|
||||
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
|
||||
);
|
||||
}
|
||||
for (file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
@@ -138,67 +137,6 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A native trait mod's bundle in `dir` (docs/native-mods.md, "Trait mods"): its
|
||||
/// `horizon-mixin.toml` and an `[api]` crate with one hook. It is what the launcher
|
||||
/// builds (`native-build <dir>`) and packs as `mixins/<id>/` of a `.hnmod`
|
||||
/// (docs/api/mod-files.md). `horizon` pins the Horizon commit it is made for.
|
||||
fn new_native(
|
||||
dir: &Path,
|
||||
id: &str,
|
||||
horizon: Option<&str>,
|
||||
fill: impl Fn(&str) -> String,
|
||||
) -> Result<(), String> {
|
||||
// `[mod] id`, as `horizon-native.json` needs it: the template keeps to its characters.
|
||||
if id.len() > 64 {
|
||||
return Err(format!("mod id {id}: 64 characters at most"));
|
||||
}
|
||||
let (compatibility, rev) = match horizon {
|
||||
Some(commit) if horizon_mods::index::native::revision(commit) => (
|
||||
format!("[compatibility]\nhorizon_revision = \"{commit}\"\n"),
|
||||
format!(", rev = \"{commit}\""),
|
||||
),
|
||||
Some(commit) => {
|
||||
return Err(format!(
|
||||
"--horizon {commit}: a Horizon commit is 40 lowercase hex digits"
|
||||
));
|
||||
}
|
||||
// Unpinned, it builds on the instance's own Horizon.
|
||||
None => (
|
||||
"# The Horizon commit it is made for; unpinned, it builds on the instance's own.\n\
|
||||
# [compatibility]\n# horizon_revision = \"<40 hex digits>\"\n"
|
||||
.to_owned(),
|
||||
String::new(),
|
||||
),
|
||||
};
|
||||
let files = [
|
||||
(
|
||||
"horizon-mixin.toml",
|
||||
include_str!("../template/native/horizon-mixin.toml"),
|
||||
),
|
||||
(
|
||||
"api/Cargo.toml",
|
||||
include_str!("../template/native/api.toml"),
|
||||
),
|
||||
("api/src/lib.rs", include_str!("../template/native/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
for (file, text) in files {
|
||||
let to = dir.join(file);
|
||||
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let text = fill(text)
|
||||
.replace("{compatibility}", &compatibility)
|
||||
.replace("{rev}", &rev);
|
||||
std::fs::write(&to, text).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
println!(
|
||||
"Created {} (native mod id {id}). Next: horizon-launcher native-build {} --instance <instance> --trust-native-code",
|
||||
dir.display(),
|
||||
dir.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
|
||||
/// manifest and the files in `assets/` and `data/`.
|
||||
fn build() -> Result<PathBuf, String> {
|
||||
@@ -334,68 +272,4 @@ mod tests {
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_template_packs_into_an_hnmod_the_registry_accepts() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-native-{}", std::process::id()));
|
||||
let path = dir.join("machine-mod");
|
||||
let commit = "0123456789abcdef0123456789abcdef01234567";
|
||||
let args = [
|
||||
path.display().to_string(),
|
||||
"--template".into(),
|
||||
"native".into(),
|
||||
"--horizon".into(),
|
||||
commit.into(),
|
||||
];
|
||||
new(&args).unwrap();
|
||||
let api = std::fs::read_to_string(path.join("api/Cargo.toml")).unwrap();
|
||||
assert!(api.contains(&format!("rev = \"{commit}\"")), "{api}");
|
||||
// Packed as the launcher packs a mod folder: the manifest, then the bundle under
|
||||
// `mixins/<id>/` (docs/api/mod-files.md).
|
||||
let manifest = serde_json::json!({
|
||||
"schema": 1, "id": "machine_mod", "version": "0.1.0", "game": "26.3",
|
||||
"horizon": commit, "mixins": "b".repeat(40),
|
||||
});
|
||||
let hnmod = dir.join("machine_mod.hnmod");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&hnmod).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file("horizon-native.json", options).unwrap();
|
||||
zip.write_all(manifest.to_string().as_bytes()).unwrap();
|
||||
for file in [
|
||||
"horizon-mixin.toml",
|
||||
"api/Cargo.toml",
|
||||
"api/src/lib.rs",
|
||||
".gitignore",
|
||||
] {
|
||||
assert!(horizon_mods::index::native::allowed(file), "{file}");
|
||||
zip.start_file(format!("mixins/machine_mod/{file}"), options)
|
||||
.unwrap();
|
||||
zip.write_all(&std::fs::read(path.join(file)).unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
|
||||
assert!(report.text().contains("machine_mod"), "{}", report.text());
|
||||
// A pin that is not a commit is refused; with none, the folder builds on the
|
||||
// instance's Horizon.
|
||||
let bad = [
|
||||
dir.join("bad").display().to_string(),
|
||||
"--template".into(),
|
||||
"native".into(),
|
||||
"--horizon".into(),
|
||||
"main".into(),
|
||||
];
|
||||
assert!(new(&bad).unwrap_err().contains("40 lowercase hex"));
|
||||
let free = dir.join("free");
|
||||
new(&[
|
||||
free.display().to_string(),
|
||||
"--template".into(),
|
||||
"native".into(),
|
||||
])
|
||||
.unwrap();
|
||||
let mixin = std::fs::read_to_string(free.join("horizon-mixin.toml")).unwrap();
|
||||
assert!(!mixin.contains("\nhorizon_revision"), "{mixin}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,5 @@ authors = []
|
||||
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks, items, mob effects and entity types to your worlds".
|
||||
# registries: "May add blocks, items and mob effects to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "mod-{id}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# The launcher points this at the Horizon source it builds the game from. Any other
|
||||
# dependency must be a crate and version in Horizon's Cargo.lock.
|
||||
horizon-client = { git = "https://github.com/horizon-rust/horizon"{rev} }
|
||||
@@ -1,9 +0,0 @@
|
||||
# {name}: a native trait mod (docs/native-mods.md, "Trait mods"). Build it with
|
||||
# `horizon-launcher native-build <this folder> --instance <instance> --trust-native-code`.
|
||||
[mod]
|
||||
id = "{id}"
|
||||
version = "0.1.0"
|
||||
|
||||
{compatibility}
|
||||
[api]
|
||||
path = "api"
|
||||
@@ -1,18 +0,0 @@
|
||||
//! The trait mod: the launcher links this crate into the game and calls `hooks()`. Each
|
||||
//! method of `Hooks` is one of Horizon's hook points; implement the ones the mod needs
|
||||
//! (docs/native-mods.md, "Trait mods").
|
||||
use horizon_client::mod_api::{Hooks, Host, PlayerLoggedInEvent, Text};
|
||||
|
||||
pub fn hooks() -> Box<dyn Hooks> {
|
||||
Box::new(Mod)
|
||||
}
|
||||
|
||||
struct Mod;
|
||||
|
||||
impl Hooks for Mod {
|
||||
/// A player joined the server (NeoForge's `PlayerLoggedInEvent`): greet them.
|
||||
fn player_logged_in(&self, host: &mut dyn Host, event: &mut PlayerLoggedInEvent<'_>) {
|
||||
let line = format!("{id} says hello, {}!", event.name);
|
||||
host.tell(event.player, Text::literal(line));
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,12 @@
|
||||
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
|
||||
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
|
||||
//! player is sent back where the server last had them); from `alert` on, online operators and the
|
||||
//! console are told; from `kick` on, the player is disconnected. A check's kicks are counted per player
|
||||
//! across sessions ([`Strikes`]), and the kick that reaches its `ban_after` bans the player instead
|
||||
//! (`banned-players.json`, reason "Horizon anticheat: <check>"). Any threshold at 0 turns that action
|
||||
//! off.
|
||||
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
|
||||
//! that action off.
|
||||
//!
|
||||
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
|
||||
//! do, never from another anticheat's code.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
@@ -32,14 +28,12 @@ pub struct Actions {
|
||||
pub kick: f32,
|
||||
/// Violation level lost each second.
|
||||
pub decay: f32,
|
||||
/// The kick, counting this check's earlier ones for the player, that bans instead; 0: never.
|
||||
pub ban_after: u32,
|
||||
}
|
||||
|
||||
macro_rules! checks {
|
||||
($(
|
||||
$(#[$doc:meta])*
|
||||
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr, $ban_after:expr] {
|
||||
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
|
||||
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
|
||||
}
|
||||
)*) => {
|
||||
@@ -65,7 +59,6 @@ macro_rules! checks {
|
||||
pub alert: f32,
|
||||
pub kick: f32,
|
||||
pub decay: f32,
|
||||
pub ban_after: u32,
|
||||
$( $(#[$xdoc])* pub $extra: $ty, )*
|
||||
}
|
||||
|
||||
@@ -77,7 +70,6 @@ macro_rules! checks {
|
||||
alert: $alert,
|
||||
kick: $kick,
|
||||
decay: $decay,
|
||||
ban_after: $ban_after,
|
||||
$($extra: $default,)*
|
||||
}
|
||||
}
|
||||
@@ -95,9 +87,6 @@ macro_rules! checks {
|
||||
pub alert_level: u8,
|
||||
/// Every flag, one line each, relative to the server's folder; empty: no file.
|
||||
pub log: String,
|
||||
/// The players' kicks by check ([`Strikes`]), relative to the server's folder; empty: kept
|
||||
/// only while the server runs.
|
||||
pub strikes: String,
|
||||
$(pub $field: $Config,)*
|
||||
}
|
||||
|
||||
@@ -108,7 +97,6 @@ macro_rules! checks {
|
||||
alerts: true,
|
||||
alert_level: 2,
|
||||
log: "logs/anticheat.log".into(),
|
||||
strikes: "anticheat-strikes.json".into(),
|
||||
$($field: $Config::default(),)*
|
||||
}
|
||||
}
|
||||
@@ -124,13 +112,7 @@ macro_rules! checks {
|
||||
match check {
|
||||
$(Check::$Check => {
|
||||
let c = &self.$field;
|
||||
Actions {
|
||||
setback: c.setback,
|
||||
alert: c.alert,
|
||||
kick: c.kick,
|
||||
decay: c.decay,
|
||||
ban_after: c.ban_after,
|
||||
}
|
||||
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
|
||||
})*
|
||||
}
|
||||
}
|
||||
@@ -143,7 +125,7 @@ checks! {
|
||||
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
|
||||
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
|
||||
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02, 0] {
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
|
||||
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
|
||||
tolerance_ms: f64 = 300.0,
|
||||
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
|
||||
@@ -153,12 +135,9 @@ checks! {
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
|
||||
/// fall damage follows (the player is never sent back: `setback = 0` makes the check log only), and
|
||||
/// a player that keeps claiming is kicked, then banned at the second kick (`ban_after = 2`). A claim standing still isn't
|
||||
/// judged (no fall to skip). On the official server's first two hours (67 players), every player who
|
||||
/// wasn't flying or walking on water peaked at level 7 (4 claims standing still in one second); the
|
||||
/// ones who were, at 113 to 3749.
|
||||
ground: Ground => GroundConfig [10.0, 15.0, 30.0, 2.0, 2] {}
|
||||
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
|
||||
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
|
||||
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
@@ -170,7 +149,7 @@ checks! {
|
||||
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
|
||||
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
|
||||
/// `kick = 60`.
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0, 0] {
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
|
||||
/// How far, in blocks, a move may land from where the replay puts it.
|
||||
tolerance: f64 = 0.03,
|
||||
}
|
||||
@@ -178,9 +157,8 @@ checks! {
|
||||
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
|
||||
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
|
||||
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default: the first
|
||||
/// kick warns, the second bans (`ban_after = 2`).
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5, 2] {}
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
@@ -229,33 +207,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times each check kicked each player, kept across sessions (`strikes`, a JSON object of
|
||||
/// players' UUIDs to check names to counts): the kick that reaches a check's `ban_after` is a ban.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Strikes(pub BTreeMap<String, BTreeMap<String, u32>>);
|
||||
|
||||
impl Strikes {
|
||||
/// Player `uuid` was kicked by `check`: its kicks by it so far, this one included, and whether this
|
||||
/// one bans. A ban starts the count again, so a player an operator pardons is kicked before the
|
||||
/// next ban.
|
||||
pub fn kick(&mut self, uuid: &str, check: Check, ban_after: u32) -> (u32, bool) {
|
||||
let count = self
|
||||
.0
|
||||
.entry(uuid.to_owned())
|
||||
.or_default()
|
||||
.entry(check.name().to_owned())
|
||||
.or_default();
|
||||
*count = count.saturating_add(1);
|
||||
let kicks = *count;
|
||||
let ban = ban_after > 0 && kicks >= ban_after;
|
||||
if ban {
|
||||
*count = 0;
|
||||
}
|
||||
(kicks, ban)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flag leads to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Verdict {
|
||||
@@ -281,13 +232,6 @@ pub struct Player {
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
@@ -333,19 +277,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_by_default_but_only_combat_and_ground_act() {
|
||||
fn on_by_default_but_only_combat_acts() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
for &check in Check::ALL {
|
||||
assert!(c.runs(check), "{}", check.name());
|
||||
let a = c.actions(check);
|
||||
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
|
||||
assert_eq!(
|
||||
acts,
|
||||
matches!(check, Check::Combat | Check::Ground),
|
||||
"{}",
|
||||
check.name()
|
||||
);
|
||||
assert_eq!(acts, check == Check::Combat, "{}", check.name());
|
||||
}
|
||||
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
|
||||
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
|
||||
@@ -397,25 +336,6 @@ mod tests {
|
||||
assert!(v.kick && v.alert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kick_that_reaches_ban_after_bans() {
|
||||
let c = Config::default();
|
||||
assert_eq!(c.actions(Check::Combat).ban_after, 2);
|
||||
assert_eq!(c.actions(Check::Prediction).ban_after, 0);
|
||||
let mut s = Strikes::default();
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
|
||||
// Another player, another check: their own counts.
|
||||
assert_eq!(s.kick("b", Check::Combat, 2), (1, false));
|
||||
assert_eq!(s.kick("a", Check::Timer, 2), (1, false));
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (2, true));
|
||||
// Pardoned, the player is kicked once more before the next ban.
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
|
||||
// 0 never bans; 1 bans at the first kick.
|
||||
assert_eq!(s.kick("c", Check::Timer, 0), (1, false));
|
||||
assert_eq!(s.kick("c", Check::Timer, 0), (2, false));
|
||||
assert_eq!(s.kick("d", Check::Ground, 1), (1, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_turns_an_action_off() {
|
||||
let mut c = on();
|
||||
|
||||
@@ -33,21 +33,9 @@ pub enum Change {
|
||||
Velocity([f64; 3]),
|
||||
/// ClientboundExplodePacket's player knockback, added to the velocity.
|
||||
Knockback([f64; 3]),
|
||||
/// ClientboundUpdateAttributesPacket for the player itself: the server's attributes as they
|
||||
/// are when the replay reaches it, but for the movement speed's sprint modifier, which the
|
||||
/// client's sprinting decides: `sprint` is whether the packet had it when it carried the
|
||||
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
|
||||
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
|
||||
Attributes { sprint: Option<bool> },
|
||||
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
|
||||
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
|
||||
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
|
||||
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
|
||||
Sprinting(bool),
|
||||
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
|
||||
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
|
||||
/// are the server's: they reach the client as attributes.)
|
||||
Effects,
|
||||
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
|
||||
/// attributes and effects, as they are when the replay reaches it.
|
||||
State,
|
||||
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
|
||||
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
|
||||
/// tick played before the client had them may be off.
|
||||
@@ -398,16 +386,16 @@ mod tests {
|
||||
fn changes_keep_the_server_tick_that_sent_them() {
|
||||
let mut r = Record::default();
|
||||
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
|
||||
r.changed(5, Change::Effects);
|
||||
r.changed(5, Change::State);
|
||||
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
|
||||
assert_eq!(
|
||||
all,
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::Effects)]
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
|
||||
);
|
||||
assert_eq!(r.changes_since(1).unwrap().count(), 1);
|
||||
assert!(r.changes_since(3).is_none());
|
||||
for i in 0..CHANGES_KEPT as u64 {
|
||||
r.changed(6 + i, Change::Effects);
|
||||
r.changed(6 + i, Change::State);
|
||||
}
|
||||
// The first two are gone.
|
||||
assert!(r.changes_since(1).is_none());
|
||||
|
||||
@@ -8,7 +8,6 @@ description = "Horizon version-specific model and resource decoding"
|
||||
|
||||
[dependencies]
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
log = "0.4"
|
||||
serde = "1.0.229"
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
//! Cuboid geometry in block units, including blockstate rotation and UV locking.
|
||||
use crate::{
|
||||
json::*,
|
||||
model::*,
|
||||
obj::{MtlTexture, ObjFace, ObjModel},
|
||||
resolve::{ResolvedCuboidModel, ResolvedLoader},
|
||||
};
|
||||
use crate::{json::*, model::*, resolve::ResolvedCuboidModel};
|
||||
mod transform;
|
||||
pub use transform::BlockModelTransform;
|
||||
|
||||
@@ -18,40 +13,7 @@ pub struct ModelQuad {
|
||||
pub material: Material,
|
||||
pub tint_index: i32,
|
||||
pub shade_direction_override: Option<Direction>,
|
||||
/// The face's or element's `light_emission`.
|
||||
pub light_emission: u8,
|
||||
/// Least (block, sky) light the quad is drawn with: its emission, raised by `neoforge_data`.
|
||||
pub light: [u8; 2],
|
||||
/// `neoforge_data` colour (ARGB) multiplied into the quad's tint.
|
||||
pub color: Option<i32>,
|
||||
/// `neoforge_data` override of the model's ambient occlusion for this quad.
|
||||
pub ambient_occlusion: Option<bool>,
|
||||
pub render_type: Option<RenderType>,
|
||||
/// False for a loader quad that takes no directional shading.
|
||||
pub shade: bool,
|
||||
}
|
||||
|
||||
/// NeoForge's `render_type` names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderType {
|
||||
Solid,
|
||||
Cutout,
|
||||
CutoutMipped,
|
||||
Translucent,
|
||||
Tripwire,
|
||||
}
|
||||
impl RenderType {
|
||||
/// Accepts a bare or `minecraft:` name; others (mods' own types) are `None`.
|
||||
pub fn by_name(name: &str) -> Option<Self> {
|
||||
match name.strip_prefix("minecraft:").unwrap_or(name) {
|
||||
"solid" => Some(Self::Solid),
|
||||
"cutout" => Some(Self::Cutout),
|
||||
"cutout_mipped" => Some(Self::CutoutMipped),
|
||||
"translucent" => Some(Self::Translucent),
|
||||
"tripwire" => Some(Self::Tripwire),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -224,17 +186,6 @@ fn bake_elements(
|
||||
skip_flat: bool,
|
||||
) -> Result<Vec<ModelQuad>> {
|
||||
let mut output = Vec::new();
|
||||
bake_into(model, transform, skip_flat, &mut output)?;
|
||||
Ok(output)
|
||||
}
|
||||
/// The elements, then what the model's loader adds.
|
||||
fn bake_into(
|
||||
model: &ResolvedCuboidModel,
|
||||
transform: BlockModelTransform,
|
||||
skip_flat: bool,
|
||||
output: &mut Vec<ModelQuad>,
|
||||
) -> Result<()> {
|
||||
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
|
||||
for element in model.elements() {
|
||||
let element = element
|
||||
.as_ref()
|
||||
@@ -256,108 +207,11 @@ fn bake_into(
|
||||
force_translucent: false,
|
||||
});
|
||||
output.push(bake_face(
|
||||
element,
|
||||
face,
|
||||
*direction,
|
||||
matrix,
|
||||
material,
|
||||
transform,
|
||||
render_type,
|
||||
element, face, *direction, matrix, material, transform,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
match model.loader() {
|
||||
Some(ResolvedLoader::Composite(children)) => {
|
||||
for child in children {
|
||||
bake_into(child, transform, skip_flat, output)?;
|
||||
}
|
||||
}
|
||||
Some(ResolvedLoader::Obj {
|
||||
model: Some(obj),
|
||||
flip_v,
|
||||
shade,
|
||||
hidden,
|
||||
}) => bake_obj(model, obj, *flip_v, *shade, hidden, transform, output)?,
|
||||
Some(ResolvedLoader::Obj { model: None, .. }) | None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Obj faces are unculled quads in block units; the blockstate transform moves them whole.
|
||||
fn bake_obj(
|
||||
model: &ResolvedCuboidModel,
|
||||
obj: &ObjModel,
|
||||
flip_v: bool,
|
||||
shade: bool,
|
||||
hidden: &std::collections::BTreeSet<String>,
|
||||
transform: BlockModelTransform,
|
||||
output: &mut Vec<ModelQuad>,
|
||||
) -> Result<()> {
|
||||
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
|
||||
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
|
||||
let slot = match obj.materials.get(&face.material) {
|
||||
Some(MtlTexture::Sprite(sprite)) => {
|
||||
output.push(obj_quad(
|
||||
face,
|
||||
Material {
|
||||
sprite: identifier(sprite)?,
|
||||
force_translucent: false,
|
||||
},
|
||||
flip_v,
|
||||
shade,
|
||||
render_type,
|
||||
transform,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
Some(MtlTexture::Slot(slot)) => slot,
|
||||
None => &face.material,
|
||||
};
|
||||
let material = if slot.is_empty() {
|
||||
None
|
||||
} else {
|
||||
model.material(slot)?
|
||||
};
|
||||
let material = material.cloned().unwrap_or(Material {
|
||||
sprite: "minecraft:missingno".to_owned(),
|
||||
force_translucent: false,
|
||||
});
|
||||
output.push(obj_quad(
|
||||
face,
|
||||
material,
|
||||
flip_v,
|
||||
shade,
|
||||
render_type,
|
||||
transform,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn obj_quad(
|
||||
face: &ObjFace,
|
||||
material: Material,
|
||||
flip_v: bool,
|
||||
shade: bool,
|
||||
render_type: Option<RenderType>,
|
||||
transform: BlockModelTransform,
|
||||
) -> ModelQuad {
|
||||
let positions = face.vertices.map(|v| transform.position(v.position));
|
||||
ModelQuad {
|
||||
positions,
|
||||
uv: face
|
||||
.vertices
|
||||
.map(|v| [v.uv[0], if flip_v { 1.0 - v.uv[1] } else { v.uv[1] }]),
|
||||
facing: facing(&positions).unwrap_or(Direction::Up),
|
||||
cull: None,
|
||||
material,
|
||||
tint_index: -1,
|
||||
shade_direction_override: None,
|
||||
light_emission: 0,
|
||||
light: [0; 2],
|
||||
color: None,
|
||||
ambient_occlusion: None,
|
||||
render_type,
|
||||
shade,
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
/// `FaceBakery.bakeQuad` for a quad with explicit corners and unit-atlas UVs
|
||||
/// (`uv` in 0..16 texels), as `ItemModelGenerator` builds its layers.
|
||||
@@ -376,7 +230,6 @@ pub fn bake_flat_face(
|
||||
rotation: None,
|
||||
shade_direction_override: None,
|
||||
light_emission: 0,
|
||||
neoforge_data: None,
|
||||
};
|
||||
let face = Face {
|
||||
cull: None,
|
||||
@@ -384,8 +237,6 @@ pub fn bake_flat_face(
|
||||
texture: String::new(),
|
||||
uv: Some(uv),
|
||||
rotation: 0,
|
||||
light_emission: None,
|
||||
neoforge_data: None,
|
||||
};
|
||||
bake_face(
|
||||
&element,
|
||||
@@ -394,7 +245,6 @@ pub fn bake_flat_face(
|
||||
None,
|
||||
material,
|
||||
BlockModelTransform::new([0; 3], false),
|
||||
None,
|
||||
)
|
||||
}
|
||||
fn bake_face(
|
||||
@@ -404,7 +254,6 @@ fn bake_face(
|
||||
matrix: Option<ElementMatrix>,
|
||||
material: Material,
|
||||
transform: BlockModelTransform,
|
||||
render_type: Option<RenderType>,
|
||||
) -> Result<ModelQuad> {
|
||||
let mut positions = corners(element.from, element.to, direction).map(|v| v.map(|c| c / 16.0));
|
||||
if let (Some(rotation), Some(matrix)) = (&element.rotation, matrix) {
|
||||
@@ -454,11 +303,6 @@ fn bake_face(
|
||||
uv.swap(index, found);
|
||||
}
|
||||
}
|
||||
let data = face
|
||||
.neoforge_data
|
||||
.unwrap_or_default()
|
||||
.over(element.neoforge_data.unwrap_or_default());
|
||||
let light_emission = face.light_emission.unwrap_or(element.light_emission);
|
||||
Ok(ModelQuad {
|
||||
positions,
|
||||
uv,
|
||||
@@ -467,196 +311,6 @@ fn bake_face(
|
||||
material,
|
||||
tint_index: face.tint_index,
|
||||
shade_direction_override: element.shade_direction_override,
|
||||
light_emission,
|
||||
light: [data.block_light, data.sky_light].map(|l| l.unwrap_or(0).max(light_emission)),
|
||||
color: data.color,
|
||||
ambient_occlusion: data.ambient_occlusion,
|
||||
render_type,
|
||||
shade: true,
|
||||
light_emission: element.light_emission,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod loader_tests {
|
||||
use super::*;
|
||||
use crate::resolve::{AssetReader, ModelLibrary};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
struct Files(BTreeMap<&'static str, &'static str>);
|
||||
impl AssetReader for Files {
|
||||
fn read_text(&self, location: &str) -> Option<String> {
|
||||
self.0.get(location).map(|text| (*text).to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn library(models: &[(&str, &str)], files: &[(&'static str, &'static str)]) -> ModelLibrary {
|
||||
let models = models
|
||||
.iter()
|
||||
.map(|(name, json)| ((*name).to_owned(), CuboidModel::from_json(json).unwrap()))
|
||||
.collect();
|
||||
ModelLibrary::new(models)
|
||||
.unwrap()
|
||||
.with_assets(Arc::new(Files(files.iter().copied().collect())))
|
||||
}
|
||||
fn bake(library: &ModelLibrary, name: &str) -> Vec<ModelQuad> {
|
||||
bake_block_model(
|
||||
&library.resolve(name).unwrap(),
|
||||
BlockModelTransform::new([0; 3], false),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn sprites(quads: &[ModelQuad]) -> Vec<&str> {
|
||||
quads.iter().map(|q| q.material.sprite.as_str()).collect()
|
||||
}
|
||||
const CUBE: &str =
|
||||
r##"{"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"up":{"texture":"#all"}}}]}"##;
|
||||
const COMPOSITE: &str = r#"{"loader":"neoforge:composite","textures":{"all":"block/shared"},
|
||||
"children":{"zeta":{"parent":"t:cube","textures":{"all":"block/own"}},"alpha":{"parent":"t:cube"}}}"#;
|
||||
|
||||
#[test]
|
||||
fn composite_bakes_children_in_document_order_with_slot_fallback() {
|
||||
let library = library(&[("t:cube", CUBE), ("t:comp", COMPOSITE)], &[]);
|
||||
let quads = bake(&library, "t:comp");
|
||||
assert_eq!(
|
||||
sprites(&quads),
|
||||
["minecraft:block/own", "minecraft:block/shared"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn composite_visibility_hides_a_child() {
|
||||
let hidden =
|
||||
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
|
||||
let library = library(&[("t:cube", CUBE), ("t:comp", &hidden)], &[]);
|
||||
assert_eq!(
|
||||
sprites(&bake(&library, "t:comp")),
|
||||
["minecraft:block/shared"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_inheriting_model_overrides_which_children_are_visible() {
|
||||
let hidden =
|
||||
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
|
||||
let shown = r#"{"parent":"t:hidden","visibility":{"zeta":true,"alpha":false}}"#;
|
||||
let library = library(
|
||||
&[("t:cube", CUBE), ("t:hidden", &hidden), ("t:shown", shown)],
|
||||
&[],
|
||||
);
|
||||
assert_eq!(sprites(&bake(&library, "t:shown")), ["minecraft:block/own"]);
|
||||
}
|
||||
#[test]
|
||||
fn loader_is_inherited_by_parent_after_own_elements() {
|
||||
let derived = r##"{"parent":"t:comp","textures":{"all":"block/derived"},
|
||||
"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"down":{"texture":"#all"}}}]}"##;
|
||||
let library = library(
|
||||
&[
|
||||
("t:cube", CUBE),
|
||||
("t:comp", COMPOSITE),
|
||||
("t:derived", derived),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
let quads = bake(&library, "t:derived");
|
||||
assert_eq!(quads[0].facing, Direction::Down);
|
||||
assert_eq!(
|
||||
sprites(&quads),
|
||||
[
|
||||
"minecraft:block/derived",
|
||||
"minecraft:block/own",
|
||||
"minecraft:block/derived"
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn own_loader_wins_and_unknown_loader_adds_nothing() {
|
||||
let other = r#"{"parent":"t:comp","loader":"somemod:thing"}"#;
|
||||
let library = library(
|
||||
&[("t:cube", CUBE), ("t:comp", COMPOSITE), ("t:other", other)],
|
||||
&[],
|
||||
);
|
||||
assert!(bake(&library, "t:other").is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn composites_that_nest_through_themselves_are_an_error() {
|
||||
let looping = r#"{"loader":"neoforge:composite","children":{"a":{"parent":"t:loop"}}}"#;
|
||||
let library = library(&[("t:loop", looping)], &[]);
|
||||
assert!(library.resolve("t:loop").is_err());
|
||||
}
|
||||
|
||||
const OBJ: &str = "mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\nv 0 1 0\n\
|
||||
vt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
|
||||
usemtl top\nf 1/1 4/4 3/3 2/2\nusemtl side\nf 1/1 2/2 5/4\nusemtl bare\nf 1/1 2/2 5/4\n";
|
||||
const MTL: &str = "newmtl top\nmap_Kd #lid\nnewmtl side\nmap_Kd #wall\nnewmtl bare\nKd 1 1 1\n";
|
||||
const OBJ_FILES: [(&str, &str); 2] = [("t:models/x.obj", OBJ), ("t:models/m.mtl", MTL)];
|
||||
const OBJ_MODEL: &str = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","flip_v":true,
|
||||
"textures":{"lid":"block/lid","wall":"block/wall"}}"#;
|
||||
|
||||
#[test]
|
||||
fn obj_faces_become_quads_with_materials_and_flipped_v() {
|
||||
let flat = OBJ_MODEL.replace(r#""flip_v":true,"#, "");
|
||||
let library = library(&[("t:o", OBJ_MODEL), ("t:flat", &flat)], &OBJ_FILES);
|
||||
let quads = bake(&library, "t:o");
|
||||
assert_eq!(
|
||||
sprites(&quads),
|
||||
[
|
||||
"minecraft:block/lid",
|
||||
"minecraft:block/wall",
|
||||
"minecraft:missingno"
|
||||
]
|
||||
);
|
||||
assert_eq!(quads[0].facing, Direction::Up);
|
||||
assert_eq!(quads[1].facing, Direction::South);
|
||||
assert_eq!(quads[0].positions[1], [0.0, 0.0, 1.0]);
|
||||
assert_eq!(quads[0].uv[0], [0.0, 1.0]);
|
||||
assert_eq!(quads[0].uv[2], [1.0, 0.0]);
|
||||
// A triangle repeats its last vertex.
|
||||
assert_eq!(quads[1].positions[3], quads[1].positions[2]);
|
||||
assert_eq!(quads[1].uv[3], quads[1].uv[2]);
|
||||
assert!(quads.iter().all(|q| q.cull.is_none() && q.shade));
|
||||
assert_eq!(bake(&library, "t:flat")[0].uv[0], [0.0, 0.0]);
|
||||
}
|
||||
#[test]
|
||||
fn obj_shade_quads_false_and_mtl_override() {
|
||||
let unshaded = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","shade_quads":false,
|
||||
"mtl_override":"t:models/o.mtl","textures":{"s":"block/s"}}"#;
|
||||
let files = [
|
||||
("t:models/x.obj", OBJ),
|
||||
("t:models/o.mtl", "newmtl top\nmap_Kd #s\n"),
|
||||
];
|
||||
let library = library(&[("t:u", unshaded)], &files);
|
||||
let quads = bake(&library, "t:u");
|
||||
assert!(quads.iter().all(|q| !q.shade));
|
||||
assert_eq!(quads[0].material.sprite, "minecraft:block/s");
|
||||
}
|
||||
#[test]
|
||||
fn missing_or_broken_obj_bakes_nothing() {
|
||||
let missing = library(&[("t:o", OBJ_MODEL)], &[]);
|
||||
assert!(bake(&missing, "t:o").is_empty());
|
||||
let broken = library(
|
||||
&[("t:o", OBJ_MODEL)],
|
||||
&[("t:models/x.obj", "v 0 0 0\nf 1 2 3\n")],
|
||||
);
|
||||
assert!(bake(&broken, "t:o").is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn obj_visibility_skips_the_objects_a_model_hides() {
|
||||
let two = "v 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\no a\nusemtl m\nf 1 2 3 4\no b\nf 4 3 2 1\n";
|
||||
let files = [("t:models/two.obj", two)];
|
||||
let base =
|
||||
r#"{"loader":"neoforge:obj","model":"t:models/two.obj","textures":{"m":"block/m"}}"#;
|
||||
let hiding = r#"{"parent":"t:base","visibility":{"b":false}}"#;
|
||||
let restoring = r#"{"parent":"t:hiding","visibility":{"b":true}}"#;
|
||||
let library = library(
|
||||
&[
|
||||
("t:base", base),
|
||||
("t:hiding", hiding),
|
||||
("t:restoring", restoring),
|
||||
],
|
||||
&files,
|
||||
);
|
||||
assert_eq!(bake(&library, "t:base").len(), 2);
|
||||
let quads = bake(&library, "t:hiding");
|
||||
assert_eq!(quads.len(), 1);
|
||||
assert_eq!(quads[0].facing, Direction::Down);
|
||||
assert_eq!(bake(&library, "t:restoring").len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
json::Result,
|
||||
math::{Mat4, quaternion_xyz},
|
||||
model::{Direction, DisplayTransform, GuiLight, ItemTransform, Material},
|
||||
resolve::{ModelLibrary, ResolvedCuboidModel},
|
||||
resolve::ModelLibrary,
|
||||
};
|
||||
use std::{collections::BTreeSet, f32::consts::PI};
|
||||
|
||||
@@ -110,15 +110,6 @@ pub fn bake_item_model(
|
||||
let model = library
|
||||
.resolve(name)
|
||||
.or_else(|_| library.resolve(crate::resolve::MISSING_MODEL))?;
|
||||
bake_resolved(&model, sprites)
|
||||
}
|
||||
|
||||
/// Bake an already resolved model, e.g. one from
|
||||
/// [`ModelLibrary::resolve_with_textures`].
|
||||
pub fn bake_resolved(
|
||||
model: &ResolvedCuboidModel,
|
||||
sprites: &dyn SpriteLookup,
|
||||
) -> Result<BakedItemModel> {
|
||||
let known = |sprite: &str| sprite == MISSING_SPRITE || sprites.mask(sprite).is_some();
|
||||
let material = |m: &Material| {
|
||||
if known(&m.sprite) {
|
||||
@@ -152,7 +143,7 @@ pub fn bake_resolved(
|
||||
quads
|
||||
} else {
|
||||
let mut quads =
|
||||
bake_block_model(model, crate::bake::BlockModelTransform::new([0; 3], false))?;
|
||||
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))?;
|
||||
for q in &mut quads {
|
||||
q.material = material(&q.material);
|
||||
}
|
||||
@@ -307,87 +298,3 @@ fn extrude_layer(mask: &SpriteMask, material: &Material, layer: i32) -> Result<V
|
||||
}
|
||||
Ok(quads)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{CuboidModel, TextureSlot};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The stick: 2x2 with one transparent pixel. `item/cit`: one opaque pixel, so a bake
|
||||
/// shows whose mask it extruded.
|
||||
struct Sprites;
|
||||
impl SpriteLookup for Sprites {
|
||||
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
match sprite {
|
||||
"minecraft:item/stick" => Some(SpriteMask {
|
||||
width: 2,
|
||||
height: 2,
|
||||
frames: vec![vec![true, false, false, false]],
|
||||
}),
|
||||
"horizon_cit:item/cit" => Some(SpriteMask {
|
||||
width: 1,
|
||||
height: 1,
|
||||
frames: vec![vec![false]],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn library() -> ModelLibrary {
|
||||
ModelLibrary::with_item_generator(BTreeMap::from(
|
||||
[
|
||||
("item/generated", r#"{"parent":"builtin/generated","display":{"gui":{"scale":[2,2,2]}}}"#),
|
||||
("item/stick", r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#),
|
||||
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
|
||||
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
|
||||
("block/slab", r##"{"textures":{"particle":"#side","side":"block/absent"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"},"down":{"texture":"#side","cullface":"down"}}}]}"##),
|
||||
]
|
||||
.map(|(name, json)| (name.to_owned(), CuboidModel::from_json(json).unwrap())),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn baking_by_name_falls_back_to_the_missing_model() {
|
||||
let library = library();
|
||||
// Front, back, and the 8 unit edges around the L of 3 opaque pixels.
|
||||
let stick = bake_item_model(&library, &Sprites, "item/stick").unwrap();
|
||||
assert_eq!(stick.quads.len(), 10);
|
||||
// An unknown sprite bakes as the missing sprite.
|
||||
let slab = bake_item_model(&library, &Sprites, "block/slab").unwrap();
|
||||
assert_eq!(slab.quads.len(), 2);
|
||||
assert_eq!(slab.particle.sprite, MISSING_SPRITE);
|
||||
// A cyclic chain, an error to `resolve`, bakes as the missing cube.
|
||||
let cyclic = bake_item_model(&library, &Sprites, "horizon:loop").unwrap();
|
||||
assert_eq!(cyclic.quads.len(), 6);
|
||||
assert!(
|
||||
cyclic
|
||||
.quads
|
||||
.iter()
|
||||
.all(|q| q.material.sprite == MISSING_SPRITE)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn replaced_layer_bakes_with_its_own_sprite() {
|
||||
let library = library();
|
||||
let child = BTreeMap::from([(
|
||||
"layer0".to_owned(),
|
||||
TextureSlot::Material(Material {
|
||||
sprite: "horizon_cit:item/cit".to_owned(),
|
||||
force_translucent: false,
|
||||
}),
|
||||
)]);
|
||||
let model = library.resolve_with_textures("item/stick", &child).unwrap();
|
||||
let baked = bake_resolved(&model, &Sprites).unwrap();
|
||||
// The CIT sprite's own mask, one opaque pixel: front, back and 4 edges.
|
||||
assert_eq!(baked.quads.len(), 6);
|
||||
assert!(
|
||||
baked
|
||||
.quads
|
||||
.iter()
|
||||
.all(|q| q.material.sprite == "horizon_cit:item/cit")
|
||||
);
|
||||
assert_eq!(baked.particle.sprite, "horizon_cit:item/cit");
|
||||
assert_eq!(baked.transforms[5].as_ref().unwrap().scale, [2.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ use std::{
|
||||
};
|
||||
|
||||
mod component_predicate;
|
||||
mod foreign;
|
||||
mod special;
|
||||
pub use special::{SpecialKind, SpecialModel};
|
||||
|
||||
@@ -432,17 +431,6 @@ impl ItemModelNode {
|
||||
pub fn decode(v: &Value) -> Result<Self> {
|
||||
let m = object(v)?;
|
||||
let kind = ident(required(m, "type")?)?;
|
||||
if kind == "minecraft:condition" {
|
||||
let property = ident(required(m, "property")?)?;
|
||||
if !property.starts_with("minecraft:") {
|
||||
return Ok(Self::Condition {
|
||||
transformation: optional_transformation(m)?,
|
||||
property: ConditionProperty::Custom(property),
|
||||
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
|
||||
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(match kind.as_str() {
|
||||
"minecraft:empty" => Self::Empty,
|
||||
"minecraft:model" => Self::Model(ModelLeaf {
|
||||
@@ -463,21 +451,12 @@ impl ItemModelNode {
|
||||
.collect::<Result<_>>()?,
|
||||
transformation: optional_transformation(m)?,
|
||||
},
|
||||
"minecraft:condition" if foreign::property_is_foreign(m)? => foreign::wrap(
|
||||
Self::decode(required(m, "on_false")?)?,
|
||||
optional_transformation(m)?,
|
||||
),
|
||||
"minecraft:condition" => Self::Condition {
|
||||
transformation: optional_transformation(m)?,
|
||||
property: ConditionProperty::decode(m)?,
|
||||
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
|
||||
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
|
||||
},
|
||||
"minecraft:select" if foreign::property_is_foreign(m)? => {
|
||||
// No case can match a property Horizon cannot read.
|
||||
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
|
||||
foreign::wrap(fallback.unwrap_or(Self::Empty), optional_transformation(m)?)
|
||||
}
|
||||
"minecraft:select" => {
|
||||
let transformation = optional_transformation(m)?;
|
||||
let property = SelectProperty::decode(m)?;
|
||||
@@ -505,7 +484,6 @@ impl ItemModelNode {
|
||||
}
|
||||
}
|
||||
"minecraft:range_dispatch" => {
|
||||
let foreign_property = foreign::property_is_foreign(m)?;
|
||||
let mut entries = list(required(m, "entries")?)?
|
||||
.iter()
|
||||
.map(|e| {
|
||||
@@ -518,17 +496,6 @@ impl ItemModelNode {
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// `Comparator.comparingDouble`: Double.compare, a total order.
|
||||
entries.sort_by(|a, b| f64::from(a.0).total_cmp(&f64::from(b.0)));
|
||||
if foreign_property {
|
||||
// A property Horizon cannot read is 0.
|
||||
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
|
||||
let chosen = last_index_less_or_equal(&entries, 0.0)
|
||||
.map(|i| entries.swap_remove(i).1)
|
||||
.or(fallback);
|
||||
return Ok(foreign::wrap(
|
||||
chosen.unwrap_or(Self::Empty),
|
||||
optional_transformation(m)?,
|
||||
));
|
||||
}
|
||||
Self::RangeDispatch {
|
||||
transformation: optional_transformation(m)?,
|
||||
property: NumericProperty::decode(m)?,
|
||||
@@ -539,21 +506,12 @@ impl ItemModelNode {
|
||||
.transpose()?,
|
||||
}
|
||||
}
|
||||
"minecraft:special" if SpecialModel::is_foreign(required(m, "model")?) => {
|
||||
// The mod's own renderer is not run: the base model stands in.
|
||||
Self::Model(ModelLeaf {
|
||||
model: ident(required(m, "base")?)?,
|
||||
transformation: optional_transformation(m)?,
|
||||
tints: Vec::new(),
|
||||
})
|
||||
}
|
||||
"minecraft:special" => Self::Special {
|
||||
base: ident(required(m, "base")?)?,
|
||||
transformation: optional_transformation(m)?,
|
||||
model: SpecialModel::decode(required(m, "model")?)?,
|
||||
},
|
||||
"minecraft:bundle/selected_item" => Self::BundleSelectedItem,
|
||||
other if foreign::is_foreign(other) => foreign::decode_node(other, m),
|
||||
_ => return Err(err("unknown item model type")),
|
||||
})
|
||||
}
|
||||
@@ -645,8 +603,6 @@ impl TintSource {
|
||||
"minecraft:firework" => Self::Firework(default()?),
|
||||
"minecraft:potion" => Self::Potion(default()?),
|
||||
"minecraft:team" => Self::Team(default()?),
|
||||
// A mod's own tint source, which its Java code computes: untinted here.
|
||||
other if foreign::is_foreign(other) => Self::Constant(-1),
|
||||
_ => return Err(err("unknown item tint source")),
|
||||
})
|
||||
}
|
||||
@@ -658,7 +614,6 @@ impl TintSource {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ConditionProperty {
|
||||
Custom(String),
|
||||
CustomModelData(i32),
|
||||
UsingItem,
|
||||
Broken,
|
||||
@@ -991,10 +946,6 @@ pub struct Context<'a> {
|
||||
|
||||
/// Host services the resolver cannot derive from data alone.
|
||||
pub trait ItemEnv {
|
||||
/// A mod's namespaced conditional property, false when no mod supplies it.
|
||||
fn item_model_property(&self, _property: &str, _stack: &StackView) -> bool {
|
||||
false
|
||||
}
|
||||
/// `PotionContents.getColorOr` for the potion/effects part: the mixed
|
||||
/// effect colour, or `None` when the contents have no visible effects.
|
||||
fn potion_effect_color(&self, _potion_contents: &Value) -> Option<i32> {
|
||||
@@ -1348,9 +1299,6 @@ impl Resolve<'_> {
|
||||
fn condition(&self, property: &ConditionProperty) -> bool {
|
||||
let owner = self.ctx.owner;
|
||||
match property {
|
||||
ConditionProperty::Custom(property) => {
|
||||
self.env.item_model_property(property, self.stack)
|
||||
}
|
||||
ConditionProperty::CustomModelData(i) => {
|
||||
self.component("minecraft:custom_model_data")
|
||||
.and_then(|d| custom_data_at(d, "flags", *i))
|
||||
@@ -1769,7 +1717,5 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
Err(low as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod foreign_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,114 +0,0 @@
|
||||
//! Nodes and properties of other namespaces (a mod's, NeoForge's) decode to the closest vanilla behaviour.
|
||||
use super::{
|
||||
ItemModelNode, ModelLeaf, Result, TintSource, get, ident, identifier, list, object,
|
||||
optional_transformation,
|
||||
};
|
||||
use crate::{json::error as err, math::Mat4};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Keys a mod's node usually keeps its real model under, in order of preference.
|
||||
const MODEL_KEYS: [&str; 4] = ["model", "base_model", "base", "fallback"];
|
||||
|
||||
pub fn is_foreign(id: &str) -> bool {
|
||||
!id.starts_with("minecraft:")
|
||||
}
|
||||
|
||||
/// A composite carries the transformation a collapsed node had.
|
||||
pub fn wrap(node: ItemModelNode, transformation: Option<Mat4>) -> ItemModelNode {
|
||||
match transformation {
|
||||
None => node,
|
||||
Some(_) => ItemModelNode::Composite {
|
||||
models: vec![node],
|
||||
transformation,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn property_is_foreign(m: &Map<String, Value>) -> Result<bool> {
|
||||
let property = m
|
||||
.get("property")
|
||||
.ok_or_else(|| err("missing property"))
|
||||
.and_then(ident)?;
|
||||
Ok(is_foreign(&property))
|
||||
}
|
||||
|
||||
fn leaf(model: String, m: &Map<String, Value>) -> ItemModelNode {
|
||||
let tints = get(m, "tints")
|
||||
.and_then(|t| list(t).ok())
|
||||
.map(|t| {
|
||||
t.iter()
|
||||
.filter_map(|t| TintSource::decode(t).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ItemModelNode::Model(ModelLeaf {
|
||||
model,
|
||||
transformation: optional_transformation(m).ok().flatten(),
|
||||
tints,
|
||||
})
|
||||
}
|
||||
|
||||
/// A node of an unknown non-vanilla type: its nested node, else its model id, else nothing.
|
||||
pub fn decode_node(kind: &str, m: &Map<String, Value>) -> ItemModelNode {
|
||||
if kind == "neoforge:fluid_container" {
|
||||
return fluid_container(m);
|
||||
}
|
||||
for key in MODEL_KEYS {
|
||||
match m.get(key) {
|
||||
Some(nested @ Value::Object(_)) => {
|
||||
if let Ok(node) = ItemModelNode::decode(nested) {
|
||||
return wrap(node, optional_transformation(m).ok().flatten());
|
||||
}
|
||||
}
|
||||
Some(Value::String(id)) => {
|
||||
if let Ok(id) = identifier(id) {
|
||||
return leaf(id, m);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let by_suffix = m
|
||||
.iter()
|
||||
.filter(|(k, _)| k.ends_with("_model"))
|
||||
.find_map(|(_, v)| v.as_str().and_then(|id| identifier(id).ok()));
|
||||
match by_suffix {
|
||||
Some(id) => leaf(id, m),
|
||||
None => ItemModelNode::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
// The fluid is not drawn: only the base (and an unmasked cover) texture, taken as a model id (a bucket's).
|
||||
fn fluid_container(m: &Map<String, Value>) -> ItemModelNode {
|
||||
let textures = m.get("textures").and_then(|t| object(t).ok());
|
||||
let texture = |key: &str| {
|
||||
textures
|
||||
.and_then(|t| t.get(key))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| identifier(id).ok())
|
||||
};
|
||||
let mut layers = vec![texture("base")];
|
||||
if m.get("cover_is_mask") == Some(&Value::Bool(false)) {
|
||||
layers.push(texture("cover"));
|
||||
}
|
||||
let mut models: Vec<ItemModelNode> = layers
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|model| {
|
||||
ItemModelNode::Model(ModelLeaf {
|
||||
model,
|
||||
transformation: None,
|
||||
tints: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let transformation = optional_transformation(m).ok().flatten();
|
||||
match models.len() {
|
||||
0 => ItemModelNode::Empty,
|
||||
1 => wrap(models.remove(0), transformation),
|
||||
_ => ItemModelNode::Composite {
|
||||
models,
|
||||
transformation,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn node(json: &str) -> Result<ItemModelNode> {
|
||||
ItemModelNode::decode(&serde_json::from_str(json).unwrap())
|
||||
}
|
||||
fn model_id(n: &ItemModelNode) -> &str {
|
||||
match n {
|
||||
ItemModelNode::Model(leaf) => &leaf.model,
|
||||
other => panic!("not a model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_with_a_model_id_draws_that_model() {
|
||||
let n = node(r#"{"type":"mod:thing","model":"mod:item/thing","tints":[{"type":"mod:tint"}]}"#)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/thing");
|
||||
let ItemModelNode::Model(leaf) = n else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(leaf.tints, vec![TintSource::Constant(-1)]);
|
||||
let n = node(r#"{"type":"mod:thing","base_model":"mod:item/base"}"#).unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/base");
|
||||
let n = node(r#"{"type":"mod:thing","on_model":"mod:item/a"}"#).unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_with_a_nested_node_decodes_it() {
|
||||
let n = node(
|
||||
r#"{"type":"neoforge:trimmed_armor","base_model":{"type":"minecraft:model","model":"mod:item/boots"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/boots");
|
||||
let n = node(
|
||||
r#"{"type":"mod:x","model":{"type":"mod:y","model":"mod:item/inner"},"transformation":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let ItemModelNode::Composite {
|
||||
models,
|
||||
transformation,
|
||||
} = n
|
||||
else {
|
||||
panic!("a transformation wraps the nested node")
|
||||
};
|
||||
assert!(transformation.is_some());
|
||||
assert_eq!(model_id(&models[0]), "mod:item/inner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_with_nothing_usable_is_empty() {
|
||||
assert!(matches!(
|
||||
node(r#"{"type":"mod:facade"}"#).unwrap(),
|
||||
ItemModelNode::Empty
|
||||
));
|
||||
assert!(matches!(
|
||||
node(r#"{"type":"mod:x","model":7}"#).unwrap(),
|
||||
ItemModelNode::Empty
|
||||
));
|
||||
assert!(ClientItem::from_json(r#"{"model":{"type":"ae2:facade"}}"#).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_vanilla_names_still_fail() {
|
||||
assert!(node(r#"{"type":"minecraft:nope"}"#).is_err());
|
||||
assert!(node(r#"{"type":"nope","model":"a:b"}"#).is_err());
|
||||
let leaf = r#"{"type":"minecraft:model","model":"a:b"}"#;
|
||||
for json in [
|
||||
format!(
|
||||
r#"{{"type":"minecraft:condition","property":"minecraft:nope","on_true":{leaf},"on_false":{leaf}}}"#
|
||||
),
|
||||
format!(
|
||||
r#"{{"type":"minecraft:select","property":"minecraft:nope","cases":[],"fallback":{leaf}}}"#
|
||||
),
|
||||
format!(
|
||||
r#"{{"type":"minecraft:range_dispatch","property":"minecraft:nope","entries":[],"fallback":{leaf}}}"#
|
||||
),
|
||||
] {
|
||||
assert!(node(&json).is_err(), "{json}");
|
||||
}
|
||||
assert!(
|
||||
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:nope"}}"#)
|
||||
.is_err()
|
||||
);
|
||||
assert!(ClientItem::from_json(r#"{"model":{"type":"minecraft:nope"}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn condition_property_is_false() {
|
||||
let n = node(
|
||||
r#"{"type":"minecraft:condition","property":"mod:flag",
|
||||
"on_true":{"type":"minecraft:model","model":"a:yes"},
|
||||
"on_false":{"type":"minecraft:model","model":"a:no"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A mod's property stays a condition its mod answers per stack
|
||||
// (`item_model_property`); with no mod answering it, `on_false` is drawn.
|
||||
let ItemModelNode::Condition {
|
||||
property: ConditionProperty::Custom(property),
|
||||
on_false,
|
||||
..
|
||||
} = &n
|
||||
else {
|
||||
panic!("not a custom condition: {n:?}")
|
||||
};
|
||||
assert_eq!(property, "mod:flag");
|
||||
assert_eq!(model_id(on_false), "a:no");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_property_takes_the_fallback_or_nothing() {
|
||||
let n = node(
|
||||
r#"{"type":"minecraft:select","property":"mod:level",
|
||||
"cases":[{"when":"low","model":{"type":"minecraft:model","model":"a:low"}}],
|
||||
"fallback":{"type":"minecraft:model","model":"a:other"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "a:other");
|
||||
let n = node(r#"{"type":"minecraft:select","property":"mod:level","cases":[]}"#).unwrap();
|
||||
assert!(matches!(n, ItemModelNode::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_property_reads_zero() {
|
||||
let entry = |t: f32, m: &str| {
|
||||
format!(r#"{{"threshold":{t},"model":{{"type":"minecraft:model","model":"{m}"}}}}"#)
|
||||
};
|
||||
let fallback = r#","fallback":{"type":"minecraft:model","model":"a:fb"}"#;
|
||||
let make = |entries: &[String], fallback: &str| {
|
||||
node(&format!(
|
||||
r#"{{"type":"minecraft:range_dispatch","property":"mod:fill","entries":[{}]{fallback}}}"#,
|
||||
entries.join(",")
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let n = make(&[entry(0.2, "a:one"), entry(0.4, "a:two")], fallback);
|
||||
assert_eq!(model_id(&n), "a:fb");
|
||||
let n = make(
|
||||
&[
|
||||
entry(0.0, "a:zero"),
|
||||
entry(-1.0, "a:neg"),
|
||||
entry(0.5, "a:half"),
|
||||
],
|
||||
fallback,
|
||||
);
|
||||
assert_eq!(model_id(&n), "a:zero");
|
||||
let n = make(&[entry(0.2, "a:one")], "");
|
||||
assert!(matches!(n, ItemModelNode::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_type_keeps_its_base_model() {
|
||||
let n = node(
|
||||
r#"{"type":"minecraft:special","base":"mod:item/cube","model":{"type":"mod:energy_cube"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/cube");
|
||||
let n =
|
||||
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:shield"}}"#)
|
||||
.unwrap();
|
||||
assert!(matches!(n, ItemModelNode::Special { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_container_draws_its_base_without_the_fluid() {
|
||||
let n = node(
|
||||
r#"{"type":"neoforge:fluid_container","fluid":"mod:gas","cover_is_mask":false,"flip_gas":true,
|
||||
"textures":{"base":"minecraft:item/bucket","fluid":"neoforge:item/mask/bucket_fluid"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "minecraft:item/bucket");
|
||||
let n = node(
|
||||
r#"{"type":"neoforge:fluid_container","cover_is_mask":false,
|
||||
"textures":{"base":"a:item/flask","cover":"a:item/flask_cover"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let ItemModelNode::Composite { models, .. } = n else {
|
||||
panic!("base and cover")
|
||||
};
|
||||
assert_eq!(models.len(), 2);
|
||||
assert!(matches!(
|
||||
node(r#"{"type":"neoforge:fluid_container"}"#).unwrap(),
|
||||
ItemModelNode::Empty
|
||||
));
|
||||
}
|
||||
@@ -77,13 +77,6 @@ fn one_of(v: &Value, names: &[&str]) -> Result<String> {
|
||||
}
|
||||
|
||||
impl SpecialModel {
|
||||
/// A special model type of another namespace, which Horizon has no renderer for.
|
||||
pub fn is_foreign(v: &Value) -> bool {
|
||||
v.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|t| super::identifier(t).ok())
|
||||
.is_some_and(|t| super::foreign::is_foreign(&t))
|
||||
}
|
||||
pub fn decode(v: &Value) -> Result<Self> {
|
||||
let m = object(v)?;
|
||||
let float_or = |key: &str, default: f32| get(m, key).map_or(Ok(default), float);
|
||||
|
||||
@@ -18,96 +18,3 @@ fn display_context_names_round_trip() {
|
||||
assert_eq!(DisplayContext::by_name(name).unwrap().name(), name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mods_tint_source_is_untinted_and_an_unknown_vanilla_one_fails() {
|
||||
let tint = |json: &str| TintSource::decode(&serde_json::from_str(json).unwrap());
|
||||
assert_eq!(
|
||||
tint(r#"{"type":"ae2:storage_cell_state"}"#).unwrap(),
|
||||
TintSource::Constant(-1)
|
||||
);
|
||||
assert!(tint(r#"{"type":"minecraft:nonsense"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_condition_is_evaluated_per_stack_at_branch_selection() {
|
||||
struct Env;
|
||||
impl ItemEnv for Env {
|
||||
fn item_model_property(&self, property: &str, stack: &StackView) -> bool {
|
||||
assert_eq!(property, "example:charged");
|
||||
stack.count > 1 && stack.patch.get("example:charged") == Some(&Some(Value::Bool(true)))
|
||||
}
|
||||
}
|
||||
let definition = ClientItem::from_json(
|
||||
r#"{"model":{
|
||||
"type":"minecraft:condition", "property":"example:charged",
|
||||
"on_true":{"type":"minecraft:model","model":"example:item/charged"},
|
||||
"on_false":{"type":"minecraft:model","model":"example:item/idle"}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut models = ItemModels::default();
|
||||
models.definitions.insert("example:tool".into(), definition);
|
||||
models.prototypes.insert(
|
||||
"example:tool".into(),
|
||||
BTreeMap::from([(
|
||||
"minecraft:item_model".into(),
|
||||
Value::String("example:tool".into()),
|
||||
)]),
|
||||
);
|
||||
let context = Context {
|
||||
display: DisplayContext::Gui,
|
||||
owner: None,
|
||||
level: None,
|
||||
seed: 0,
|
||||
};
|
||||
let mut stack = StackView::new("example:tool", 1);
|
||||
let chosen = |stack: &StackView, env: &dyn ItemEnv| {
|
||||
let resolved = models.resolve(stack, &context, env);
|
||||
assert_eq!(resolved.layers.len(), 1);
|
||||
let LayerContent::Model { model } = &resolved.layers[0].content else {
|
||||
panic!("expected model leaf")
|
||||
};
|
||||
model.clone()
|
||||
};
|
||||
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
|
||||
assert_eq!(chosen(&stack, &Env), "example:item/idle");
|
||||
stack
|
||||
.patch
|
||||
.insert("example:charged".into(), Some(Value::Bool(true)));
|
||||
assert_eq!(chosen(&stack, &Env), "example:item/idle");
|
||||
stack.count = 2;
|
||||
assert_eq!(chosen(&stack, &Env), "example:item/charged");
|
||||
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
|
||||
stack.patch.insert("example:charged".into(), None);
|
||||
assert_eq!(chosen(&stack, &Env), "example:item/idle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_condition_validates_its_name_and_both_branches() {
|
||||
let node = |property: &str, on_true: Value| {
|
||||
ItemModelNode::decode(&serde_json::json!({
|
||||
"type": "minecraft:condition", "property": property,
|
||||
"on_true": on_true, "on_false": {"type": "minecraft:empty"}
|
||||
}))
|
||||
};
|
||||
let empty = serde_json::json!({"type": "minecraft:empty"});
|
||||
for name in [
|
||||
"minecraft:unknown",
|
||||
"unknown",
|
||||
"example:Bad",
|
||||
"bad name:charged",
|
||||
] {
|
||||
assert!(node(name, empty.clone()).is_err(), "{name}");
|
||||
}
|
||||
assert!(node("example:charged", Value::Null).is_err());
|
||||
assert!(matches!(
|
||||
node("minecraft:damaged", empty.clone()).unwrap(),
|
||||
ItemModelNode::Condition {
|
||||
property: ConditionProperty::Damaged,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(matches!(node("example:charged", empty).unwrap(),
|
||||
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
|
||||
}
|
||||
@@ -87,39 +87,6 @@ pub(crate) fn object(raw: &RawValue) -> Result<Object<'_>> {
|
||||
}
|
||||
Ok(serde_json::from_str(raw.get())?)
|
||||
}
|
||||
/// The members of an object in document order, which `object` loses.
|
||||
pub(crate) fn ordered_object(raw: &RawValue) -> Result<Vec<(String, &RawValue)>> {
|
||||
struct Members<'a>(Vec<(String, &'a RawValue)>);
|
||||
impl<'de> serde::Deserialize<'de> for Members<'de> {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self, D::Error> {
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = Members<'de>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("an object")
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: A,
|
||||
) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut members: Vec<(String, &'de RawValue)> = Vec::new();
|
||||
while let Some(entry) = map.next_entry::<String, &'de RawValue>()? {
|
||||
members.retain(|(name, _)| *name != entry.0);
|
||||
members.push(entry);
|
||||
}
|
||||
Ok(Members(members))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Visitor)
|
||||
}
|
||||
}
|
||||
if !raw.get().starts_with('{') {
|
||||
return Err(error("expected object"));
|
||||
}
|
||||
Ok(serde_json::from_str::<Members>(raw.get())?.0)
|
||||
}
|
||||
pub(crate) fn array(raw: &RawValue) -> Result<Vec<&RawValue>> {
|
||||
Ok(serde_json::from_str(raw.get())?)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ pub mod item_model;
|
||||
mod json;
|
||||
pub mod math;
|
||||
pub mod model;
|
||||
pub mod obj;
|
||||
pub mod palette;
|
||||
pub mod resolve;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! does; render preparation must diagnose these before baking.
|
||||
use crate::json::*;
|
||||
use serde_json::value::RawValue;
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Direction {
|
||||
@@ -59,92 +59,11 @@ pub enum TextureSlot {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CuboidModel {
|
||||
pub parent: Option<String>,
|
||||
pub loader: Option<Loader>,
|
||||
pub ambient_occlusion: Option<bool>,
|
||||
pub gui_light: Option<GuiLight>,
|
||||
pub textures: BTreeMap<String, TextureSlot>,
|
||||
pub elements: Option<Vec<Option<Element>>>,
|
||||
pub display: Option<ItemTransforms>,
|
||||
/// NeoForge's `render_type`, without a `minecraft:` prefix.
|
||||
pub render_type: Option<String>,
|
||||
/// A model's `visibility`: an obj object or composite child name to shown; absent is shown.
|
||||
pub visibility: BTreeMap<String, bool>,
|
||||
}
|
||||
/// A custom model loader's payload; a model inherits its parent's loader unless it names its own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Loader {
|
||||
/// `neoforge:composite`: children in document order.
|
||||
Composite {
|
||||
children: Vec<(String, Arc<CuboidModel>)>,
|
||||
/// Child name to shown; a child absent from the map is shown.
|
||||
visibility: BTreeMap<String, bool>,
|
||||
},
|
||||
/// `neoforge:obj`: `model` and `mtl_override` are asset locations.
|
||||
Obj {
|
||||
model: String,
|
||||
flip_v: bool,
|
||||
shade_quads: Option<bool>,
|
||||
mtl_override: Option<String>,
|
||||
},
|
||||
/// A loader this decoder does not know: it adds no geometry.
|
||||
Unknown(String),
|
||||
}
|
||||
/// NeoForge's `neoforge_data` of an element or a face; a face's keys win.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct NeoforgeData {
|
||||
pub block_light: Option<u8>,
|
||||
pub sky_light: Option<u8>,
|
||||
/// ARGB.
|
||||
pub color: Option<i32>,
|
||||
pub ambient_occlusion: Option<bool>,
|
||||
}
|
||||
impl NeoforgeData {
|
||||
/// `self` over `base`, key by key.
|
||||
pub fn over(self, base: Self) -> Self {
|
||||
Self {
|
||||
block_light: self.block_light.or(base.block_light),
|
||||
sky_light: self.sky_light.or(base.sky_light),
|
||||
color: self.color.or(base.color),
|
||||
ambient_occlusion: self.ambient_occlusion.or(base.ambient_occlusion),
|
||||
}
|
||||
}
|
||||
fn decode(raw: &RawValue) -> Result<Self> {
|
||||
let obj = object(raw)?;
|
||||
let light = |key: &str| -> Result<Option<u8>> {
|
||||
Ok(obj
|
||||
.get(key)
|
||||
.map(|r| integer(r))
|
||||
.transpose()?
|
||||
.map(|v| v.clamp(0, 15) as u8))
|
||||
};
|
||||
// Some mods spell both lights as one `light_emission`.
|
||||
let emission = light("light_emission")?;
|
||||
let color = match obj.get("color") {
|
||||
Some(raw) if raw.get().starts_with('"') => {
|
||||
let text = string(raw)?;
|
||||
let text = text.trim();
|
||||
let hex = text
|
||||
.strip_prefix('#')
|
||||
.or_else(|| text.strip_prefix("0x"))
|
||||
.or_else(|| text.strip_prefix("0X"));
|
||||
match hex {
|
||||
Some(hex) => u32::from_str_radix(hex, 16).ok().map(|v| v as i32),
|
||||
None => text.parse::<u32>().ok().map(|v| v as i32),
|
||||
}
|
||||
}
|
||||
Some(raw) => Some(integer(raw)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
block_light: light("block_light")?.or(emission),
|
||||
sky_light: light("sky_light")?.or(emission),
|
||||
color,
|
||||
ambient_occlusion: obj
|
||||
.get("ambient_occlusion")
|
||||
.map(|r| boolean(r))
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Element {
|
||||
@@ -154,7 +73,6 @@ pub struct Element {
|
||||
pub rotation: Option<ElementRotation>,
|
||||
pub shade_direction_override: Option<Direction>,
|
||||
pub light_emission: u8,
|
||||
pub neoforge_data: Option<NeoforgeData>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Face {
|
||||
@@ -164,9 +82,6 @@ pub struct Face {
|
||||
pub uv: Option<[f32; 4]>,
|
||||
/// Normalized counterclockwise quadrants, in the range 0..4.
|
||||
pub rotation: u8,
|
||||
/// A face-level `light_emission`, which wins over its element's.
|
||||
pub light_emission: Option<u8>,
|
||||
pub neoforge_data: Option<NeoforgeData>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Axis {
|
||||
@@ -239,11 +154,6 @@ impl CuboidModel {
|
||||
} else {
|
||||
Some(identifier(&parent)?)
|
||||
};
|
||||
let loader = obj
|
||||
.get("loader")
|
||||
.filter(|raw| raw.get() != "null")
|
||||
.map(|raw| Loader::decode(raw, &obj))
|
||||
.transpose()?;
|
||||
let ambient_occlusion = obj
|
||||
.get("ambientocclusion")
|
||||
.map(|r| boolean(r))
|
||||
@@ -275,83 +185,15 @@ impl CuboidModel {
|
||||
.get("display")
|
||||
.map(|r| ItemTransforms::decode(r))
|
||||
.transpose()?;
|
||||
let render_type = obj
|
||||
.get("render_type")
|
||||
.filter(|raw| raw.get().starts_with('"'))
|
||||
.map(|raw| string(raw))
|
||||
.transpose()?
|
||||
.map(|name| name.strip_prefix("minecraft:").unwrap_or(&name).to_owned());
|
||||
Ok(Self {
|
||||
parent,
|
||||
loader,
|
||||
ambient_occlusion,
|
||||
gui_light,
|
||||
textures,
|
||||
elements,
|
||||
display,
|
||||
render_type,
|
||||
visibility: visibility(&obj)?,
|
||||
})
|
||||
}
|
||||
/// The models this one needs besides itself: its parent and its composite children's parents.
|
||||
pub fn referenced_models(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self.parent.iter().cloned().collect();
|
||||
if let Some(Loader::Composite { children, .. }) = &self.loader {
|
||||
for (_, child) in children {
|
||||
names.extend(child.referenced_models());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
}
|
||||
fn visibility(obj: &Object<'_>) -> Result<BTreeMap<String, bool>> {
|
||||
let mut visibility = BTreeMap::new();
|
||||
if let Some(raw) = obj.get("visibility") {
|
||||
for (key, raw) in object(raw)? {
|
||||
visibility.insert(key, boolean(raw)?);
|
||||
}
|
||||
}
|
||||
Ok(visibility)
|
||||
}
|
||||
impl Loader {
|
||||
fn decode(raw: &RawValue, obj: &Object<'_>) -> Result<Self> {
|
||||
let name = primitive_string(raw)?;
|
||||
match name.as_str() {
|
||||
"neoforge:composite" => {
|
||||
let mut children = Vec::new();
|
||||
if let Some(raw) = obj.get("children") {
|
||||
for (name, raw) in ordered_object(raw)? {
|
||||
children.push((name, Arc::new(CuboidModel::decode(raw)?)));
|
||||
}
|
||||
}
|
||||
Ok(Self::Composite {
|
||||
children,
|
||||
visibility: visibility(obj)?,
|
||||
})
|
||||
}
|
||||
"neoforge:obj" => {
|
||||
let optional = |key: &str| {
|
||||
obj.get(key)
|
||||
.filter(|raw| raw.get() != "null")
|
||||
.map(|raw| primitive_string(raw))
|
||||
.transpose()
|
||||
};
|
||||
Ok(Self::Obj {
|
||||
model: identifier(&optional("model")?.ok_or_else(|| error("missing model"))?)?,
|
||||
flip_v: obj
|
||||
.get("flip_v")
|
||||
.map(|r| boolean(r))
|
||||
.transpose()?
|
||||
.unwrap_or(false),
|
||||
shade_quads: obj.get("shade_quads").map(|r| boolean(r)).transpose()?,
|
||||
mtl_override: optional("mtl_override")?
|
||||
.map(|name| identifier(&name))
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
_ => Ok(Self::Unknown(name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn nullable<T>(raw: &RawValue, decode: impl FnOnce(&RawValue) -> Result<T>) -> Result<Option<T>> {
|
||||
if raw.get() == "null" {
|
||||
@@ -431,10 +273,6 @@ impl Element {
|
||||
rotation,
|
||||
shade_direction_override,
|
||||
light_emission: light_emission as u8,
|
||||
neoforge_data: obj
|
||||
.get("neoforge_data")
|
||||
.map(|r| NeoforgeData::decode(r))
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -468,15 +306,6 @@ impl Face {
|
||||
texture,
|
||||
uv,
|
||||
rotation: (rotation / 90) as u8,
|
||||
light_emission: obj
|
||||
.get("light_emission")
|
||||
.map(|r| integer(r))
|
||||
.transpose()?
|
||||
.map(|v| v.clamp(0, 15) as u8),
|
||||
neoforge_data: obj
|
||||
.get("neoforge_data")
|
||||
.map(|r| NeoforgeData::decode(r))
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
//! Wavefront OBJ and MTL text, as obj-loader models use them.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ObjVertex {
|
||||
/// Block units.
|
||||
pub position: [f32; 3],
|
||||
/// Texture coordinates as written, 0..1 across the sprite.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
/// One quad; a triangle repeats its last vertex.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ObjFace {
|
||||
/// Empty before any `usemtl`.
|
||||
pub material: String,
|
||||
/// The `o` (else `g`) it belongs to, which a model's `visibility` names.
|
||||
pub object: String,
|
||||
pub vertices: [ObjVertex; 4],
|
||||
}
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ParsedObj {
|
||||
pub mtllib: Option<String>,
|
||||
pub faces: Vec<ObjFace>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MtlTexture {
|
||||
/// `map_Kd #name`: a texture slot of the model.
|
||||
Slot(String),
|
||||
/// `map_Kd namespace:path`: a sprite.
|
||||
Sprite(String),
|
||||
}
|
||||
/// A parsed OBJ with its materials.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ObjModel {
|
||||
pub faces: Vec<ObjFace>,
|
||||
pub materials: BTreeMap<String, MtlTexture>,
|
||||
}
|
||||
|
||||
pub fn parse_obj(text: &str) -> Result<ParsedObj, String> {
|
||||
let mut positions: Vec<[f32; 3]> = Vec::new();
|
||||
let mut uvs: Vec<[f32; 2]> = Vec::new();
|
||||
let mut parsed = ParsedObj::default();
|
||||
let mut material = String::new();
|
||||
let mut object = String::new();
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
let mut words = line.split_whitespace();
|
||||
let Some(keyword) = words.next() else {
|
||||
continue;
|
||||
};
|
||||
let at = |message: &str| format!("line {}: {message}", number + 1);
|
||||
match keyword {
|
||||
_ if keyword.starts_with('#') => {}
|
||||
"v" => positions.push(floats::<3>(words).ok_or_else(|| at("bad vertex"))?),
|
||||
"vt" => {
|
||||
let mut words = words;
|
||||
let u = words.next().and_then(|w| w.parse().ok());
|
||||
let v = words.next().map_or(Some(0.0), |w| w.parse().ok());
|
||||
let (Some(u), Some(v)) = (u, v) else {
|
||||
return Err(at("bad texture coordinate"));
|
||||
};
|
||||
uvs.push([u, v]);
|
||||
}
|
||||
"mtllib" => parsed.mtllib = words.next().map(str::to_owned),
|
||||
"usemtl" => material = words.next().unwrap_or("").to_owned(),
|
||||
"o" | "g" => object = words.next().unwrap_or("").to_owned(),
|
||||
"f" => {
|
||||
let mut corners = Vec::new();
|
||||
for word in words {
|
||||
let mut parts = word.split('/');
|
||||
let position = index(parts.next(), positions.len())
|
||||
.ok_or_else(|| at("bad vertex index"))?;
|
||||
let uv = match parts.next() {
|
||||
None | Some("") => [0.0; 2],
|
||||
some => {
|
||||
uvs[index(some, uvs.len()).ok_or_else(|| at("bad texture index"))?]
|
||||
}
|
||||
};
|
||||
corners.push(ObjVertex {
|
||||
position: positions[position],
|
||||
uv,
|
||||
});
|
||||
}
|
||||
if corners.len() < 3 {
|
||||
return Err(at("face needs three vertices"));
|
||||
}
|
||||
let mut push = |vertices: [ObjVertex; 4]| {
|
||||
parsed.faces.push(ObjFace {
|
||||
material: material.clone(),
|
||||
object: object.clone(),
|
||||
vertices,
|
||||
});
|
||||
};
|
||||
if corners.len() <= 4 {
|
||||
let last = corners[corners.len() - 1];
|
||||
push(std::array::from_fn(|i| {
|
||||
corners.get(i).copied().unwrap_or(last)
|
||||
}));
|
||||
} else {
|
||||
for i in 1..corners.len() - 1 {
|
||||
push([corners[0], corners[i], corners[i + 1], corners[i + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
fn floats<'a, const N: usize>(mut words: impl Iterator<Item = &'a str>) -> Option<[f32; N]> {
|
||||
let mut result = [0.0; N];
|
||||
for value in &mut result {
|
||||
*value = words.next()?.parse().ok()?;
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
/// A 1-based index, or a negative one counted from the end.
|
||||
fn index(word: Option<&str>, len: usize) -> Option<usize> {
|
||||
let value: i64 = word?.parse().ok()?;
|
||||
let index = if value < 0 {
|
||||
len as i64 + value
|
||||
} else {
|
||||
value - 1
|
||||
};
|
||||
(0..len as i64).contains(&index).then_some(index as usize)
|
||||
}
|
||||
|
||||
/// Material name to its `map_Kd`; other statements are ignored.
|
||||
pub fn parse_mtl(text: &str) -> BTreeMap<String, MtlTexture> {
|
||||
let mut materials = BTreeMap::new();
|
||||
let mut current = None;
|
||||
for line in text.lines() {
|
||||
let mut words = line.split_whitespace();
|
||||
match words.next() {
|
||||
Some("newmtl") => current = words.next().map(str::to_owned),
|
||||
Some("map_Kd") => {
|
||||
let (Some(name), Some(value)) = (¤t, words.last()) else {
|
||||
continue;
|
||||
};
|
||||
let texture = match value.strip_prefix('#') {
|
||||
Some(slot) => MtlTexture::Slot(slot.to_owned()),
|
||||
None => MtlTexture::Sprite(value.to_owned()),
|
||||
};
|
||||
materials.insert(name.clone(), texture);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
materials
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quads_triangles_and_negative_indices() {
|
||||
let text = "mtllib a.mtl\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
|
||||
usemtl first\nf 1/1/1 2/2/1 3/3/1 4/4/1\nusemtl second\nf -4//1 -3//1 -2//1\n";
|
||||
let parsed = parse_obj(text).unwrap();
|
||||
assert_eq!(parsed.mtllib.as_deref(), Some("a.mtl"));
|
||||
assert_eq!(parsed.faces.len(), 2);
|
||||
assert_eq!(parsed.faces[0].material, "first");
|
||||
assert_eq!(parsed.faces[0].vertices[2].uv, [1.0, 1.0]);
|
||||
let triangle = &parsed.faces[1];
|
||||
assert_eq!(triangle.material, "second");
|
||||
assert_eq!(triangle.vertices[0].position, [0.0, 0.0, 0.0]);
|
||||
assert_eq!(triangle.vertices[3], triangle.vertices[2]);
|
||||
assert_eq!(triangle.vertices[2].position, [1.0, 1.0, 0.0]);
|
||||
}
|
||||
#[test]
|
||||
fn bad_indices_are_errors() {
|
||||
assert!(parse_obj("v 0 0 0\nf 1 2 3\n").is_err());
|
||||
assert!(parse_obj("v 0 0 0\nv 1 0 0\nf 1 2\n").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn mtl_maps_slots_and_sprites() {
|
||||
let materials =
|
||||
parse_mtl("newmtl a\nKd 1 1 1\nmap_Kd #top\nnewmtl b\nmap_Kd mod:block/x\nnewmtl c\n");
|
||||
assert_eq!(materials["a"], MtlTexture::Slot("top".into()));
|
||||
assert_eq!(materials["b"], MtlTexture::Sprite("mod:block/x".into()));
|
||||
assert!(!materials.contains_key("c"));
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,18 @@
|
||||
//! Cuboid parent inheritance and texture-slot resolution.
|
||||
use crate::{
|
||||
json::*,
|
||||
model::*,
|
||||
obj::{ObjModel, parse_mtl, parse_obj},
|
||||
};
|
||||
use crate::{json::*, model::*};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::{Arc, Mutex},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub const MISSING_MODEL: &str = "minecraft:builtin/missing";
|
||||
/// `ItemModelGenerator.GENERATED_ITEM_MODEL_ID`.
|
||||
pub const GENERATED_MODEL: &str = "minecraft:builtin/generated";
|
||||
/// Composite children nest through parents that may name the composite again.
|
||||
const MAX_LOADER_DEPTH: u8 = 8;
|
||||
|
||||
/// Reads asset files that models point at (`namespace:path` is `assets/namespace/path`).
|
||||
pub trait AssetReader: Send + Sync {
|
||||
fn read_text(&self, location: &str) -> Option<String>;
|
||||
}
|
||||
/// Decoded models are immutable and their inherited geometry is shared.
|
||||
pub struct ModelLibrary {
|
||||
models: BTreeMap<String, Arc<CuboidModel>>,
|
||||
missing: Arc<CuboidModel>,
|
||||
assets: Option<Arc<dyn AssetReader>>,
|
||||
/// Keyed by obj and mtl override; `None` is a file that failed, already warned about.
|
||||
objs: Mutex<BTreeMap<(String, Option<String>), Option<Arc<ObjModel>>>>,
|
||||
}
|
||||
/// What a model's loader adds to its own elements.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ResolvedLoader {
|
||||
/// The shown children, resolved on their own, in order.
|
||||
Composite(Vec<ResolvedCuboidModel>),
|
||||
Obj {
|
||||
/// `None` when the file failed to load.
|
||||
model: Option<Arc<ObjModel>>,
|
||||
flip_v: bool,
|
||||
shade: bool,
|
||||
/// Objects the model chain's `visibility` hides.
|
||||
hidden: BTreeSet<String>,
|
||||
},
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ResolvedCuboidModel {
|
||||
@@ -49,17 +22,10 @@ pub struct ResolvedCuboidModel {
|
||||
pub textures: BTreeMap<String, Material>,
|
||||
/// Cyclic and dangling aliases are absent from `textures`.
|
||||
pub unresolved_textures: BTreeSet<String>,
|
||||
/// NeoForge's `render_type`; the leaf-most model that sets one wins.
|
||||
pub render_type: Option<String>,
|
||||
geometry: Option<Arc<CuboidModel>>,
|
||||
generated: bool,
|
||||
loader: Option<ResolvedLoader>,
|
||||
}
|
||||
impl ResolvedCuboidModel {
|
||||
/// Geometry from a custom loader, baked after the elements.
|
||||
pub fn loader(&self) -> Option<&ResolvedLoader> {
|
||||
self.loader.as_ref()
|
||||
}
|
||||
/// Whether the top geometry is `builtin/generated`'s layer extrusion
|
||||
/// (no model closer to the leaf has elements of its own).
|
||||
pub fn uses_item_generator(&self) -> bool {
|
||||
@@ -100,7 +66,6 @@ impl ModelLibrary {
|
||||
fn item_generator_model() -> CuboidModel {
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
loader: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: Some(GuiLight::Front),
|
||||
textures: BTreeMap::from([(
|
||||
@@ -109,8 +74,6 @@ impl ModelLibrary {
|
||||
)]),
|
||||
elements: None,
|
||||
display: None,
|
||||
render_type: None,
|
||||
visibility: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
@@ -137,112 +100,27 @@ impl ModelLibrary {
|
||||
Ok(Self {
|
||||
models: decoded,
|
||||
missing,
|
||||
assets: None,
|
||||
objs: Mutex::default(),
|
||||
})
|
||||
}
|
||||
/// Where `neoforge:obj` models find their files.
|
||||
pub fn with_assets(mut self, assets: Arc<dyn AssetReader>) -> Self {
|
||||
self.assets = Some(assets);
|
||||
self
|
||||
}
|
||||
/// This library plus `extra` models; an `extra` model replaces a
|
||||
/// same-named one, but not the built-in missing and generated models.
|
||||
/// Every existing model is shared, not copied.
|
||||
pub fn overlaid(&self, extra: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
let mut models = self.models.clone();
|
||||
let mut added = BTreeSet::new();
|
||||
for (name, model) in extra {
|
||||
let name = identifier(&name)?;
|
||||
if !added.insert(name.clone()) {
|
||||
return Err(error("duplicate normalized model identifier"));
|
||||
}
|
||||
// The built-ins win, as `from_models` keeps `missing` and
|
||||
// `with_item_generator` keeps `generated`.
|
||||
if name != MISSING_MODEL && name != GENERATED_MODEL {
|
||||
models.insert(name, Arc::new(model));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
models,
|
||||
missing: Arc::clone(&self.missing),
|
||||
assets: self.assets.clone(),
|
||||
objs: Mutex::default(),
|
||||
})
|
||||
}
|
||||
/// Follow the complete parent chain. A missing resource uses the built-in
|
||||
/// model; a cyclic chain is rejected, as native discovery excludes it.
|
||||
/// The returned model can outlive this library without copying its elements.
|
||||
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
|
||||
self.resolve_name(name, None)
|
||||
}
|
||||
/// Whether `name` and every parent it reaches are models of this library,
|
||||
/// with no cycle: `resolve` puts the missing model in for any other.
|
||||
pub fn resolves(&self, name: &str) -> bool {
|
||||
let Ok(mut name) = identifier(name) else {
|
||||
return false;
|
||||
};
|
||||
let mut name = identifier(name)?;
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut chain = Vec::new();
|
||||
let mut names = Vec::new();
|
||||
loop {
|
||||
let Some(model) = self.models.get(&name) else {
|
||||
return false;
|
||||
};
|
||||
if !visited.insert(name.clone()) {
|
||||
return false;
|
||||
}
|
||||
match &model.parent {
|
||||
Some(parent) => name = parent.clone(),
|
||||
None => return true,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `name`'s resolved model with a virtual child in front of it that has
|
||||
/// only `textures`: the child's slots win, and `#references` anywhere in
|
||||
/// the chain re-bind through them (so `builtin/generated`'s
|
||||
/// `particle: #layer0` follows a replaced `layer0`). The child's slots are
|
||||
/// taken as given: sprites namespaced, references without their `#`, as
|
||||
/// the JSON decoder leaves them. A cyclic chain is an
|
||||
/// error, as for `resolve`: a caller wanting the missing model falls
|
||||
/// back to it.
|
||||
pub fn resolve_with_textures(
|
||||
&self,
|
||||
name: &str,
|
||||
child: &BTreeMap<String, TextureSlot>,
|
||||
) -> Result<ResolvedCuboidModel> {
|
||||
self.resolve_name(name, Some(child))
|
||||
}
|
||||
fn resolve_name(
|
||||
&self,
|
||||
name: &str,
|
||||
child: Option<&BTreeMap<String, TextureSlot>>,
|
||||
) -> Result<ResolvedCuboidModel> {
|
||||
let next = Some(identifier(name)?);
|
||||
self.resolve_chain(Vec::new(), Vec::new(), next, &BTreeMap::new(), child, 0)
|
||||
}
|
||||
/// `chain` and `names` start with models that are not in the library (a
|
||||
/// composite child); `next` is the parent to follow. `fallback` slots lose
|
||||
/// to any slot the chain defines.
|
||||
fn resolve_chain(
|
||||
&self,
|
||||
mut chain: Vec<Arc<CuboidModel>>,
|
||||
mut names: Vec<String>,
|
||||
mut next: Option<String>,
|
||||
fallback: &BTreeMap<String, TextureSlot>,
|
||||
child: Option<&BTreeMap<String, TextureSlot>>,
|
||||
depth: u8,
|
||||
) -> Result<ResolvedCuboidModel> {
|
||||
if depth > MAX_LOADER_DEPTH {
|
||||
return Err(error("composite models nest too deeply"));
|
||||
}
|
||||
let mut visited = BTreeSet::new();
|
||||
while let Some(name) = next.take() {
|
||||
if !visited.insert(name.clone()) {
|
||||
return Err(error("cyclic model parent"));
|
||||
}
|
||||
let model = self.models.get(&name).unwrap_or(&self.missing);
|
||||
chain.push(Arc::clone(model));
|
||||
names.push(name);
|
||||
next = model.parent.clone();
|
||||
chain.push(model);
|
||||
names.push(name.clone());
|
||||
match &model.parent {
|
||||
Some(parent) => name = parent.clone(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let ambient_occlusion = chain
|
||||
.iter()
|
||||
@@ -252,8 +130,10 @@ impl ModelLibrary {
|
||||
.iter()
|
||||
.find_map(|m| m.gui_light)
|
||||
.unwrap_or(GuiLight::Side);
|
||||
let render_type = chain.iter().find_map(|m| m.render_type.clone());
|
||||
let geometry = chain.iter().find(|m| m.elements.is_some()).map(Arc::clone);
|
||||
let geometry = chain
|
||||
.iter()
|
||||
.find(|m| m.elements.is_some())
|
||||
.map(|m| Arc::clone(m));
|
||||
let generated = chain
|
||||
.iter()
|
||||
.zip(&names)
|
||||
@@ -272,68 +152,10 @@ impl ModelLibrary {
|
||||
};
|
||||
// Resolve aliases only after applying every child override. A reference
|
||||
// inherited from a parent binds to the child's final slot definition.
|
||||
let mut slots: BTreeMap<String, &TextureSlot> =
|
||||
fallback.iter().map(|(k, v)| (k.clone(), v)).collect();
|
||||
for model in chain.iter().rev() {
|
||||
let mut slots = BTreeMap::new();
|
||||
for model in chain.into_iter().rev() {
|
||||
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
|
||||
}
|
||||
if let Some(child) = child {
|
||||
slots.extend(child.iter().map(|(k, v)| (k.clone(), v)));
|
||||
}
|
||||
let loader = match chain.iter().find_map(|m| m.loader.as_ref()) {
|
||||
Some(Loader::Composite {
|
||||
children,
|
||||
visibility,
|
||||
}) => {
|
||||
let inherited: BTreeMap<String, TextureSlot> = slots
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), (*v).clone()))
|
||||
.collect();
|
||||
// A model closer to the leaf overrides the loader's own map.
|
||||
let mut visibility = visibility.clone();
|
||||
for model in chain.iter().rev() {
|
||||
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
|
||||
}
|
||||
let mut resolved = Vec::new();
|
||||
for (child_name, child_model) in children {
|
||||
if visibility.get(child_name) == Some(&false) {
|
||||
continue;
|
||||
}
|
||||
resolved.push(self.resolve_chain(
|
||||
vec![Arc::clone(child_model)],
|
||||
vec![format!("{}#{child_name}", names[0])],
|
||||
child_model.parent.clone(),
|
||||
&inherited,
|
||||
None,
|
||||
depth + 1,
|
||||
)?);
|
||||
}
|
||||
Some(ResolvedLoader::Composite(resolved))
|
||||
}
|
||||
Some(Loader::Obj {
|
||||
model,
|
||||
flip_v,
|
||||
shade_quads,
|
||||
mtl_override,
|
||||
}) => {
|
||||
// Leaf wins: a model closer to the leaf overrides its parents' entries.
|
||||
let mut visibility = BTreeMap::new();
|
||||
for model in chain.iter().rev() {
|
||||
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
|
||||
}
|
||||
Some(ResolvedLoader::Obj {
|
||||
model: self.load_obj(&names[0], model, mtl_override.as_deref()),
|
||||
flip_v: *flip_v,
|
||||
shade: shade_quads.unwrap_or(true),
|
||||
hidden: visibility
|
||||
.into_iter()
|
||||
.filter(|(_, shown)| !shown)
|
||||
.map(|(name, _)| name)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
Some(Loader::Unknown(_)) | None => None,
|
||||
};
|
||||
let mut textures = BTreeMap::<String, Material>::new();
|
||||
let mut unresolved_textures = BTreeSet::<String>::new();
|
||||
for start in slots.keys() {
|
||||
@@ -375,61 +197,12 @@ impl ModelLibrary {
|
||||
display,
|
||||
textures,
|
||||
unresolved_textures,
|
||||
render_type,
|
||||
geometry,
|
||||
generated,
|
||||
loader,
|
||||
})
|
||||
}
|
||||
/// Parse an obj and its materials once; a failure warns once and yields no geometry.
|
||||
fn load_obj(&self, label: &str, model: &str, mtl: Option<&str>) -> Option<Arc<ObjModel>> {
|
||||
let key = (model.to_owned(), mtl.map(str::to_owned));
|
||||
let mut cache = self.objs.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(found) = cache.get(&key) {
|
||||
return found.clone();
|
||||
}
|
||||
let loaded = self.read_obj(model, mtl);
|
||||
if let Err(message) = &loaded {
|
||||
log::warn!("Horizon: obj model {label} ({model}): {message}");
|
||||
}
|
||||
let loaded = loaded.ok().map(Arc::new);
|
||||
cache.insert(key, loaded.clone());
|
||||
loaded
|
||||
}
|
||||
fn read_obj(&self, model: &str, mtl: Option<&str>) -> std::result::Result<ObjModel, String> {
|
||||
let assets = self.assets.as_ref().ok_or("no asset source")?;
|
||||
let text = assets.read_text(model).ok_or("file not found")?;
|
||||
let parsed = parse_obj(&text)?;
|
||||
let mtl = match (mtl, &parsed.mtllib) {
|
||||
(Some(location), _) => Some(location.to_owned()),
|
||||
(None, Some(file)) if file.contains(':') => Some(file.clone()),
|
||||
(None, Some(file)) => {
|
||||
let (namespace, path) = model.split_once(':').unwrap_or(("minecraft", model));
|
||||
let directory = path.rsplit_once('/').map_or("", |(dir, _)| dir);
|
||||
Some(if directory.is_empty() {
|
||||
format!("{namespace}:{file}")
|
||||
} else {
|
||||
format!("{namespace}:{directory}/{file}")
|
||||
})
|
||||
}
|
||||
(None, None) => None,
|
||||
};
|
||||
let materials = match mtl {
|
||||
Some(location) => match assets.read_text(&location) {
|
||||
Some(text) => parse_mtl(&text),
|
||||
None => {
|
||||
log::warn!("Horizon: obj material file {location} not found");
|
||||
BTreeMap::new()
|
||||
}
|
||||
},
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
Ok(ObjModel {
|
||||
faces: parsed.faces,
|
||||
materials,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The native missing-model cube uses slot names without a leading '#'.
|
||||
pub fn missing_model() -> CuboidModel {
|
||||
let faces = Direction::ALL
|
||||
@@ -443,15 +216,12 @@ pub fn missing_model() -> CuboidModel {
|
||||
texture: "missingno".to_owned(),
|
||||
uv: Some([0.0, 0.0, 16.0, 16.0]),
|
||||
rotation: 0,
|
||||
light_emission: None,
|
||||
neoforge_data: None,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
loader: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: None,
|
||||
elements: Some(vec![Some(Element {
|
||||
@@ -461,12 +231,10 @@ pub fn missing_model() -> CuboidModel {
|
||||
rotation: None,
|
||||
shade_direction_override: None,
|
||||
light_emission: 0,
|
||||
neoforge_data: None,
|
||||
})]),
|
||||
display: Some(ItemTransforms {
|
||||
contexts: std::array::from_fn(|_| DisplayTransform::Inherit),
|
||||
}),
|
||||
render_type: None,
|
||||
textures: BTreeMap::from([
|
||||
(
|
||||
"particle".to_owned(),
|
||||
@@ -480,7 +248,6 @@ pub fn missing_model() -> CuboidModel {
|
||||
}),
|
||||
),
|
||||
]),
|
||||
visibility: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,195 +274,6 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
fn library(models: &[(&str, &str)]) -> ModelLibrary {
|
||||
ModelLibrary::with_item_generator(
|
||||
models
|
||||
.iter()
|
||||
.map(|(name, json)| (name.to_string(), CuboidModel::from_json(json).unwrap()))
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
const GENERATED: &str = r#"{"parent":"builtin/generated","display":{"ground":{"translation":[0,2,0],"scale":[0.5,0.5,0.5]}}}"#;
|
||||
const CIT: &str = "horizon_cit:minecraft/optifine/cit/x";
|
||||
#[test]
|
||||
fn child_textures_replace_slots_and_rebind_the_generated_particle() {
|
||||
let library = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
]);
|
||||
let child = BTreeMap::from([(
|
||||
"layer0".to_owned(),
|
||||
TextureSlot::Material(Material {
|
||||
sprite: CIT.to_owned(),
|
||||
force_translucent: false,
|
||||
}),
|
||||
)]);
|
||||
let model = library.resolve_with_textures("item/stick", &child).unwrap();
|
||||
assert!(model.uses_item_generator());
|
||||
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(
|
||||
model.display.contexts[6],
|
||||
DisplayTransform::Explicit(Some(ItemTransform {
|
||||
rotation: [0.0; 3],
|
||||
translation: [0.0, 0.125, 0.0],
|
||||
scale: [0.5; 3],
|
||||
}))
|
||||
);
|
||||
// The library itself is untouched.
|
||||
let plain = library.resolve("item/stick").unwrap();
|
||||
assert_eq!(
|
||||
plain.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/stick"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn overlaid_models_inherit_from_the_library_and_share_the_rest() {
|
||||
let base = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/handheld",
|
||||
r#"{"parent":"item/generated","display":{"thirdperson_righthand":{"rotation":[0,-90,55]}}}"#,
|
||||
),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
(
|
||||
"item/apple",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/apple"}}"#,
|
||||
),
|
||||
]);
|
||||
let cit = format!(r#"{{"parent":"item/handheld","textures":{{"layer0":"{CIT}"}}}}"#);
|
||||
let over = base
|
||||
.overlaid(BTreeMap::from([
|
||||
(CIT.to_owned(), CuboidModel::from_json(&cit).unwrap()),
|
||||
(
|
||||
"item/stick".to_owned(),
|
||||
CuboidModel::from_json(
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/bone"}}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
let model = over.resolve(CIT).unwrap();
|
||||
assert!(model.uses_item_generator());
|
||||
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
|
||||
let DisplayTransform::Explicit(Some(right)) = &model.display.contexts[1] else {
|
||||
panic!("handheld's right-hand transform is inherited");
|
||||
};
|
||||
assert_eq!(right.rotation, [0.0, -90.0, 55.0]);
|
||||
let sprite = |l: &ModelLibrary| {
|
||||
l.resolve("item/stick").unwrap().textures["layer0"]
|
||||
.sprite
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(sprite(&over), "minecraft:item/bone");
|
||||
assert_eq!(sprite(&base), "minecraft:item/stick");
|
||||
assert!(!base.resolve(CIT).unwrap().uses_item_generator());
|
||||
assert!(Arc::ptr_eq(
|
||||
&base.models["minecraft:item/apple"],
|
||||
&over.models["minecraft:item/apple"]
|
||||
));
|
||||
}
|
||||
fn material(sprite: &str) -> TextureSlot {
|
||||
TextureSlot::Material(Material {
|
||||
sprite: sprite.to_owned(),
|
||||
force_translucent: false,
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn child_slots_take_part_in_reference_chains() {
|
||||
let library = library(&[
|
||||
(
|
||||
"item/two",
|
||||
r##"{"textures":{"layer0":"#tex","layer1":"item/b","tex":"item/a"}}"##,
|
||||
),
|
||||
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
|
||||
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
|
||||
]);
|
||||
let child = BTreeMap::from([
|
||||
// A base alias binds through a slot only the child sets...
|
||||
("tex".to_owned(), material(CIT)),
|
||||
// ...and a child alias through the base's slots.
|
||||
(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer1".to_owned()),
|
||||
),
|
||||
// A child alias to itself resolves to nothing, without looping.
|
||||
("self".to_owned(), TextureSlot::Reference("self".to_owned())),
|
||||
]);
|
||||
let model = library.resolve_with_textures("item/two", &child).unwrap();
|
||||
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(
|
||||
model.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/b"
|
||||
);
|
||||
assert_eq!(model.material("self").unwrap(), None);
|
||||
assert!(model.unresolved_textures.contains("self"));
|
||||
// An absent base is the missing cube under the child; a cyclic one an error.
|
||||
let absent = library
|
||||
.resolve_with_textures("item/absent", &child)
|
||||
.unwrap();
|
||||
assert_eq!(absent.elements().len(), 1);
|
||||
assert!(
|
||||
library
|
||||
.resolve_with_textures("horizon:loop", &child)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_overlay_replaces_parents_but_not_the_built_ins_and_refuses_twins() {
|
||||
let base = library(&[
|
||||
("item/generated", GENERATED),
|
||||
("item/handheld", r#"{"parent":"item/generated"}"#),
|
||||
(
|
||||
"item/sword",
|
||||
r#"{"parent":"item/handheld","textures":{"layer0":"item/sword"}}"#,
|
||||
),
|
||||
]);
|
||||
let model = |json: &str| CuboidModel::from_json(json).unwrap();
|
||||
let over = base
|
||||
.overlaid(BTreeMap::from([
|
||||
(
|
||||
"item/handheld".to_owned(),
|
||||
model(
|
||||
r#"{"parent":"item/generated","display":{"head":{"rotation":[0,45,0]}}}"#,
|
||||
),
|
||||
),
|
||||
// Neither built-in can be replaced.
|
||||
("builtin/missing".to_owned(), model(r#"{"elements":[]}"#)),
|
||||
("builtin/generated".to_owned(), model("{}")),
|
||||
]))
|
||||
.unwrap();
|
||||
// The base's sword sees the overlay's parent; the base library does not.
|
||||
let head = |l: &ModelLibrary| l.resolve("item/sword").unwrap().display.contexts[4].clone();
|
||||
let DisplayTransform::Explicit(Some(rotated)) = head(&over) else {
|
||||
panic!("the replaced parent's head transform");
|
||||
};
|
||||
assert_eq!(rotated.rotation, [0.0, 45.0, 0.0]);
|
||||
assert_ne!(head(&base), head(&over));
|
||||
// The generator kept: its `particle: #layer0`, which `{}` would drop.
|
||||
let sword = over.resolve("item/sword").unwrap();
|
||||
assert_eq!(
|
||||
sword.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/sword"
|
||||
);
|
||||
// The missing cube kept, looked up by its own name, not as a fallback.
|
||||
let missing = over.resolve("builtin/missing").unwrap();
|
||||
assert_eq!(missing.elements().len(), 1);
|
||||
// Two spellings of one id.
|
||||
let twins = BTreeMap::from([
|
||||
("item/x".to_owned(), model("{}")),
|
||||
("minecraft:item/x".to_owned(), model("{}")),
|
||||
]);
|
||||
assert!(base.overlaid(twins).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn absent_parents_fall_back_but_cycles_invalidate_descendants() {
|
||||
let a = CuboidModel::from_json(r#"{"parent":"horizon:b"}"#).unwrap();
|
||||
@@ -715,115 +293,4 @@ mod tests {
|
||||
"minecraft:missingno"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_model_resolves_only_with_its_whole_parent_chain() {
|
||||
let library = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
("item/orphan", r#"{"parent":"item/gone"}"#),
|
||||
("item/a", r#"{"parent":"item/b"}"#),
|
||||
("item/b", r#"{"parent":"item/a"}"#),
|
||||
]);
|
||||
assert!(library.resolves("item/stick"));
|
||||
assert!(library.resolves("minecraft:item/stick"));
|
||||
assert!(!library.resolves("item/absent"));
|
||||
assert!(!library.resolves("item/orphan"));
|
||||
assert!(!library.resolves("item/a"));
|
||||
assert!(!library.resolves("Item/Stick"));
|
||||
}
|
||||
const LIGHTS: &str = r##"{"textures":{"a":"block/a"},"render_type":"minecraft:cutout","elements":[
|
||||
{"from":[0,0,0],"to":[16,16,16],"light_emission":4,"neoforge_data":{"block_light":9,"color":-65536,"ambient_occlusion":false},
|
||||
"faces":{"north":{"texture":"#a","neoforge_data":{"block_light":2,"sky_light":15,"unknown":1}},
|
||||
"south":{"texture":"#a","light_emission":11},
|
||||
"up":{"texture":"#a"}}}]}"##;
|
||||
#[test]
|
||||
fn neoforge_data_of_a_face_overrides_its_element_key_by_key() {
|
||||
use crate::bake::bake_block_model;
|
||||
let library = library(&[("block/lights", LIGHTS)]);
|
||||
let model = library.resolve("block/lights").unwrap();
|
||||
let quads =
|
||||
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false)).unwrap();
|
||||
let by = |d| quads.iter().find(|q| q.facing == d).unwrap();
|
||||
let north = by(Direction::North);
|
||||
assert_eq!(north.light, [4, 15]);
|
||||
assert_eq!(north.color, Some(-65536));
|
||||
assert_eq!(north.ambient_occlusion, Some(false));
|
||||
assert_eq!(by(Direction::South).light, [11, 11]);
|
||||
assert_eq!(by(Direction::South).light_emission, 11);
|
||||
assert_eq!(by(Direction::Up).light, [9, 4]);
|
||||
assert!(
|
||||
quads
|
||||
.iter()
|
||||
.all(|q| q.render_type == Some(crate::bake::RenderType::Cutout))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn vanilla_models_bake_without_extra_face_data() {
|
||||
use crate::bake::bake_block_model;
|
||||
let library = library(&[(
|
||||
"block/plain",
|
||||
r##"{"textures":{"a":"block/a"},"elements":[{"from":[0,0,0],"to":[16,16,16],"light_emission":6,"faces":{"up":{"texture":"#a"}}}]}"##,
|
||||
)]);
|
||||
let model = library.resolve("block/plain").unwrap();
|
||||
let quad = &bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))
|
||||
.unwrap()[0];
|
||||
assert_eq!((quad.light, quad.light_emission), ([6, 6], 6));
|
||||
assert_eq!(
|
||||
(quad.color, quad.ambient_occlusion, quad.render_type),
|
||||
(None, None, None)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
|
||||
let library = library(&[
|
||||
("block/base", r#"{"render_type":"translucent"}"#),
|
||||
("block/mid", r#"{"parent":"block/base"}"#),
|
||||
(
|
||||
"block/leaf",
|
||||
r#"{"parent":"block/mid","render_type":"minecraft:cutout_mipped"}"#,
|
||||
),
|
||||
("block/bare", r#"{"parent":"block/mid"}"#),
|
||||
]);
|
||||
let render = |name| library.resolve(name).unwrap().render_type;
|
||||
assert_eq!(render("block/leaf").as_deref(), Some("cutout_mipped"));
|
||||
assert_eq!(render("block/bare").as_deref(), Some("translucent"));
|
||||
assert_eq!(render("block/absent"), None);
|
||||
assert_eq!(
|
||||
crate::bake::RenderType::by_name("minecraft:tripwire"),
|
||||
Some(crate::bake::RenderType::Tripwire)
|
||||
);
|
||||
assert_eq!(crate::bake::RenderType::by_name("mod:glow"), None);
|
||||
}
|
||||
#[test]
|
||||
fn neoforge_data_colour_and_legacy_emission_spellings() {
|
||||
let model = CuboidModel::from_json(
|
||||
r##"{"elements":[{"from":[0,0,0],"to":[1,1,1],"faces":{
|
||||
"up":{"texture":"#a","neoforge_data":{"color":"#80FF0000","light_emission":7}},
|
||||
"down":{"texture":"#a","neoforge_data":{"color":4294967295,"block_light":99}}}}]}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let elements = model.elements.unwrap();
|
||||
let faces = &elements[0].as_ref().unwrap().faces;
|
||||
let up = faces[&Direction::Up]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.neoforge_data
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(up.color, up.block_light, up.sky_light),
|
||||
(Some(0x80FF0000u32 as i32), Some(7), Some(7))
|
||||
);
|
||||
let down = faces[&Direction::Down]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.neoforge_data
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(down.color, down.block_light, down.sky_light),
|
||||
(Some(-1), Some(15), None)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -202,8 +202,10 @@ fn os_version() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
// x86 names the CPU per core; ARM boards name the machine once.
|
||||
key_value(&info, "model name", ':')
|
||||
.or_else(|| key_value(&info, "Model", ':'))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -225,12 +227,12 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
output("cmd", &["/C", "ver"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -279,16 +281,4 @@ mod tests {
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reports_the_cpu_brand() {
|
||||
let cpu = Hardware::detect().cpu.expect("a CPU name");
|
||||
assert!(
|
||||
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
|
||||
"family and model, not a name: {cpu:?}"
|
||||
);
|
||||
assert_eq!(cpu, cpu.trim());
|
||||
}
|
||||
}
|
||||
+31
-434
@@ -6,16 +6,9 @@
|
||||
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
|
||||
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
|
||||
//!
|
||||
//! `--behaviour fight` is the exception: the bots play in survival (`gamemode=survival`, `force-gamemode=true`,
|
||||
//! and `allow-flight=true` because they hold their spawn height instead of standing on terrain they cannot
|
||||
//! see). They track the entities the server sends, walk to the nearest living one and attack it at vanilla's
|
||||
//! bare-hand cooldown (every 5 ticks). With `--pvp true` (the default) the other bots are targets too, which
|
||||
//! hurts only when the server has `pvp=true`.
|
||||
//!
|
||||
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
|
||||
//!
|
||||
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
|
||||
//! writes a summary with `--report`.
|
||||
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
|
||||
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
|
||||
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
|
||||
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
|
||||
@@ -30,7 +23,7 @@ use horizon_protocol::{
|
||||
buf::{Reader, Writer},
|
||||
connection::{ConnWriter, Connection, Counters},
|
||||
crypto::offline_uuid,
|
||||
entity_data, ids,
|
||||
ids,
|
||||
item::Stack,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
@@ -41,7 +34,7 @@ use horizon_protocol::{
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, LazyLock, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
@@ -55,8 +48,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore, fight or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
fight needs gamemode=survival, force-gamemode=true and allow-flight=true.
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
stand: stays on the ground where the server puts it (/tp), for a survival test.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
@@ -74,11 +66,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health. A UUID instead of a NAME: the
|
||||
entity with that UUID (a boat summoned with it, say)
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
|
||||
default no limit
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
@@ -89,7 +78,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--view-distance N what the bots ask for; default 10
|
||||
--seed N default 12345
|
||||
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
|
||||
--pvp B fight bots also attack each other (true or false); default true
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--brand B the client brand sent in configuration (minecraft:brand); default none
|
||||
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
|
||||
@@ -103,8 +91,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
|
||||
for crates/horizon-protocol/examples/net_replay.rs
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -113,7 +99,6 @@ enum Behaviour {
|
||||
Stand,
|
||||
Walk,
|
||||
Explore,
|
||||
Fight,
|
||||
FloodMove,
|
||||
FloodAcks,
|
||||
FloodPackets,
|
||||
@@ -133,7 +118,6 @@ impl Behaviour {
|
||||
Self::Stand => "stand",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::Fight => "fight",
|
||||
Self::FloodMove => "flood-move",
|
||||
Self::FloodAcks => "flood-acks",
|
||||
Self::FloodPackets => "flood-packets",
|
||||
@@ -158,12 +142,9 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
pvp: bool,
|
||||
/// The players or entity UUIDs to attack, how long after joining to start,
|
||||
/// and how many rounds.
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
@@ -180,8 +161,6 @@ struct Options {
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
report: Option<String>,
|
||||
/// --capture: where the first bot's play packets go.
|
||||
capture: Option<String>,
|
||||
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
|
||||
timer: f64,
|
||||
}
|
||||
@@ -197,10 +176,8 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
pvp: true,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
@@ -212,7 +189,6 @@ fn options() -> Result<Options, String> {
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
capture: None,
|
||||
brand: None,
|
||||
timer: 1.0,
|
||||
};
|
||||
@@ -255,14 +231,11 @@ fn options() -> Result<Options, String> {
|
||||
"--explore-speed" => {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--capture" => o.capture = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--give" => {
|
||||
for part in value.split(',') {
|
||||
@@ -321,11 +294,10 @@ fn options() -> Result<Options, String> {
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
const BEHAVIOURS: [&str; 16] = [
|
||||
const BEHAVIOURS: [&str; 15] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"fight",
|
||||
"mixed",
|
||||
"stand",
|
||||
"flood-move",
|
||||
@@ -341,7 +313,7 @@ fn options() -> Result<Options, String> {
|
||||
];
|
||||
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore, fight, mixed or a flood behaviour (--help lists them)",
|
||||
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
@@ -362,10 +334,6 @@ struct Bot {
|
||||
pulled_back: AtomicU64,
|
||||
/// Ticks the bot couldn't move because it didn't hold its chunk yet.
|
||||
waited: AtomicU64,
|
||||
/// Attack packets sent (fight bots and --attack).
|
||||
attacks: AtomicU64,
|
||||
/// The entities the server tells the bot about (fight bots only).
|
||||
entities: Mutex<Entities>,
|
||||
/// The chunks the bot holds (ClientChunkCache).
|
||||
held: Mutex<HashSet<(i32, i32)>>,
|
||||
/// Where the server last put the bot (a teleport), to start from.
|
||||
@@ -378,6 +346,7 @@ struct Bot {
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<Vec<(u128, Option<i32>)>>,
|
||||
attacks: AtomicU64,
|
||||
/// The last SetHealth.
|
||||
health: Mutex<Option<f32>>,
|
||||
}
|
||||
@@ -415,16 +384,6 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
|
||||
/// the offline UUID of the player with that name (a name has no `-`).
|
||||
fn target_uuid(name: &str) -> u128 {
|
||||
let hex: String = name.chars().filter(|&c| c != '-').collect();
|
||||
match u128::from_str_radix(&hex, 16) {
|
||||
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
|
||||
_ => offline_uuid(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -440,142 +399,6 @@ impl Random {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vanilla's bare-hand attack speed is 4 a second: a full-strength swing every 20 / 4 ticks.
|
||||
const ATTACK_COOLDOWN_TICKS: u32 = 5;
|
||||
/// How far, in blocks, a fight bot strikes (the survival entity interaction range is 3).
|
||||
const ATTACK_REACH: f64 = 2.5;
|
||||
/// A fight bot walks up to this horizontal distance, so it does not stand inside its target.
|
||||
const CLOSE_IN: f64 = 1.5;
|
||||
/// Walking speed in blocks a tick.
|
||||
const FIGHT_STEP: f64 = 0.215;
|
||||
/// `LivingEntity.DATA_HEALTH_ID`: only living entities carry it, and it is sent once it differs from 1.
|
||||
const HEALTH_DATA: u8 = 9;
|
||||
|
||||
/// What a fight bot knows of one entity.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Tracked {
|
||||
uuid: u128,
|
||||
pos: [f64; 3],
|
||||
/// Set once the server sends the health of the entity, which only living entities have.
|
||||
health: Option<f32>,
|
||||
}
|
||||
|
||||
/// The entities around a bot, from AddEntity, the move packets, SetEntityData and RemoveEntities.
|
||||
/// Ordered by id, so ties between equally near targets always go to the same entity.
|
||||
#[derive(Default)]
|
||||
struct Entities(BTreeMap<i32, Tracked>);
|
||||
|
||||
impl Entities {
|
||||
fn update(&mut self, id: i32, body: &[u8]) {
|
||||
let Ok(packet) = play::Clientbound::decode(id, body) else {
|
||||
return;
|
||||
};
|
||||
match packet {
|
||||
play::Clientbound::AddEntity(p) => {
|
||||
self.0.insert(
|
||||
p.id,
|
||||
Tracked {
|
||||
uuid: p.uuid,
|
||||
pos: p.position,
|
||||
health: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
play::Clientbound::MoveEntityPos(play::MoveEntityPos(m))
|
||||
| play::Clientbound::MoveEntityPosRot(play::MoveEntityPosRot(m)) => {
|
||||
if let (Some(e), Some(delta)) = (self.0.get_mut(&m.id), &m.delta) {
|
||||
e.pos = delta.end(e.pos);
|
||||
}
|
||||
}
|
||||
play::Clientbound::EntityPositionSync(p) => {
|
||||
if let Some(e) = self.0.get_mut(&p.id) {
|
||||
e.pos = p.path.end();
|
||||
}
|
||||
}
|
||||
play::Clientbound::SetEntityData(p) => {
|
||||
if let Some(e) = self.0.get_mut(&p.id) {
|
||||
for (index, _, value) in &p.values {
|
||||
if *index == HEALTH_DATA
|
||||
&& let entity_data::Value::Float(health) = value
|
||||
{
|
||||
e.health = Some(*health);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
play::Clientbound::RemoveEntities(p) => {
|
||||
for id in p.ids {
|
||||
self.0.remove(&id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest living entity that is not dead; other bots only when `pvp`.
|
||||
fn nearest(&self, from: [f64; 3], pvp: bool, bots: &HashSet<u128>) -> Option<(i32, [f64; 3])> {
|
||||
let mut best: Option<(f64, i32, [f64; 3])> = None;
|
||||
for (id, e) in &self.0 {
|
||||
if !e.health.is_some_and(|h| h > 0.0) || (!pvp && bots.contains(&e.uuid)) {
|
||||
continue;
|
||||
}
|
||||
let d = distance(from, e.pos);
|
||||
if best.is_none_or(|(b, ..)| d < b) {
|
||||
best = Some((d, *id, e.pos));
|
||||
}
|
||||
}
|
||||
best.map(|(_, id, pos)| (id, pos))
|
||||
}
|
||||
}
|
||||
|
||||
fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
|
||||
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
|
||||
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
|
||||
*since_attack = since_attack.saturating_add(1);
|
||||
let (id, at) = target;
|
||||
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
|
||||
let horizontal = dx.hypot(dz);
|
||||
if horizontal > CLOSE_IN {
|
||||
let step = FIGHT_STEP.min(horizontal - CLOSE_IN);
|
||||
pos[0] += dx / horizontal * step;
|
||||
pos[2] += dz / horizontal * step;
|
||||
}
|
||||
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
|
||||
*since_attack = 0;
|
||||
return Some(id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
|
||||
fn walk_step(
|
||||
pos: &mut [f64; 3],
|
||||
target: &mut [f64; 3],
|
||||
spawn: [f64; 3],
|
||||
cruise: f64,
|
||||
random: &mut Random,
|
||||
) {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
if distance < 1.0 {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 8.0 + random.unit() * 40.0;
|
||||
*target = [
|
||||
spawn[0] + angle.cos() * reach,
|
||||
cruise,
|
||||
spawn[2] + angle.sin() * reach,
|
||||
];
|
||||
} else {
|
||||
let step = 0.28_f64.min(distance);
|
||||
pos[0] += dx / distance * step;
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
|
||||
/// One bot thread that panics poisons the locks it held; the others and the report go on.
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
@@ -826,53 +649,11 @@ impl BatchSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
|
||||
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
|
||||
struct Capture {
|
||||
out: std::io::BufWriter<std::fs::File>,
|
||||
start: Instant,
|
||||
/// The second last flushed: the process may end before the reader thread does.
|
||||
flushed: u32,
|
||||
}
|
||||
impl Capture {
|
||||
fn create(path: &str) -> Option<Self> {
|
||||
match std::fs::File::create(path) {
|
||||
Ok(file) => Some(Self {
|
||||
out: std::io::BufWriter::new(file),
|
||||
start: Instant::now(),
|
||||
flushed: 0,
|
||||
}),
|
||||
Err(e) => {
|
||||
eprintln!("--capture {path}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
fn record(&mut self, id: i32, body: &[u8]) {
|
||||
use std::io::Write;
|
||||
let ms = self.start.elapsed().as_millis() as u32;
|
||||
let mut head = [0u8; 12];
|
||||
head[..4].copy_from_slice(&ms.to_le_bytes());
|
||||
head[4..8].copy_from_slice(&id.to_le_bytes());
|
||||
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
let _ = self
|
||||
.out
|
||||
.write_all(&head)
|
||||
.and_then(|()| self.out.write_all(body));
|
||||
if ms / 1000 != self.flushed {
|
||||
self.flushed = ms / 1000;
|
||||
let _ = self.out.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
|
||||
fn read(
|
||||
mut reader: horizon_protocol::connection::ConnReader,
|
||||
writer: Arc<Mutex<ConnWriter>>,
|
||||
bot: Arc<Bot>,
|
||||
track: bool,
|
||||
mut capture: Option<Capture>,
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
@@ -896,9 +677,6 @@ fn read(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(c) = &mut capture {
|
||||
c.record(id, &body);
|
||||
}
|
||||
let mut r = Reader::new(&body);
|
||||
let sent = match id {
|
||||
cb::KEEP_ALIVE => match r.i64() {
|
||||
@@ -938,9 +716,6 @@ fn read(
|
||||
}
|
||||
}
|
||||
}
|
||||
if track {
|
||||
lock(&bot.entities).update(id, &body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::SET_HEALTH => {
|
||||
@@ -993,16 +768,6 @@ fn read(
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
cb::MOVE_ENTITY_POS
|
||||
| cb::MOVE_ENTITY_POS_ROT
|
||||
| cb::ENTITY_POSITION_SYNC
|
||||
| cb::SET_ENTITY_DATA
|
||||
| cb::REMOVE_ENTITIES
|
||||
if track =>
|
||||
{
|
||||
lock(&bot.entities).update(id, &body);
|
||||
Ok(())
|
||||
}
|
||||
cb::DISCONNECT | cb::START_CONFIGURATION => {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
|
||||
@@ -1032,7 +797,6 @@ fn run_bot(
|
||||
o: Arc<Options>,
|
||||
index: usize,
|
||||
behaviour: Behaviour,
|
||||
bots: Arc<HashSet<u128>>,
|
||||
bot: Arc<Bot>,
|
||||
until: Arc<Mutex<Option<Instant>>>,
|
||||
) {
|
||||
@@ -1052,16 +816,12 @@ fn run_bot(
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (target_uuid(name), None))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
let track = behaviour == Behaviour::Fight;
|
||||
let capture = (index == 0)
|
||||
.then(|| o.capture.as_deref().and_then(Capture::create))
|
||||
.flatten();
|
||||
thread::spawn(move || read(reader, writer, bot, track, capture));
|
||||
thread::spawn(move || read(reader, writer, bot));
|
||||
}
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
@@ -1079,10 +839,6 @@ fn run_bot(
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
if index == 0 {
|
||||
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
|
||||
println!("{}", json!({ "spawn": spawn }));
|
||||
}
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
if !o.no_loaded {
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
@@ -1114,16 +870,9 @@ fn run_bot(
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut rounds = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
|
||||
let cruise = if behaviour == Behaviour::Fight {
|
||||
spawn[1]
|
||||
} else {
|
||||
spawn[1] + o.height
|
||||
};
|
||||
let mut since_attack = ATTACK_COOLDOWN_TICKS;
|
||||
let cruise = spawn[1] + o.height;
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
@@ -1177,22 +926,22 @@ fn run_bot(
|
||||
} else {
|
||||
match behaviour {
|
||||
Behaviour::Idle | Behaviour::Stand => {}
|
||||
Behaviour::Walk => walk_step(&mut pos, &mut target, spawn, cruise, &mut random),
|
||||
Behaviour::Fight => {
|
||||
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
|
||||
if let Some(near) = near {
|
||||
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
|
||||
// The client sends the attack, then swings the arm.
|
||||
let sent = send(
|
||||
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
|
||||
) && send(raw(sb::PUNCH, |w| w.varint(0)));
|
||||
if sent {
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Behaviour::Walk => {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
if distance < 1.0 {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 8.0 + random.unit() * 40.0;
|
||||
target = [
|
||||
spawn[0] + angle.cos() * reach,
|
||||
cruise,
|
||||
spawn[2] + angle.sin() * reach,
|
||||
];
|
||||
} else {
|
||||
since_attack = since_attack.saturating_add(1);
|
||||
walk_step(&mut pos, &mut target, spawn, cruise, &mut random);
|
||||
// Sprinting: 5.6 blocks a second.
|
||||
let step = 0.28_f64.min(distance);
|
||||
pos[0] += dx / distance * step;
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::FloodMove
|
||||
@@ -1278,13 +1027,8 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
&& rounds < o.attack_rounds
|
||||
{
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
// A round counts once a target is known.
|
||||
rounds += u32::from(!targets.is_empty());
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
@@ -1300,10 +1044,7 @@ fn run_bot(
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
// Fight bots stand on the ground; the others fly.
|
||||
flags: u8::from(
|
||||
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
|
||||
),
|
||||
flags: u8::from(behaviour == Behaviour::Stand || o.ground),
|
||||
})
|
||||
.encode(),
|
||||
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
|
||||
@@ -1330,7 +1071,6 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
"stand" => Behaviour::Stand,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"fight" => Behaviour::Fight,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
"flood-acks" => Behaviour::FloodAcks,
|
||||
"flood-packets" => Behaviour::FloodPackets,
|
||||
@@ -1361,23 +1101,14 @@ fn main() {
|
||||
let start = Instant::now();
|
||||
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
|
||||
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
|
||||
// The uuids of all bots, so that `--pvp false` leaves them alone.
|
||||
let bot_uuids: Arc<HashSet<u128>> = Arc::new(
|
||||
(0..o.bots)
|
||||
.map(|i| offline_uuid(&format!("{}{i}", o.name)))
|
||||
.collect(),
|
||||
);
|
||||
let launcher = {
|
||||
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
|
||||
let bot_uuids = bot_uuids.clone();
|
||||
thread::spawn(move || {
|
||||
let mut handles = Vec::new();
|
||||
for (i, bot) in bots.iter().enumerate() {
|
||||
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
|
||||
let (behaviour, uuids) = (behaviour_of(&o.behaviour, i), bot_uuids.clone());
|
||||
handles.push(thread::spawn(move || {
|
||||
run_bot(o2, i, behaviour, uuids, bot, until);
|
||||
}));
|
||||
let behaviour = behaviour_of(&o.behaviour, i);
|
||||
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
|
||||
thread::sleep(Duration::from_secs_f64(o.join_every));
|
||||
}
|
||||
handles
|
||||
@@ -1414,7 +1145,6 @@ fn main() {
|
||||
"resent": total(|b| &b.resent),
|
||||
"pulled_back": total(|b| &b.pulled_back),
|
||||
"waited_ticks": total(|b| &b.waited),
|
||||
"attacks": total(|b| &b.attacks),
|
||||
})
|
||||
);
|
||||
previous_chunks = chunks;
|
||||
@@ -1434,7 +1164,6 @@ fn main() {
|
||||
"behaviour": o.behaviour,
|
||||
"seed": o.seed,
|
||||
"explore_speed": o.explore_speed,
|
||||
"pvp": o.pvp,
|
||||
"view_distance": o.view_distance,
|
||||
"seconds": o.seconds,
|
||||
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
|
||||
@@ -1442,7 +1171,6 @@ fn main() {
|
||||
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"attacks": bots.iter().map(|b| b.attacks.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
|
||||
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
|
||||
"join_time": join_summary(
|
||||
@@ -1495,137 +1223,6 @@ fn main() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use play::{
|
||||
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
|
||||
};
|
||||
|
||||
fn feed(entities: &mut Entities, packet: &Clientbound) {
|
||||
let bytes = packet.encode();
|
||||
let mut r = Reader::new(&bytes);
|
||||
let id = r.varint().unwrap();
|
||||
entities.update(id, &bytes[bytes.len() - r.remaining()..]);
|
||||
}
|
||||
|
||||
fn add(id: i32, uuid: u128, pos: [f64; 3]) -> Clientbound {
|
||||
Clientbound::AddEntity(AddEntity {
|
||||
id,
|
||||
uuid,
|
||||
kind: 1,
|
||||
position: pos,
|
||||
velocity: [0.0; 3],
|
||||
pitch: 0,
|
||||
yaw: 0,
|
||||
head_yaw: 0,
|
||||
data: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn health(id: i32, health: f32) -> Clientbound {
|
||||
Clientbound::SetEntityData(SetEntityData {
|
||||
id,
|
||||
values: vec![(HEALTH_DATA, 3, entity_data::Value::Float(health))],
|
||||
})
|
||||
}
|
||||
|
||||
/// `--attack`: a UUID names that entity, anything else a player.
|
||||
#[test]
|
||||
fn attack_targets_a_uuid_or_a_player() {
|
||||
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
|
||||
assert_eq!(
|
||||
target_uuid("0000000a-0000-0001-0000-000000000002"),
|
||||
0x0000000a_0000_0001_0000_000000000002
|
||||
);
|
||||
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
|
||||
// A name of 32 hex digits is a name: a UUID is written with dashes.
|
||||
let name = "0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(target_uuid(name), offline_uuid(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_living_entities_only() {
|
||||
let mut e = Entities::default();
|
||||
feed(&mut e, &add(1, 10, [3.0, 64.0, 0.0]));
|
||||
feed(&mut e, &add(2, 11, [5.0, 64.0, 0.0]));
|
||||
feed(&mut e, &health(2, 20.0));
|
||||
// Entity 1 never sent a health (an item, an arrow), so it is no target.
|
||||
assert_eq!(
|
||||
e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()),
|
||||
Some((2, [5.0, 64.0, 0.0]))
|
||||
);
|
||||
feed(&mut e, &health(2, 0.0));
|
||||
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
|
||||
feed(&mut e, &health(2, 4.0));
|
||||
feed(
|
||||
&mut e,
|
||||
&Clientbound::RemoveEntities(RemoveEntities { ids: vec![2] }),
|
||||
);
|
||||
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_follow_the_deltas() {
|
||||
let mut e = Entities::default();
|
||||
feed(&mut e, &add(7, 1, [0.0, 64.0, 0.0]));
|
||||
feed(&mut e, &health(7, 20.0));
|
||||
feed(
|
||||
&mut e,
|
||||
&Clientbound::MoveEntityPos(MoveEntityPos(MoveEntity {
|
||||
id: 7,
|
||||
delta: Some(VecDelta::Linear([4096, 0, -8192])),
|
||||
rotation: None,
|
||||
on_ground: true,
|
||||
})),
|
||||
);
|
||||
assert_eq!(e.0[&7].pos, [1.0, 64.0, -2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pvp_false_spares_the_bots_and_ties_go_to_the_lowest_id() {
|
||||
let mut e = Entities::default();
|
||||
feed(&mut e, &add(9, 100, [2.0, 64.0, 0.0]));
|
||||
feed(&mut e, &add(4, 101, [-2.0, 64.0, 0.0]));
|
||||
feed(&mut e, &add(5, 102, [0.0, 64.0, 6.0]));
|
||||
for id in [9, 4, 5] {
|
||||
feed(&mut e, &health(id, 20.0));
|
||||
}
|
||||
let bots = HashSet::from([100, 101]);
|
||||
let from = [0.0, 64.0, 0.0];
|
||||
assert_eq!(e.nearest(from, true, &bots).map(|t| t.0), Some(4));
|
||||
assert_eq!(e.nearest(from, false, &bots).map(|t| t.0), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attacks_at_the_vanilla_cooldown() {
|
||||
let mut pos = [0.0, 64.0, 0.0];
|
||||
let target = (3, [1.0, 64.0, 0.0]);
|
||||
let mut since = ATTACK_COOLDOWN_TICKS;
|
||||
let hits: Vec<u32> = (0..40)
|
||||
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
|
||||
.collect();
|
||||
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
|
||||
assert!(
|
||||
hits.windows(2)
|
||||
.all(|w| w[1] - w[0] == ATTACK_COOLDOWN_TICKS)
|
||||
);
|
||||
assert_eq!(hits.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_into_reach_before_striking() {
|
||||
let mut pos = [0.0, 64.0, 0.0];
|
||||
let target = (3, [10.0, 64.0, 0.0]);
|
||||
let mut since = ATTACK_COOLDOWN_TICKS;
|
||||
let mut first = None;
|
||||
for tick in 0..100 {
|
||||
if fight_tick(&mut pos, target, &mut since).is_some() {
|
||||
first = Some(tick);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(first.is_some_and(|t| t > 30));
|
||||
assert!((target.1[0] - pos[0]).abs() <= ATTACK_REACH);
|
||||
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentiles_use_nearest_rank() {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.88"
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,63 +0,0 @@
|
||||
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
|
||||
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
|
||||
# words are written here only so the filter can recognise them, because players spammed them on the
|
||||
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
|
||||
# aren't slurs out of it (moderators handle those).
|
||||
#
|
||||
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
|
||||
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
|
||||
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
|
||||
# doubled in the text: "niger", the river and the country, isn't "nigger".
|
||||
# word matches inside a longer word too
|
||||
# =word only as a whole word ("spic", not "suspicious")
|
||||
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
|
||||
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
|
||||
|
||||
~nigger
|
||||
~nigga
|
||||
nigguh
|
||||
niggah
|
||||
niqqa
|
||||
niqqer
|
||||
nlgger
|
||||
nlgga
|
||||
kneegrow
|
||||
=nig
|
||||
=nigs
|
||||
=niglet
|
||||
=niglets
|
||||
=nibba
|
||||
=nibbas
|
||||
=nibber
|
||||
=nibbers
|
||||
jigaboo
|
||||
porchmonkey
|
||||
=coon
|
||||
=coons
|
||||
~faggot
|
||||
=fag
|
||||
=fags
|
||||
=faggy
|
||||
=fagot
|
||||
=fagots
|
||||
=tranny
|
||||
=trannies
|
||||
=kike
|
||||
=kikes
|
||||
=chink
|
||||
=chinks
|
||||
=gook
|
||||
=gooks
|
||||
=spic
|
||||
=spics
|
||||
wetback
|
||||
=beaner
|
||||
=beaners
|
||||
raghead
|
||||
towelhead
|
||||
zipperhead
|
||||
=paki
|
||||
=pakis
|
||||
|
||||
!snigger*
|
||||
!niggard*
|
||||
@@ -1,571 +0,0 @@
|
||||
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
|
||||
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
|
||||
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
|
||||
//! player's text and carries out what it decides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
pub const DEFAULT_WORDS: &str = include_str!("../data/slurs.txt");
|
||||
|
||||
/// The mutes, beside server.properties.
|
||||
pub const MUTES_FILE: &str = "chat-filter-mutes.json";
|
||||
|
||||
/// `server.toml`'s `[chat_filter]`: on by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// The hit, counted within `window_minutes`, that mutes the player; 0: never.
|
||||
pub mute_after: u32,
|
||||
/// The hit that bans the player for `tempban_hours`; 0: never.
|
||||
pub tempban_after: u32,
|
||||
/// How long a hit counts towards the next ones (the time spent muted doesn't count).
|
||||
pub window_minutes: u32,
|
||||
pub mute_minutes: u32,
|
||||
pub tempban_hours: u32,
|
||||
/// The slur list, relative to the server's folder; empty: the list built into the game.
|
||||
pub words_file: String,
|
||||
/// Every refused text with the player and what followed, relative to the server's folder.
|
||||
pub log: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
mute_after: 2,
|
||||
tempban_after: 3,
|
||||
window_minutes: 10,
|
||||
mute_minutes: 10,
|
||||
tempban_hours: 24,
|
||||
words_file: String::new(),
|
||||
log: "logs/chat-filter.log".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// The `[chat_filter]` section of a whole `server.toml`; the default without it.
|
||||
pub fn from_server_toml(text: &str) -> Result<Self, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
chat_filter: Config,
|
||||
}
|
||||
// Other sections are the supervisor's to check: only this one is read here.
|
||||
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut only = toml::Table::new();
|
||||
if let Some(section) = table.remove("chat_filter") {
|
||||
only.insert("chat_filter".into(), section);
|
||||
}
|
||||
let file: File = only
|
||||
.try_into()
|
||||
.map_err(|e: toml::de::Error| format!("[chat_filter]: {e}"))?;
|
||||
file.chat_filter.check()?;
|
||||
Ok(file.chat_filter)
|
||||
}
|
||||
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.window_minutes == 0 {
|
||||
return Err("chat_filter.window_minutes must be 1 or more".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// How an entry of the list matches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
/// Inside a longer word too.
|
||||
Embedded,
|
||||
/// Only as a whole word.
|
||||
Whole,
|
||||
/// Inside a word, or spread over several words from the start of one.
|
||||
Spread,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entry {
|
||||
runs: Vec<Run>,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
/// The slur list, compiled: each entry's letters as runs, indexed by first letter.
|
||||
#[derive(Debug)]
|
||||
pub struct Filter {
|
||||
entries: Vec<Entry>,
|
||||
by_first: [Vec<u16>; 27],
|
||||
/// Words never matched: exact (`false`) or as a prefix (`true`).
|
||||
allowed: Vec<(Vec<Run>, bool)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Compiles a list in `data/slurs.txt`'s format.
|
||||
pub fn new(list: &str) -> Self {
|
||||
let mut f = Filter {
|
||||
entries: Vec::new(),
|
||||
by_first: Default::default(),
|
||||
allowed: Vec::new(),
|
||||
};
|
||||
for line in list.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(word) = line.strip_prefix('!') {
|
||||
let (word, prefix) = match word.strip_suffix('*') {
|
||||
Some(w) => (w, true),
|
||||
None => (word, false),
|
||||
};
|
||||
// An allow entry with no letters would allow every word (an empty prefix).
|
||||
let runs = normalise::word(word);
|
||||
if !runs.is_empty() {
|
||||
f.allowed.push((runs, prefix));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (kind, word) = match line.as_bytes()[0] {
|
||||
b'=' => (Kind::Whole, &line[1..]),
|
||||
b'~' => (Kind::Spread, &line[1..]),
|
||||
_ => (Kind::Embedded, line),
|
||||
};
|
||||
let runs = normalise::word(word);
|
||||
let Some(first) = runs.first() else {
|
||||
continue;
|
||||
};
|
||||
f.by_first[usize::from(first.letter)].push(f.entries.len() as u16);
|
||||
f.entries.push(Entry { runs, kind });
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The built-in list.
|
||||
pub fn default_list() -> Self {
|
||||
Self::new(DEFAULT_WORDS)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `text` holds a slur of the list.
|
||||
pub fn matches(&self, text: &str) -> bool {
|
||||
let t = Text::new(text);
|
||||
let runs = &t.runs;
|
||||
for &(start, end) in t.words.iter().chain(&t.spaced) {
|
||||
let word = &runs[start..end];
|
||||
if self.is_allowed(word) {
|
||||
continue;
|
||||
}
|
||||
for at in 0..word.len() {
|
||||
for &e in &self.by_first[usize::from(word[at].letter)] {
|
||||
let e = &self.entries[usize::from(e)];
|
||||
let whole = e.kind == Kind::Whole;
|
||||
if (!whole || at == 0) && fits(&e.runs, &word[at..], whole) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spread entries over the whole text, from the start of a word, across at least two.
|
||||
for (w, &at) in t.joined_at.iter().enumerate() {
|
||||
for &e in &self.by_first[usize::from(t.joined[at].letter)] {
|
||||
let e = &self.entries[usize::from(e)];
|
||||
if e.kind == Kind::Spread
|
||||
&& fits(&e.runs, &t.joined[at..], false)
|
||||
&& t.spans_words(w, e.runs.len())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_allowed(&self, word: &[Run]) -> bool {
|
||||
self.allowed.iter().any(|(a, prefix)| {
|
||||
if *prefix {
|
||||
// A prefix: its last run may be part of a longer one.
|
||||
word.len() >= a.len() && fits(a, word, false)
|
||||
} else {
|
||||
fits(a, word, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `entry`'s runs start `runs` (are all of it with `whole`): the same letters, each at least
|
||||
/// as many times as the entry has it.
|
||||
fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
|
||||
if runs.len() < entry.len() || (whole && runs.len() != entry.len()) {
|
||||
return false;
|
||||
}
|
||||
entry
|
||||
.iter()
|
||||
.zip(runs)
|
||||
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
|
||||
}
|
||||
|
||||
/// What a hit leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The text is refused, nothing more.
|
||||
Refused,
|
||||
/// Refused, and the player can't chat until then (epoch milliseconds).
|
||||
Muted { until: i64 },
|
||||
/// Refused, and the player is banned until then.
|
||||
Banned { until: i64 },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Refused => "refused",
|
||||
Action::Muted { .. } => "muted",
|
||||
Action::Banned { .. } => "banned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds; `None`: until `/unmute`.
|
||||
pub until: Option<i64>,
|
||||
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Mute {
|
||||
pub fn active(&self, now: i64) -> bool {
|
||||
self.until.is_none_or(|until| until > now)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct MutesFile {
|
||||
mutes: Vec<Mute>,
|
||||
}
|
||||
|
||||
/// One player's hits: how many, and when the last one stops counting.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Strikes {
|
||||
count: u32,
|
||||
counts_until: i64,
|
||||
}
|
||||
|
||||
/// The filter, the players' hits and the mutes.
|
||||
pub struct Guard {
|
||||
pub config: Config,
|
||||
pub filter: Filter,
|
||||
strikes: HashMap<u128, Strikes>,
|
||||
mutes: HashMap<u128, Mute>,
|
||||
/// Where the mutes are kept; `None` keeps them in memory only (tests).
|
||||
file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
const MINUTE: i64 = 60_000;
|
||||
|
||||
impl Guard {
|
||||
pub fn new(config: Config, filter: Filter, file: Option<PathBuf>) -> Self {
|
||||
let mut mutes = HashMap::new();
|
||||
if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok())
|
||||
&& let Ok(saved) = serde_json::from_str::<MutesFile>(&text)
|
||||
{
|
||||
for m in saved.mutes {
|
||||
if let Some(uuid) = parse_uuid(&m.uuid) {
|
||||
mutes.insert(uuid, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
config,
|
||||
filter,
|
||||
strikes: HashMap::new(),
|
||||
mutes,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's guard: `words_file` or the built-in list, the mutes in `dir`. An unreadable
|
||||
/// `words_file` falls back to the built-in list, with why.
|
||||
pub fn load(config: Config, dir: &Path) -> (Self, Option<String>) {
|
||||
let mut problem = None;
|
||||
let filter = if config.words_file.is_empty() {
|
||||
Filter::default_list()
|
||||
} else {
|
||||
match std::fs::read_to_string(dir.join(&config.words_file)) {
|
||||
Ok(text) => Filter::new(&text),
|
||||
Err(e) => {
|
||||
problem = Some(format!(
|
||||
"chat_filter.words_file {}: {e}; using the built-in list",
|
||||
config.words_file
|
||||
));
|
||||
Filter::default_list()
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
Self::new(config, filter, Some(dir.join(MUTES_FILE))),
|
||||
problem,
|
||||
)
|
||||
}
|
||||
|
||||
/// The player's mute, if they are muted at `now`.
|
||||
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
|
||||
self.mutes.get(&uuid).filter(|m| m.active(now))
|
||||
}
|
||||
|
||||
/// The mutes in force at `now`, by name.
|
||||
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
|
||||
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
mutes
|
||||
}
|
||||
|
||||
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
|
||||
pub fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
by: Some(by.to_owned()),
|
||||
reason,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
|
||||
pub fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
|
||||
let c = &self.config;
|
||||
let window = i64::from(c.window_minutes) * MINUTE;
|
||||
let s = self.strikes.entry(uuid).or_insert(Strikes {
|
||||
count: 0,
|
||||
counts_until: 0,
|
||||
});
|
||||
if now > s.counts_until {
|
||||
s.count = 0;
|
||||
}
|
||||
s.count += 1;
|
||||
s.counts_until = now + window;
|
||||
let count = s.count;
|
||||
let action = if c.tempban_after > 0 && count >= c.tempban_after {
|
||||
self.strikes.remove(&uuid);
|
||||
Action::Banned {
|
||||
until: now + i64::from(c.tempban_hours) * 60 * MINUTE,
|
||||
}
|
||||
} else if c.mute_after > 0 && count >= c.mute_after {
|
||||
let until = now + i64::from(c.mute_minutes) * MINUTE;
|
||||
// The window starts again when the mute ends.
|
||||
s.counts_until = until + window;
|
||||
// An operator's longer mute stays.
|
||||
if !self
|
||||
.mutes
|
||||
.get(&uuid)
|
||||
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
|
||||
{
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until: Some(until),
|
||||
by: None,
|
||||
reason: None,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
Action::Muted { until }
|
||||
} else {
|
||||
Action::Refused
|
||||
};
|
||||
(count, action)
|
||||
}
|
||||
|
||||
/// Lifts a player's mute (and forgets their hits), by name; the name as muted, if they were.
|
||||
pub fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
let uuid = *self
|
||||
.mutes
|
||||
.iter()
|
||||
.find(|(_, m)| m.name.eq_ignore_ascii_case(name))?
|
||||
.0;
|
||||
let mute = self.mutes.remove(&uuid)?;
|
||||
self.strikes.remove(&uuid);
|
||||
self.save();
|
||||
Some(mute.name)
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
let Some(file) = &self.file else {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.active(now));
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, text).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of `logs/chat-filter.log`, without its newline.
|
||||
pub fn log_line(
|
||||
now: i64,
|
||||
name: &str,
|
||||
uuid: u128,
|
||||
place: &str,
|
||||
count: u32,
|
||||
action: Action,
|
||||
text: &str,
|
||||
) -> String {
|
||||
let text: String = text
|
||||
.chars()
|
||||
.map(|c| if c == '\n' { ' ' } else { c })
|
||||
.collect();
|
||||
format!(
|
||||
"{} {name} {} {place} hit {count} {}: {text}",
|
||||
utc(now),
|
||||
uuid_string(uuid),
|
||||
action.name()
|
||||
)
|
||||
}
|
||||
|
||||
/// Appends a line to the log at `path` (its folder made if needed).
|
||||
pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(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.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
|
||||
rest / 3600,
|
||||
rest / 60 % 60,
|
||||
rest % 60
|
||||
)
|
||||
}
|
||||
|
||||
fn uuid_string(u: u128) -> String {
|
||||
let h = format!("{u:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..]
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_uuid(s: &str) -> Option<u128> {
|
||||
u128::from_str_radix(&s.replace('-', ""), 16).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,255 +0,0 @@
|
||||
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
|
||||
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
|
||||
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
|
||||
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
|
||||
|
||||
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
|
||||
const OTHER: u8 = 26;
|
||||
|
||||
/// One letter and how many times it repeats (`gg` is `g` twice).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Run {
|
||||
/// 0..=25 for `a`..`z`, 26 for any other letter.
|
||||
pub letter: u8,
|
||||
pub count: u8,
|
||||
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
|
||||
pub weak: bool,
|
||||
}
|
||||
|
||||
/// A whole message, normalised.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Text {
|
||||
pub runs: Vec<Run>,
|
||||
/// Each word's runs, `runs[start..end]`.
|
||||
pub words: Vec<(usize, usize)>,
|
||||
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
|
||||
/// without the punctuation they end with (`fag!`).
|
||||
pub spaced: Vec<(usize, usize)>,
|
||||
/// Every word one after the other, a letter that ends one and starts the next merged.
|
||||
pub joined: Vec<Run>,
|
||||
/// Where each word starts in `joined`.
|
||||
pub joined_at: Vec<usize>,
|
||||
/// The last word each run of `joined` belongs to.
|
||||
joined_last: Vec<usize>,
|
||||
}
|
||||
|
||||
enum Fold {
|
||||
Letter(u8, bool),
|
||||
Separator,
|
||||
Space,
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn new(text: &str) -> Self {
|
||||
let mut t = Text::default();
|
||||
let mut start = 0;
|
||||
for c in text.chars() {
|
||||
match fold(c) {
|
||||
Fold::Letter(letter, weak) => {
|
||||
let in_word = t.runs.len() > start;
|
||||
match t.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == letter => {
|
||||
last.count = last.count.saturating_add(1);
|
||||
last.weak &= weak;
|
||||
}
|
||||
_ => t.runs.push(Run {
|
||||
letter,
|
||||
count: 1,
|
||||
weak,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Fold::Space => {
|
||||
t.end_word(start);
|
||||
start = t.runs.len();
|
||||
}
|
||||
Fold::Separator | Fold::Skip => {}
|
||||
}
|
||||
}
|
||||
t.end_word(start);
|
||||
t.add_spaced();
|
||||
t.join();
|
||||
t
|
||||
}
|
||||
|
||||
fn end_word(&mut self, start: usize) {
|
||||
if self.runs.len() > start {
|
||||
self.words.push((start, self.runs.len()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
|
||||
/// copied after the real words').
|
||||
fn add_spaced(&mut self) {
|
||||
let words = self.words.len();
|
||||
let mut w = 0;
|
||||
while w < words {
|
||||
let mut end = w;
|
||||
while end < words && self.words[end].1 - self.words[end].0 == 1 {
|
||||
end += 1;
|
||||
}
|
||||
if end - w >= 2 {
|
||||
let first = self.runs.len();
|
||||
for k in w..end {
|
||||
let run = self.runs[self.words[k].0];
|
||||
let in_word = self.runs.len() > first;
|
||||
match self.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
}
|
||||
_ => self.runs.push(run),
|
||||
}
|
||||
}
|
||||
self.spaced.push((first, self.runs.len()));
|
||||
w = end;
|
||||
} else {
|
||||
w += 1;
|
||||
}
|
||||
}
|
||||
for k in 0..words {
|
||||
let (start, mut end) = self.words[k];
|
||||
while end > start + 1 && self.runs[end - 1].weak {
|
||||
end -= 1;
|
||||
}
|
||||
if end < self.words[k].1 {
|
||||
self.spaced.push((start, end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&mut self) {
|
||||
for (w, &(start, end)) in self.words.iter().enumerate() {
|
||||
let mut first = true;
|
||||
for run in &self.runs[start..end] {
|
||||
match self.joined.last_mut() {
|
||||
Some(last) if first && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
self.joined_at.push(self.joined.len() - 1);
|
||||
*self.joined_last.last_mut().expect("beside joined") = w;
|
||||
}
|
||||
_ => {
|
||||
if first {
|
||||
self.joined_at.push(self.joined.len());
|
||||
}
|
||||
self.joined.push(*run);
|
||||
self.joined_last.push(w);
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
|
||||
pub fn spans_words(&self, w: usize, len: usize) -> bool {
|
||||
self.joined_last
|
||||
.get(self.joined_at[w] + len - 1)
|
||||
.is_some_and(|&last| last > w)
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry of the list, normalised the same way (one word, whitespace ignored).
|
||||
pub fn word(text: &str) -> Vec<Run> {
|
||||
let t = Text::new(&text.replace(char::is_whitespace, ""));
|
||||
t.words
|
||||
.first()
|
||||
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
|
||||
}
|
||||
|
||||
fn letter(c: char) -> Fold {
|
||||
Fold::Letter(c as u8 - b'a', false)
|
||||
}
|
||||
|
||||
fn fold(c: char) -> Fold {
|
||||
if c.is_ascii_alphabetic() {
|
||||
return letter(c.to_ascii_lowercase());
|
||||
}
|
||||
if c.is_ascii() {
|
||||
return match c {
|
||||
'0' => letter('o'),
|
||||
'1' => letter('i'),
|
||||
'3' => letter('e'),
|
||||
'4' => letter('a'),
|
||||
'5' => letter('s'),
|
||||
'6' | '9' => letter('g'),
|
||||
'7' => letter('t'),
|
||||
'8' => letter('b'),
|
||||
'!' | '|' => Fold::Letter(b'i' - b'a', true),
|
||||
'@' => Fold::Letter(0, true),
|
||||
'$' => Fold::Letter(b's' - b'a', true),
|
||||
c if c.is_ascii_whitespace() => Fold::Space,
|
||||
_ => Fold::Separator,
|
||||
};
|
||||
}
|
||||
let u = c as u32;
|
||||
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
|
||||
let mapped = match u {
|
||||
// Zero-width characters, soft hyphen, combining marks, variation selectors.
|
||||
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
|
||||
0x300..=0x36F
|
||||
| 0x1AB0..=0x1AFF
|
||||
| 0x1DC0..=0x1DFF
|
||||
| 0x20D0..=0x20FF
|
||||
| 0xFE00..=0xFE0F
|
||||
| 0xFE20..=0xFE2F
|
||||
| 0xE0100..=0xE01EF => return Fold::Skip,
|
||||
// Full-width Latin and digits.
|
||||
0xFF21..=0xFF3A => from(0xFF21, 26),
|
||||
0xFF41..=0xFF5A => from(0xFF41, 26),
|
||||
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
|
||||
// Mathematical alphanumerics: 52 letters a style, then digits.
|
||||
0x1D400..=0x1D6A3 => from(0x1D400, 26),
|
||||
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
|
||||
// Circled, parenthesised, squared and negative letters, regional indicators.
|
||||
0x24B6..=0x24CF => from(0x24B6, 26),
|
||||
0x24D0..=0x24E9 => from(0x24D0, 26),
|
||||
0x249C..=0x24B5 => from(0x249C, 26),
|
||||
0x1F130..=0x1F149 => from(0x1F130, 26),
|
||||
0x1F150..=0x1F169 => from(0x1F150, 26),
|
||||
0x1F170..=0x1F189 => from(0x1F170, 26),
|
||||
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
|
||||
_ => {
|
||||
if c.is_whitespace() {
|
||||
return Fold::Space;
|
||||
}
|
||||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||||
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
|
||||
Some((base, _)) => *base,
|
||||
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
|
||||
None => return Fold::Separator,
|
||||
}
|
||||
}
|
||||
};
|
||||
letter(mapped)
|
||||
}
|
||||
|
||||
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
|
||||
const LOOK_ALIKES: &[(char, &str)] = &[
|
||||
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
|
||||
('b', "ƀɓḃḅḇвьъβʙᵇ"),
|
||||
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
|
||||
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
|
||||
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
|
||||
('f', "ƒḟꜰᶠ"),
|
||||
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
|
||||
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
|
||||
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
|
||||
('j', "ĵǰјᴊʲ"),
|
||||
('k', "ķĸǩḱḳḵкκᴋᵏ"),
|
||||
('l', "ĺļľŀłḷḹḻḽʟˡ"),
|
||||
('m', "мᴍᵐ"),
|
||||
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
|
||||
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
|
||||
('p', "ṕṗрρᴘᵖ"),
|
||||
('q', "ԛ"),
|
||||
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
|
||||
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
|
||||
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
|
||||
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
|
||||
('v', "νᴠᵛ"),
|
||||
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
|
||||
('x', "хχˣ"),
|
||||
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
|
||||
('z', "źżžƶẑẓẕᴢᶻ"),
|
||||
];
|
||||
@@ -1,345 +0,0 @@
|
||||
// The test texts spell the slurs the filter must refuse: that is what they test.
|
||||
use super::*;
|
||||
|
||||
fn filter() -> Filter {
|
||||
Filter::default_list()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_compiles() {
|
||||
let f = filter();
|
||||
assert!(f.len() > 30, "{} entries", f.len());
|
||||
assert_eq!(f.allowed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalising_catches_the_usual_disguises() {
|
||||
let f = filter();
|
||||
for text in [
|
||||
"nigger",
|
||||
"NIGGER",
|
||||
"shut up nigga",
|
||||
"Niggers",
|
||||
"n1gg3r",
|
||||
"ni99a",
|
||||
"N!GGA",
|
||||
"nigg@",
|
||||
"n.i.g.g.e.r",
|
||||
"n_i_g_g_a",
|
||||
"n i g g e r",
|
||||
"n i gg a",
|
||||
"nig ger",
|
||||
"nig gaaa",
|
||||
"niiiiggggggerrrrr",
|
||||
"ñíggér",
|
||||
"nigger",
|
||||
"Niggeг", // Cyrillic ghe for r
|
||||
"nіgger", // Cyrillic i
|
||||
"nigg\u{0435}r", // Cyrillic e
|
||||
"n\u{200B}igger", // a zero-width space
|
||||
"n\u{0301}igger", // a combining accent
|
||||
"𝐧𝐢𝐠𝐠𝐞𝐫",
|
||||
"ⓝⓘⓖⓖⓐ",
|
||||
"ɴɪɢɢᴀ",
|
||||
"hi nig",
|
||||
"f a g",
|
||||
"fag!",
|
||||
"you $pic",
|
||||
"FAGGOTINO-CHAN",
|
||||
"sandnigger",
|
||||
"lol kike",
|
||||
] {
|
||||
assert!(f.matches(text), "{text:?} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_words_pass() {
|
||||
let f = filter();
|
||||
for text in [
|
||||
"hello",
|
||||
"Niger",
|
||||
"I live in Niger",
|
||||
"Nigeria and the Niger river",
|
||||
"nigerian food",
|
||||
"proud2benigerian",
|
||||
"don't snigger",
|
||||
"sniggering at that",
|
||||
"a niggardly offer",
|
||||
"knight",
|
||||
"qKnights",
|
||||
"its still night",
|
||||
"anyone able to boot the latest nightly version?",
|
||||
"i hate nighbours",
|
||||
"can someune give mi night vision",
|
||||
"suspicious stew",
|
||||
"spicy food",
|
||||
"a raccoon in a cocoon",
|
||||
"Scunthorpe",
|
||||
"bigger trigger digger",
|
||||
"gg",
|
||||
"gg ez",
|
||||
"mini gg all",
|
||||
"kniga",
|
||||
"shenanigans",
|
||||
"dignity and signing",
|
||||
"Pakistan",
|
||||
"chinking glasses",
|
||||
"the trannys? no: transmission",
|
||||
"1nicerOli",
|
||||
"ask me evernig",
|
||||
"冰淇淋",
|
||||
"привет",
|
||||
"",
|
||||
"!!!",
|
||||
] {
|
||||
assert!(!f.matches(text), "{text:?} should pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_list_has_the_same_syntax() {
|
||||
let f = Filter::new("# comment\n=bad\n~worse\nmeh\n!mehish*\n");
|
||||
assert!(f.matches("so bad"));
|
||||
assert!(!f.matches("badly"));
|
||||
assert!(f.matches("wor se"));
|
||||
assert!(f.matches("xxmehxx"));
|
||||
assert!(!f.matches("mehishness"));
|
||||
assert!(!f.matches("me h"));
|
||||
}
|
||||
|
||||
fn guard(config: Config) -> Guard {
|
||||
Guard::new(config, filter(), None)
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_793_469_939_000;
|
||||
const MIN: i64 = 60_000;
|
||||
|
||||
#[test]
|
||||
fn the_second_hit_mutes_and_the_third_bans() {
|
||||
let mut g = guard(Config::default());
|
||||
let id = 7;
|
||||
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.muted(id, NOW), None);
|
||||
let until = NOW + 3 * MIN + 10 * MIN;
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", NOW + 3 * MIN),
|
||||
(2, Action::Muted { until })
|
||||
);
|
||||
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
|
||||
assert_eq!(g.muted(id, until), None);
|
||||
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", until + 8 * MIN),
|
||||
(
|
||||
3,
|
||||
Action::Banned {
|
||||
until: until + 8 * MIN + 24 * 60 * MIN
|
||||
}
|
||||
)
|
||||
);
|
||||
// Then it starts again.
|
||||
assert_eq!(g.strike(id, "Ann", until + 9 * MIN), (1, Action::Refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hits_far_apart_dont_add_up() {
|
||||
let mut g = guard(Config::default());
|
||||
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.strike(1, "Bo", NOW + 11 * MIN), (1, Action::Refused));
|
||||
// Players are counted apart.
|
||||
assert_eq!(g.strike(2, "Cy", NOW + 12 * MIN), (1, Action::Refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thresholds_are_configurable() {
|
||||
let mut g = guard(Config {
|
||||
mute_after: 0,
|
||||
tempban_after: 2,
|
||||
tempban_hours: 1,
|
||||
..Config::default()
|
||||
});
|
||||
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
|
||||
assert_eq!(
|
||||
g.strike(1, "Bo", NOW + MIN),
|
||||
(
|
||||
2,
|
||||
Action::Banned {
|
||||
until: NOW + MIN + 60 * MIN
|
||||
}
|
||||
)
|
||||
);
|
||||
let mut off = guard(Config {
|
||||
mute_after: 0,
|
||||
tempban_after: 0,
|
||||
..Config::default()
|
||||
});
|
||||
for k in 1..5 {
|
||||
assert_eq!(off.strike(1, "Bo", NOW + k), (k as u32, Action::Refused));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutes_survive_a_restart_and_unmute_lifts_them() {
|
||||
let dir = std::env::temp_dir().join(format!("chat-filter-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let now = now_millis();
|
||||
let (mut g, problem) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(problem, None);
|
||||
g.strike(5, "Dee", now);
|
||||
let (_, action) = g.strike(5, "Dee", now);
|
||||
assert!(matches!(action, Action::Muted { .. }));
|
||||
let (mut again, _) = Guard::load(Config::default(), &dir);
|
||||
assert!(again.muted(5, now).is_some());
|
||||
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
|
||||
assert_eq!(again.unmute("dee"), None);
|
||||
assert_eq!(again.muted(5, now), None);
|
||||
// An operator's mutes are kept too, with who and why.
|
||||
again.mute(6, "Eve", None, "Op", Some("spam".into()));
|
||||
again.mute(7, "Fay", Some(now + MIN), "Op", None);
|
||||
let (reloaded, _) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(reloaded.muted(5, now), None);
|
||||
let names: Vec<_> = reloaded
|
||||
.mutes(now)
|
||||
.iter()
|
||||
.map(|m| m.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, ["Eve", "Fay"]);
|
||||
let eve = reloaded.muted(6, now).unwrap();
|
||||
assert_eq!(
|
||||
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
|
||||
(None, Some("Op"), Some("spam"))
|
||||
);
|
||||
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mute_file_from_before_manual_mutes_still_loads() {
|
||||
let saved: MutesFile = serde_json::from_str(
|
||||
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved.mutes[0].until, Some(5));
|
||||
assert_eq!(saved.mutes[0].by, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filter_never_shortens_an_operators_mute() {
|
||||
let mut g = guard(Config::default());
|
||||
g.mute(1, "Bo", None, "Op", None);
|
||||
g.strike(1, "Bo", NOW);
|
||||
g.strike(1, "Bo", NOW + MIN);
|
||||
assert_eq!(
|
||||
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
|
||||
Some("Op")
|
||||
);
|
||||
// A shorter one is replaced by the filter's.
|
||||
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
|
||||
g.strike(2, "Cy", NOW);
|
||||
g.strike(2, "Cy", NOW);
|
||||
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_read_and_print() {
|
||||
assert_eq!(parse_duration("10m"), Some(10 * MIN));
|
||||
assert_eq!(parse_duration("2h"), Some(120 * MIN));
|
||||
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
|
||||
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
|
||||
assert_eq!(parse_duration("30s"), Some(30_000));
|
||||
for bad in [
|
||||
"",
|
||||
"m",
|
||||
"0m",
|
||||
"-1m",
|
||||
"1.5h",
|
||||
"10",
|
||||
"10x",
|
||||
"99999999999999999w",
|
||||
] {
|
||||
assert_eq!(parse_duration(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(duration_text(61_000), "2 minutes");
|
||||
assert_eq!(duration_text(MIN), "1 minute");
|
||||
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
|
||||
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
|
||||
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_words_file_falls_back_to_the_built_in_list() {
|
||||
let config = Config {
|
||||
words_file: "nope.txt".into(),
|
||||
..Config::default()
|
||||
};
|
||||
let (g, problem) = Guard::load(config, Path::new("/nonexistent-horizon"));
|
||||
assert!(problem.unwrap().contains("nope.txt"));
|
||||
assert!(g.filter.matches("nigger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_section_reads_from_server_toml() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(c.enabled);
|
||||
let c = Config::from_server_toml(
|
||||
"[chat_filter]\nenabled = true\nmute_after = 3\ntempban_after = 5\nwords_file = \"slurs.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((c.mute_after, c.tempban_after), (3, 5));
|
||||
assert_eq!(c.words_file, "slurs.txt");
|
||||
assert!(
|
||||
!Config::from_server_toml("[chat_filter]\nenabled = false\n")
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
assert!(Config::from_server_toml("[chat_filter]\nmute = 2\n").is_err());
|
||||
assert!(Config::from_server_toml("[chat_filter]\nwindow_minutes = 0\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_lines_have_the_time_the_player_and_the_text() {
|
||||
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(utc(951_782_400_000), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(
|
||||
log_line(NOW, "Ann", 1, "chat", 2, Action::Muted { until: 0 }, "a\nb"),
|
||||
"2026-10-31T18:05:39Z Ann 00000000-0000-0000-0000-000000000001 chat hit 2 muted: a b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_costs_little() {
|
||||
// A long message through the whole list stays far below a tick (debug builds included).
|
||||
let f = filter();
|
||||
let text = "the quick brown fox jumps over the lazy dog ".repeat(6);
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
assert!(!f.matches(&text));
|
||||
}
|
||||
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allow_line_without_letters_allows_nothing() {
|
||||
for line in ["!*", "!-*", "!", "!-"] {
|
||||
let f = Filter::new(&format!("nigger\n{line}\n"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// renames (`url` / `command` / `page`).
|
||||
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
@@ -249,13 +249,6 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
Some("custom") => Some(crate::text::ClickEvent::Custom {
|
||||
id: crate::identifier::parse_identifier(&text("id")?)?,
|
||||
payload: e
|
||||
.get("payload")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(crate::scoreboard::json_to_tag),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -264,7 +257,10 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
|
||||
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
let mut style = Style::default();
|
||||
if let Some(c) = o.get("color").and_then(Value::as_str) {
|
||||
style.color = named_color(c).or_else(|| hex_color(c));
|
||||
style.color = named_color(c).or_else(|| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
|
||||
style.bold = style_flag(o.get("bold"));
|
||||
@@ -307,13 +303,6 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
style
|
||||
}
|
||||
|
||||
/// `TextColor.parseColor` for `#...`: `Integer.parseInt(rest, 16)` (so a sign
|
||||
/// and any digit count are accepted), kept only within `0..=0xFFFFFF`.
|
||||
pub(crate) fn hex_color(c: &str) -> Option<u32> {
|
||||
let value = i64::from_str_radix(c.strip_prefix('#')?, 16).ok()?;
|
||||
(0..=0xFF_FFFF).contains(&value).then_some(value as u32)
|
||||
}
|
||||
|
||||
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
|
||||
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
|
||||
if let Some(packed) = value.as_i64() {
|
||||
@@ -496,33 +485,6 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_color_follows_text_color_parse_color() {
|
||||
for bad in [
|
||||
"#1000000",
|
||||
"#FFFFFFFF",
|
||||
"#-FF",
|
||||
"#",
|
||||
"#zz",
|
||||
"FFFFFF",
|
||||
"#99999999999",
|
||||
] {
|
||||
assert_eq!(hex_color(bad), None, "{bad}");
|
||||
}
|
||||
for (good, want) in [
|
||||
("#FFFFFF", 0xFFFFFF),
|
||||
("#000000", 0),
|
||||
("#F", 15),
|
||||
("#+FF", 255),
|
||||
("#-0", 0),
|
||||
] {
|
||||
assert_eq!(hex_color(good), Some(want), "{good}");
|
||||
}
|
||||
let out = serde_json::json!({"text": "A", "color": "#1000000"});
|
||||
let text = to_text(&out, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.color, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_color_accepts_packed_and_rgba_styles() {
|
||||
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
|
||||
@@ -623,63 +585,6 @@ mod tests {
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
|
||||
use crate::text::ClickEvent;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
for (payload, expected) in [
|
||||
(None, None),
|
||||
(Some(serde_json::Value::Null), None),
|
||||
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
|
||||
(
|
||||
Some(serde_json::json!({"shop": "potions", "amount": 3})),
|
||||
Some(Tag::Compound(
|
||||
[
|
||||
("amount".into(), Tag::Int(3)),
|
||||
("shop".into(), Tag::String("potions".into())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)),
|
||||
),
|
||||
] {
|
||||
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
|
||||
if let Some(payload) = payload {
|
||||
event["payload"] = payload;
|
||||
}
|
||||
let component = serde_json::json!({
|
||||
"text": "", "click_event": event,
|
||||
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
|
||||
});
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
for (_, style) in text.chars() {
|
||||
assert_eq!(
|
||||
style.click,
|
||||
Some(ClickEvent::Custom {
|
||||
id: "test:shop".into(),
|
||||
payload: expected.clone(),
|
||||
})
|
||||
);
|
||||
}
|
||||
let encoded = text.component();
|
||||
let decoded = to_text(&encoded, &mut |_| Text::empty());
|
||||
assert_eq!(decoded, text);
|
||||
if expected.is_none() {
|
||||
assert!(!encoded.to_string().contains("\"payload\""));
|
||||
}
|
||||
}
|
||||
for id in ["Bad ID", "Uppercase:shop"] {
|
||||
let text = to_text(
|
||||
&serde_json::json!({
|
||||
"text": "Shop", "click_event": {"action": "custom", "id": id}
|
||||
}),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
assert_eq!(text.spans[0].style.click, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
@@ -719,15 +624,4 @@ mod tests {
|
||||
let (v, _) = parse_snbt("{n:1.5}").unwrap();
|
||||
assert_eq!(v["n"], Value::from(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_text_preserves_legacy_and_decodes_components() {
|
||||
let legacy = Tag::String("§6Golden Name".into());
|
||||
let text = name_text(&legacy);
|
||||
assert_eq!(text.plain(), "Golden Name");
|
||||
|
||||
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
|
||||
let text = name_text(&comp);
|
||||
assert_eq!(text.plain(), "Custom Name");
|
||||
}
|
||||
}
|
||||
@@ -125,51 +125,19 @@ pub const RULES: &[Rule] = &[
|
||||
b("universal_anger", "mobs", false),
|
||||
b("water_source_conversion", "updates", true),
|
||||
];
|
||||
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
|
||||
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
|
||||
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
|
||||
|
||||
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
|
||||
RULES
|
||||
.iter()
|
||||
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
|
||||
}
|
||||
|
||||
pub fn rule(name: &str) -> Option<&'static Rule> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
available_rules(true).find(|r| r.name == name)
|
||||
RULES.iter().find(|r| r.name == name)
|
||||
}
|
||||
|
||||
/// Current values (defaults unless set).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GameRules {
|
||||
values: BTreeMap<&'static str, Value>,
|
||||
minecart_improvements: bool,
|
||||
}
|
||||
impl GameRules {
|
||||
pub fn with_features(features: &[String]) -> Self {
|
||||
Self {
|
||||
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
|
||||
/// newly enabled rules and discard rules absent from the target feature set.
|
||||
pub fn copy_with_features(&self, features: &[String]) -> Self {
|
||||
let mut rules = Self::with_features(features);
|
||||
for (&name, &value) in &self.values {
|
||||
rules.set(name, value);
|
||||
}
|
||||
rules
|
||||
}
|
||||
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
|
||||
available_rules(self.minecart_improvements)
|
||||
}
|
||||
pub fn get(&self, name: &str) -> Option<Value> {
|
||||
let r = rule(name)?;
|
||||
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
|
||||
return None;
|
||||
}
|
||||
Some(self.values.get(r.name).copied().unwrap_or(r.default))
|
||||
}
|
||||
pub fn bool(&self, name: &str) -> bool {
|
||||
@@ -192,9 +160,7 @@ impl GameRules {
|
||||
}
|
||||
/// Set a rule; false if the name or value type is wrong.
|
||||
pub fn set(&mut self, name: &str, value: Value) -> bool {
|
||||
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(r) = rule(name) else { return false };
|
||||
let ok = match (r.default, value) {
|
||||
(Value::Bool(_), Value::Bool(_)) => true,
|
||||
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
|
||||
@@ -210,7 +176,8 @@ impl GameRules {
|
||||
/// Every rule's value by name without namespace, as `save::Level` holds
|
||||
/// them (booleans as 0 or 1).
|
||||
pub fn by_name(&self) -> BTreeMap<String, i32> {
|
||||
self.available_rules()
|
||||
RULES
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
|
||||
Value::Bool(b) => i32::from(b),
|
||||
@@ -222,8 +189,8 @@ impl GameRules {
|
||||
}
|
||||
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
|
||||
pub fn to_nbt(&self) -> NamedTag {
|
||||
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
|
||||
.available_rules()
|
||||
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let v = self.values.get(r.name).copied().unwrap_or(r.default);
|
||||
let tag = match v {
|
||||
@@ -245,14 +212,11 @@ impl GameRules {
|
||||
}
|
||||
}
|
||||
pub fn from_nbt(root: &Tag) -> Self {
|
||||
Self::from_nbt_with_features(root, &[])
|
||||
}
|
||||
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
|
||||
let mut rules = Self::with_features(features);
|
||||
let mut rules = Self::default();
|
||||
let Some(data) = root.get("data") else {
|
||||
return rules;
|
||||
};
|
||||
for r in rules.available_rules() {
|
||||
for r in RULES {
|
||||
let key = format!("minecraft:{}", r.name);
|
||||
let value = data.get(&key).or_else(|| data.get(r.name));
|
||||
match (r.default, value) {
|
||||
@@ -268,18 +232,15 @@ impl GameRules {
|
||||
rules
|
||||
}
|
||||
pub fn load(world: &Path) -> Self {
|
||||
Self::load_with_features(world, &[])
|
||||
}
|
||||
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
|
||||
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
|
||||
return Self::with_features(features);
|
||||
return Self::default();
|
||||
};
|
||||
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
|
||||
return Self::with_features(features);
|
||||
return Self::default();
|
||||
};
|
||||
match nbt::read_named(&raw, Limits::default()) {
|
||||
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
|
||||
Err(_) => Self::with_features(features),
|
||||
Ok((root, _)) => Self::from_nbt(&root.tag),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
pub fn save(&self, world: &Path) -> crate::Result<()> {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! from `pack.mcmeta` `language` sections, and a process-wide current
|
||||
//! language used by every screen.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
@@ -35,15 +34,6 @@ pub struct Language {
|
||||
pub const DEFAULT: &str = "en_us";
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
|
||||
/// A language that only the calling thread sees, and the keys it was asked for and did not have
|
||||
/// (`scoped`).
|
||||
struct Scope {
|
||||
language: Arc<Language>,
|
||||
missed: Vec<String>,
|
||||
}
|
||||
thread_local! {
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -79,256 +69,6 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
"assets/horizon/lang/hi_in.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hi_in.json")
|
||||
}
|
||||
"assets/horizon/lang/id_id.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/id_id.json")
|
||||
}
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
|
||||
}
|
||||
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
|
||||
}
|
||||
"assets/horizon/lang/pt_pt.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/pt_pt.json")
|
||||
}
|
||||
"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")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
@@ -498,50 +238,8 @@ pub fn set(language: Language) {
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
|
||||
return Some(language);
|
||||
}
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
/// Runs `f` with `language` as the current language of this thread only, and returns what it
|
||||
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
|
||||
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
|
||||
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
|
||||
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
|
||||
/// language's.
|
||||
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
|
||||
/// Puts the outer scope back, also when `f` panics.
|
||||
struct Restore(Option<Scope>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
|
||||
}
|
||||
}
|
||||
let outer = SCOPE.with(|s| {
|
||||
s.borrow_mut().replace(Scope {
|
||||
language: Arc::new(language),
|
||||
missed: Vec::new(),
|
||||
})
|
||||
});
|
||||
let _restore = Restore(outer);
|
||||
let result = f();
|
||||
let missed = SCOPE.with(|s| {
|
||||
s.borrow_mut()
|
||||
.as_mut()
|
||||
.map(|s| std::mem::take(&mut s.missed))
|
||||
});
|
||||
(result, missed.unwrap_or_default())
|
||||
}
|
||||
/// `key` was asked for in a `scoped` language that has no text for it.
|
||||
fn missed(key: &str) {
|
||||
SCOPE.with(|s| {
|
||||
if let Some(scope) = s.borrow_mut().as_mut()
|
||||
&& !scope.missed.iter().any(|m| m == key)
|
||||
{
|
||||
scope.missed.push(key.to_owned());
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ClientLanguage.defaultRightToLeft` of the current language.
|
||||
pub fn bidirectional() -> bool {
|
||||
BIDIRECTIONAL.load(Ordering::Relaxed)
|
||||
@@ -553,19 +251,13 @@ pub fn code() -> String {
|
||||
pub fn get(key: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
key.to_owned()
|
||||
})
|
||||
.unwrap_or_else(|| key.to_owned())
|
||||
}
|
||||
/// The translation, or `fallback` when the key is missing.
|
||||
pub fn get_or(key: &str, fallback: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
fallback.to_owned()
|
||||
})
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
}
|
||||
pub fn has(key: &str) -> bool {
|
||||
current().is_some_and(|l| l.get(key).is_some())
|
||||
@@ -617,24 +309,8 @@ mod tests {
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
let tr = Language::load(&[&empty], "tr_tr");
|
||||
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
// German and French, and their regional variants on the same file
|
||||
// (Swiss German without "ß").
|
||||
let de = Language::load(&[&empty], "de_de");
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
let fr = Language::load(&[&empty], "fr_fr");
|
||||
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
|
||||
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
|
||||
let variant = Language::load(&[&empty], code);
|
||||
assert_eq!(variant.map(), base.map(), "{code}");
|
||||
}
|
||||
let de_ch = Language::load(&[&empty], "de_ch");
|
||||
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
|
||||
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
|
||||
assert_eq!(de_ch.len(), de.len());
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
@@ -642,194 +318,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let ((german, english_only, fallback, elsewhere), missed) =
|
||||
scoped(Language::load(&[&empty], "de_de"), || {
|
||||
(
|
||||
get("horizon.osk.close"),
|
||||
get("horizon.no_such_key"),
|
||||
get_or("horizon.no_fallback", "English"),
|
||||
// Another thread keeps the process-wide language.
|
||||
std::thread::spawn(code).join().unwrap(),
|
||||
)
|
||||
});
|
||||
assert_eq!(german, "Schließen");
|
||||
assert_eq!(english_only, "horizon.no_such_key");
|
||||
assert_eq!(fallback, "English");
|
||||
assert_eq!(elsewhere, outside);
|
||||
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
|
||||
// The scope ends with its closure.
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_end_when_their_closure_panics() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
|
||||
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
|
||||
(code(), get("horizon.inner_miss"))
|
||||
});
|
||||
assert_eq!(inner_missed, ["horizon.inner_miss"]);
|
||||
assert_eq!(code(), "fr_fr");
|
||||
inner.0
|
||||
});
|
||||
assert_eq!(inner, "pl_pl");
|
||||
assert!(missed.is_empty());
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
|
||||
panic!("a test failed inside a scope")
|
||||
})
|
||||
});
|
||||
assert!(panicked.is_err());
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
body.lines()
|
||||
.filter(|line| line.starts_with(" \""))
|
||||
.map(|line| {
|
||||
serde_json::Deserializer::from_str(line.trim_start())
|
||||
.into_iter::<String>()
|
||||
.next()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_else(|| panic!("no key on line {line}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
|
||||
/// percent sign that starts none of them ("100% is") is plain text.
|
||||
fn placeholders(text: &str) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find('%') {
|
||||
rest = &rest[at + 1..];
|
||||
let spec = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
|
||||
.unwrap_or(rest.len());
|
||||
let after = rest[spec..].chars().next();
|
||||
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
|
||||
let end = spec + 1;
|
||||
found.push(format!("%{}", &rest[..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
};
|
||||
seen += 1;
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
let map: HashMap<String, String> = serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
|
||||
let keys = file_keys(&body);
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
map.len(),
|
||||
"{name} repeats a key or has a multi-line entry"
|
||||
);
|
||||
for (key, value) in &map {
|
||||
// Stale: the code no longer asks for it, or it was renamed in en_us only.
|
||||
let english = en
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
|
||||
// A translation keeps the fields the code fills in.
|
||||
assert_eq!(
|
||||
placeholders(value),
|
||||
placeholders(english),
|
||||
"{code}: {key} changes the placeholders of the English text"
|
||||
);
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_horizon_key_the_code_names_is_in_en_us() {
|
||||
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
"horizon-client",
|
||||
"horizon-client-render",
|
||||
"horizon-client-core",
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
let text = std::fs::read_to_string(&file).unwrap();
|
||||
for (at, _) in text.match_indices("\"horizon.") {
|
||||
let rest = &text[at + 1..];
|
||||
let Some(end) = rest.find('"') else { continue };
|
||||
let key = &rest[..end];
|
||||
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
|
||||
// between the dots. A key built at run time has a brace; a file name
|
||||
// has one dot; the server addresses that look like keys are listed.
|
||||
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
let key_like = key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
|
||||
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
assert!(
|
||||
en.contains_key(key),
|
||||
"{} names {key}, which is not in en_us.json",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(found > 50, "found only {found} key names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
|
||||
@@ -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)
|
||||
@@ -386,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,
|
||||
@@ -409,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)
|
||||
@@ -469,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,
|
||||
@@ -510,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() {
|
||||
@@ -527,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)
|
||||
@@ -563,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) => {
|
||||
@@ -584,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 {
|
||||
@@ -639,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 {
|
||||
@@ -657,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)
|
||||
@@ -941,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}");
|
||||
|
||||
@@ -118,12 +118,6 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
None => mod_effect(id),
|
||||
}
|
||||
}
|
||||
/// A content mod's effect id (`<ns>:<path>`), not vanilla's with or
|
||||
/// without `minecraft:`.
|
||||
#[inline]
|
||||
pub fn is_mod_effect(id: &str) -> bool {
|
||||
!id.starts_with("minecraft:") && id.bytes().any(|b| b == b':')
|
||||
}
|
||||
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
@@ -136,28 +130,6 @@ pub fn description_id(id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
format!("effect.{namespace}.{}", path.replace('/', "."))
|
||||
}
|
||||
/// The effect's sprite in the GUI atlas (`Hud.getMobEffectSprite`: the key
|
||||
/// with `mob_effect/` before its path): `mob_effect/<path>` for vanilla's,
|
||||
/// `<ns>:mob_effect/<path>` for a mod's.
|
||||
pub fn sprite(id: &str) -> String {
|
||||
let mut out = String::new();
|
||||
push_sprite(&mut out, id);
|
||||
out
|
||||
}
|
||||
/// [`sprite`], appended to `out`.
|
||||
pub fn push_sprite(out: &mut String, id: &str) {
|
||||
let path = match id.split_once(':') {
|
||||
None => id,
|
||||
Some(("minecraft", path)) => path,
|
||||
Some((namespace, path)) => {
|
||||
out.push_str(namespace);
|
||||
out.push(':');
|
||||
path
|
||||
}
|
||||
};
|
||||
out.push_str("mob_effect/");
|
||||
out.push_str(path);
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage; a mod's id already is.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
@@ -328,13 +300,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn description_ids_and_sprites_keep_the_namespace() {
|
||||
fn description_ids_keep_the_namespace() {
|
||||
assert_eq!(description_id("speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
|
||||
assert_eq!(sprite("speed"), "mob_effect/speed");
|
||||
assert_eq!(sprite("minecraft:speed"), "mob_effect/speed");
|
||||
assert_eq!(sprite("ruby:curse/dark"), "ruby:mob_effect/curse/dark");
|
||||
}
|
||||
|
||||
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
|
||||
@@ -371,8 +340,6 @@ mod tests {
|
||||
(0xFF0000, Category::Neutral, false)
|
||||
);
|
||||
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
|
||||
assert!(is_mod_effect(glow.id) && !is_mod_effect("speed"));
|
||||
assert!(!is_mod_effect(namespaced("speed")));
|
||||
assert_eq!(namespaced(glow.id), "ruby:glow");
|
||||
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
|
||||
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
|
||||
|
||||
@@ -28,12 +28,6 @@ pub struct Registries {
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// A dedicated server's lists, for the completions of `/op`, `/deop`, `/pardon`, `/pardon-ip` and
|
||||
/// `/whitelist`: the operators' names, the banned names and addresses, the whitelisted names.
|
||||
pub ops: Vec<String>,
|
||||
pub banned_players: Vec<String>,
|
||||
pub banned_ips: Vec<String>,
|
||||
pub whitelisted: Vec<String>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
|
||||
/// each once.
|
||||
@@ -173,16 +167,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",
|
||||
@@ -223,58 +207,6 @@ impl Registries {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Only what [`Self::fill_from_export`] takes from an export, to keep
|
||||
/// in place of the export itself (a third of its memory).
|
||||
pub fn from_export(r: &serde_json::Value) -> Self {
|
||||
let mut out = Self::default();
|
||||
out.fill_from_export(r);
|
||||
out
|
||||
}
|
||||
/// [`Self::fill_from_export`] from an export already read with
|
||||
/// [`Self::from_export`]: each field the game left empty, each registry
|
||||
/// it did not list.
|
||||
pub fn fill_from_exported(&mut self, e: &Self) {
|
||||
let fill = |field: &mut Vec<String>, from: &Vec<String>| {
|
||||
if field.is_empty() {
|
||||
field.clone_from(from);
|
||||
}
|
||||
};
|
||||
fill(&mut self.effects, &e.effects);
|
||||
fill(&mut self.enchantments, &e.enchantments);
|
||||
fill(&mut self.attributes, &e.attributes);
|
||||
fill(&mut self.damage_types, &e.damage_types);
|
||||
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());
|
||||
}
|
||||
}
|
||||
if self.block_entities.is_empty() {
|
||||
self.block_entities.clone_from(&e.block_entities);
|
||||
}
|
||||
if self.block_tag_members.is_empty() {
|
||||
self.block_tag_members.clone_from(&e.block_tag_members);
|
||||
}
|
||||
if self.clock_time_markers.is_empty() {
|
||||
self.clock_time_markers.clone_from(&e.clock_time_markers);
|
||||
}
|
||||
if self.advancement_criteria.is_empty() {
|
||||
self.advancement_criteria
|
||||
.clone_from(&e.advancement_criteria);
|
||||
}
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
|
||||
@@ -107,7 +107,10 @@ pub fn style_from_json(v: &Value) -> Style {
|
||||
let mut s = Style::default();
|
||||
let Some(o) = v.as_object() else { return s };
|
||||
if let Some(c) = o.get("color").and_then(Value::as_str) {
|
||||
s.color = text::named_color(c).or_else(|| component::hex_color(c));
|
||||
s.color = text::named_color(c).or_else(|| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
@@ -1338,9 +1341,9 @@ fn decompose(c: &Comp) -> (serde_json::Map<String, Value>, Vec<Comp>) {
|
||||
}
|
||||
"color" => {
|
||||
let color = v.as_str().map(|s| match s.strip_prefix('#') {
|
||||
Some(_) => match component::hex_color(s) {
|
||||
Some(rgb) => format!("#{rgb:06X}"),
|
||||
None => s.to_owned(),
|
||||
Some(hex) => match u32::from_str_radix(hex, 16) {
|
||||
Ok(rgb) => format!("#{rgb:06X}"),
|
||||
Err(_) => s.to_owned(),
|
||||
},
|
||||
None => s.to_owned(),
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
|
||||
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
|
||||
}
|
||||
|
||||
/// Click actions carried by text components.
|
||||
/// Click actions carried by locally constructed text components.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClickEvent {
|
||||
/// `ClickEvent.OpenUrl`: an http or https link.
|
||||
@@ -53,11 +53,6 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
|
||||
/// dialog itself as the component carried it.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<horizon_world::nbt::Tag>,
|
||||
},
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
@@ -246,28 +241,6 @@ impl Text {
|
||||
t.source = Some(Value::Object(c).into());
|
||||
t
|
||||
}
|
||||
/// Component.translatableWithFallback(key, fallback, args): `translate`, with `fallback` as
|
||||
/// the template when the language has no `key`.
|
||||
pub fn translate_with_fallback(key: &str, fallback: &str, args: &[Text]) -> Self {
|
||||
let template = lang::current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| fallback.to_owned());
|
||||
let mut t = format_translation(&template, args);
|
||||
let mut c = Map::new();
|
||||
c.insert("translate".into(), key.into());
|
||||
c.insert("fallback".into(), fallback.into());
|
||||
if !args.is_empty() {
|
||||
c.insert("with".into(), args.iter().map(Text::argument).collect());
|
||||
}
|
||||
t.source = Some(Value::Object(c).into());
|
||||
t
|
||||
}
|
||||
/// CommonComponents.optionNameValue: "Caption: value" joined as the language joins them
|
||||
/// (`options.generic_value`: "%s : %s" in French, "%s:%s" in Chinese). English's "%s: %s"
|
||||
/// when the language has no such key, as without prepared resources.
|
||||
pub fn option_name_value(caption: Text, value: Text) -> Self {
|
||||
Self::translate_with_fallback("options.generic_value", "%s: %s", &[caption, value])
|
||||
}
|
||||
/// A translatable message whose arguments are plain strings.
|
||||
pub fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> Self {
|
||||
let args: Vec<Text> = args.iter().map(|a| Text::literal(a.to_string())).collect();
|
||||
@@ -566,7 +539,7 @@ const STYLE_KEYS: [&str; 11] = [
|
||||
"insertion",
|
||||
"font",
|
||||
];
|
||||
/// `Style.Serializer`: the fields a style sets.
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(c) = style.color {
|
||||
@@ -608,13 +581,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
"action": "show_dialog",
|
||||
"dialog": crate::scoreboard::tag_to_json(dialog),
|
||||
}),
|
||||
ClickEvent::Custom { id, payload } => {
|
||||
let mut event = json!({ "action": "custom", "id": id });
|
||||
if let Some(payload) = payload {
|
||||
event["payload"] = crate::scoreboard::tag_to_json(payload);
|
||||
}
|
||||
event
|
||||
}
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
|
||||
@@ -12,48 +12,13 @@ pub fn uuid_text(u: [i32; 4]) -> String {
|
||||
)
|
||||
}
|
||||
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
|
||||
let mut hex = [0u8; 32];
|
||||
let mut len = 0;
|
||||
for b in s.bytes() {
|
||||
if b == b'-' {
|
||||
continue;
|
||||
}
|
||||
if !b.is_ascii_hexdigit() || len >= 32 {
|
||||
return None;
|
||||
}
|
||||
hex[len] = b;
|
||||
len += 1;
|
||||
}
|
||||
if len != 32 {
|
||||
let hex: String = s.chars().filter(|c| *c != '-').collect();
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let hex = std::str::from_utf8(&hex).ok()?;
|
||||
let mut out = [0i32; 4];
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn uuid_from_text_rejects_multibyte_and_non_hex() {
|
||||
assert_eq!(uuid_from_text("€€€€€€€€€€aa"), None);
|
||||
assert_eq!(uuid_from_text("€€€€€€€€€€aa----"), None);
|
||||
assert_eq!(uuid_from_text("+0000001-0000-0000-0000-000000000000"), None);
|
||||
assert_eq!(uuid_from_text("00000000-0000-0000-0000-00000000000g"), None);
|
||||
assert_eq!(uuid_from_text(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_from_text_parses_hex_and_dashes() {
|
||||
let u = [0x01234567, -0x76543211, 0x01234567, -0x76543211];
|
||||
let text = uuid_text(u);
|
||||
assert_eq!(uuid_from_text(&text), Some(u));
|
||||
let undashed: String = text.chars().filter(|c| *c != '-').collect();
|
||||
assert_eq!(uuid_from_text(&undashed), Some(u));
|
||||
}
|
||||
}
|
||||
@@ -179,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)
|
||||
@@ -198,8 +197,6 @@ impl Edges {
|
||||
now &= !button.bit();
|
||||
}
|
||||
}
|
||||
self.suppressed &= now;
|
||||
now &= !self.suppressed;
|
||||
let changed = now ^ self.held;
|
||||
self.held = now;
|
||||
Button::ALL
|
||||
@@ -220,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).
|
||||
@@ -295,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 {
|
||||
@@ -393,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 {
|
||||
@@ -454,12 +399,9 @@ pub struct PadSettings {
|
||||
/// holding it, unless vanilla's toggle crouch is on already.
|
||||
pub toggle_sneak: bool,
|
||||
pub glyphs: GlyphStyle,
|
||||
/// 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>>,
|
||||
}
|
||||
@@ -478,11 +420,8 @@ impl Default for PadSettings {
|
||||
trigger_threshold: 0.4,
|
||||
toggle_sneak: true,
|
||||
glyphs: GlyphStyle::Auto,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -523,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),
|
||||
@@ -545,11 +477,8 @@ impl PadSettings {
|
||||
}
|
||||
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"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" {
|
||||
@@ -568,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:{}\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),
|
||||
@@ -581,12 +510,9 @@ impl PadSettings {
|
||||
self.trigger_threshold,
|
||||
bool_text(self.toggle_sneak),
|
||||
self.glyphs.name(),
|
||||
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",
|
||||
@@ -1237,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();
|
||||
@@ -1613,13 +1414,14 @@ 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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,6 @@ impl InteractionInput {
|
||||
pub fn press(&mut self, button: Button) {
|
||||
self.clicks[button as usize] += 1;
|
||||
}
|
||||
/// Whether a click waits for the next tick.
|
||||
pub fn has_clicks(&self) -> bool {
|
||||
self.clicks.iter().any(|&c| c > 0)
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
@@ -85,19 +81,6 @@ impl InteractionInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentence of `Minecraft.handleKeybinds` that follows a key which opened
|
||||
/// a screen: `Gui.setScreen` has already run `KeyMapping.releaseAll()`, so
|
||||
/// `key.use` reads up and the same call releases an active use
|
||||
/// (`player.isUsingItem() && !keyUse.isDown()` → `MultiPlayerGameMode.releaseUsingItem`).
|
||||
/// Only the screens opened from the keybinds do this (the inventory, chat and
|
||||
/// advancements); the pause menu, server containers, mount inventories and
|
||||
/// dialogs release on the first tick after closing instead, as vanilla does.
|
||||
pub fn release_on_screen_open(world: &mut impl InteractionWorld) {
|
||||
if world.using_item() {
|
||||
world.release_using();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -483,21 +483,6 @@ impl KeyBinds {
|
||||
.find(|mapping| self.get(mapping) == name)
|
||||
.and_then(|mapping| key_code(Self::default_of(mapping)))
|
||||
}
|
||||
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
|
||||
/// hotbar slots, as the controls bind them now. Answers the key the menu
|
||||
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
|
||||
/// swaps instead of reaching the screen.
|
||||
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
|
||||
let name = key_name(key)?;
|
||||
if self.get("key.swapOffhand") == name {
|
||||
return Some(KeyCode::KeyF);
|
||||
}
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.filter(|m| m.0.starts_with("key.hotbar."))
|
||||
.find(|m| self.get(m.0) == name)
|
||||
.and_then(|m| key_code(Self::default_of(m.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -534,29 +519,6 @@ mod tests {
|
||||
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_hotkeys_follow_the_bound_keys() {
|
||||
let mut keys = KeyBinds::default();
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyF),
|
||||
Some(KeyCode::KeyF)
|
||||
);
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::Digit3),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
|
||||
// The report's key: bound to the off hand, the menu takes it first.
|
||||
keys.set("key.swapOffhand", "key.keyboard.tab");
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
|
||||
keys.set("key.hotbar.3", "key.keyboard.t");
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyT),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_translates_to_defaults() {
|
||||
let mut k = KeyBinds::default();
|
||||
|
||||
@@ -207,20 +207,6 @@ impl ToggleKeys {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `KeyMapping.resetToggleKeys` (a new `LocalPlayer`): a sneak and a sprint toggled on in one
|
||||
/// world are off in the next.
|
||||
#[test]
|
||||
fn resetting_the_toggle_keys_puts_every_mapping_up() {
|
||||
let mut keys = ToggleKeys::default();
|
||||
keys.key(0, KeyAction::Press, false, true);
|
||||
keys.key(1, KeyAction::Press, false, true);
|
||||
assert!(keys.is_down(0) && keys.is_down(1));
|
||||
for key in &mut keys.0 {
|
||||
key.reset();
|
||||
}
|
||||
assert!(!keys.is_down(0) && !keys.is_down(1));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn toggle_flips_on_press_and_ignores_release() {
|
||||
|
||||
@@ -5,13 +5,10 @@
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod snap;
|
||||
pub mod window;
|
||||
@@ -1,232 +0,0 @@
|
||||
//! OS modifier reconciliation and modifiers synthesized by agent control.
|
||||
|
||||
use crate::{
|
||||
keybinds::{KeyBinds, key_code},
|
||||
keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use winit::keyboard::{KeyCode, ModifiersState};
|
||||
|
||||
const MODIFIERS: [(ModifiersState, [KeyCode; 2]); 4] = [
|
||||
(
|
||||
ModifiersState::SHIFT,
|
||||
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
|
||||
),
|
||||
(
|
||||
ModifiersState::CONTROL,
|
||||
[KeyCode::ControlLeft, KeyCode::ControlRight],
|
||||
),
|
||||
(ModifiersState::ALT, [KeyCode::AltLeft, KeyCode::AltRight]),
|
||||
(
|
||||
ModifiersState::SUPER,
|
||||
[KeyCode::SuperLeft, KeyCode::SuperRight],
|
||||
),
|
||||
];
|
||||
|
||||
/// A synthesized key updates the same modifier word as window events.
|
||||
/// Releasing one side must preserve the other side's hold.
|
||||
pub fn set_key(
|
||||
modifiers: &mut ModifiersState,
|
||||
physical: &mut HashSet<KeyCode>,
|
||||
key: KeyCode,
|
||||
down: bool,
|
||||
) {
|
||||
let Some((bit, pair)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&key)) else {
|
||||
return;
|
||||
};
|
||||
if down {
|
||||
physical.insert(key);
|
||||
} else {
|
||||
physical.remove(&key);
|
||||
}
|
||||
modifiers.set(*bit, pair.iter().any(|key| physical.contains(key)));
|
||||
}
|
||||
|
||||
/// A modifier held for one synthesized click (the pad's Y, an agent's
|
||||
/// shift-click): what [`restore`] puts back once the click is done.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Held {
|
||||
key: KeyCode,
|
||||
was_down: bool,
|
||||
bit: bool,
|
||||
}
|
||||
|
||||
/// Holds `key` in both the physical keys and the modifier word, which is
|
||||
/// what screens read (`InputWithModifiers.hasShiftDown`).
|
||||
pub fn hold(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, key: KeyCode) -> Held {
|
||||
let bit = MODIFIERS
|
||||
.iter()
|
||||
.find(|(_, pair)| pair.contains(&key))
|
||||
.is_some_and(|(bit, _)| modifiers.contains(*bit));
|
||||
let held = Held {
|
||||
key,
|
||||
was_down: physical.contains(&key),
|
||||
bit,
|
||||
};
|
||||
set_key(modifiers, physical, key, true);
|
||||
held
|
||||
}
|
||||
|
||||
/// Undoes [`hold`]: a key or bit that was already down stays down.
|
||||
pub fn restore(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, held: Held) {
|
||||
if !held.was_down {
|
||||
physical.remove(&held.key);
|
||||
}
|
||||
if let Some((bit, _)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&held.key)) {
|
||||
modifiers.set(*bit, held.bit);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover missing modifier releases without releasing a rebound key or a pad.
|
||||
/// ToggleKeyMapping itself preserves keyboard and controller toggles on release.
|
||||
pub fn reconcile(
|
||||
modifiers: ModifiersState,
|
||||
physical: &mut HashSet<KeyCode>,
|
||||
bindings: &KeyBinds,
|
||||
toggles: &mut ToggleKeys,
|
||||
options: [bool; 2],
|
||||
pad_held: [bool; 2],
|
||||
) {
|
||||
for (bit, pair) in MODIFIERS {
|
||||
if modifiers.contains(bit) {
|
||||
continue;
|
||||
}
|
||||
for key in pair {
|
||||
if !physical.remove(&key) {
|
||||
continue;
|
||||
}
|
||||
for (slot, name) in [(SNEAK, "key.sneak"), (SPRINT, "key.sprint")] {
|
||||
if key_code(bindings.get(name)) == Some(key) && !pad_held[slot] {
|
||||
toggles.key(slot, KeyAction::Release, false, options[slot]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modifier_taps_release_both_bits() {
|
||||
let mut modifiers = ModifiersState::empty();
|
||||
let mut physical = HashSet::new();
|
||||
for (bit, pair) in MODIFIERS {
|
||||
for key in pair {
|
||||
set_key(&mut modifiers, &mut physical, key, true);
|
||||
assert!(modifiers.contains(bit));
|
||||
set_key(&mut modifiers, &mut physical, key, false);
|
||||
assert!(modifiers.is_empty());
|
||||
assert!(physical.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_one_side_keeps_the_other_modifier_held() {
|
||||
let mut modifiers = ModifiersState::empty();
|
||||
let mut physical = HashSet::new();
|
||||
for (bit, [left, right]) in MODIFIERS {
|
||||
set_key(&mut modifiers, &mut physical, left, true);
|
||||
set_key(&mut modifiers, &mut physical, right, true);
|
||||
set_key(&mut modifiers, &mut physical, left, false);
|
||||
assert!(modifiers.contains(bit));
|
||||
set_key(&mut modifiers, &mut physical, right, false);
|
||||
assert!(modifiers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_click_restores_the_previous_state() {
|
||||
let mut modifiers = ModifiersState::empty();
|
||||
let mut physical = HashSet::new();
|
||||
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
|
||||
assert!(modifiers.shift_key());
|
||||
restore(&mut modifiers, &mut physical, held);
|
||||
assert!(modifiers.is_empty());
|
||||
assert!(physical.is_empty());
|
||||
|
||||
// Right Shift already held, or the OS word set with no physical key.
|
||||
for start in [HashSet::from([KeyCode::ShiftRight]), HashSet::new()] {
|
||||
let mut modifiers = ModifiersState::SHIFT;
|
||||
let mut physical = start.clone();
|
||||
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
|
||||
restore(&mut modifiers, &mut physical, held);
|
||||
assert_eq!(modifiers, ModifiersState::SHIFT);
|
||||
assert_eq!(physical, start);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_releases_respect_rebindings_and_pad_holds() {
|
||||
for slot in [SNEAK, SPRINT] {
|
||||
for rebound in [false, true] {
|
||||
for pad in [false, true] {
|
||||
for toggle in [false, true] {
|
||||
let mut bindings = KeyBinds::default();
|
||||
let (name, modifier) = if slot == SNEAK {
|
||||
("key.sneak", KeyCode::ShiftLeft)
|
||||
} else {
|
||||
("key.sprint", KeyCode::ControlLeft)
|
||||
};
|
||||
if rebound {
|
||||
bindings.set(name, "key.keyboard.c");
|
||||
}
|
||||
let mut physical = HashSet::from([modifier]);
|
||||
if rebound {
|
||||
physical.insert(KeyCode::KeyC);
|
||||
}
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.key(slot, KeyAction::Press, false, toggle);
|
||||
let mut options = [false; 2];
|
||||
options[slot] = toggle;
|
||||
let mut pad_held = [false; 2];
|
||||
pad_held[slot] = pad;
|
||||
reconcile(
|
||||
ModifiersState::ALT,
|
||||
&mut physical,
|
||||
&bindings,
|
||||
&mut toggles,
|
||||
options,
|
||||
pad_held,
|
||||
);
|
||||
assert_eq!(toggles.is_down(slot), rebound || pad || toggle);
|
||||
assert!(!physical.contains(&modifier));
|
||||
assert_eq!(physical.contains(&KeyCode::KeyC), rebound);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_modifier_change_does_not_release_an_action() {
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.key(SNEAK, KeyAction::Press, false, false);
|
||||
reconcile(
|
||||
ModifiersState::ALT,
|
||||
&mut HashSet::new(),
|
||||
&KeyBinds::default(),
|
||||
&mut toggles,
|
||||
[false; 2],
|
||||
[false; 2],
|
||||
);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_modifier_release_preserves_a_controller_toggle() {
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.pad_toggle(SNEAK, false);
|
||||
reconcile(
|
||||
ModifiersState::empty(),
|
||||
&mut HashSet::from([KeyCode::ShiftLeft]),
|
||||
&KeyBinds::default(),
|
||||
&mut toggles,
|
||||
[false; 2],
|
||||
[false; 2],
|
||||
);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 2299 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user