Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 266c4576a5 Launcher: java_launch's Windows paths, one part at a time
The log config and old versions' asset folder were joined from
"a/b" strings; on Windows their tests compared those mixed
separators with paths joined part by part. Join them part by part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:21:50 +01:00
2174 changed files with 61733 additions and 553875 deletions

No files matched your search

-76
View File
@@ -1,76 +0,0 @@
---
name: vanilla-parity
description: Fix a Horizon bug or gap so it behaves 1:1 like vanilla 26.3, starting from the port ledger (tools/port_ledger.py) instead of grepping. Use for any issue, Discord report or PR that makes Horizon match vanilla: "X doesn't work like in Java", a missing sound, particle, animation, mob behaviour, block rule, screen or packet.
---
# Vanilla parity with the port ledger
The port ledger pairs every vanilla method with the Rust fn that ports it (or says it is missing, or needs
no port) and lists where a port isn't wired like vanilla. Ask it first; grep only for what it can't answer.
Full reference: `docs/port-ledger.md`.
## 0. Have a fresh ledger
```sh
python3 tools/port_ledger.py build # ~20 s; again after pulling main
```
It needs the vanilla jar (the launcher's `versions/26.3/26.3.jar`, or `--jar`) and/or the lab's decompiled
sources (`python3 tools/lab.py decompile`, or `--sources DIR`). If `build` says it has no vanilla code, run
the lab first; don't guess vanilla from memory.
## 1. From the report to the code
```sh
python3 tools/port_ledger.py issue 3236 # a GitHub issue number
python3 tools/port_ledger.py issue "mooshroom stays red after lightning" # or the report's text
```
It ranks the vanilla classes the report names and lists, per class, its Rust home files, where each method is
ported, and the known gaps (`missing` rows from the verdict map, seam/client/drift gaps). Often the cause is
already written there ("Type is not saved"). Then drill in:
```sh
python3 tools/port_ledger.py where MushroomCow.readAdditionalSaveData # ports, callers, callees, gaps
python3 tools/port_ledger.py class MushroomCow # every method: where, or missing
python3 tools/port_ledger.py what crates/horizon-client/src/mobs/farm.rs:120 # which vanilla method a fn ports
```
A ledger answer is a lead, not a verdict: about 1 in 12 map rows is wrong. Read the vanilla method (path and
line are in the output) and the Rust fn before changing anything.
## 2. Port it the vanilla way
- Translate the vanilla method, don't reinvent it: same order of operations, same constants, the same random
draws in the same order (`horizon_core::random`), Java `Math` through `horizon_core::jmath`.
- Open the fn's doc with the vanilla method it ports: `` /// `MushroomCow.readAdditionalSaveData`: ... ``.
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`. 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.
## 3. Before the PR
```sh
python3 tools/port_ledger.py check --base origin/main
```
It lists the ports your branch touches with their open gaps, citations to add, stale citations, and (against
the base) the gaps the branch closes and opens. Close what belongs to your fix, cite what you ported, leave
no new stale citation. If you ported a method the verdict map called `missing`, change its row in
`tools/port_map/*.tsv` to `port` with the fn (`file.rs::Owner::fn`) and run
`python3 tools/port_ledger.py map-check tools/port_map/<file>.tsv`.
Then the usual: the harness for the area (`docs/architecture.md`), `cargo check`/tests per `AGENTS.md`, and
a small PR that says which vanilla method it now matches.
## Finding work
```sh
python3 tools/port_ledger.py gaps --kind seam # ported on two paths, wired on one: the best leads
python3 tools/port_ledger.py gaps --kind client # client-only effects the server path never reaches
python3 tools/port_ledger.py gaps --kind drift # a port missing a sound, particle, item or constant
rg -n "player:" tools/port_map/*.tsv # missing behaviour a player would see
```
-5
View File
@@ -4,8 +4,3 @@ docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
-20
View File
@@ -1,32 +1,12 @@
<!-- Opened by an AI agent? First line: "> 🤖 Opened by <your GitHub login>'s AI agent (<tool, model>), not by <your GitHub login> personally." Use your own login, not someone else's. -->
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
## Kind
<!-- Tick one: it sets the pull request's label (CONTRIBUTING.md "Pull requests"). -->
- [ ] parity: Horizon now does what vanilla 26.3 does
- [ ] bug: a crash or a breakage
- [ ] perf: faster, or less memory
- [ ] feature: something new for players (listed under New in the release notes)
- [ ] extra: Horizon-only, off by default
- [ ] tooling: tools, CI, tests, harnesses
- [ ] docs
## Vanilla reference
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
## Other projects' code
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". -->
## Self-check
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
-1
View File
@@ -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)
+5 -9
View File
@@ -9,23 +9,19 @@ permissions:
actions: write
jobs:
cancel:
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
steps:
- name: Cancel the pull request's unfinished runs
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.event.pull_request.head.ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Every head the pull request had, not only its last: a run-parity label added on an older head left that
# run queued after the merge (#3104, 2026-10-29). Its branch in its own repository names its runs (another
# fork can have a branch of the same name).
for status in queued in_progress waiting pending requested; do
gh api -X GET "repos/$REPO/actions/runs" -f branch="$BRANCH" -f event=pull_request -f status="$status" \
-f per_page=100 --jq ".workflow_runs[] | select(.head_repository.full_name == \"$HEAD_REPO\") | .id"
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
--json databaseId,event,headSha \
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
done | sort -u | while read -r id; do
gh run cancel -R "$REPO" "$id" || true
done
+17 -45
View File
@@ -24,23 +24,14 @@ 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: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
# Python and the Rust cache steps are GitHub's only.
verify:
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
- name: Does this pull request need Rust checks?
@@ -50,33 +41,20 @@ jobs:
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:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python tools/check_public_tree.py
if [ "$EVENT" = pull_request ]; then
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
else
python tools/check_public_tree.py --history
fi
python tools/check_public_tree.py --history
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
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
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
# 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' && runner.environment == 'github-hosted'
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -84,27 +62,26 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
# With the game's code mods (on in every release build, off by default).
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
# 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
# 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).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
# 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 == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
@@ -118,16 +95,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
@@ -144,8 +117,7 @@ jobs:
# 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 playhorizon's small runners (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
-78
View File
@@ -1,78 +0,0 @@
name: claim command
# `/claim` and `/unclaim` comments: anyone takes an issue before working on it, and a daily run releases the claims
# older than 5 days that no open pull request links (CONTRIBUTING.md "Claim before you start",
# tools/ci/claim_command.py).
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` and `schedule` always run this file from main, whoever comments and wherever.
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step
# and can only write issues (assignees, comments, reactions).
on:
issue_comment:
types: [created]
schedule:
- cron: "23 5 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts. Pull requests are answered by the script.
if: >-
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/claim') ||
startsWith(github.event.comment.body, '/unclaim') ||
startsWith(github.event.comment.body, '🤖 Agent: /claim') ||
startsWith(github.event.comment.body, '🤖 Agent: /unclaim'))
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
tools/ci/claim_command.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
tools/ci/nightly_set.py
tools/ci/claim_command.py
sparse-checkout-cone-mode: false
- name: Release stale claims
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: python3 tools/ci/claim_command.py release
+18 -30
View File
@@ -1,7 +1,6 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). 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,
# which never runs what this one built. See docs/release-flow.md.
@@ -38,10 +37,9 @@ permissions:
jobs:
exporters:
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
# goes to the Mac mini's nightly runner (macOS + horizon-nightly: ludovics-mac-mini-nightly, beside the one that
# runs pull requests' parity jobs, so a nightly never waits behind their queue), never the release runner
# (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]') }}
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
@@ -105,14 +103,7 @@ jobs:
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
# programs under Rosetta 2.
- name: macos-x64
mac: true
target: x86_64-apple-darwin
dir: horizon-macos-x64
asset: horizon-macos-x64.zip
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
@@ -122,8 +113,6 @@ jobs:
HORIZON_VERSION: ${{ inputs.version }}
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
HORIZON_NIGHTLY: ${{ inputs.nightly }}
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
defaults:
run:
shell: bash
@@ -138,8 +127,6 @@ jobs:
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -150,11 +137,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
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- 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; }
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
@@ -169,15 +157,15 @@ jobs:
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
codesign --verify --strict --verbose=2 "$BIN/$binary"
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="$BIN/$binary${{ matrix.exe }}"
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
@@ -188,7 +176,7 @@ jobs:
- name: Every program starts
run: |
set -euo pipefail
bin=$BIN
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
@@ -214,7 +202,7 @@ jobs:
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/$BIN/horizon-client"
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
@@ -229,9 +217,8 @@ jobs:
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted' && !matrix.target
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
@@ -244,7 +231,7 @@ jobs:
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
@@ -279,8 +266,9 @@ jobs:
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
+11 -50
View File
@@ -8,11 +8,7 @@ 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
# macOS: 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.
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
@@ -22,7 +18,7 @@ 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/**'
@@ -32,8 +28,6 @@ on:
- 'crates/horizon-release-sign/**'
- 'crates/horizon-telemetry/**'
- 'crates/horizon-theme/**'
- 'packaging/linux/**'
- 'packaging/icons/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
@@ -111,13 +105,6 @@ jobs:
mac: true
asset: horizon-launcher-macos-arm64.zip
binary: horizon-launcher
mac_arch: arm64
- name: macos-x64
mac: true
target: x86_64-apple-darwin
asset: horizon-launcher-macos-x64.zip
binary: horizon-launcher
mac_arch: x64
- name: windows
os: windows-latest
asset: horizon-launcher-windows-x64.zip
@@ -130,8 +117,6 @@ jobs:
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
# variable, not a secret. Unset, the launcher says sign-in is not configured.
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
@@ -155,8 +140,6 @@ jobs:
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -166,7 +149,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.
@@ -177,17 +160,17 @@ jobs:
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"}
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
out=$(target/release/${{ matrix.binary }} --version)
echo "$out"
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
- name: Only system libraries
run: |
file="$BIN/${{ matrix.binary }}"
file=target/release/${{ matrix.binary }}
case "$RUNNER_OS" in
Windows) python ../tools/check_imports.py "$file" ;;
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
@@ -202,28 +185,13 @@ jobs:
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
rm -rf "$HORIZON_LAUNCHER_HOME"
launcher="$PWD/$BIN/${{ matrix.binary }}"
launcher="$PWD/target/release/${{ matrix.binary }}"
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
retry "$launcher" versions
"$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.
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)'
status=0
python3 -c "$no_build" <<<"$versions" || status=$?
if [[ $status == 3 ]]; then
echo "::warning::No game build has an asset for ${{ matrix.name }} yet: install not tested"
rm -rf "$HORIZON_LAUNCHER_HOME"
exit 0
fi
[[ $status == 0 ]] || exit $status
fi
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
client="${clients[0]}"
@@ -256,18 +224,11 @@ jobs:
# Developer ID): packaging/macos.
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
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/
cp target/release/${{ matrix.binary }} dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
else
-31
View File
@@ -1,31 +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:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
-55
View File
@@ -1,55 +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@v4
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"
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.'
+4 -6
View File
@@ -172,8 +172,7 @@ jobs:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -234,10 +233,9 @@ jobs:
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS 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-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
-43
View File
@@ -1,43 +0,0 @@
name: overlap note
# One comment on a pull request naming the other open pull requests that close the same issue or change the same
# functions, edited on every push (tools/ci/overlap_note.py, issue #3859).
#
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write:
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
# - The checkout is main's (`ref: main`), blobless, with the script alone on disk. The pull requests' heads are fetched
# as git objects and only diffed, never checked out; git runs with hooks, fsmonitor, external diff and textconv off.
# - The rest comes from the API. The token writes the comment only.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: read
jobs:
note:
if: github.event.pull_request.base.ref == 'main'
# A newer push replaces the note being computed.
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
filter: blob:none
persist-credentials: false
sparse-checkout: |
tools/ci/overlap_note.py
sparse-checkout-cone-mode: false
- name: Note
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: python3 tools/ci/overlap_note.py event
-61
View File
@@ -1,61 +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:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
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"
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.
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
--example pack_load_bench -- --scale "$SCALE") > base.md \
|| echo "The bench does not build on the base." > base.md
- name: Summary
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"
-38
View File
@@ -1,38 +0,0 @@
name: pr labels
# A pull request's kind label (the box ticked in the template) and area labels (the paths it changes):
# tools/ci/pr_labels.py, CONTRIBUTING.md "Pull requests".
#
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write to fork PRs:
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
# - It checks out main's script only (`ref: main`), never the pull request's head or merge commit.
# - The script reads the event file (the description is data in JSON, never in a shell line) and the API's list of
# changed file names; it runs, builds and imports nothing of theirs. The token writes labels only.
on:
pull_request_target:
types: [opened, reopened, synchronize, edited, ready_for_review]
permissions:
contents: read
jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
tools/ci/pr_labels.py
sparse-checkout-cone-mode: false
- name: Label
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: python3 tools/ci/pr_labels.py event
+2 -6
View File
@@ -5,15 +5,11 @@ name: run-parity
# skipped `verify`, and GitHub counts a skipped required check as passed.
on:
pull_request:
# A push starts a run only to cancel the old head's (see concurrency): the job runs on the label alone.
types: [labeled, synchronize]
types: [labeled]
permissions:
contents: read
concurrency:
# The run-parity label and pushes share one group per pull request, so the newest run cancels the one before: a
# pushed pull request's run tested a head the merge would no longer land. Any other label gets a group of its own:
# in the shared one, adding needs-proof cancelled the parity run queued seconds before (run 40766180150).
group: run-parity-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-parity' && format('-{0}', github.run_id) || '' }}
group: run-parity-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
-1
View File
@@ -21,7 +21,6 @@
.DS_Store
# What the client writes to its working directory when run from the repo root.
/options.txt
/debug-profile.json
/horizon.txt
/horizon-graphics.txt
/horizon-controller.txt
+27 -54
View File
@@ -13,15 +13,13 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
the tool: `> 🤖 Opened by <login>'s AI agent (<tool, model>), not by <login> personally.`, where `<login>`
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
with that agent's own "Generated with" line. Ludovic's agents use
`Co-Authored-By: Claude <model> <[email protected]>` and
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
@@ -30,9 +28,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.
## Crates
@@ -40,13 +35,11 @@ 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`, ~170 flat modules) 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`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -54,11 +47,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `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-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-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/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 |
@@ -71,28 +62,18 @@ 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 --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
@@ -104,12 +85,12 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
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.
- 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`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
@@ -118,9 +99,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## Vanilla reference
- Start parity work from the port ledger, not grep: `python3 tools/port_ledger.py issue N` (or `where
Class.method`, `what file:line`), and `check --base origin/main` before the PR. How:
[.claude/skills/vanilla-parity/SKILL.md](.claude/skills/vanilla-parity/SKILL.md), [docs/port-ledger.md](docs/port-ledger.md).
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
@@ -132,12 +110,8 @@ 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.
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
|---|---|
@@ -155,11 +129,11 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| 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` |
@@ -168,7 +142,7 @@ 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
@@ -188,18 +162,17 @@ state stays separate from the library tests. This compiles gameplay twice for ex
"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.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
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:
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
+12 -51
View File
@@ -2,7 +2,7 @@
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/playhorizon).
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
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.
@@ -47,31 +47,14 @@ 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
## Only original code
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
another project's source may also be read and used, **only when that project's license allows it**:
- **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.
- **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.
- **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 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.
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
packs and mods are fine to run and observe. 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
@@ -91,20 +74,6 @@ including examples. The dependency scope includes optional, platform, build and
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
## Claim before you start
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
enough), then claim it. `/unclaim` gives it back.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
pull requests already fix. Agents claim the same way.
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
## Pull requests
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
@@ -113,16 +82,8 @@ 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.
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`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
@@ -140,5 +101,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
Generated
+807 -665
View File
File diff suppressed because it is too large. Load diff
+15 -13
View File
@@ -1,22 +1,17 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
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-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
@@ -27,14 +22,26 @@ members = [
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mod-sdk",
"crates/horizon-mods",
"crates/horizon-mods/tests/guest",
"crates/horizon-mods/tests/ui_guest",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
]
resolver = "2"
@@ -57,14 +64,9 @@ large_enum_variant = "allow"
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# And gfx-rs/wgpu#10714: on Windows, Vulkan waits for the acquire fence only in
# FIFO (VSync), where it was meant to pace frames; with VSync off it made every
# frame wait for the GPU to finish the one before (#4604).
# Drop this and the folder once a wgpu release has these fixes.
# Drop this and the folder once a wgpu release has both 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" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
+51 -40
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
</p>
<p align="center">
@@ -7,7 +7,6 @@
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -24,32 +23,34 @@
# Horizon
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
</tr>
</table>
## What works today
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
The [documentation](https://playhorizon.dev/docs/) covers each of these.
In the Rust client, with vanilla assets and no mods or shaders:
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
<table>
<tr>
@@ -67,15 +68,15 @@ In the Rust client, with vanilla assets and no mods or shaders:
## Host a server
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Modpacks
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
## Agent control
AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, reads its state as JSON and acts like a player; `horizon-ctl mcp` serves the same commands over MCP.
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
```sh
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
@@ -86,14 +87,16 @@ horizon-ctl break -1 65 -3 # mines it; replies with
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
```
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
## Build from source
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
```sh
git clone https://github.com/horizon-rust/horizon.git
@@ -103,57 +106,65 @@ cargo run --release -p horizon-prepare -- sounds # optional: sounds from your
cargo run --release -p horizon-client
```
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
## Roadmap
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
## Contributing
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
```sh
git config core.hooksPath .githooks
python3 tools/pr_ready.py
```
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table 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.
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
### Java parity: 100%
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.
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
and verified against vanilla by differential tests). The percentage is the
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
2026-10-13 against the harness results: all 281 harnesses pass on Linux, each
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
them too (Rust uses Java's own float maths on each platform).
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
| Window, input and game loop | 1 | None: game loop, options, camera, auto-jump, toggle sprint/sneak and frame limiter match |
| Assets and resources | 1 | None: item models match over 196,290 stacks; the resource pack screen over 3,000 sessions |
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server matches vanilla's files, login, configuration, play and command tree; LAN discovery over 462 cases (offline mode only) |
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
| Assets and resources | 1 | None: item models match vanilla over 196,290 stacks and the client draws every item through them; the resource pack screen matches vanilla's drawing over 3,000 sessions of clicks, scrolling and searching, its incompatibility confirmation included |
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server (`--dedicated`) matches vanilla's files, status ping, login, configuration, play, chunks, chat, recipes, advancements and command tree, a client built from vanilla's packet code joins it, Horizon joins a vanilla server, and LAN discovery matches over 462 cases (offline mode only) |
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
| Rendering | 1 | None: entity models, block entity renderers (407 cases) and animations (296), HUD overlays (2,200 states), the recipe book (600), player list, subtitles and world border match |
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
| Region files and saves | 1 | None: saves round-trip with vanilla; worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do |
| Entities and physics | 1 | None: attributes, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900), `finalizeSpawn` (24,000) and mob AI (92 scenarios) match |
| Interaction and inventory | 1 | None: menu clicks over 8,000 sequences; recipe book placement (6,000), buttons (1,200), saved book (1,500), loom and crafter screens (1,600) |
| Menus, HUD and chat | 1 | None: every command (758 over 39,685 inputs), scoreboards (81,000 operations), the player list (872,000 draw calls), chat and its suggestions (3,000 sessions) match |
| Region files and saves | 1 | None: saves round-trip with vanilla, and worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do, superflat worlds included; old options.txt and hotbar.nbt files are upgraded too |
| Entities and physics | 1 | None: attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases), spawned mobs' gear, enchantments and state (`finalizeSpawn`, 24,000 cases) and mob AI (92 scenarios: villager breeding and gossip, the warden, ghastlings and copper golems included) match vanilla |
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
| Menus, HUD and chat | 1 | None: every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), the player list matches over 872,000 draw calls with heads drawn from players' skins (authlib's textures, SkinManager's lookup, legacy skin conversion), and the chat matches focused and scrolled; the resource pack screen (selection over 4,000 cases, drawing and its incompatibility confirmation over 3,000 sessions) and the chat input with its command suggestions, usage lines, Tab completion and history (3,000 sessions on vanilla's client command tree) match vanilla |
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Licence
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
## Author
+522 -458
View File
File diff suppressed because it is too large. Load diff
+2 -98
View File
@@ -3,17 +3,10 @@
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
developers, with one upstream fix applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). 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`).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
@@ -26,92 +19,3 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## 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
[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
trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
The launcher shows the logo of the mod loader an instance plays with.
- `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.
## Sign in with Microsoft
The launcher's sign-in buttons follow Microsoft's published branding guidelines for apps that sign in
with Microsoft accounts
([Sign in with Microsoft branding](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps)).
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.
-457
View File
@@ -1,457 +0,0 @@
#!/usr/bin/env python3
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
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:
- TPS: `time query gametime` at the start and the end of the window, as game ticks per wall-clock second;
- MSPT: `tick query` every 5 s (average, P50, P95, P99 over the last 100 ticks), averaged over the window;
- RSS and CPU of the server's whole process tree, once a second (needs `ps`: Linux and macOS);
- bandwidth: `bytes_in`/`bytes_out` from the bots' report, per player and second online (each bot's whole time
on the server, its join included), when the bots report them (they do not yet: the field is null until S-6); join time: the bots' `joined_after_ms`.
It writes one JSON report (--out) and prints the Markdown table of the design (docs/design.md, section 1).
Vanilla is the owner's own server.jar (--jar): nothing of Mojang's is fetched or committed. Pumpkin is a released
binary run as a black box (--exe); its Bedrock listeners, LAN broadcast and telemetry are turned off in the
config this script writes (--pumpkin-config adds a TOML file of your own on top if a release names a key
differently). `--pre-generated DIR` copies a finished world into every run's scratch folder as `world`, so runs
repeat on the same terrain. Build the bots first: cargo build --release -p horizon-bots
"""
import argparse
import datetime
import json
import os
from pathlib import Path
import platform
import re
import shutil
import socket
import statistics
import subprocess
import sys
import tempfile
import threading
import time
ROOT = Path(__file__).resolve().parent.parent
EXE = '.exe' if os.name == 'nt' else ''
SERVERS = ('horizon', 'vanilla', 'pumpkin')
PROPERTIES = {
'level-seed': '12345',
'view-distance': '10',
'simulation-distance': '10',
'online-mode': 'false',
'white-list': 'false',
'gamemode': 'creative',
'force-gamemode': 'true',
'spawn-protection': '0',
'max-players': '200',
'difficulty': 'easy',
'server-ip': '127.0.0.1',
'enable-query': 'false',
'enable-rcon': 'false',
}
# Pumpkin's two config files. Bedrock, LAN broadcast and telemetry (plugins' included) are off.
PUMPKIN_CONFIGURATION = '''\
server_address = "127.0.0.1:{port}"
seed = "{seed}"
max_players = 200
view_distance = {view}
simulation_distance = {view}
online_mode = false
default_difficulty = "Easy"
allow_chat_reports = false
encryption = false
lan_broadcast = false
[networking.bedrock]
enabled = false
[networking.lan_broadcast]
enabled = false
[networking.query]
enabled = false
[networking.rcon]
enabled = false
'''
PUMPKIN_FEATURES = '''\
[telemetry]
enabled = false
[plugins]
enabled = false
'''
DONE = re.compile(r'Done|listening|Server started', re.IGNORECASE)
TICK_AVERAGE = re.compile(r'Average time per tick: ([0-9.]+) ?ms')
TICK_PERCENTILES = re.compile(r'P50: ([0-9.]+) ?ms P95: ([0-9.]+) ?ms P99: ([0-9.]+) ?ms')
GAMETIME = re.compile(r'The time is (-?\d+)')
def free_port():
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]
def parse_tick_query(lines):
"""MSPT numbers from the lines `tick query` printed, or None until both lines are there."""
average = percentiles = None
for line in lines:
if (m := TICK_AVERAGE.search(line)) and average is None:
average = float(m.group(1))
if (m := TICK_PERCENTILES.search(line)) and percentiles is None:
percentiles = tuple(float(g) for g in m.groups())
if average is None or percentiles is None:
return None
return {'average': average, 'p50': percentiles[0], 'p95': percentiles[1], 'p99': percentiles[2]}
def parse_gametime(lines):
"""The game time `time query gametime` printed, or None."""
for line in lines:
if m := GAMETIME.search(line):
return int(m.group(1))
return None
def tps(start_ticks, end_ticks, seconds):
"""Game ticks per wall-clock second over the window, or None when a query went unanswered."""
if start_ticks is None or end_ticks is None or seconds <= 0:
return None
return round((end_ticks - start_ticks) / seconds, 2)
def percentile(values, p):
if not values:
return None
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(p / 100 * len(ordered)))]
def cpu_seconds(text):
"""ps's cumulative CPU time: [[DD-]HH:]MM:SS[.ss]."""
days, _, rest = text.rpartition('-')
seconds = 0.0
for part in rest.split(':'):
seconds = seconds * 60 + float(part)
return seconds + (float(days) * 86400 if days else 0)
def tree_usage(root):
"""RSS (bytes) and CPU seconds of a process and all its descendants; None without `ps`."""
try:
out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], capture_output=True, text=True).stdout
except OSError:
return None
rows = {}
for line in out.splitlines():
parts = line.split()
if len(parts) == 4:
rows[int(parts[0])] = (int(parts[1]), int(parts[2]) * 1024, cpu_seconds(parts[3]))
tree, frontier = {root}, [root]
while frontier:
parent = frontier.pop()
for pid, (ppid, _, _) in rows.items():
if ppid == parent and pid not in tree:
tree.add(pid)
frontier.append(pid)
return sum(rows[p][1] for p in tree if p in rows), sum(rows[p][2] for p in tree if p in rows)
def prepare(kind, directory, port, args):
"""Writes the server's settings into its scratch folder and returns the command that starts it."""
directory = Path(directory)
if args.pre_generated:
shutil.copytree(args.pre_generated, directory / 'world')
props = dict(PROPERTIES, **{'server-port': str(port), 'level-seed': str(args.seed),
'view-distance': str(args.view_distance),
'simulation-distance': str(args.view_distance)})
if kind == 'pumpkin':
config = directory / 'config'
config.mkdir()
(config / 'configuration.toml').write_text(PUMPKIN_CONFIGURATION.format(
port=port, seed=args.seed, view=args.view_distance))
(config / 'features.toml').write_text(PUMPKIN_FEATURES)
if args.pumpkin_config:
with (config / 'configuration.toml').open('a') as f:
f.write('\n' + Path(args.pumpkin_config).read_text())
return [Path(args.exe).resolve()]
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(directory / 'eula.txt').write_text('eula=true\n')
if kind == 'vanilla':
return ['java', f'-Xms{args.heap}', f'-Xmx{args.heap}', '-jar', Path(args.jar).resolve(), 'nogui']
server = Path(args.exe).resolve()
subprocess.run([server, '--dir', directory, 'import', directory / 'server.properties'], check=True,
capture_output=True)
toml = directory / 'server.toml'
# No web panel in a bench.
toml.write_text(re.sub(r'(?m)^bind = "127\.0\.0\.1:8130"', 'bind = ""', toml.read_text()))
return [server, '--dir', directory, 'run']
class Server:
"""A server with its console on stdin and its output collected."""
def __init__(self, command, directory, log_path):
self.lines = []
self.lock = threading.Lock()
self.log = log_path.open('w', encoding='utf-8')
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)
threading.Thread(target=self._read, daemon=True).start()
def _read(self):
for line in self.process.stdout:
with self.lock:
self.lines.append(line.rstrip('\n'))
self.log.write(line)
self.log.flush()
def count(self):
with self.lock:
return len(self.lines)
def since(self, n):
with self.lock:
return self.lines[n:]
def wait_for(self, regex, timeout):
deadline = time.time() + timeout
while time.time() < deadline:
if any(regex.search(line) for line in self.since(0)):
return
if self.process.poll() is not None:
raise RuntimeError('the server exited:\n' + '\n'.join(self.since(0)[-20:]))
time.sleep(0.1)
raise RuntimeError(f'the server was not up within {timeout} s')
def ask(self, command, parse, timeout=5):
"""Sends a console command and returns parse(its output lines), or None if nothing parsed in time."""
start = self.count()
try:
self.process.stdin.write(command + '\n')
self.process.stdin.flush()
except (BrokenPipeError, OSError):
return None
deadline = time.time() + timeout
while time.time() < deadline:
if (value := parse(self.since(start))) is not None:
return value
time.sleep(0.05)
return None
def stop(self):
if self.process.poll() is None:
try:
self.process.stdin.write('stop\n')
self.process.stdin.flush()
self.process.wait(timeout=60)
except (BrokenPipeError, OSError, subprocess.TimeoutExpired):
self.process.kill()
self.process.wait()
self.log.close()
def bots_command(args, port, total):
return [str(args.bots_bin), '--server', f'127.0.0.1:{port}', '--bots', str(args.bots),
'--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)),
'--seed', str(args.seed), '--view-distance', str(args.view_distance), '--report', '-']
def split_bot_output(lines):
"""The per-second samples (JSON lines with a `t`) and the final summary the bots printed."""
samples, rest = [], []
for line in lines:
try:
sample = json.loads(line)
except ValueError:
sample = None
if isinstance(sample, dict) and 't' in sample:
samples.append(sample)
else:
rest.append(line)
text = ''.join(rest)
return samples, (json.loads(text) if text.strip() else {})
def bytes_per_player_second(per_bot, run_seconds, join_every):
"""The bots' bytes over the seconds they were connected: bot i connects i * join_every after the first and
stays to the end of the run. So it is each bot's average from its login on, its join included, not the
measuring window's alone (the bots report one total at the end). None until the bots count bytes (S-6)."""
counted = [(i, b) for i, b in enumerate(per_bot) if 'bytes_in' in b and not b.get('error')]
online = sum(max(run_seconds - i * join_every, 0) for i, _ in counted)
if not counted or online <= 0:
return None
return {k: round(sum(b.get(k, 0) for _, b in counted) / online) for k in ('bytes_in', 'bytes_out')}
def summarize(kind, args, ticks, rss, cpu, gametime, window_seconds, summary, start_seconds, run_seconds):
mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None
joins = [b['joined_after_ms'] for b in summary.get('per_bot', []) if b.get('joined_after_ms', 0) > 0]
bandwidth = bytes_per_player_second(summary.get('per_bot', []), run_seconds, args.join_every)
return {
'server': kind,
'bots': args.bots,
'behaviour': args.behaviour,
'start_seconds': start_seconds,
'tps': tps(*gametime, window_seconds),
'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')},
'mspt_worst_p99': max((t['p99'] for t in ticks), default=None),
'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None},
'cpu_percent': mean(cpu),
'bytes_per_player_second': bandwidth,
'join_ms': {'p50': percentile(joins, 50), 'worst': max(joins, default=None)},
'bots_joined': summary.get('joined'),
'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')],
'chunks_total': summary.get('chunks'),
'tick_queries': len(ticks),
}
def run_once(kind, args, out):
scratch = Path(tempfile.mkdtemp(prefix=f'horizon-bench-{kind}-'))
result = {'server': kind, 'bots': args.bots, 'behaviour': args.behaviour}
server = None
try:
port = free_port()
command = prepare(kind, scratch, port, args)
started = time.time()
server = Server(command, scratch, out / f'{kind}-{args.bots}.log')
server.wait_for(DONE, args.start_timeout)
start_seconds = round(time.time() - started, 2)
total = args.bots * args.join_every + args.warmup + args.seconds
bot_process = subprocess.Popen(bots_command(args, port, total), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
bot_lines = []
threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start()
time.sleep(args.bots * args.join_every + args.warmup)
first_time = server.ask('time query gametime', parse_gametime)
began = time.time()
rss, cpu, ticks = [], [], []
usage = tree_usage(server.process.pid)
last_cpu, last_time = (usage[1] if usage else 0), time.time()
next_query = time.time()
while time.time() - began < args.seconds:
time.sleep(1)
now = time.time()
if usage and (u := tree_usage(server.process.pid)):
rss.append(u[0])
cpu.append((u[1] - last_cpu) / (now - last_time) * 100)
last_cpu, last_time = u[1], now
if now >= next_query:
if (t := server.ask('tick query', parse_tick_query)) is not None:
ticks.append(t)
next_query = now + 5
window = time.time() - began
last = server.ask('time query gametime', parse_gametime)
bot_process.wait(timeout=total + 120)
time.sleep(0.5)
_, summary = split_bot_output(list(bot_lines))
result.update(summarize(kind, args, ticks, rss, cpu, (first_time, last), window, summary, start_seconds,
total))
except Exception as e: # a failed run is reported, and the next one still runs
result['error'] = str(e)
finally:
if server:
server.stop()
shutil.rmtree(scratch, ignore_errors=True)
return result
def table(runs):
"""The Markdown table of the design: one column per server, one row per measure."""
ok = [r for r in runs if 'error' not in r]
head = '| | ' + ' | '.join(r['server'] for r in ok) + ' |\n|---|' + '---|' * len(ok) + '\n'
def cell(r, path):
value = r
for key in path:
value = value.get(key) if isinstance(value, dict) else None
return '-' if value is None else str(value)
rows = [
('Players', ('bots',)),
('TPS', ('tps',)),
('MSPT average', ('mspt', 'average')),
('MSPT p99 (mean of 5 s queries)', ('mspt', 'p99')),
('MSPT p99 (worst query)', ('mspt_worst_p99',)),
('RSS mean MB', ('rss_mb', 'mean')),
('CPU %', ('cpu_percent',)),
('Bytes in per player and s online', ('bytes_per_player_second', 'bytes_in')),
('Join p50 ms', ('join_ms', 'p50')),
('Join worst ms', ('join_ms', 'worst')),
('Start s', ('start_seconds',)),
]
body = ''.join(f'| {name} | ' + ' | '.join(cell(r, path) for r in ok) + ' |\n' for name, path in rows)
failed = ''.join(f'\n{r["server"]} failed: {r["error"].splitlines()[0]}\n' for r in runs if 'error' in r)
return head + body + failed
def check(args):
for kind in args.server:
if kind not in SERVERS:
sys.exit(f'--server: {kind!r} is not one of {", ".join(SERVERS)}')
if 'vanilla' in args.server and not args.jar:
sys.exit('vanilla needs --jar, your own server.jar')
if any(k != 'vanilla' for k in args.server) and not args.exe:
sys.exit('horizon and pumpkin need --exe, the server binary')
if not Path(args.bots_bin).exists():
sys.exit(f'{args.bots_bin} is missing: cargo build --release -p horizon-bots')
if args.pre_generated and not Path(args.pre_generated).is_dir():
sys.exit(f'--pre-generated {args.pre_generated} is not a folder')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
parser.add_argument('--server', type=lambda s: s.split(','), default=['horizon'],
help='horizon, vanilla or pumpkin (comma-separated, one run each)')
parser.add_argument('--exe', help='the horizon-server or Pumpkin binary')
parser.add_argument('--jar', help="vanilla's server.jar (your own copy)")
parser.add_argument('--bots-bin', default=str(ROOT / 'target/release' / f'horizon-bots{EXE}'))
parser.add_argument('--bots', type=int, default=50)
parser.add_argument('--behaviour', default='mixed')
parser.add_argument('--join-every', type=float, default=0.5)
parser.add_argument('--warmup', type=int, default=30)
parser.add_argument('--seconds', type=int, default=60)
parser.add_argument('--seed', type=int, default=12345)
parser.add_argument('--view-distance', type=int, default=10)
parser.add_argument('--heap', default='4g', help='the Java heap for vanilla')
parser.add_argument('--pre-generated', help='a finished world folder, copied into every run as `world`')
parser.add_argument('--pumpkin-config', help='extra TOML appended to Pumpkin\'s configuration.toml')
parser.add_argument('--start-timeout', type=int, default=600)
parser.add_argument('--out', type=Path, default=ROOT / '.local/bench', help='the folder for report.json and logs')
args = parser.parse_args(argv)
check(args)
args.out.mkdir(parents=True, exist_ok=True)
runs = []
for kind in args.server:
print(f'{kind}, {args.bots} {args.behaviour} bots...', flush=True)
runs.append(run_once(kind, args, args.out))
print(json.dumps(runs[-1]), flush=True)
report = {
'kind': 'horizon-bench',
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
'machine': {'system': platform.system(), 'machine': platform.machine(), 'cpus': os.cpu_count()},
'settings': {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
'runs': runs,
}
path = args.out / 'report.json'
path.write_text(json.dumps(report, indent=2) + '\n')
print('\n' + table(runs) + f'\nWrote {path}')
return 1 if any('error' in r for r in runs) else 0
if __name__ == '__main__':
sys.exit(main())
@@ -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,773 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T01:13:01Z",
"app": {
"name": "horizon-client",
"version": "v0.2.97",
"commit": null
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080",
"vendor": "nvidia",
"driver": "NVIDIA 617.42",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 3158.208,
"stdev": 215.77,
"min": 3023.046,
"max": 3407.049,
"ci95": 536.046
},
"startup.world_ms": {
"n": 3,
"mean": 7170.164,
"stdev": 557.958,
"min": 6703.242,
"max": 7788.08,
"ci95": 1386.157
},
"chunks.village.load_ms": {
"n": 3,
"mean": 1694.647,
"stdev": 41.285,
"min": 1653.122,
"max": 1735.687,
"ci95": 102.566
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 23544.0,
"stdev": 47.127,
"min": 23495.0,
"max": 23589.0,
"ci95": 117.081
},
"fps.village.avg_fps": {
"n": 3,
"mean": 1711.652,
"stdev": 77.412,
"min": 1623.403,
"max": 1768.093,
"ci95": 192.317
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 593.407,
"stdev": 32.279,
"min": 571.485,
"max": 630.473,
"ci95": 80.191
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 358.473,
"stdev": 27.832,
"min": 326.35,
"max": 375.353,
"ci95": 69.143
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 631.813,
"stdev": 8.381,
"min": 626.803,
"max": 641.489,
"ci95": 20.822
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 2521.363,
"stdev": 291.624,
"min": 2336.881,
"max": 2857.57,
"ci95": 724.493
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 27346.667,
"stdev": 57.934,
"min": 27306.0,
"max": 27413.0,
"ci95": 143.927
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2423.783,
"stdev": 55.746,
"min": 2386.353,
"max": 2487.851,
"ci95": 138.493
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 709.165,
"stdev": 66.309,
"min": 638.666,
"max": 770.286,
"ci95": 164.734
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 401.039,
"stdev": 40.413,
"min": 354.472,
"max": 426.929,
"ci95": 100.399
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 705.008,
"stdev": 6.062,
"min": 698.466,
"max": 710.435,
"ci95": 15.06
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2094.909,
"stdev": 521.225,
"min": 1758.024,
"max": 2695.274,
"ci95": 1294.9
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 24312.667,
"stdev": 261.934,
"min": 24116.0,
"max": 24610.0,
"ci95": 650.732
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2201.125,
"stdev": 73.595,
"min": 2153.6,
"max": 2285.897,
"ci95": 182.834
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 553.968,
"stdev": 100.955,
"min": 445.599,
"max": 645.355,
"ci95": 250.806
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 325.078,
"stdev": 131.154,
"min": 177.4,
"max": 427.981,
"ci95": 325.831
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 652.944,
"stdev": 5.177,
"min": 647.51,
"max": 657.818,
"ci95": 12.862
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 3304.58,
"stdev": 134.041,
"min": 3155.996,
"max": 3416.409,
"ci95": 333.003
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 26591.0,
"stdev": 57.472,
"min": 26525.0,
"max": 26630.0,
"ci95": 142.779
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1961.086,
"stdev": 39.725,
"min": 1921.241,
"max": 2000.69,
"ci95": 98.69
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 536.973,
"stdev": 38.828,
"min": 495.61,
"max": 572.635,
"ci95": 96.461
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 296.586,
"stdev": 30.236,
"min": 275.859,
"max": 331.281,
"ci95": 75.118
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 896.941,
"stdev": 5.177,
"min": 891.506,
"max": 901.814,
"ci95": 12.862
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1458.578,
"stdev": 3.229,
"min": 1455.32,
"max": 1461.777,
"ci95": 8.022
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1236.28,
"stdev": 4.869,
"min": 1230.701,
"max": 1239.672,
"ci95": 12.096
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 21218,
"seconds": 12.0,
"avg_fps": 1768.093,
"low_1pct_fps": 630.473,
"low_01pct_fps": 375.353,
"p50_ms": 0.537,
"p95_ms": 0.846,
"p99_ms": 1.222,
"p999_ms": 2.081,
"max_ms": 6.256,
"cpu_p50_ms": 0.38,
"gpu_p50_ms": 0.104,
"histogram": [
20695,
499,
22,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 19482,
"seconds": 12.001,
"avg_fps": 1623.403,
"low_1pct_fps": 578.262,
"low_01pct_fps": 373.717,
"p50_ms": 0.586,
"p95_ms": 0.95,
"p99_ms": 1.357,
"p999_ms": 2.158,
"max_ms": 7.35,
"cpu_p50_ms": 0.445,
"gpu_p50_ms": 0.104,
"histogram": [
18729,
713,
38,
1,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 20922,
"seconds": 12.0,
"avg_fps": 1743.46,
"low_1pct_fps": 571.485,
"low_01pct_fps": 326.35,
"p50_ms": 0.526,
"p95_ms": 0.883,
"p99_ms": 1.313,
"p999_ms": 2.14,
"max_ms": 12.027,
"cpu_p50_ms": 0.379,
"gpu_p50_ms": 0.104,
"histogram": [
20308,
582,
28,
2,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 29855,
"seconds": 12.0,
"avg_fps": 2487.851,
"low_1pct_fps": 770.286,
"low_01pct_fps": 421.717,
"p50_ms": 0.365,
"p95_ms": 0.619,
"p99_ms": 0.948,
"p999_ms": 1.717,
"max_ms": 9.446,
"cpu_p50_ms": 0.232,
"gpu_p50_ms": 0.157,
"histogram": [
29607,
233,
13,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 28766,
"seconds": 12.0,
"avg_fps": 2397.145,
"low_1pct_fps": 718.542,
"low_01pct_fps": 426.929,
"p50_ms": 0.374,
"p95_ms": 0.662,
"p99_ms": 1.016,
"p999_ms": 1.858,
"max_ms": 6.379,
"cpu_p50_ms": 0.239,
"gpu_p50_ms": 0.157,
"histogram": [
28457,
293,
14,
0,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 28637,
"seconds": 12.0,
"avg_fps": 2386.353,
"low_1pct_fps": 638.666,
"low_01pct_fps": 354.472,
"p50_ms": 0.373,
"p95_ms": 0.704,
"p99_ms": 1.159,
"p999_ms": 1.952,
"max_ms": 14.388,
"cpu_p50_ms": 0.233,
"gpu_p50_ms": 0.157,
"histogram": [
28198,
415,
18,
5,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 25968,
"seconds": 12.001,
"avg_fps": 2163.877,
"low_1pct_fps": 570.949,
"low_01pct_fps": 369.854,
"p50_ms": 0.408,
"p95_ms": 0.767,
"p99_ms": 1.339,
"p999_ms": 2.38,
"max_ms": 3.329,
"cpu_p50_ms": 0.271,
"gpu_p50_ms": 0.141,
"histogram": [
25383,
534,
47,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 25843,
"seconds": 12.0,
"avg_fps": 2153.6,
"low_1pct_fps": 445.599,
"low_01pct_fps": 177.4,
"p50_ms": 0.413,
"p95_ms": 0.725,
"p99_ms": 1.309,
"p999_ms": 3.451,
"max_ms": 12.071,
"cpu_p50_ms": 0.275,
"gpu_p50_ms": 0.142,
"histogram": [
25375,
373,
56,
22,
1,
7,
3,
5,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 27431,
"seconds": 12.0,
"avg_fps": 2285.897,
"low_1pct_fps": 645.355,
"low_01pct_fps": 427.981,
"p50_ms": 0.388,
"p95_ms": 0.693,
"p99_ms": 1.163,
"p999_ms": 2.041,
"max_ms": 3.068,
"cpu_p50_ms": 0.254,
"gpu_p50_ms": 0.141,
"histogram": [
27036,
363,
31,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 23536,
"seconds": 12.0,
"avg_fps": 1961.326,
"low_1pct_fps": 542.675,
"low_01pct_fps": 331.281,
"p50_ms": 0.436,
"p95_ms": 0.856,
"p99_ms": 1.425,
"p999_ms": 2.22,
"max_ms": 9.458,
"cpu_p50_ms": 0.296,
"gpu_p50_ms": 0.14,
"histogram": [
22819,
671,
40,
5,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 23056,
"seconds": 12.001,
"avg_fps": 1921.241,
"low_1pct_fps": 495.61,
"low_01pct_fps": 282.617,
"p50_ms": 0.44,
"p95_ms": 0.911,
"p99_ms": 1.516,
"p999_ms": 2.543,
"max_ms": 14.342,
"cpu_p50_ms": 0.299,
"gpu_p50_ms": 0.141,
"histogram": [
22198,
783,
65,
7,
0,
2,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 24009,
"seconds": 12.0,
"avg_fps": 2000.69,
"low_1pct_fps": 572.635,
"low_01pct_fps": 275.859,
"p50_ms": 0.436,
"p95_ms": 0.819,
"p99_ms": 1.274,
"p999_ms": 2.085,
"max_ms": 14.003,
"cpu_p50_ms": 0.297,
"gpu_p50_ms": 0.14,
"histogram": [
23421,
560,
22,
0,
1,
0,
2,
2,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-win-9800x3d-5080"
}
@@ -1,773 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T18:55:59Z",
"app": {
"name": "horizon-client",
"version": "v0.2.97",
"commit": null
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080",
"vendor": "nvidia",
"driver": "NVIDIA 617.42",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 2897.812,
"stdev": 82.455,
"min": 2844.677,
"max": 2992.8,
"ci95": 204.846
},
"startup.world_ms": {
"n": 3,
"mean": 10503.454,
"stdev": 493.592,
"min": 9934.491,
"max": 10816.992,
"ci95": 1226.251
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3717.47,
"stdev": 1330.649,
"min": 2942.872,
"max": 5253.954,
"ci95": 3305.783
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 47525.333,
"stdev": 1369.122,
"min": 46133.0,
"max": 48870.0,
"ci95": 3401.363
},
"fps.village.avg_fps": {
"n": 3,
"mean": 1163.541,
"stdev": 3.485,
"min": 1159.52,
"max": 1165.693,
"ci95": 8.658
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 415.992,
"stdev": 15.742,
"min": 399.062,
"max": 430.188,
"ci95": 39.108
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 215.549,
"stdev": 34.678,
"min": 177.857,
"max": 246.101,
"ci95": 86.151
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 1833.432,
"stdev": 4.415,
"min": 1828.334,
"max": 1835.997,
"ci95": 10.969
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 5455.049,
"stdev": 593.897,
"min": 4973.659,
"max": 6118.723,
"ci95": 1475.44
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 82758.0,
"stdev": 413.547,
"min": 82487.0,
"max": 83234.0,
"ci95": 1027.39
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1389.46,
"stdev": 28.128,
"min": 1371.096,
"max": 1421.843,
"ci95": 69.88
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 504.377,
"stdev": 13.251,
"min": 492.444,
"max": 518.637,
"ci95": 32.919
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 329.682,
"stdev": 47.451,
"min": 294.872,
"max": 383.732,
"ci95": 117.885
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 1985.175,
"stdev": 6.354,
"min": 1978.907,
"max": 1991.612,
"ci95": 15.786
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 4504.145,
"stdev": 745.393,
"min": 3650.589,
"max": 5026.8,
"ci95": 1851.809
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 74927.333,
"stdev": 399.375,
"min": 74476.0,
"max": 75235.0,
"ci95": 992.182
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1188.639,
"stdev": 10.807,
"min": 1177.005,
"max": 1198.365,
"ci95": 26.849
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 154.773,
"stdev": 3.712,
"min": 151.38,
"max": 158.738,
"ci95": 9.223
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 67.809,
"stdev": 3.032,
"min": 65.903,
"max": 71.305,
"ci95": 7.532
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 2084.845,
"stdev": 9.674,
"min": 2074.009,
"max": 2092.615,
"ci95": 24.034
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 9048.431,
"stdev": 344.33,
"min": 8821.41,
"max": 9444.624,
"ci95": 855.432
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 89659.667,
"stdev": 87.123,
"min": 89561.0,
"max": 89726.0,
"ci95": 216.442
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1085.847,
"stdev": 13.819,
"min": 1070.378,
"max": 1096.974,
"ci95": 34.332
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 257.7,
"stdev": 31.437,
"min": 224.366,
"max": 286.814,
"ci95": 78.101
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 107.783,
"stdev": 14.883,
"min": 91.541,
"max": 120.768,
"ci95": 36.975
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 2708.833,
"stdev": 9.674,
"min": 2697.997,
"max": 2716.603,
"ci95": 24.034
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2044.895,
"stdev": 10.562,
"min": 2032.875,
"max": 2052.691,
"ci95": 26.238
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1730.107,
"stdev": 14.764,
"min": 1717.52,
"max": 1746.357,
"ci95": 36.678
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 13986,
"seconds": 12.001,
"avg_fps": 1165.41,
"low_1pct_fps": 399.062,
"low_01pct_fps": 177.857,
"p50_ms": 0.822,
"p95_ms": 1.21,
"p99_ms": 1.691,
"p999_ms": 3.499,
"max_ms": 13.089,
"cpu_p50_ms": 0.665,
"gpu_p50_ms": 0.193,
"histogram": [
12380,
1527,
58,
13,
2,
2,
1,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 13915,
"seconds": 12.001,
"avg_fps": 1159.52,
"low_1pct_fps": 430.188,
"low_01pct_fps": 246.101,
"p50_ms": 0.809,
"p95_ms": 1.212,
"p99_ms": 1.702,
"p999_ms": 3.291,
"max_ms": 6.894,
"cpu_p50_ms": 0.648,
"gpu_p50_ms": 0.192,
"histogram": [
11996,
1839,
59,
17,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 13989,
"seconds": 12.001,
"avg_fps": 1165.693,
"low_1pct_fps": 418.725,
"low_01pct_fps": 222.688,
"p50_ms": 0.816,
"p95_ms": 1.22,
"p99_ms": 1.692,
"p999_ms": 3.264,
"max_ms": 7.781,
"cpu_p50_ms": 0.669,
"gpu_p50_ms": 0.193,
"histogram": [
12321,
1578,
72,
11,
2,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 17063,
"seconds": 12.001,
"avg_fps": 1421.843,
"low_1pct_fps": 502.049,
"low_01pct_fps": 294.872,
"p50_ms": 0.666,
"p95_ms": 0.935,
"p99_ms": 1.549,
"p999_ms": 2.692,
"max_ms": 6.831,
"cpu_p50_ms": 0.533,
"gpu_p50_ms": 0.362,
"histogram": [
16417,
596,
40,
7,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 16454,
"seconds": 12.001,
"avg_fps": 1371.096,
"low_1pct_fps": 492.444,
"low_01pct_fps": 310.443,
"p50_ms": 0.688,
"p95_ms": 0.979,
"p99_ms": 1.556,
"p999_ms": 2.693,
"max_ms": 4.596,
"cpu_p50_ms": 0.553,
"gpu_p50_ms": 0.36,
"histogram": [
15692,
697,
57,
7,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 16506,
"seconds": 12.001,
"avg_fps": 1375.442,
"low_1pct_fps": 518.637,
"low_01pct_fps": 383.732,
"p50_ms": 0.675,
"p95_ms": 1.017,
"p99_ms": 1.605,
"p999_ms": 2.369,
"max_ms": 3.201,
"cpu_p50_ms": 0.54,
"gpu_p50_ms": 0.364,
"histogram": [
15628,
833,
44,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 14381,
"seconds": 12.001,
"avg_fps": 1198.365,
"low_1pct_fps": 154.2,
"low_01pct_fps": 66.219,
"p50_ms": 0.731,
"p95_ms": 1.023,
"p99_ms": 3.003,
"p999_ms": 12.478,
"max_ms": 18.863,
"cpu_p50_ms": 0.596,
"gpu_p50_ms": 0.315,
"histogram": [
13586,
546,
105,
60,
13,
13,
8,
16,
8,
11,
5,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 14287,
"seconds": 12.0,
"avg_fps": 1190.548,
"low_1pct_fps": 158.738,
"low_01pct_fps": 71.305,
"p50_ms": 0.741,
"p95_ms": 1.041,
"p99_ms": 2.9,
"p999_ms": 12.0,
"max_ms": 17.23,
"cpu_p50_ms": 0.602,
"gpu_p50_ms": 0.315,
"histogram": [
13450,
591,
109,
57,
13,
8,
10,
11,
14,
10,
7,
6,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 14124,
"seconds": 12.0,
"avg_fps": 1177.005,
"low_1pct_fps": 151.38,
"low_01pct_fps": 65.903,
"p50_ms": 0.739,
"p95_ms": 1.096,
"p99_ms": 2.928,
"p999_ms": 11.884,
"max_ms": 19.375,
"cpu_p50_ms": 0.604,
"gpu_p50_ms": 0.314,
"histogram": [
13052,
822,
114,
54,
16,
8,
4,
14,
11,
15,
3,
8,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 12845,
"seconds": 12.0,
"avg_fps": 1070.378,
"low_1pct_fps": 224.366,
"low_01pct_fps": 91.541,
"p50_ms": 0.825,
"p95_ms": 1.362,
"p99_ms": 2.325,
"p999_ms": 9.621,
"max_ms": 13.309,
"cpu_p50_ms": 0.689,
"gpu_p50_ms": 0.307,
"histogram": [
10937,
1699,
143,
25,
5,
6,
4,
8,
8,
8,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 13083,
"seconds": 12.001,
"avg_fps": 1090.188,
"low_1pct_fps": 261.921,
"low_01pct_fps": 111.039,
"p50_ms": 0.816,
"p95_ms": 1.335,
"p99_ms": 2.14,
"p999_ms": 8.222,
"max_ms": 11.95,
"cpu_p50_ms": 0.681,
"gpu_p50_ms": 0.306,
"histogram": [
11105,
1812,
112,
21,
5,
5,
5,
6,
11,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 13164,
"seconds": 12.0,
"avg_fps": 1096.974,
"low_1pct_fps": 286.814,
"low_01pct_fps": 120.768,
"p50_ms": 0.822,
"p95_ms": 1.307,
"p99_ms": 1.989,
"p999_ms": 7.701,
"max_ms": 9.429,
"cpu_p50_ms": 0.687,
"gpu_p50_ms": 0.307,
"histogram": [
11280,
1758,
82,
13,
5,
5,
6,
8,
7,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-win-9800x3d-5080"
}
@@ -1,773 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T18:37:03Z",
"app": {
"name": "horizon-client",
"version": "v0.2.97",
"commit": null
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080",
"vendor": "nvidia",
"driver": "NVIDIA 617.42",
"backend": "vulkan",
"kind": "discrete"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 2950.343,
"stdev": 162.732,
"min": 2822.925,
"max": 3133.657,
"ci95": 404.282
},
"startup.world_ms": {
"n": 3,
"mean": 6135.1,
"stdev": 863.831,
"min": 5547.861,
"max": 7126.98,
"ci95": 2146.05
},
"chunks.village.load_ms": {
"n": 3,
"mean": 985.519,
"stdev": 28.184,
"min": 956.244,
"max": 1012.469,
"ci95": 70.019
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 6908.0,
"stdev": 9.849,
"min": 6897.0,
"max": 6916.0,
"ci95": 24.468
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2941.608,
"stdev": 79.283,
"min": 2850.416,
"max": 2994.188,
"ci95": 196.965
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 1271.318,
"stdev": 58.797,
"min": 1203.758,
"max": 1310.917,
"ci95": 146.072
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 839.261,
"stdev": 37.982,
"min": 795.415,
"max": 862.067,
"ci95": 94.36
},
"memory.village.gpu_mb": {
"n": 3,
"mean": 363.984,
"stdev": 0.926,
"min": 363.449,
"max": 365.053,
"ci95": 2.299
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 1350.752,
"stdev": 293.128,
"min": 1106.764,
"max": 1675.913,
"ci95": 728.23
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 8066.0,
"stdev": 108.885,
"min": 7986.0,
"max": 8190.0,
"ci95": 270.508
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 3494.693,
"stdev": 110.997,
"min": 3389.894,
"max": 3610.993,
"ci95": 275.755
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 1295.14,
"stdev": 159.638,
"min": 1148.468,
"max": 1465.171,
"ci95": 396.596
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 859.326,
"stdev": 119.845,
"min": 721.994,
"max": 942.743,
"ci95": 297.734
},
"memory.forest.gpu_mb": {
"n": 3,
"mean": 381.189,
"stdev": 5.222,
"min": 376.423,
"max": 386.77,
"ci95": 12.972
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 1123.386,
"stdev": 604.175,
"min": 734.302,
"max": 1819.414,
"ci95": 1500.976
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 6613.0,
"stdev": 135.632,
"min": 6523.0,
"max": 6769.0,
"ci95": 336.955
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 3346.003,
"stdev": 59.312,
"min": 3303.535,
"max": 3413.77,
"ci95": 147.352
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 1357.991,
"stdev": 95.364,
"min": 1248.887,
"max": 1425.451,
"ci95": 236.917
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 911.822,
"stdev": 94.717,
"min": 802.528,
"max": 969.989,
"ci95": 235.308
},
"memory.ocean.gpu_mb": {
"n": 3,
"mean": 391.636,
"stdev": 5.435,
"min": 387.424,
"max": 397.771,
"ci95": 13.502
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 1588.954,
"stdev": 458.924,
"min": 1309.548,
"max": 2118.606,
"ci95": 1140.123
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 7887.667,
"stdev": 41.041,
"min": 7862.0,
"max": 7935.0,
"ci95": 101.959
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 3375.623,
"stdev": 26.82,
"min": 3352.309,
"max": 3404.933,
"ci95": 66.629
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 1383.745,
"stdev": 67.628,
"min": 1324.071,
"max": 1457.203,
"ci95": 168.01
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 850.213,
"stdev": 103.149,
"min": 790.305,
"max": 969.318,
"ci95": 256.256
},
"memory.mountain.gpu_mb": {
"n": 3,
"mean": 448.64,
"stdev": 5.435,
"min": 444.428,
"max": 454.775,
"ci95": 13.502
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1171.629,
"stdev": 0.337,
"min": 1171.398,
"max": 1172.016,
"ci95": 0.837
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1082.078,
"stdev": 12.494,
"min": 1069.117,
"max": 1094.047,
"ci95": 31.04
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 35764,
"seconds": 12.0,
"avg_fps": 2980.22,
"low_1pct_fps": 1310.917,
"low_01pct_fps": 860.301,
"p50_ms": 0.317,
"p95_ms": 0.433,
"p99_ms": 0.633,
"p999_ms": 0.937,
"max_ms": 2.807,
"cpu_p50_ms": 0.192,
"gpu_p50_ms": 0.063,
"histogram": [
35741,
22,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 35931,
"seconds": 12.0,
"avg_fps": 2994.188,
"low_1pct_fps": 1299.278,
"low_01pct_fps": 795.415,
"p50_ms": 0.318,
"p95_ms": 0.428,
"p99_ms": 0.62,
"p999_ms": 0.943,
"max_ms": 5.797,
"cpu_p50_ms": 0.191,
"gpu_p50_ms": 0.063,
"histogram": [
35906,
23,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 34206,
"seconds": 12.0,
"avg_fps": 2850.416,
"low_1pct_fps": 1203.758,
"low_01pct_fps": 862.067,
"p50_ms": 0.326,
"p95_ms": 0.508,
"p99_ms": 0.705,
"p999_ms": 1.025,
"max_ms": 1.398,
"cpu_p50_ms": 0.196,
"gpu_p50_ms": 0.063,
"histogram": [
34167,
39,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 41800,
"seconds": 12.0,
"avg_fps": 3483.193,
"low_1pct_fps": 1271.782,
"low_01pct_fps": 913.242,
"p50_ms": 0.264,
"p95_ms": 0.415,
"p99_ms": 0.657,
"p999_ms": 0.935,
"max_ms": 1.482,
"cpu_p50_ms": 0.144,
"gpu_p50_ms": 0.075,
"histogram": [
41768,
32,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 43333,
"seconds": 12.0,
"avg_fps": 3610.993,
"low_1pct_fps": 1465.171,
"low_01pct_fps": 942.743,
"p50_ms": 0.263,
"p95_ms": 0.355,
"p99_ms": 0.561,
"p999_ms": 0.859,
"max_ms": 1.694,
"cpu_p50_ms": 0.143,
"gpu_p50_ms": 0.074,
"histogram": [
43310,
23,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 40679,
"seconds": 12.0,
"avg_fps": 3389.894,
"low_1pct_fps": 1148.468,
"low_01pct_fps": 721.994,
"p50_ms": 0.267,
"p95_ms": 0.446,
"p99_ms": 0.67,
"p999_ms": 1.183,
"max_ms": 2.434,
"cpu_p50_ms": 0.146,
"gpu_p50_ms": 0.076,
"histogram": [
40590,
87,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 39849,
"seconds": 12.0,
"avg_fps": 3320.704,
"low_1pct_fps": 1399.634,
"low_01pct_fps": 969.989,
"p50_ms": 0.281,
"p95_ms": 0.404,
"p99_ms": 0.601,
"p999_ms": 0.875,
"max_ms": 1.494,
"cpu_p50_ms": 0.159,
"gpu_p50_ms": 0.069,
"histogram": [
39831,
18,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 40966,
"seconds": 12.0,
"avg_fps": 3413.77,
"low_1pct_fps": 1425.451,
"low_01pct_fps": 962.948,
"p50_ms": 0.278,
"p95_ms": 0.372,
"p99_ms": 0.581,
"p999_ms": 0.857,
"max_ms": 1.576,
"cpu_p50_ms": 0.157,
"gpu_p50_ms": 0.069,
"histogram": [
40947,
19,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 39643,
"seconds": 12.0,
"avg_fps": 3303.535,
"low_1pct_fps": 1248.887,
"low_01pct_fps": 802.528,
"p50_ms": 0.28,
"p95_ms": 0.413,
"p99_ms": 0.642,
"p999_ms": 1.087,
"max_ms": 2.215,
"cpu_p50_ms": 0.158,
"gpu_p50_ms": 0.069,
"histogram": [
39582,
60,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 40436,
"seconds": 12.0,
"avg_fps": 3369.626,
"low_1pct_fps": 1369.962,
"low_01pct_fps": 790.305,
"p50_ms": 0.279,
"p95_ms": 0.403,
"p99_ms": 0.604,
"p999_ms": 0.812,
"max_ms": 10.313,
"cpu_p50_ms": 0.159,
"gpu_p50_ms": 0.069,
"histogram": [
40425,
8,
1,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 40860,
"seconds": 12.0,
"avg_fps": 3404.933,
"low_1pct_fps": 1457.203,
"low_01pct_fps": 969.318,
"p50_ms": 0.278,
"p95_ms": 0.389,
"p99_ms": 0.574,
"p999_ms": 0.814,
"max_ms": 2.714,
"cpu_p50_ms": 0.158,
"gpu_p50_ms": 0.069,
"histogram": [
40847,
11,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 40229,
"seconds": 12.0,
"avg_fps": 3352.309,
"low_1pct_fps": 1324.071,
"low_01pct_fps": 791.016,
"p50_ms": 0.28,
"p95_ms": 0.402,
"p99_ms": 0.617,
"p999_ms": 0.968,
"max_ms": 3.746,
"cpu_p50_ms": 0.159,
"gpu_p50_ms": 0.069,
"histogram": [
40192,
35,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-win-9800x3d-5080"
}
@@ -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,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,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"
}
@@ -1,706 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T01:25:53Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 10777.756,
"stdev": 129.67,
"min": 10636.92,
"max": 10892.2,
"ci95": 322.144
},
"startup.world_ms": {
"n": 3,
"mean": 23477.423,
"stdev": 346.614,
"min": 23130.92,
"max": 23824.148,
"ci95": 861.106
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6007.033,
"stdev": 104.227,
"min": 5891.431,
"max": 6093.825,
"ci95": 258.936
},
"fps.village.avg_fps": {
"n": 3,
"mean": 1282.11,
"stdev": 65.207,
"min": 1228.13,
"max": 1354.56,
"ci95": 161.997
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 343.465,
"stdev": 9.96,
"min": 335.223,
"max": 354.532,
"ci95": 24.744
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 131.669,
"stdev": 4.692,
"min": 126.427,
"max": 135.476,
"ci95": 11.656
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8669.311,
"stdev": 434.39,
"min": 8280.854,
"max": 9138.35,
"ci95": 1079.173
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1745.757,
"stdev": 13.014,
"min": 1734.41,
"max": 1759.963,
"ci95": 32.332
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 460.958,
"stdev": 10.859,
"min": 449.051,
"max": 470.316,
"ci95": 26.978
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 178.955,
"stdev": 3.777,
"min": 175.213,
"max": 182.766,
"ci95": 9.383
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 7056.368,
"stdev": 117.329,
"min": 6986.912,
"max": 7191.833,
"ci95": 291.485
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1815.056,
"stdev": 47.875,
"min": 1759.788,
"max": 1843.745,
"ci95": 118.937
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 514.215,
"stdev": 57.205,
"min": 452.445,
"max": 565.368,
"ci95": 142.117
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 191.842,
"stdev": 23.217,
"min": 175.546,
"max": 218.426,
"ci95": 57.68
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 14094.582,
"stdev": 399.624,
"min": 13641.534,
"max": 14397.0,
"ci95": 992.801
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1499.768,
"stdev": 22.197,
"min": 1477.269,
"max": 1521.651,
"ci95": 55.146
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 408.466,
"stdev": 16.114,
"min": 395.097,
"max": 426.358,
"ci95": 40.032
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 139.743,
"stdev": 3.969,
"min": 136.465,
"max": 144.156,
"ci95": 9.86
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2495.607,
"stdev": 75.339,
"min": 2412.738,
"max": 2559.965,
"ci95": 187.166
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2326.611,
"stdev": 15.702,
"min": 2314.98,
"max": 2344.473,
"ci95": 39.01
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 14738,
"seconds": 12.0,
"avg_fps": 1228.13,
"low_1pct_fps": 335.223,
"low_01pct_fps": 133.103,
"p50_ms": 0.722,
"p95_ms": 1.384,
"p99_ms": 2.043,
"p999_ms": 5.224,
"max_ms": 11.459,
"histogram": [
12159,
2421,
128,
14,
1,
5,
2,
3,
4,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 16255,
"seconds": 12.0,
"avg_fps": 1354.56,
"low_1pct_fps": 354.532,
"low_01pct_fps": 135.476,
"p50_ms": 0.672,
"p95_ms": 1.136,
"p99_ms": 1.841,
"p999_ms": 4.913,
"max_ms": 16.94,
"histogram": [
14841,
1298,
86,
13,
2,
5,
4,
1,
3,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 15164,
"seconds": 12.0,
"avg_fps": 1263.64,
"low_1pct_fps": 340.639,
"low_01pct_fps": 126.427,
"p50_ms": 0.708,
"p95_ms": 1.299,
"p99_ms": 1.895,
"p999_ms": 5.173,
"max_ms": 16.748,
"histogram": [
13026,
2014,
94,
13,
1,
3,
3,
6,
2,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 20915,
"seconds": 12.0,
"avg_fps": 1742.897,
"low_1pct_fps": 470.316,
"low_01pct_fps": 182.766,
"p50_ms": 0.5,
"p95_ms": 0.936,
"p99_ms": 1.437,
"p999_ms": 2.77,
"max_ms": 13.414,
"histogram": [
20067,
788,
44,
4,
0,
4,
3,
3,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 21120,
"seconds": 12.0,
"avg_fps": 1759.963,
"low_1pct_fps": 449.051,
"low_01pct_fps": 178.887,
"p50_ms": 0.49,
"p95_ms": 0.901,
"p99_ms": 1.531,
"p999_ms": 3.005,
"max_ms": 12.907,
"histogram": [
20414,
644,
40,
10,
1,
3,
2,
2,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 20813,
"seconds": 12.0,
"avg_fps": 1734.41,
"low_1pct_fps": 463.506,
"low_01pct_fps": 175.213,
"p50_ms": 0.496,
"p95_ms": 0.924,
"p99_ms": 1.414,
"p999_ms": 2.733,
"max_ms": 16.832,
"histogram": [
20049,
707,
41,
5,
0,
1,
5,
2,
2,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 22100,
"seconds": 12.0,
"avg_fps": 1841.634,
"low_1pct_fps": 565.368,
"low_01pct_fps": 218.426,
"p50_ms": 0.483,
"p95_ms": 0.835,
"p99_ms": 1.209,
"p999_ms": 2.082,
"max_ms": 14.627,
"histogram": [
21540,
532,
18,
1,
1,
1,
2,
2,
1,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 21118,
"seconds": 12.0,
"avg_fps": 1759.788,
"low_1pct_fps": 452.445,
"low_01pct_fps": 175.546,
"p50_ms": 0.489,
"p95_ms": 0.915,
"p99_ms": 1.532,
"p999_ms": 2.811,
"max_ms": 11.846,
"histogram": [
20308,
755,
36,
9,
0,
2,
0,
3,
2,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 22125,
"seconds": 12.0,
"avg_fps": 1843.745,
"low_1pct_fps": 524.832,
"low_01pct_fps": 181.555,
"p50_ms": 0.477,
"p95_ms": 0.855,
"p99_ms": 1.241,
"p999_ms": 2.236,
"max_ms": 12.478,
"histogram": [
21487,
608,
18,
1,
1,
1,
2,
2,
1,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 18005,
"seconds": 12.0,
"avg_fps": 1500.383,
"low_1pct_fps": 403.944,
"low_01pct_fps": 136.465,
"p50_ms": 0.591,
"p95_ms": 1.058,
"p99_ms": 1.592,
"p999_ms": 3.186,
"max_ms": 12.608,
"histogram": [
16864,
1084,
32,
8,
0,
5,
3,
5,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 17728,
"seconds": 12.001,
"avg_fps": 1477.269,
"low_1pct_fps": 395.097,
"low_01pct_fps": 138.609,
"p50_ms": 0.598,
"p95_ms": 1.042,
"p99_ms": 1.561,
"p999_ms": 5.033,
"max_ms": 11.462,
"histogram": [
16612,
1051,
39,
7,
1,
6,
5,
3,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 18260,
"seconds": 12.0,
"avg_fps": 1521.651,
"low_1pct_fps": 426.358,
"low_01pct_fps": 144.156,
"p50_ms": 0.585,
"p95_ms": 1.015,
"p99_ms": 1.458,
"p999_ms": 3.609,
"max_ms": 11.945,
"histogram": [
17267,
943,
26,
6,
5,
2,
3,
3,
2,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-mods-win-9800x3d-5080"
}
@@ -1,706 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T19:27:56Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 11038.922,
"stdev": 118.058,
"min": 10935.424,
"max": 11167.506,
"ci95": 293.295
},
"startup.world_ms": {
"n": 3,
"mean": 39248.588,
"stdev": 479.755,
"min": 38964.834,
"max": 39802.506,
"ci95": 1191.875
},
"chunks.village.load_ms": {
"n": 3,
"mean": 11176.953,
"stdev": 356.144,
"min": 10836.65,
"max": 11547.064,
"ci95": 884.781
},
"fps.village.avg_fps": {
"n": 3,
"mean": 925.977,
"stdev": 6.727,
"min": 918.832,
"max": 932.189,
"ci95": 16.713
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 250.092,
"stdev": 1.868,
"min": 248.431,
"max": 252.114,
"ci95": 4.64
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 92.692,
"stdev": 4.466,
"min": 88.423,
"max": 97.332,
"ci95": 11.095
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 17335.638,
"stdev": 687.588,
"min": 16544.168,
"max": 17785.772,
"ci95": 1708.201
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 929.034,
"stdev": 3.11,
"min": 926.32,
"max": 932.428,
"ci95": 7.727
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 312.69,
"stdev": 16.386,
"min": 297.667,
"max": 330.163,
"ci95": 40.708
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 129.331,
"stdev": 12.786,
"min": 115.486,
"max": 140.694,
"ci95": 31.765
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 12601.063,
"stdev": 148.042,
"min": 12430.182,
"max": 12690.506,
"ci95": 367.785
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1016.598,
"stdev": 5.481,
"min": 1010.482,
"max": 1021.068,
"ci95": 13.618
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 325.361,
"stdev": 15.112,
"min": 315.682,
"max": 342.774,
"ci95": 37.543
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 117.042,
"stdev": 11.095,
"min": 107.807,
"max": 129.349,
"ci95": 27.563
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 38513.008,
"stdev": 958.119,
"min": 37447.333,
"max": 39303.226,
"ci95": 2380.292
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 644.782,
"stdev": 3.94,
"min": 640.866,
"max": 648.745,
"ci95": 9.788
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 166.029,
"stdev": 4.758,
"min": 162.326,
"max": 171.395,
"ci95": 11.819
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 86.231,
"stdev": 8.587,
"min": 76.632,
"max": 93.184,
"ci95": 21.334
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2964.911,
"stdev": 8.27,
"min": 2959.813,
"max": 2974.453,
"ci95": 20.545
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2894.541,
"stdev": 12.847,
"min": 2879.928,
"max": 2904.059,
"ci95": 31.917
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 11123,
"seconds": 12.0,
"avg_fps": 926.911,
"low_1pct_fps": 249.732,
"low_01pct_fps": 92.322,
"p50_ms": 0.989,
"p95_ms": 1.492,
"p99_ms": 2.33,
"p999_ms": 7.409,
"max_ms": 15.258,
"histogram": [
6224,
4701,
147,
28,
3,
2,
5,
3,
3,
3,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 11187,
"seconds": 12.001,
"avg_fps": 932.189,
"low_1pct_fps": 252.114,
"low_01pct_fps": 88.423,
"p50_ms": 0.984,
"p95_ms": 1.496,
"p99_ms": 2.328,
"p999_ms": 7.858,
"max_ms": 16.843,
"histogram": [
6518,
4486,
138,
20,
9,
1,
1,
6,
1,
2,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 11026,
"seconds": 12.0,
"avg_fps": 918.832,
"low_1pct_fps": 248.431,
"low_01pct_fps": 97.332,
"p50_ms": 0.989,
"p95_ms": 1.524,
"p99_ms": 2.37,
"p999_ms": 7.446,
"max_ms": 16.573,
"histogram": [
6075,
4740,
162,
23,
5,
2,
4,
7,
4,
1,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 11117,
"seconds": 12.001,
"avg_fps": 926.32,
"low_1pct_fps": 310.239,
"low_01pct_fps": 131.814,
"p50_ms": 1.011,
"p95_ms": 1.34,
"p99_ms": 2.045,
"p999_ms": 4.711,
"max_ms": 14.077,
"histogram": [
3849,
7150,
78,
26,
5,
1,
3,
2,
1,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 11190,
"seconds": 12.001,
"avg_fps": 932.428,
"low_1pct_fps": 330.163,
"low_01pct_fps": 140.694,
"p50_ms": 1.008,
"p95_ms": 1.307,
"p99_ms": 1.869,
"p999_ms": 4.341,
"max_ms": 13.489,
"histogram": [
4273,
6822,
60,
20,
7,
0,
3,
3,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 11141,
"seconds": 12.001,
"avg_fps": 928.354,
"low_1pct_fps": 297.667,
"low_01pct_fps": 115.486,
"p50_ms": 1.005,
"p95_ms": 1.352,
"p99_ms": 2.054,
"p999_ms": 5.292,
"max_ms": 14.339,
"histogram": [
4901,
6118,
83,
23,
3,
2,
4,
3,
0,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 12126,
"seconds": 12.0,
"avg_fps": 1010.482,
"low_1pct_fps": 317.626,
"low_01pct_fps": 113.971,
"p50_ms": 0.933,
"p95_ms": 1.23,
"p99_ms": 1.935,
"p999_ms": 4.393,
"max_ms": 14.824,
"histogram": [
9183,
2830,
82,
17,
3,
1,
2,
3,
0,
1,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 12219,
"seconds": 12.0,
"avg_fps": 1018.243,
"low_1pct_fps": 342.774,
"low_01pct_fps": 129.349,
"p50_ms": 0.929,
"p95_ms": 1.222,
"p99_ms": 1.867,
"p999_ms": 4.329,
"max_ms": 10.33,
"histogram": [
9421,
2710,
60,
15,
3,
0,
0,
5,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 12253,
"seconds": 12.0,
"avg_fps": 1021.068,
"low_1pct_fps": 315.682,
"low_01pct_fps": 107.807,
"p50_ms": 0.923,
"p95_ms": 1.225,
"p99_ms": 1.969,
"p999_ms": 5.235,
"max_ms": 19.524,
"histogram": [
9231,
2904,
92,
10,
2,
5,
0,
4,
1,
2,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 7691,
"seconds": 12.001,
"avg_fps": 640.866,
"low_1pct_fps": 164.367,
"low_01pct_fps": 93.184,
"p50_ms": 1.454,
"p95_ms": 1.95,
"p99_ms": 3.4,
"p999_ms": 9.754,
"max_ms": 14.995,
"histogram": [
36,
7331,
233,
26,
17,
13,
20,
3,
6,
5,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 7737,
"seconds": 12.0,
"avg_fps": 644.736,
"low_1pct_fps": 162.326,
"low_01pct_fps": 76.632,
"p50_ms": 1.454,
"p95_ms": 1.905,
"p99_ms": 3.676,
"p999_ms": 9.926,
"max_ms": 18.459,
"histogram": [
46,
7399,
199,
24,
21,
24,
10,
6,
1,
1,
4,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 7785,
"seconds": 12.0,
"avg_fps": 648.745,
"low_1pct_fps": 171.395,
"low_01pct_fps": 88.876,
"p50_ms": 1.439,
"p95_ms": 1.886,
"p99_ms": 3.562,
"p999_ms": 10.342,
"max_ms": 12.927,
"histogram": [
57,
7432,
186,
44,
23,
20,
9,
3,
3,
7,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-mods-win-9800x3d-5080"
}
@@ -1,706 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T18:49:40Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 11089.831,
"stdev": 46.336,
"min": 11036.36,
"max": 11118.192,
"ci95": 115.114
},
"startup.world_ms": {
"n": 3,
"mean": 17643.831,
"stdev": 47.672,
"min": 17589.192,
"max": 17676.942,
"ci95": 118.433
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3603.773,
"stdev": 161.342,
"min": 3488.106,
"max": 3788.086,
"ci95": 400.828
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2103.54,
"stdev": 40.465,
"min": 2078.464,
"max": 2150.222,
"ci95": 100.529
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 577.326,
"stdev": 33.693,
"min": 557.613,
"max": 616.23,
"ci95": 83.704
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 195.53,
"stdev": 18.899,
"min": 181.495,
"max": 217.019,
"ci95": 46.951
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3282.612,
"stdev": 48.569,
"min": 3233.16,
"max": 3330.246,
"ci95": 120.661
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2424.739,
"stdev": 26.074,
"min": 2394.789,
"max": 2442.383,
"ci95": 64.777
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 732.987,
"stdev": 11.926,
"min": 724.144,
"max": 746.55,
"ci95": 29.627
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 249.926,
"stdev": 8.826,
"min": 241.293,
"max": 258.934,
"ci95": 21.928
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2705.954,
"stdev": 173.487,
"min": 2538.486,
"max": 2884.891,
"ci95": 431.0
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2431.99,
"stdev": 33.071,
"min": 2394.02,
"max": 2454.493,
"ci95": 82.159
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 764.065,
"stdev": 14.782,
"min": 747.287,
"max": 775.168,
"ci95": 36.723
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 261.954,
"stdev": 14.724,
"min": 252.457,
"max": 278.916,
"ci95": 36.58
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 4982.865,
"stdev": 115.523,
"min": 4849.837,
"max": 5057.932,
"ci95": 286.997
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 2538.05,
"stdev": 67.405,
"min": 2462.815,
"max": 2592.938,
"ci95": 167.458
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 760.16,
"stdev": 13.503,
"min": 746.001,
"max": 772.895,
"ci95": 33.547
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 240.897,
"stdev": 17.872,
"min": 224.391,
"max": 259.878,
"ci95": 44.401
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2052.982,
"stdev": 57.756,
"min": 1992.574,
"max": 2107.66,
"ci95": 143.487
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1903.921,
"stdev": 62.254,
"min": 1838.307,
"max": 1962.156,
"ci95": 154.659
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 25803,
"seconds": 12.0,
"avg_fps": 2150.222,
"low_1pct_fps": 616.23,
"low_01pct_fps": 217.019,
"p50_ms": 0.433,
"p95_ms": 0.642,
"p99_ms": 1.04,
"p999_ms": 2.307,
"max_ms": 7.466,
"histogram": [
25503,
269,
8,
6,
7,
6,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 24942,
"seconds": 12.0,
"avg_fps": 2078.464,
"low_1pct_fps": 557.613,
"low_01pct_fps": 181.495,
"p50_ms": 0.45,
"p95_ms": 0.665,
"p99_ms": 1.1,
"p999_ms": 2.305,
"max_ms": 9.819,
"histogram": [
24615,
288,
17,
2,
4,
9,
2,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 24984,
"seconds": 12.0,
"avg_fps": 2081.934,
"low_1pct_fps": 558.135,
"low_01pct_fps": 188.076,
"p50_ms": 0.444,
"p95_ms": 0.682,
"p99_ms": 1.101,
"p999_ms": 2.412,
"max_ms": 11.567,
"histogram": [
24645,
308,
9,
3,
5,
5,
7,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 29245,
"seconds": 12.0,
"avg_fps": 2437.044,
"low_1pct_fps": 728.266,
"low_01pct_fps": 249.55,
"p50_ms": 0.38,
"p95_ms": 0.555,
"p99_ms": 0.892,
"p999_ms": 1.851,
"max_ms": 6.973,
"histogram": [
29066,
154,
4,
6,
7,
6,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 28738,
"seconds": 12.0,
"avg_fps": 2394.789,
"low_1pct_fps": 724.144,
"low_01pct_fps": 241.293,
"p50_ms": 0.384,
"p95_ms": 0.571,
"p99_ms": 0.907,
"p999_ms": 1.833,
"max_ms": 8.35,
"histogram": [
28573,
140,
6,
3,
6,
5,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 29309,
"seconds": 12.0,
"avg_fps": 2442.383,
"low_1pct_fps": 746.55,
"low_01pct_fps": 258.934,
"p50_ms": 0.378,
"p95_ms": 0.566,
"p99_ms": 0.876,
"p999_ms": 1.744,
"max_ms": 10.291,
"histogram": [
29138,
148,
5,
6,
5,
2,
4,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 29454,
"seconds": 12.0,
"avg_fps": 2454.493,
"low_1pct_fps": 769.741,
"low_01pct_fps": 252.457,
"p50_ms": 0.378,
"p95_ms": 0.55,
"p99_ms": 0.853,
"p999_ms": 1.471,
"max_ms": 6.629,
"histogram": [
29323,
110,
2,
2,
7,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 28729,
"seconds": 12.0,
"avg_fps": 2394.02,
"low_1pct_fps": 747.287,
"low_01pct_fps": 254.49,
"p50_ms": 0.383,
"p95_ms": 0.579,
"p99_ms": 0.896,
"p999_ms": 1.56,
"max_ms": 7.367,
"histogram": [
28567,
141,
3,
5,
4,
4,
4,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 29370,
"seconds": 12.0,
"avg_fps": 2447.458,
"low_1pct_fps": 775.168,
"low_01pct_fps": 278.916,
"p50_ms": 0.376,
"p95_ms": 0.559,
"p99_ms": 0.887,
"p999_ms": 1.567,
"max_ms": 9.65,
"histogram": [
29209,
141,
3,
9,
0,
5,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 31116,
"seconds": 12.0,
"avg_fps": 2592.938,
"low_1pct_fps": 761.584,
"low_01pct_fps": 224.391,
"p50_ms": 0.356,
"p95_ms": 0.511,
"p99_ms": 0.767,
"p999_ms": 1.843,
"max_ms": 8.478,
"histogram": [
31011,
76,
4,
3,
13,
4,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 29554,
"seconds": 12.0,
"avg_fps": 2462.815,
"low_1pct_fps": 746.001,
"low_01pct_fps": 238.423,
"p50_ms": 0.366,
"p95_ms": 0.579,
"p99_ms": 0.849,
"p999_ms": 1.75,
"max_ms": 7.607,
"histogram": [
29430,
101,
2,
3,
9,
4,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 30701,
"seconds": 12.0,
"avg_fps": 2558.396,
"low_1pct_fps": 772.895,
"low_01pct_fps": 259.878,
"p50_ms": 0.357,
"p95_ms": 0.537,
"p99_ms": 0.81,
"p999_ms": 1.702,
"max_ms": 6.682,
"histogram": [
30568,
112,
1,
6,
5,
3,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-mods-win-9800x3d-5080"
}
@@ -1,690 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T01:19:23Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7681.834,
"stdev": 189.48,
"min": 7538.777,
"max": 7896.728,
"ci95": 470.732
},
"startup.world_ms": {
"n": 3,
"mean": 19898.834,
"stdev": 192.981,
"min": 19695.728,
"max": 20079.777,
"ci95": 479.431
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6127.947,
"stdev": 26.756,
"min": 6097.19,
"max": 6145.854,
"ci95": 66.471
},
"fps.village.avg_fps": {
"n": 3,
"mean": 1218.486,
"stdev": 26.337,
"min": 1188.13,
"max": 1235.248,
"ci95": 65.43
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 318.468,
"stdev": 6.292,
"min": 313.2,
"max": 325.435,
"ci95": 15.632
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 126.59,
"stdev": 1.844,
"min": 124.744,
"max": 128.431,
"ci95": 4.58
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8548.814,
"stdev": 152.302,
"min": 8380.912,
"max": 8678.071,
"ci95": 378.37
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1912.359,
"stdev": 130.238,
"min": 1791.386,
"max": 2050.215,
"ci95": 323.555
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 401.907,
"stdev": 35.911,
"min": 379.849,
"max": 443.345,
"ci95": 89.216
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 149.26,
"stdev": 9.983,
"min": 139.809,
"max": 159.701,
"ci95": 24.801
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 6953.478,
"stdev": 276.636,
"min": 6635.649,
"max": 7140.072,
"ci95": 687.258
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1860.512,
"stdev": 116.512,
"min": 1726.149,
"max": 1933.597,
"ci95": 289.454
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 446.336,
"stdev": 56.993,
"min": 382.26,
"max": 491.369,
"ci95": 141.589
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 164.934,
"stdev": 11.839,
"min": 152.493,
"max": 176.062,
"ci95": 29.413
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 15211.386,
"stdev": 256.348,
"min": 14938.008,
"max": 15446.375,
"ci95": 636.856
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 1653.223,
"stdev": 61.705,
"min": 1584.652,
"max": 1704.273,
"ci95": 153.297
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 331.929,
"stdev": 10.858,
"min": 319.394,
"max": 338.396,
"ci95": 26.974
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 113.436,
"stdev": 3.255,
"min": 110.869,
"max": 117.097,
"ci95": 8.086
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2540.129,
"stdev": 152.057,
"min": 2373.152,
"max": 2670.633,
"ci95": 377.761
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2299.514,
"stdev": 81.451,
"min": 2207.129,
"max": 2360.973,
"ci95": 202.352
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 14785,
"seconds": 12.0,
"avg_fps": 1232.081,
"low_1pct_fps": 325.435,
"low_01pct_fps": 128.431,
"p50_ms": 0.742,
"p95_ms": 1.265,
"p99_ms": 2.032,
"p999_ms": 5.653,
"max_ms": 12.791,
"histogram": [
12845,
1784,
127,
7,
1,
8,
4,
3,
5,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 14258,
"seconds": 12.0,
"avg_fps": 1188.13,
"low_1pct_fps": 313.2,
"low_01pct_fps": 126.595,
"p50_ms": 0.757,
"p95_ms": 1.349,
"p99_ms": 2.12,
"p999_ms": 5.541,
"max_ms": 13.998,
"histogram": [
11994,
2076,
157,
9,
2,
8,
2,
4,
5,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 14823,
"seconds": 12.0,
"avg_fps": 1235.248,
"low_1pct_fps": 316.768,
"low_01pct_fps": 124.744,
"p50_ms": 0.744,
"p95_ms": 1.199,
"p99_ms": 2.047,
"p999_ms": 5.628,
"max_ms": 15.519,
"histogram": [
13214,
1453,
118,
16,
2,
8,
6,
3,
0,
1,
0,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 21497,
"seconds": 12.0,
"avg_fps": 1791.386,
"low_1pct_fps": 382.527,
"low_01pct_fps": 139.809,
"p50_ms": 0.474,
"p95_ms": 0.931,
"p99_ms": 1.646,
"p999_ms": 5.274,
"max_ms": 13.459,
"histogram": [
20598,
799,
66,
9,
3,
8,
8,
2,
2,
0,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 24603,
"seconds": 12.0,
"avg_fps": 2050.215,
"low_1pct_fps": 443.345,
"low_01pct_fps": 159.701,
"p50_ms": 0.426,
"p95_ms": 0.752,
"p99_ms": 1.442,
"p999_ms": 3.134,
"max_ms": 13.282,
"histogram": [
24041,
487,
48,
6,
5,
6,
2,
3,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 22746,
"seconds": 12.0,
"avg_fps": 1895.476,
"low_1pct_fps": 379.849,
"low_01pct_fps": 148.269,
"p50_ms": 0.456,
"p95_ms": 0.829,
"p99_ms": 1.561,
"p999_ms": 4.176,
"max_ms": 10.671,
"histogram": [
22092,
523,
91,
17,
4,
7,
3,
4,
4,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 23062,
"seconds": 12.0,
"avg_fps": 1921.791,
"low_1pct_fps": 465.379,
"low_01pct_fps": 166.247,
"p50_ms": 0.463,
"p95_ms": 0.809,
"p99_ms": 1.45,
"p999_ms": 2.398,
"max_ms": 11.808,
"histogram": [
22454,
561,
27,
3,
2,
6,
0,
4,
3,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 23204,
"seconds": 12.0,
"avg_fps": 1933.597,
"low_1pct_fps": 491.369,
"low_01pct_fps": 176.062,
"p50_ms": 0.466,
"p95_ms": 0.766,
"p99_ms": 1.406,
"p999_ms": 2.229,
"max_ms": 15.102,
"histogram": [
22699,
469,
19,
4,
1,
1,
4,
2,
2,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 20714,
"seconds": 12.0,
"avg_fps": 1726.149,
"low_1pct_fps": 382.26,
"low_01pct_fps": 152.493,
"p50_ms": 0.5,
"p95_ms": 0.956,
"p99_ms": 1.742,
"p999_ms": 3.852,
"max_ms": 12.888,
"histogram": [
19814,
787,
83,
11,
2,
7,
3,
5,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 20049,
"seconds": 12.0,
"avg_fps": 1670.745,
"low_1pct_fps": 338.396,
"low_01pct_fps": 117.097,
"p50_ms": 0.502,
"p95_ms": 0.949,
"p99_ms": 1.673,
"p999_ms": 6.456,
"max_ms": 16.791,
"histogram": [
19187,
753,
70,
8,
1,
6,
12,
4,
3,
4,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 19016,
"seconds": 12.0,
"avg_fps": 1584.652,
"low_1pct_fps": 337.998,
"low_01pct_fps": 112.343,
"p50_ms": 0.531,
"p95_ms": 1.082,
"p99_ms": 1.717,
"p999_ms": 5.939,
"max_ms": 12.958,
"histogram": [
17849,
1069,
63,
7,
3,
6,
2,
7,
4,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 20452,
"seconds": 12.0,
"avg_fps": 1704.273,
"low_1pct_fps": 319.394,
"low_01pct_fps": 110.869,
"p50_ms": 0.496,
"p95_ms": 0.911,
"p99_ms": 1.706,
"p999_ms": 5.938,
"max_ms": 24.863,
"histogram": [
19694,
626,
82,
14,
3,
14,
5,
4,
5,
3,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-win-9800x3d-5080"
}
@@ -1,690 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T19:06:49Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7518.874,
"stdev": 42.968,
"min": 7469.532,
"max": 7548.049,
"ci95": 106.748
},
"startup.world_ms": {
"n": 3,
"mean": 35424.207,
"stdev": 144.711,
"min": 35318.049,
"max": 35589.041,
"ci95": 359.511
},
"chunks.village.load_ms": {
"n": 3,
"mean": 11146.828,
"stdev": 49.683,
"min": 11094.798,
"max": 11193.773,
"ci95": 123.43
},
"fps.village.avg_fps": {
"n": 3,
"mean": 882.053,
"stdev": 19.433,
"min": 866.126,
"max": 903.706,
"ci95": 48.279
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 183.995,
"stdev": 4.924,
"min": 178.512,
"max": 188.041,
"ci95": 12.234
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 83.193,
"stdev": 6.797,
"min": 77.734,
"max": 90.806,
"ci95": 16.886
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 18184.002,
"stdev": 266.879,
"min": 17880.667,
"max": 18382.735,
"ci95": 663.017
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 1202.907,
"stdev": 20.426,
"min": 1179.9,
"max": 1218.906,
"ci95": 50.744
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 228.139,
"stdev": 5.166,
"min": 222.394,
"max": 232.401,
"ci95": 12.833
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 107.956,
"stdev": 4.675,
"min": 105.185,
"max": 113.354,
"ci95": 11.614
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 13146.632,
"stdev": 507.943,
"min": 12572.118,
"max": 13536.145,
"ci95": 1261.902
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 1223.169,
"stdev": 13.11,
"min": 1208.837,
"max": 1234.556,
"ci95": 32.569
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 236.27,
"stdev": 6.77,
"min": 228.686,
"max": 241.702,
"ci95": 16.818
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 99.089,
"stdev": 8.354,
"min": 91.897,
"max": 108.253,
"ci95": 20.755
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 41846.641,
"stdev": 661.649,
"min": 41435.666,
"max": 42609.895,
"ci95": 1643.76
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 815.008,
"stdev": 24.644,
"min": 797.565,
"max": 843.201,
"ci95": 61.224
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 55.371,
"stdev": 5.128,
"min": 50.389,
"max": 60.633,
"ci95": 12.739
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 7.966,
"stdev": 1.103,
"min": 6.848,
"max": 9.053,
"ci95": 2.74
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2892.492,
"stdev": 29.54,
"min": 2859.574,
"max": 2916.691,
"ci95": 73.387
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2799.159,
"stdev": 12.256,
"min": 2786.234,
"max": 2810.613,
"ci95": 30.447
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 10845,
"seconds": 12.001,
"avg_fps": 903.706,
"low_1pct_fps": 185.432,
"low_01pct_fps": 81.039,
"p50_ms": 0.98,
"p95_ms": 1.708,
"p99_ms": 3.082,
"p999_ms": 9.304,
"max_ms": 15.487,
"histogram": [
6030,
4403,
297,
49,
25,
16,
8,
2,
6,
2,
4,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 10516,
"seconds": 12.0,
"avg_fps": 876.328,
"low_1pct_fps": 188.041,
"low_01pct_fps": 90.806,
"p50_ms": 1.012,
"p95_ms": 1.806,
"p99_ms": 3.354,
"p999_ms": 8.714,
"max_ms": 14.39,
"histogram": [
4927,
5158,
292,
70,
33,
13,
6,
5,
6,
2,
3,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 10394,
"seconds": 12.001,
"avg_fps": 866.126,
"low_1pct_fps": 178.512,
"low_01pct_fps": 77.734,
"p50_ms": 1.026,
"p95_ms": 1.789,
"p99_ms": 3.399,
"p999_ms": 10.6,
"max_ms": 15.389,
"histogram": [
4437,
5552,
273,
65,
18,
28,
8,
1,
1,
3,
5,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 14519,
"seconds": 12.0,
"avg_fps": 1209.916,
"low_1pct_fps": 232.401,
"low_01pct_fps": 105.185,
"p50_ms": 0.726,
"p95_ms": 1.306,
"p99_ms": 2.25,
"p999_ms": 7.033,
"max_ms": 13.303,
"histogram": [
13121,
1197,
103,
36,
24,
19,
4,
3,
8,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 14159,
"seconds": 12.0,
"avg_fps": 1179.9,
"low_1pct_fps": 222.394,
"low_01pct_fps": 105.33,
"p50_ms": 0.732,
"p95_ms": 1.366,
"p99_ms": 2.499,
"p999_ms": 8.061,
"max_ms": 11.797,
"histogram": [
12501,
1356,
195,
45,
25,
14,
6,
3,
9,
5,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 14627,
"seconds": 12.0,
"avg_fps": 1218.906,
"low_1pct_fps": 229.622,
"low_01pct_fps": 113.354,
"p50_ms": 0.713,
"p95_ms": 1.292,
"p99_ms": 2.317,
"p999_ms": 7.136,
"max_ms": 12.041,
"histogram": [
13194,
1224,
97,
47,
27,
21,
2,
6,
6,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 14714,
"seconds": 12.001,
"avg_fps": 1226.113,
"low_1pct_fps": 238.423,
"low_01pct_fps": 97.117,
"p50_ms": 0.722,
"p95_ms": 1.223,
"p99_ms": 2.243,
"p999_ms": 7.032,
"max_ms": 13.21,
"histogram": [
13490,
1027,
115,
29,
18,
13,
7,
3,
3,
5,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 14507,
"seconds": 12.001,
"avg_fps": 1208.837,
"low_1pct_fps": 228.686,
"low_01pct_fps": 91.897,
"p50_ms": 0.719,
"p95_ms": 1.256,
"p99_ms": 2.354,
"p999_ms": 7.473,
"max_ms": 15.432,
"histogram": [
13124,
1112,
180,
35,
23,
9,
8,
4,
2,
6,
2,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 14815,
"seconds": 12.0,
"avg_fps": 1234.556,
"low_1pct_fps": 241.702,
"low_01pct_fps": 108.253,
"p50_ms": 0.721,
"p95_ms": 1.2,
"p99_ms": 2.197,
"p999_ms": 6.689,
"max_ms": 12.791,
"histogram": [
13565,
1055,
107,
29,
23,
15,
8,
3,
4,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 10119,
"seconds": 12.001,
"avg_fps": 843.201,
"low_1pct_fps": 60.633,
"low_01pct_fps": 9.053,
"p50_ms": 0.891,
"p95_ms": 1.845,
"p99_ms": 3.91,
"p999_ms": 8.209,
"max_ms": 316.409,
"histogram": [
6956,
2747,
240,
83,
45,
22,
8,
8,
5,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
4,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 9693,
"seconds": 12.052,
"avg_fps": 804.258,
"low_1pct_fps": 50.389,
"low_01pct_fps": 6.848,
"p50_ms": 0.906,
"p95_ms": 1.924,
"p99_ms": 4.157,
"p999_ms": 8.578,
"max_ms": 297.695,
"histogram": [
6578,
2678,
248,
94,
45,
18,
16,
5,
4,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
5,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 9571,
"seconds": 12.0,
"avg_fps": 797.565,
"low_1pct_fps": 55.09,
"low_01pct_fps": 7.997,
"p50_ms": 0.929,
"p95_ms": 1.944,
"p99_ms": 4.495,
"p999_ms": 7.764,
"max_ms": 321.994,
"histogram": [
5954,
3172,
230,
103,
39,
34,
19,
11,
4,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
4,
0,
0
]
}
],
"label": "vanilla-win-9800x3d-5080"
}
@@ -1,690 +0,0 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-10-29T18:43:17Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "windows",
"arch": "x86_64",
"cpu_threads": 16,
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
"ram_mb": 95943,
"gpu": {
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
"vendor": "NVIDIA Corporation",
"driver": "3.3.0 NVIDIA 617.42",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": false,
"repeats": 3,
"warmup_s": 2.0,
"window": [
1280,
720
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7676.526,
"stdev": 305.763,
"min": 7426.024,
"max": 8017.247,
"ci95": 759.617
},
"startup.world_ms": {
"n": 3,
"mean": 13862.526,
"stdev": 284.577,
"min": 13569.024,
"max": 14137.247,
"ci95": 706.985
},
"chunks.village.load_ms": {
"n": 3,
"mean": 3808.014,
"stdev": 61.275,
"min": 3737.989,
"max": 3851.799,
"ci95": 152.227
},
"fps.village.avg_fps": {
"n": 3,
"mean": 2216.576,
"stdev": 45.247,
"min": 2166.285,
"max": 2253.985,
"ci95": 112.409
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 545.307,
"stdev": 14.876,
"min": 528.15,
"max": 554.607,
"ci95": 36.957
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 215.412,
"stdev": 5.692,
"min": 209.522,
"max": 220.882,
"ci95": 14.14
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 3212.958,
"stdev": 151.722,
"min": 3076.655,
"max": 3376.428,
"ci95": 376.928
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 2456.542,
"stdev": 336.369,
"min": 2068.162,
"max": 2654.549,
"ci95": 835.654
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 624.759,
"stdev": 90.0,
"min": 521.303,
"max": 685.008,
"ci95": 223.59
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 252.469,
"stdev": 21.044,
"min": 229.228,
"max": 270.233,
"ci95": 52.28
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 2983.236,
"stdev": 168.416,
"min": 2788.777,
"max": 3082.249,
"ci95": 418.403
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 2469.831,
"stdev": 333.258,
"min": 2085.121,
"max": 2669.9,
"ci95": 827.925
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 688.684,
"stdev": 96.943,
"min": 576.803,
"max": 747.759,
"ci95": 240.839
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 286.444,
"stdev": 21.36,
"min": 262.183,
"max": 302.422,
"ci95": 53.066
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 6079.869,
"stdev": 719.864,
"min": 5542.196,
"max": 6897.69,
"ci95": 1788.385
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 2605.12,
"stdev": 326.424,
"min": 2228.824,
"max": 2812.075,
"ci95": 810.948
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 699.63,
"stdev": 87.846,
"min": 599.44,
"max": 763.45,
"ci95": 218.239
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 264.461,
"stdev": 28.459,
"min": 239.602,
"max": 295.503,
"ci95": 70.701
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2120.426,
"stdev": 116.406,
"min": 2046.93,
"max": 2254.637,
"ci95": 289.191
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1912.642,
"stdev": 38.925,
"min": 1889.766,
"max": 1957.586,
"ci95": 96.702
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 27048,
"seconds": 12.0,
"avg_fps": 2253.985,
"low_1pct_fps": 553.164,
"low_01pct_fps": 215.833,
"p50_ms": 0.412,
"p95_ms": 0.617,
"p99_ms": 1.187,
"p999_ms": 2.238,
"max_ms": 9.792,
"histogram": [
26716,
296,
15,
4,
7,
5,
2,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 26754,
"seconds": 12.0,
"avg_fps": 2229.458,
"low_1pct_fps": 554.607,
"low_01pct_fps": 220.882,
"p50_ms": 0.417,
"p95_ms": 0.626,
"p99_ms": 1.212,
"p999_ms": 2.448,
"max_ms": 8.388,
"histogram": [
26423,
293,
16,
9,
4,
5,
1,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 25996,
"seconds": 12.0,
"avg_fps": 2166.285,
"low_1pct_fps": 528.15,
"low_01pct_fps": 209.522,
"p50_ms": 0.43,
"p95_ms": 0.662,
"p99_ms": 1.308,
"p999_ms": 2.477,
"max_ms": 11.033,
"histogram": [
25615,
338,
23,
5,
5,
5,
2,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 31855,
"seconds": 12.0,
"avg_fps": 2654.549,
"low_1pct_fps": 667.966,
"low_01pct_fps": 257.945,
"p50_ms": 0.341,
"p95_ms": 0.519,
"p99_ms": 0.932,
"p999_ms": 1.761,
"max_ms": 10.944,
"histogram": [
31558,
275,
6,
2,
7,
0,
4,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 31763,
"seconds": 12.0,
"avg_fps": 2646.916,
"low_1pct_fps": 685.008,
"low_01pct_fps": 270.233,
"p50_ms": 0.344,
"p95_ms": 0.52,
"p99_ms": 0.94,
"p999_ms": 1.685,
"max_ms": 11.406,
"histogram": [
31474,
272,
3,
4,
2,
2,
1,
3,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 24818,
"seconds": 12.0,
"avg_fps": 2068.162,
"low_1pct_fps": 521.303,
"low_01pct_fps": 229.228,
"p50_ms": 0.439,
"p95_ms": 0.767,
"p99_ms": 1.284,
"p999_ms": 2.351,
"max_ms": 11.468,
"histogram": [
24356,
410,
36,
4,
4,
3,
2,
2,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 32039,
"seconds": 12.0,
"avg_fps": 2669.9,
"low_1pct_fps": 741.491,
"low_01pct_fps": 294.727,
"p50_ms": 0.345,
"p95_ms": 0.501,
"p99_ms": 0.86,
"p999_ms": 1.431,
"max_ms": 9.257,
"histogram": [
31771,
250,
3,
2,
4,
6,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 31854,
"seconds": 12.0,
"avg_fps": 2654.471,
"low_1pct_fps": 747.759,
"low_01pct_fps": 302.422,
"p50_ms": 0.347,
"p95_ms": 0.503,
"p99_ms": 0.853,
"p999_ms": 1.404,
"max_ms": 13.925,
"histogram": [
31580,
261,
3,
2,
0,
1,
3,
3,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 25022,
"seconds": 12.0,
"avg_fps": 2085.121,
"low_1pct_fps": 576.803,
"low_01pct_fps": 262.183,
"p50_ms": 0.428,
"p95_ms": 0.758,
"p99_ms": 1.214,
"p999_ms": 2.109,
"max_ms": 9.206,
"histogram": [
24600,
390,
20,
3,
2,
3,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 33745,
"seconds": 12.0,
"avg_fps": 2812.075,
"low_1pct_fps": 736.0,
"low_01pct_fps": 258.279,
"p50_ms": 0.327,
"p95_ms": 0.471,
"p99_ms": 0.808,
"p999_ms": 1.689,
"max_ms": 10.07,
"histogram": [
33516,
205,
7,
0,
7,
4,
3,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 33294,
"seconds": 12.0,
"avg_fps": 2774.461,
"low_1pct_fps": 763.45,
"low_01pct_fps": 295.503,
"p50_ms": 0.334,
"p95_ms": 0.471,
"p99_ms": 0.813,
"p999_ms": 1.63,
"max_ms": 8.512,
"histogram": [
33056,
221,
3,
5,
1,
2,
3,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 26747,
"seconds": 12.0,
"avg_fps": 2228.824,
"low_1pct_fps": 599.44,
"low_01pct_fps": 239.602,
"p50_ms": 0.403,
"p95_ms": 0.678,
"p99_ms": 1.107,
"p999_ms": 2.054,
"max_ms": 9.178,
"histogram": [
26418,
298,
13,
6,
2,
8,
0,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "vanilla-win-9800x3d-5080"
}
@@ -1,106 +0,0 @@
# Horizon v0.2.97 vs vanilla 26.3 on Windows 11: Ryzen 7 9800X3D + RTX 5080 (2026-10-29, RD 8 and 32 rerun 2026-10-30)
The first `tools/perf_bench.py` run on Windows ([docs/benchmark.md](../../docs/benchmark.md)): Horizon, vanilla 26.3 and
vanilla with performance mods, render distances 8, 16 and 32, 1280x720, three games each, every game from a fresh
world. The reports are the `*-win-9800x3d-5080-rd*.json` files next to this page.
```sh
python tools/perf_bench.py --table horizon-win-9800x3d-5080 vanilla-win-9800x3d-5080 vanilla-mods-win-9800x3d-5080
```
## Setup
- **Machine:** AMD Ryzen 7 9800X3D (8 cores, 16 threads), 94 GB RAM (DDR5, EXPO off), NVIDIA GeForce RTX 5080
(driver 617.42), Windows 11 Pro 26200, Balanced power plan, desktop. One 1920x1080 120 Hz display. The game windows
were on the desktop of an unlocked session.
- **Horizon:** release v0.2.97 as the launcher installs it (`--build %APPDATA%\horizon-launcher\versions\v0.2.97`,
`--resources` its `.local\horizon-client\26.3`), Vulkan.
- **Vanilla:** the 26.3 client jar with Fabric Loader 0.19.5 and the driver mod, Java 25.0.5 (Temurin), `-Xmx2G`
with the launcher's G1 flags and the manifest's `-XX:StackShadowPages=32`, OpenGL 3.3.
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0 and
ImmediatelyFast 1.17.1 (`tools/benchmark-deps/mods.lock.json`).
- **Commands:** `python tools/perf_bench.py --game <game> --label <game>-win-9800x3d-5080 --distances <d>
--sizes 1280x720 --repeats 3` (plus `--build` and `--resources` for Horizon), with the games taking turns at
each distance.
- **Memory** is the working set on Windows (what Task Manager shows), sampled from outside the process. Horizon's
GPU figure is its own Vulkan allocator total. `nvidia-smi` gives no per-process figure under WDDM, so vanilla has
none.
## Load
Windows has no load average, so the reports have no `load_avg_1m`. Every game ran on an otherwise idle machine:
- **RD 8 and RD 32** (rerun on 2026-10-30, after the first pass had builds running alongside): a script started each
game only once no `rustc`, `cargo`, `link` (or other compiler) process and no other game (`horizon-client`, `java`,
`javaw`, Minecraft) was running, and the machine's CPU load had stayed under 15 % in every 5-second sample for
30 seconds. During each game it sampled every 2 seconds the machine's CPU load and the CPU of everything outside the
benchmark's own processes. A game during which a compiler or another game ran, or other processes used over 15 % of
the machine for 10 seconds, was discarded and run again: two of the 20 games were (vanilla + mods at RD 32, a
build started during one, background processes during the other).
- **RD 16** (2026-10-29): no `rustc`, `cargo` or `link` process at any point, the load logged every 5 seconds.
| Game | RD | Mean / max CPU load (machine, the game included) | Other processes' CPU, mean / max | Compiler processes |
|---|---|---|---|---|
| Horizon | 8 | 18 % / 49 % | 6 % / 12 % | 0 |
| Vanilla | 8 | 28 % / 75 % | 7 % / 20 % | 0 |
| Vanilla + mods | 8 | 30 % / 81 % | 7 % / 20 % | 0 |
| Horizon | 16 | 29 % / 80 % | – | 0 |
| Vanilla | 16 | 41 % / 91 % | – | 0 |
| Vanilla + mods | 16 | 51 % / 90 % | – | 0 |
| Horizon | 32 | 36 % / 100 % | 5 % / 15 % | 0 |
| Vanilla | 32 | 39 % / 73 % | 6 % / 18 % | 0 |
| Vanilla + mods | 32 | 42 % / 88 % | 6 % / 21 % | 0 |
"Other processes" is the machine's load minus the benchmark's own processes (the game and `perf_bench.py`), in
2-second samples. Most of it is the system's share of the game's own drawing (the graphics driver and the
compositor), which no process of the benchmark is charged for; the desktop apps left open (a browser, chat apps)
briefly reached 5 % at most. RD 16's load was logged from outside every 5 seconds, so it has no such split, and its mean includes the time between
games. Compared with the first pass, the idle RD 8 runs give Horizon about 10 % higher averages and about 50 % higher
1% lows, and vanilla up to 10 % higher averages.
## Results
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight at 16 blocks/s (average / 1% low /
0.1% low):
| RD | scene | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- | --- |
| 8 | forest | 3495 ±276 / 1295 ±397 / 859 ±298 | 2457 ±836 / 625 ±224 / 252 ±52 | 2425 ±65 / 733 ±30 / 250 ±22 |
| 8 | mountain | 3376 ±67 / 1384 ±168 / 850 ±256 | 2605 ±811 / 700 ±218 / 264 ±71 | 2538 ±167 / 760 ±34 / 241 ±44 |
| 8 | ocean | 3346 ±147 / 1358 ±237 / 912 ±235 | 2470 ±828 / 689 ±241 / 286 ±53 | 2432 ±82 / 764 ±37 / 262 ±37 |
| 8 | village | 2942 ±197 / 1271 ±146 / 839 ±94 | 2217 ±112 / 545 ±37 / 215 ±14 | 2104 ±101 / 577 ±84 / 196 ±47 |
| 16 | forest | 2424 ±138 / 709 ±165 / 401 ±100 | 1912 ±324 / 402 ±89 / 149 ±25 | 1746 ±32 / 461 ±27 / 179 ±9 |
| 16 | mountain | 1961 ±99 / 537 ±96 / 297 ±75 | 1653 ±153 / 332 ±27 / 113 ±8 | 1500 ±55 / 408 ±40 / 140 ±10 |
| 16 | ocean | 2201 ±183 / 554 ±251 / 325 ±326 | 1861 ±289 / 446 ±142 / 165 ±29 | 1815 ±119 / 514 ±142 / 192 ±58 |
| 16 | village | 1712 ±192 / 593 ±80 / 358 ±69 | 1218 ±65 / 318 ±16 / 127 ±5 | 1282 ±162 / 343 ±25 / 132 ±12 |
| 32 | forest | 1389 ±70 / 504 ±33 / 330 ±118 | 1203 ±51 / 228 ±13 / 108 ±12 | 929 ±8 / 313 ±41 / 129 ±32 |
| 32 | mountain | 1086 ±34 / 258 ±78 / 108 ±37 | 815 ±61 / 55 ±13 / 8 ±3 | 645 ±10 / 166 ±12 / 86 ±21 |
| 32 | ocean | 1189 ±27 / 155 ±9 / 68 ±8 | 1223 ±33 / 236 ±17 / 99 ±21 | 1017 ±14 / 325 ±38 / 117 ±28 |
| 32 | village | 1164 ±9 / 416 ±39 / 216 ±86 | 882 ±48 / 184 ±12 / 83 ±17 | 926 ±17 / 250 ±5 / 93 ±11 |
Startup, loading and memory (means of 3 games):
| RD | | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- | --- |
| 8 | start to title / in-world | 3.0 s / 6.1 s | 7.7 s / 13.9 s | 11.1 s / 17.6 s |
| 8 | mean scene load | 1.3 s | 4.0 s | 3.6 s |
| 8 | working set, steady / peak | 1082 / 1172 MB | 1913 / 2120 MB | 1904 / 2053 MB |
| 16 | start to title / in-world | 3.2 s / 7.2 s | 7.7 s / 19.9 s | 10.8 s / 23.5 s |
| 16 | mean scene load | 2.4 s | 9.2 s | 9.0 s |
| 16 | working set, steady / peak | 1236 / 1459 MB | 2300 / 2540 MB | 2327 / 2496 MB |
| 32 | start to title / in-world | 2.9 s / 10.5 s | 7.5 s / 35.4 s | 11.0 s / 39.2 s |
| 32 | mean scene load | 5.7 s | 21.1 s | 19.9 s |
| 32 | working set, steady / peak | 1730 / 2045 MB | 2799 / 2892 MB | 2895 / 2965 MB |
## What it shows
- At RD 8 and 16, Horizon's average FPS is 18–42 % above vanilla's in every scene. Its 1% lows are 1.2–2.3 times
vanilla's and its 0.1% lows 2.0–3.9 times. On this CPU and GPU the performance mods leave vanilla's averages
level or slightly lower, and raise its 1% lows by 6–23 %.
- At RD 32, Horizon's average is 15–33 % above vanilla's over the forest, the mountain and the village, with 1% lows
2.2–4.7 times vanilla's. Over the ocean its average is level with vanilla's, but its lows are lower than vanilla's
and the modded client's (155 vs 236 and 325 FPS 1% low, 68 vs 99 and 117 FPS 0.1% low), on an idle machine too.
That scene is worth a closer look.
- Horizon reaches the title screen 2.4–2.6 times faster than vanilla (3.4–3.8 times faster than the modded client)
and loads a scene's chunks 3–4 times faster. Its steady working set is 38–46 % smaller.
- Each game uses its own graphics API: Horizon Vulkan, vanilla OpenGL. That difference is part of what's compared.
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
serde_json = "1.0.151"
[lints]
+167 -18
View File
@@ -1,19 +1,35 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a `.hmod` that it is content only.
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands (a server mod:
//! a player joining, chatting, typing them and leaving), and import no more
//! than its capabilities.
use horizon_mods::{
Package,
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
index::{self, Facts, Kind},
manifest::component_imports,
};
use serde_json::{Value, json};
use std::path::Path;
/// How long the trial run plays: two seconds of game.
const TICKS: u64 = 40;
const FRAMES_PER_TICK: u64 = 3;
pub struct Report {
pub kind: Kind,
pub facts: Facts,
pub sha256: String,
pub size: u64,
pub signed: bool,
/// A mod has code to run (a content mod may have none).
pub code: bool,
/// A mod's component imports.
pub imports: Vec<String>,
/// A mod's commands, each run once.
pub commands: Vec<String>,
/// What the mod showed during the trial run.
pub output: Vec<String>,
}
impl Report {
@@ -37,6 +53,10 @@ impl Report {
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
"code": self.code,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
})
}
@@ -80,8 +100,18 @@ impl Report {
if self.kind == Kind::NativeMod {
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
}
if self.kind == Kind::Mod {
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
let commands = if self.commands.is_empty() {
String::new()
} else {
format!(", /{}", self.commands.join(", /"))
};
out += &format!(
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
TICKS * FRAMES_PER_TICK
);
}
out
}
@@ -123,26 +153,145 @@ pub fn check(
(None, None) => false,
_ => return Err("--key and --signature go together".into()),
};
if kind == Kind::Mod {
content_only(path)?;
}
Ok(Report {
let mut report = Report {
kind,
facts,
sha256: index::sha256_file(path)?,
size,
signed,
})
code: false,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
};
if report.kind == Kind::Mod {
trial_run(path, &mut report)?;
}
Ok(report)
}
/// A mod is content only: WebAssembly code mods don't run any more, code mods are native
/// (`.hnmod`, docs/native-mods.md). `inspect` already read its content as the game does.
fn content_only(path: &Path) -> Result<(), String> {
match Package::read(path)?.wasm {
Some(_) => Err(
"it has WebAssembly code, which Horizon doesn't run any more: make it a native mod (docs/native-mods.md)"
.into(),
),
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
/// storage lands in a folder removed afterwards. A content mod has no code to
/// run: `inspect` already read its content as the game does.
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
let package = Package::read(path)?;
let Some(wasm) = package.wasm.as_deref() else {
return Ok(());
};
report.code = true;
report.imports = component_imports(wasm)?;
package
.manifest
.check_imports(report.imports.iter().map(String::as_str))?;
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let copy = dir.join("mod.hmod");
let result = std::fs::copy(path, &copy)
.map_err(|e| e.to_string())
.and_then(|_| play(&copy, report));
let _ = std::fs::remove_dir_all(&dir);
result
}
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
let failed = |mods: &Mods| match &mods.entries()[0].status {
Status::Failed(reason) => Some(reason.clone()),
_ => None,
};
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
id: "00000000-0000-4000-8000-000000000001".into(),
name: "Steve".into(),
x: 0.5,
y: 64.0,
z: 0.5,
dimension: "minecraft:overworld".into(),
game_mode: GameMode::Survival,
health: 20.0,
};
let snapshot = |t: u64| Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
players: vec![steve.clone()],
// The GUI of a 1280 x 720 window at scale 3.
gui_size: (427.0, 240.0),
..Snapshot::default()
};
mods.set_snapshot(snapshot(0));
mods.player_joined(&steve);
for t in 0..TICKS {
mods.set_snapshot(snapshot(t));
mods.tick(t);
mods.server_tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
mods.player_chat(&steve, "hello");
for name in &client {
mods.command(name);
}
for name in &server {
mods.server_command(&steve, name);
}
mods.set_snapshot(Snapshot::default());
mods.player_left(&steve);
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
Output::Tell { text, .. } => format!("tell: {text}"),
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
Output::QuietCommands { lines, .. } => {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::SetBlocks {
dimension, blocks, ..
} => {
format!("set {} block(s) in {dimension}", blocks.len())
}
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::PlaceRecipe { book_id, all } => {
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
}
Output::Give { item, count, .. } => format!("gave {count} {item}"),
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
});
}
let shapes = mods.hud_shapes().count();
if shapes > 0 {
report.output.push(format!("drew {shapes} HUD shape(s)"));
}
match failed(&mods) {
Some(reason) => Err(format!("it stopped: {reason}")),
None => Ok(()),
}
}
@@ -167,7 +316,7 @@ mod tests {
.text()
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
);
// A mod with WebAssembly code is refused: code mods are native.
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
let hmod = fixture("coords_hud-0.1.0.hmod");
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
@@ -175,7 +324,7 @@ mod tests {
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
.err()
.unwrap();
assert!(e.contains("native mod"), "{e}");
assert!(e.contains("didn't start"), "{e}");
let e = check(
&hmod,
Kind::Mod,
+196 -38
View File
@@ -1,23 +1,32 @@
//! `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).
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--name "Name"]`: a content mod from the template.
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
//! and `data/`.
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod.
//! the mod; run `build` again and the game reloads it.
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
//! `tests/*.toml` scripts in the sandbox, with no game.
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`, `.hshader`).
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{Capability, Content, Manifest, Package, manifest::MANIFEST};
use horizon_mods::{
Capability, Content, Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
testing::{Script, TestGame},
};
mod check;
mod registry;
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode},
process::{Command, ExitCode, Stdio},
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
let mut args: Vec<String> = std::env::args().skip(1).collect();
@@ -26,13 +35,14 @@ fn main() -> ExitCode {
}
let result = match args.first().map(String::as_str) {
Some("new") => new(&args[1..]),
Some("build") => build().map(|p| {
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--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()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -73,9 +83,31 @@ 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);
let sdk = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
}
None => SDK_GIT.to_string(),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
(
"tests/hello.toml",
include_str!("../template/tests/hello.toml"),
),
];
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let files = [
let content = [
(
MANIFEST,
include_str!("../template/content/horizon-mod.toml"),
@@ -114,12 +146,12 @@ 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 files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
};
for &(file, text) in files {
let file = fill(file);
let file = file.as_str();
let to = dir.join(file);
@@ -137,24 +169,29 @@ fn new(args: &[String]) -> Result<(), String> {
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> {
build_in(Path::new("."))
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
/// the manifest, the crate's component (none without a `Cargo.toml`: a
/// content mod) and the files in `assets/` and `data/`.
fn build(release: bool) -> Result<PathBuf, String> {
build_in(Path::new("."), release)
}
fn build_in(root: &Path) -> Result<PathBuf, String> {
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
if root.join("Cargo.toml").exists() {
return Err("a Cargo.toml: code mods are native now (docs/native-mods.md)".into());
}
if !manifest.capabilities.contains(&Capability::Registries) {
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
let (wasm, target) = compile(root, release)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
(Some(wasm), target)
} else if manifest.capabilities.contains(&Capability::Registries) {
(None, root.join("target"))
} else {
return Err(format!(
"a content mod has capability registries in {MANIFEST}"
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
));
}
};
// The same checks as the game's, and the files in the order it lists them.
let content = Content::read(root, &manifest)?;
let mut reader = content.reader()?;
@@ -163,17 +200,53 @@ fn build_in(root: &Path) -> Result<PathBuf, String> {
files.push((name.as_str(), reader.read(name)?));
}
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
let dir = root.join("target/horizon-mod");
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
let bytes = Package::write_files(&manifest_text, None, &files)?;
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.current_dir(root).args([
"build",
"--lib",
"--target",
TARGET,
"--message-format=json-render-diagnostics",
]);
if release {
cmd.arg("--release");
}
let out = cmd
.stderr(Stdio::inherit())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!(
"cargo build failed (is the target installed? rustup target add {TARGET})"
));
}
let core = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|m| m["reason"] == "compiler-artifact")
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
.filter_map(|f| f.as_str().map(PathBuf::from))
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
Ok((wasm, target.to_path_buf()))
}
/// `check <file>`: the registry's checks of a submission, printed; fails
/// when one does.
fn check(args: &[String]) -> Result<(), String> {
@@ -205,14 +278,74 @@ fn check(args: &[String]) -> Result<(), String> {
Ok(())
}
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
/// against the built mod, or the `.hmod` given with `--hmod`.
fn test(args: &[String]) -> Result<(), String> {
let hmod = match flag(args, "--hmod") {
Some(p) => PathBuf::from(p),
None => build(!args.iter().any(|a| a == "--debug"))?,
};
let filter = args
.iter()
.enumerate()
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
.map(|(_, a)| a.as_str());
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
.map_err(|e| {
format!(
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
)
})?
.filter_map(|e| Some(e.ok()?.path()))
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
.collect();
scripts.sort();
if scripts.is_empty() {
return Err("no tests/*.toml to run".into());
}
println!(
"running {} scripts against {}",
scripts.len(),
hmod.display()
);
let mut failed = 0;
for path in &scripts {
let result = std::fs::read_to_string(path)
.map_err(|e| e.to_string())
.and_then(|text| Script::parse(&text))
.and_then(|script| {
let mut game = TestGame::load(&hmod)?;
script.run(&mut game)?;
Ok(script.steps.len())
});
match result {
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
Err(e) => {
failed += 1;
println!("test {} ... FAILED\n {e}", path.display());
}
}
}
let passed = scripts.len() - failed;
if failed > 0 {
return Err(format!("{failed} failed, {passed} passed"));
}
println!("test result: ok. {passed} passed");
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let path = build()?;
let path = build(true)?;
let game = flag(args, "--game")
.map(str::to_string)
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
.unwrap_or_else(|| "horizon-client".into());
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
println!("Starting {game} with {}", path.display());
println!(
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
path.display()
);
let status = Command::new(&game)
.arg("--dev-mod")
.arg(&path)
@@ -231,6 +364,28 @@ fn run(args: &[String]) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn the_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
let path = dir.join("My-Mod");
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(
(manifest.id.as_str(), manifest.name.as_str()),
("my_mod", "My Mod")
);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
assert!(
new(&[path.display().to_string()]).is_err(),
"never overwrites"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
@@ -242,7 +397,7 @@ mod tests {
];
new(&new_args).unwrap();
assert!(!path.join("Cargo.toml").exists());
let hmod = build_in(&path).unwrap();
let hmod = build_in(&path, true).unwrap();
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
let package = Package::read(&hmod).unwrap();
assert!(package.wasm.is_none());
@@ -254,14 +409,17 @@ mod tests {
);
assert_eq!(content.files.len(), 8, "{:?}", content.files);
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
assert!(!report.code);
assert!(
report.text().contains("no code: content only"),
"{}",
report.text()
);
// Code is native now.
std::fs::write(path.join("Cargo.toml"), "").unwrap();
assert!(build_in(&path).unwrap_err().contains("native"));
// Without registries, a mod needs code.
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
let manifest = manifest.replace("[\"registries\"]", "[]");
std::fs::write(path.join(MANIFEST), manifest).unwrap();
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
assert!(
new(&[
dir.join("x").display().to_string(),
+1 -71
View File
@@ -13,9 +13,7 @@ const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>
cargo horizon-mod registry release <checkout> <id> <file> --tag <tag> --published <YYYY-MM-DD> [--notes <file>] [--signature <file>] [--game <version>]
cargo horizon-mod registry page <checkout> <id> <horizon-registry.toml>";
cargo horizon-mod registry list <checkout>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
@@ -156,74 +154,6 @@ pub fn main(args: &[String]) -> Result<(), String> {
}
Ok(())
}
// A release of a project's repository, downloaded: the checks of a submission, then the
// version as the index lists it (JSON on stdout), for the registry's release sync.
Some("release") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let (tag, published) = (
flag(rest, "--tag").ok_or(USAGE)?,
flag(rest, "--published").ok_or(USAGE)?,
);
let read = |name: &str| {
flag(rest, name)
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()
};
let (notes, signature) = (read("--notes")?, read("--signature")?);
let entry = entry_of(checkout, id)?;
let path = Path::new(file);
let cache = registry::Cache::read(Path::new(checkout), id)?;
let entry = entry.resolved(&cache.page);
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
crate::check::check(
path,
entry.kind.clone(),
entry.key.as_deref(),
signature.as_deref(),
)?;
let version = registry::release_version(
&entry,
&registry::Release {
tag,
published,
notes: notes.as_deref().unwrap_or(""),
asset: flag(rest, "--name").unwrap_or(name),
game: flag(rest, "--game").unwrap_or("26.3"),
signature: signature.as_deref(),
},
path,
)?;
println!(
"{}",
serde_json::to_string(&version).map_err(|e| e.to_string())?
);
Ok(())
}
// The page file of a project's repository, checked against its entry: the page as JSON.
Some("page") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let entry = entry_of(checkout, id)?;
let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;
let page = registry::page(&entry, &text)?;
println!(
"{}",
serde_json::to_string(&page).map_err(|e| e.to_string())?
);
Ok(())
}
_ => Err(USAGE.into()),
}
}
/// `mods/<id>.toml` of the checkout, which must name a repository.
fn entry_of(checkout: &str, id: &str) -> Result<registry::Entry, String> {
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
entries
.into_iter()
.find(|e| e.id == id && e.repo.is_some())
.ok_or_else(|| format!("{id}: no entry with a repo"))
}
@@ -0,0 +1,14 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-sdk = {sdk}
[profile.release]
opt-level = "s"
lto = true
@@ -3,8 +3,8 @@ name = "{name}"
version = "0.1.0"
description = ""
authors = []
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
# A content mod: blocks and items 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 and items to your worlds".
capabilities = ["registries"]
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the SDK targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,19 @@
use horizon_mod_sdk::{Mod, Registration, chat};
struct MyMod {
greeted: u32,
}
impl Mod for MyMod {
fn init() -> Result<(Self, Registration), String> {
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
}
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
self.greeted += 1;
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
Ok(None)
}
}
horizon_mod_sdk::export_mod!(MyMod);
@@ -0,0 +1,14 @@
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
# steps happen in order, and `expect` checks what the mod showed during the step.
# Every key: docs/mods.md, "Testing a mod".
[[step]]
expect.commands = ["hello"]
[[step]]
command = "hello world"
expect.chat = ["Hello world! (1 so far)"]
[[step]]
command = "hello again"
expect.chat = ["Hello again! (2 so far)"]
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "horizon-anticheat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.85"
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "1.1"
[lints]
workspace = true
-99
View File
@@ -1,99 +0,0 @@
//! Combat: attacks vanilla's client can't send (docs/anticheat.md).
//!
//! Vanilla's client sends ServerboundAttackPacket from one place only, `Minecraft.startAttack`, run from
//! `handleKeybinds` once per client tick. It attacks the entity of the tick's `hitResult`, which
//! `Minecraft.tick` picks once before the keys are handled, so every attack of one client tick is on the
//! same entity. And each attack swings the arm (ServerboundPunchPacket) in the same call, so a client
//! tick never has more attacks than swings: a tick that does comes from a kill aura hiding its swing.
//! The swing is counted before or after the attack, as older clients behind a version proxy swing
//! first; a swing without an attack (a miss, a block) is fine.
/// What breaks vanilla's way of attacking.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Foul {
/// A client tick with more attacks than swings: how many more.
NoSwing(u32),
/// A second entity attacked in the same client tick: the first one and this one.
TwoTargets(i32, i32),
}
/// The attacks of the client tick being heard.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Attacks {
/// The entity this tick attacked first.
target: Option<i32>,
attacks: u32,
swings: u32,
}
impl Attacks {
/// ServerboundAttackPacket on entity `target`.
pub fn attack(&mut self, target: i32) -> Option<Foul> {
self.attacks = self.attacks.saturating_add(1);
let first = *self.target.get_or_insert(target);
(first != target).then_some(Foul::TwoTargets(first, target))
}
/// ServerboundPunchPacket: the arm swung.
pub fn swing(&mut self) {
self.swings = self.swings.saturating_add(1);
}
/// ServerboundClientTickEndPacket: the tick's attacks are counted, and the next tick picks its
/// target again.
pub fn tick_end(&mut self) -> Option<Foul> {
let unswung = self.attacks.saturating_sub(self.swings);
*self = Self::default();
(unswung > 0).then_some(Foul::NoSwing(unswung))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_vanilla_tick_is_clean() {
let mut a = Attacks::default();
// Two clicks in one tick on the same entity, each swung for.
for _ in 0..2 {
assert_eq!(a.attack(7), None);
a.swing();
}
assert_eq!(a.tick_end(), None);
// The next tick may attack another entity, swinging first (an older client).
a.swing();
assert_eq!(a.attack(8), None);
assert_eq!(a.tick_end(), None);
// A swing on its own (a miss, a block) is fine.
a.swing();
assert_eq!(a.tick_end(), None);
}
#[test]
fn an_attack_not_swung_for_is_caught() {
let mut a = Attacks::default();
a.attack(7);
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
// A swing in the tick before doesn't count for this one.
a.swing();
a.tick_end();
a.attack(7);
a.attack(7);
a.swing();
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
}
#[test]
fn two_entities_in_one_tick_are_caught() {
let mut a = Attacks::default();
a.attack(7);
a.swing();
assert_eq!(a.attack(8), Some(Foul::TwoTargets(7, 8)));
a.swing();
// Still against the tick's first target.
assert_eq!(a.attack(9), Some(Foul::TwoTargets(7, 9)));
a.swing();
assert_eq!(a.tick_end(), None);
}
}
-426
View File
@@ -1,426 +0,0 @@
//! The dedicated server's anticheat (docs/anticheat.md), without the game: the `[anticheat]` section of
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
//!
//! It is on by default, and so is every check, but only the checks a vanilla client can't trip act on
//! their own (`combat`); the others log only until they have shown no false positive with real
//! players (their `setback`, `alert` and `kick` are 0). `enabled = false` turns the anticheat, or one
//! 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.
//!
//! 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;
pub mod prediction;
pub mod timer;
/// What one check's violation level leads to.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Actions {
pub setback: f32,
pub alert: f32,
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] {
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
}
)*) => {
/// Every check, in the order of the players' violation levels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Check { $($Check,)* }
impl Check {
pub const ALL: &[Check] = &[$(Check::$Check,)*];
/// Its name in `server.toml` and in alerts.
pub fn name(self) -> &'static str {
match self { $(Check::$Check => stringify!($field),)* }
}
}
$(
$(#[$doc])*
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct $Config {
pub enabled: bool,
pub setback: f32,
pub alert: f32,
pub kick: f32,
pub decay: f32,
pub ban_after: u32,
$( $(#[$xdoc])* pub $extra: $ty, )*
}
impl Default for $Config {
fn default() -> Self {
Self {
enabled: true,
setback: $setback,
alert: $alert,
kick: $kick,
decay: $decay,
ban_after: $ban_after,
$($extra: $default,)*
}
}
}
)*
/// `server.toml`'s `[anticheat]`: on by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub enabled: bool,
/// Tell online operators of an alert in their chat (the console and the log always are).
pub alerts: bool,
/// The permission level a player needs to see alerts.
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,)*
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
alerts: true,
alert_level: 2,
log: "logs/anticheat.log".into(),
strikes: "anticheat-strikes.json".into(),
$($field: $Config::default(),)*
}
}
}
impl Config {
/// Whether `check` runs: the anticheat and the check both on.
pub fn runs(&self, check: Check) -> bool {
self.enabled && match check { $(Check::$Check => self.$field.enabled,)* }
}
pub fn actions(&self, check: Check) -> Actions {
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,
}
})*
}
}
}
};
}
checks! {
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
/// 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] {
/// 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
/// spike's backlog arrives at once).
max_lag_ms: f64 = 1000.0,
}
/// 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] {}
/// 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
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
/// 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] {
/// How far, in blocks, a move may land from where the replay puts it.
tolerance: f64 = 0.03,
}
/// 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] {}
}
/// How many checks there are.
pub const CHECKS: usize = Check::ALL.len();
impl Config {
/// The `[anticheat]` section of a whole `server.toml`; the default (off) when it has none.
pub fn from_server_toml(text: &str) -> Result<Self, String> {
#[derive(Deserialize)]
struct File {
#[serde(default)]
anticheat: 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("anticheat") {
only.insert("anticheat".into(), section);
}
let file: File = only
.try_into()
.map_err(|e: toml::de::Error| format!("[anticheat]: {e}"))?;
file.anticheat.check()?;
Ok(file.anticheat)
}
/// Thresholds can't be negative.
pub fn check(&self) -> Result<(), String> {
for &c in Check::ALL {
let a = self.actions(c);
if [a.setback, a.alert, a.kick, a.decay]
.iter()
.any(|v| !v.is_finite() || *v < 0.0)
{
return Err(format!(
"anticheat.{}: setback, alert, kick and decay are numbers from 0 up",
c.name()
));
}
}
let tolerance = self.prediction.tolerance;
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
}
Ok(())
}
}
/// 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 {
/// The check's violation level after the flag.
pub vl: f32,
/// Cancel the move or action.
pub setback: bool,
/// Tell operators and the console (at most once a second per check).
pub alert: bool,
pub kick: bool,
}
/// One player's anticheat state. Clean players cost nothing: levels only change when a check flags.
#[derive(Clone, Debug, Default)]
pub struct Player {
vl: [f32; CHECKS],
/// The server tick of each check's last flag (its decay runs from there).
flagged: [u64; CHECKS],
/// The server tick of each check's last alert.
alerted: [Option<u64>; CHECKS],
pub timer: timer::Balance,
/// The server tick and height where the ground last held the 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,
}
impl Player {
/// The check's violation level at `tick`, with its decay since the last flag.
pub fn vl(&self, config: &Config, check: Check, tick: u64) -> f32 {
let i = check as usize;
let seconds = tick.saturating_sub(self.flagged[i]) as f32 / 20.0;
(self.vl[i] - config.actions(check).decay * seconds).max(0.0)
}
/// `check` failed with this weight at server tick `tick`.
pub fn flag(&mut self, config: &Config, check: Check, weight: f32, tick: u64) -> Verdict {
let i = check as usize;
let a = config.actions(check);
let vl = self.vl(config, check, tick) + weight;
self.vl[i] = vl;
self.flagged[i] = tick;
let reached = |threshold: f32| threshold > 0.0 && vl >= threshold;
let alert = reached(a.alert) && self.alerted[i].is_none_or(|t| tick >= t + 20);
if alert {
self.alerted[i] = Some(tick);
}
Verdict {
vl,
setback: reached(a.setback),
alert,
kick: reached(a.kick),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn on() -> Config {
Config {
enabled: true,
..Config::default()
}
}
#[test]
fn on_by_default_but_only_combat_and_ground_act() {
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()
);
}
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
}
#[test]
fn reads_its_section() {
let text = "[server]\nport = 1\n[anticheat]\nenabled = true\n[anticheat.timer]\nkick = 0\ntolerance_ms = 500\n";
let c = Config::from_server_toml(text).unwrap();
assert!(c.runs(Check::Timer));
assert_eq!(c.timer.kick, 0.0);
assert_eq!(c.timer.tolerance_ms, 500.0);
assert_eq!(c.timer.alert, TimerConfig::default().alert);
let zero = "[anticheat]\n[anticheat.prediction]\ntolerance = 0\n";
assert!(Config::from_server_toml(zero).is_err());
let off = "[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n";
assert!(!Config::from_server_toml(off).unwrap().runs(Check::Timer));
}
#[test]
fn refuses_unknown_keys_and_negative_levels() {
assert!(Config::from_server_toml("[anticheat]\nenable = true\n").is_err());
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
}
#[test]
fn levels_rise_decay_and_act_in_order() {
let mut c = on();
c.timer = TimerConfig {
setback: 2.0,
alert: 3.0,
kick: 5.0,
decay: 1.0,
..TimerConfig::default()
};
let mut p = Player::default();
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert_eq!((v.setback, v.alert, v.kick), (false, false, false));
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert_eq!((v.vl, v.setback, v.alert), (2.0, true, false));
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert!(v.alert && !v.kick);
// At most one alert a second.
assert!(!p.flag(&c, Check::Timer, 0.5, 110).alert);
// 3 at tick 110 (half a level gone, half added); two seconds later two more are gone.
assert_eq!(p.vl(&c, Check::Timer, 150), 1.0);
assert_eq!(p.vl(&c, Check::Timer, 10_000), 0.0);
let v = p.flag(&c, Check::Timer, 5.0, 10_000);
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();
c.timer.kick = 0.0;
let mut p = Player::default();
assert!(!p.flag(&c, Check::Timer, 1000.0, 1).kick);
}
}
-416
View File
@@ -1,416 +0,0 @@
//! The movement prediction's record: each of a player's client ticks as the server heard them, so
//! the server can play the tick again with vanilla's movement code and compare (docs/anticheat.md).
//!
//! A vanilla client ends every tick with ServerboundClientTickEndPacket, after the tick's input
//! (ServerboundPlayerInputPacket, sent when the keys change) and its move (ServerboundMovePlayerPacket,
//! when it moved or turned). Those packets arrive in order, so the packets between two tick ends are one
//! client tick.
//!
//! What the server changes about the player (a push, a teleport, an effect) takes effect on the client
//! when it handles the packet, which is some client ticks after the server sent it. At the end of each of
//! its ticks the server sends a ClientboundPingPacket whose id is that server tick; the client answers
//! with ServerboundPongPacket as soon as it handles it, before its next tick runs. A client tick knows
//! the last server tick it had handled: every change of that server tick or an earlier one was in effect
//! during the client tick, none of a later one.
use std::collections::VecDeque;
/// Client ticks kept: two seconds at 20 a second.
pub const KEPT: usize = 40;
/// Server changes kept, the oldest dropped first.
const CHANGES_KEPT: usize = 64;
/// Changes near the player kept until every kept tick has seen them.
const NEAR_KEPT: usize = 1024;
/// Something the server sent the player that changes how its client moves. The client applies it when
/// it handles the packet, before its next tick.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Change {
/// ClientboundSetEntityMotionPacket for the player itself: its new velocity, as the client
/// decodes it.
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,
/// 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.
Abilities,
}
/// A teleport the client accepted: the replay starts again from it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Restart {
/// Where it put the player.
pub at: [f64; 3],
/// It kept the player's motion (an End gateway's).
pub kept_motion: bool,
/// Client ticks ended and changes heard before it: the ones after are the replay's.
pub ended: u64,
pub changed: u64,
}
/// One client tick.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ClientTick {
/// The keys held (ServerboundPlayerInputPacket's flags: forward, backward, left, right, jump,
/// shift, sprint), as last sent.
pub input: u8,
/// Sprinting, as the player's commands left it (ServerboundPlayerCommandPacket).
pub sprinting: bool,
/// The rotation the client last sent.
pub yaw: f32,
pub pitch: f32,
/// Where the tick's move said the player is, if it said: a move with a position.
pub position: Option<[f64; 3]>,
/// The tick's move packet's flags (1: on the ground, 2: against a wall), if it sent one.
pub flags: Option<u8>,
/// The last server tick whose packets the client had handled (its pong) before this tick ended.
pub seen: Option<u64>,
}
/// A player's last client ticks.
#[derive(Clone, Debug, Default)]
pub struct Record {
/// The tick being heard.
current: ClientTick,
/// The ended ones, oldest first, at most [`KEPT`].
ticks: VecDeque<ClientTick>,
/// The last server tick the client answered for.
seen: Option<u64>,
/// Client ticks ended since the record began.
ended: u64,
/// Where an accepted teleport put the player, until the replay starts again from there.
restart: Option<Restart>,
/// What the server changed, with the server tick that sent it, oldest first.
changes: VecDeque<(u64, Change)>,
/// Changes heard since the record began.
changed: u64,
/// The blocks the server changed near the player (None: an entity it stopped showing the player
/// from near it, which counts wherever the player has gone since), with the server tick that sent
/// each, until every kept tick has seen them.
near: VecDeque<(u64, Option<[i32; 3]>)>,
/// The server tick of the last of them dropped early ([`NEAR_KEPT`] came after it).
near_dropped: Option<u64>,
}
impl Record {
/// The tick's move: its position if it has one, the rotation if it has one, and its flags.
pub fn moved(&mut self, position: Option<[f64; 3]>, rotation: Option<(f32, f32)>, flags: u8) {
if position.is_some() {
self.current.position = position;
}
if let Some((yaw, pitch)) = rotation {
(self.current.yaw, self.current.pitch) = (yaw, pitch);
}
self.current.flags = Some(flags);
}
/// ServerboundPongPacket for the ping sent at the end of `server_tick`. False for an answer to a
/// ping that was never sent, or one older than an answer already heard: a vanilla client answers
/// every ping, once, in order.
pub fn pong(&mut self, server_tick: u64, sent_up_to: u64) -> bool {
if server_tick > sent_up_to || self.seen.is_some_and(|seen| server_tick <= seen) {
return false;
}
self.seen = Some(server_tick);
true
}
/// ServerboundClientTickEndPacket: the tick ends with these keys held and this sprinting. The
/// next tick starts with the same keys and rotation, until the client says otherwise.
pub fn tick_end(&mut self, input: u8, sprinting: bool) -> &ClientTick {
self.current.input = input;
self.current.sprinting = sprinting;
self.current.seen = self.seen;
let next = ClientTick {
yaw: self.current.yaw,
pitch: self.current.pitch,
..ClientTick::default()
};
let ended = std::mem::replace(&mut self.current, next);
if self.ticks.len() == KEPT {
self.ticks.pop_front();
}
self.ticks.push_back(ended);
self.ended += 1;
// What the oldest tick kept had seen, every later one had too.
if let Some(seen) = self.ticks.front().and_then(|t| t.seen) {
while self.near.front().is_some_and(|&(sent, _)| sent <= seen) {
self.near.pop_front();
}
self.near_dropped = self.near_dropped.filter(|&sent| sent > seen);
}
self.ticks.back().expect("just pushed")
}
/// The ended ticks, oldest first.
pub fn ticks(&self) -> impl DoubleEndedIterator<Item = &ClientTick> + ExactSizeIterator {
self.ticks.iter()
}
/// The last server tick the client answered for.
pub fn seen(&self) -> Option<u64> {
self.seen
}
/// Client ticks ended since the record began.
pub fn ended(&self) -> u64 {
self.ended
}
/// The ticks ended after the first `played`, oldest first; None when some of them are no longer
/// kept.
pub fn since(&self, played: u64) -> Option<impl Iterator<Item = &ClientTick>> {
let new = self.ended.checked_sub(played)?;
let new = usize::try_from(new)
.ok()
.filter(|&n| n <= self.ticks.len())?;
Some(self.ticks.iter().skip(self.ticks.len() - new))
}
/// `change`, sent during server tick `server_tick` (before that tick's ping).
pub fn changed(&mut self, server_tick: u64, change: Change) {
if self.changes.len() == CHANGES_KEPT {
self.changes.pop_front();
}
self.changes.push_back((server_tick, change));
self.changed += 1;
}
/// Changes heard since the record began.
pub fn changes(&self) -> u64 {
self.changed
}
/// The changes after the first `applied`, oldest first; None when some of them are no longer kept.
pub fn changes_since(
&self,
applied: u64,
) -> Option<impl Iterator<Item = &(u64, Change)> + Clone> {
let new = self.changed.checked_sub(applied)?;
let new = usize::try_from(new)
.ok()
.filter(|&n| n <= self.changes.len())?;
Some(self.changes.iter().skip(self.changes.len() - new))
}
/// During server tick `server_tick` (before that tick's ping) the server sent the client the block
/// at `pos`.
pub fn changed_near(&mut self, server_tick: u64, pos: [i32; 3]) {
self.note_near(server_tick, Some(pos));
}
/// During server tick `server_tick` the server stopped showing the client an entity near it (a
/// firework it flew with, a mob that pushed it): the client may still have it, wherever it goes.
pub fn entity_gone(&mut self, server_tick: u64) {
if self.near.back() != Some(&(server_tick, None)) {
self.note_near(server_tick, None);
}
}
fn note_near(&mut self, server_tick: u64, pos: Option<[i32; 3]>) {
if self.near.len() == NEAR_KEPT {
self.near_dropped = self.near.pop_front().map(|(sent, _)| sent);
}
self.near.push_back((server_tick, pos));
}
/// Whether a client tick that had `seen` up to that server tick (its [`ClientTick::seen`]) may
/// not have had a change at a position for which `near` holds: it still had the old block, or the
/// entity, where the server no longer has it.
pub fn unseen_near(&self, seen: Option<u64>, near: impl Fn([i32; 3]) -> bool) -> bool {
let unseen = |sent: u64| seen.is_none_or(|seen| sent > seen);
self.near_dropped.is_some_and(unseen)
|| self
.near
.iter()
.any(|&(sent, pos)| unseen(sent) && pos.is_none_or(&near))
}
/// The client accepted a teleport to `at` (ServerboundAcceptTeleportationPacket): it goes on from
/// there, standing still unless the teleport `kept_motion` (an End gateway's). The ticks it ended
/// before stay, for a replay that hasn't played them yet; the one under way starts again.
pub fn teleported(&mut self, at: [f64; 3], kept_motion: bool) {
self.restart_tick();
self.restart = Some(Restart {
at,
kept_motion,
ended: self.ended,
changed: self.changed,
});
}
/// The last teleport the client accepted, once.
pub fn take_restart(&mut self) -> Option<Restart> {
self.restart.take()
}
/// The ticks before now say nothing about the ones after.
pub fn clear(&mut self) {
self.ticks.clear();
self.restart_tick();
}
fn restart_tick(&mut self) {
self.current = ClientTick {
yaw: self.current.yaw,
pitch: self.current.pitch,
..ClientTick::default()
};
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_change_near_is_unseen_by_the_ticks_before_its_pong() {
let mut r = Record::default();
r.changed_near(4, [1, 2, 3]);
r.changed_near(5, [7, 2, 3]);
let near = |p: [i32; 3]| p[0] < 5;
assert!(r.unseen_near(None, near));
assert!(r.unseen_near(Some(3), near));
assert!(!r.unseen_near(Some(4), near));
assert!(r.unseen_near(Some(4), |_| true));
// Kept until the oldest tick kept had seen it: one that ended before the pong hadn't.
r.tick_end(0, false);
assert!(r.pong(5, 5));
r.tick_end(0, false);
assert!(r.unseen_near(Some(3), near));
for _ in 0..KEPT {
r.tick_end(0, false);
}
assert!(!r.unseen_near(None, |_| true));
// An entity gone counts anywhere.
r.entity_gone(5);
r.entity_gone(5);
assert!(r.unseen_near(Some(4), |_| false));
assert!(!r.unseen_near(Some(5), |_| false));
// One dropped early counts for the ticks that hadn't seen it.
for i in 0..=NEAR_KEPT as u64 {
r.changed_near(6 + i, [100, 0, 0]);
}
assert!(
r.unseen_near(Some(5), near),
"a change dropped unseen counts"
);
assert!(!r.unseen_near(Some(6), near));
}
#[test]
fn a_tick_is_the_packets_between_two_tick_ends() {
let mut r = Record::default();
r.moved(Some([1.0, 2.0, 3.0]), Some((90.0, 10.0)), 1);
let t = *r.tick_end(0b1001, true);
assert_eq!(t.position, Some([1.0, 2.0, 3.0]));
assert_eq!((t.yaw, t.pitch, t.flags), (90.0, 10.0, Some(1)));
assert_eq!((t.input, t.sprinting, t.seen), (0b1001, true, None));
// A tick without a move keeps the rotation and has no position.
let t = *r.tick_end(0, false);
assert_eq!((t.position, t.flags, t.yaw), (None, None, 90.0));
assert_eq!(r.ticks().len(), 2);
}
#[test]
fn pongs_say_which_server_ticks_a_client_tick_had_seen() {
let mut r = Record::default();
assert!(r.pong(5, 6));
r.tick_end(0, false);
assert!(r.pong(6, 6));
r.tick_end(0, false);
r.tick_end(0, false);
let seen: Vec<_> = r.ticks().map(|t| t.seen).collect();
assert_eq!(seen, [Some(5), Some(6), Some(6)]);
// Never sent, answered twice, or out of order.
assert!(!r.pong(9, 6));
assert!(!r.pong(6, 6));
assert!(!r.pong(4, 6));
assert_eq!(r.seen(), Some(6));
}
#[test]
fn only_the_last_two_seconds_are_kept() {
let mut r = Record::default();
for i in 0..KEPT + 5 {
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
}
assert_eq!(r.ticks().len(), KEPT);
assert_eq!(r.ticks().next().unwrap().position, Some([5.0, 0.0, 0.0]));
r.clear();
assert_eq!(r.ticks().len(), 0);
}
#[test]
fn the_replay_takes_the_ticks_it_has_not_played() {
let mut r = Record::default();
for i in 0..3 {
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
}
fn x(r: &Record, played: u64) -> Option<Vec<f64>> {
r.since(played)
.map(|t| t.map(|t| t.position.unwrap()[0]).collect())
}
assert_eq!(x(&r, 1), Some(vec![1.0, 2.0]));
assert_eq!(x(&r, 3), Some(vec![]));
// Played ahead of the record, or so far behind the ticks are gone.
assert_eq!(x(&r, 4), None);
r.clear();
assert_eq!(x(&r, 2), None);
assert_eq!(x(&r, 3), Some(vec![]));
r.moved(Some([8.0, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
r.moved(Some([9.0, 0.0, 0.0]), None, 0);
r.teleported([5.0, 6.0, 7.0], false);
// A tick that ends before the replay starts again is still one after the teleport.
r.tick_end(0, false);
let restart = r.take_restart().unwrap();
assert_eq!((restart.at, restart.kept_motion), ([5.0, 6.0, 7.0], false));
assert_eq!(r.since(restart.ended).unwrap().count(), 1);
// The tick ended before it stays for a replay that hasn't played it; the move of the one
// under way is gone.
let positions: Vec<_> = r.since(3).unwrap().map(|t| t.position).collect();
assert_eq!(positions, [Some([8.0, 0.0, 0.0]), None]);
assert_eq!(r.take_restart(), None);
}
#[test]
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);
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)]
);
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);
}
// The first two are gone.
assert!(r.changes_since(1).is_none());
assert_eq!(r.changes_since(2).unwrap().count(), CHANGES_KEPT);
}
}
-111
View File
@@ -1,111 +0,0 @@
//! The timer check's balance: how far a client's ticks are behind (positive) or ahead (negative) of the
//! server's clock, in milliseconds.
//!
//! The server adds the wall-clock time of each of its ticks, and every ServerboundClientTickEndPacket
//! takes away the length of one tick at the server's tick rate (vanilla's client ticks at that rate,
//! `TickRateManager.millisecondsPerTick`). The balance is capped at `max_lag_ms` *after* the tick's
//! packets are counted: a slow server tick that handles a second of packets at once also added that
//! second, and a quiet connection banks at most `max_lag_ms` for the burst that ends a lag spike.
use crate::TimerConfig;
#[derive(Clone, Debug, Default)]
pub struct Balance {
pub ms: f64,
}
impl Balance {
/// The start of a server tick that came `elapsed_ms` after the previous one.
pub fn elapse(&mut self, elapsed_ms: f64, config: &TimerConfig) {
self.ms = self.ms.min(config.max_lag_ms) + elapsed_ms;
}
/// One client tick ended, at a tick rate of `tick_rate` a second. Some(how far ahead, in ms) when the
/// client is further ahead than the tolerance; the balance starts again from zero then, so a
/// sustained speed-up is flagged again each time it gets that far ahead.
pub fn tick_end(&mut self, tick_rate: f32, config: &TimerConfig) -> Option<f64> {
self.ms -= 1000.0 / f64::from(tick_rate.max(1.0));
if self.ms < -config.tolerance_ms {
let ahead = -self.ms;
self.ms = 0.0;
return Some(ahead);
}
None
}
/// The tick rate changed or the player arrived: start again.
pub fn reset(&mut self) {
self.ms = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
let c = TimerConfig::default();
let mut owed = 0.0;
let mut flags = 0;
for _ in 0..(seconds * 1000.0 / server_ms) as usize {
b.elapse(server_ms, &c);
owed += per_tick;
while owed >= 1.0 {
owed -= 1.0;
flags += usize::from(b.tick_end(20.0, &c).is_some());
}
}
flags
}
#[test]
fn a_vanilla_client_is_never_flagged() {
let mut b = Balance::default();
assert_eq!(run(&mut b, 50.0, 1.0, 600.0), 0);
// A server that runs at 10 ticks a second handles two client ticks in each.
assert_eq!(run(&mut b, 100.0, 2.0, 600.0), 0);
}
#[test]
fn a_slow_server_tick_with_its_backlog_is_not_flagged() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
b.elapse(3000.0, &c);
for _ in 0..60 {
assert_eq!(b.tick_end(20.0, &c), None);
}
}
#[test]
fn a_short_network_stall_is_not_flagged() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
// 800 ms with nothing arriving, then the 16 ticks sent meanwhile.
for _ in 0..16 {
b.elapse(50.0, &c);
}
b.elapse(50.0, &c);
for _ in 0..17 {
assert_eq!(b.tick_end(20.0, &c), None);
}
}
#[test]
fn a_sped_up_client_is_flagged_again_and_again() {
let mut b = Balance::default();
// 10 % faster: 5 ms ahead each tick, 300 ms after 3 s, so close to 20 flags a minute.
let flags = run(&mut b, 50.0, 1.1, 60.0);
assert!((15..=20).contains(&flags), "{flags}");
// Twice as fast: a flag every 7 ticks.
assert!(run(&mut Balance::default(), 50.0, 2.0, 10.0) >= 25);
}
#[test]
fn a_pause_cant_be_saved_for_a_burst() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
for _ in 0..200 {
b.elapse(50.0, &c);
}
let flagged = (0..60).any(|_| b.tick_end(20.0, &c).is_some());
assert!(flagged);
}
}
+2 -95
View File
@@ -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]);
}
}
+10 -10
View File
@@ -16,7 +16,6 @@ use std::{
sync::{Arc, Mutex},
};
mod component_predicate;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -629,7 +628,7 @@ pub enum ConditionProperty {
ExtendedView,
KeybindDown(String),
ViewEntity,
/// A single `component` predicate; external predicate types use the host.
/// A `component` predicate, evaluated by the host.
Component(Value),
}
impl ConditionProperty {
@@ -652,7 +651,13 @@ impl ConditionProperty {
"minecraft:extended_view" => Self::ExtendedView,
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
"minecraft:view_entity" => Self::ViewEntity,
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
"minecraft:component" => {
let predicate = required(m, "predicate")?;
if object(predicate)?.len() != 1 {
return Err(err("a single component predicate is expected"));
}
Self::Component(predicate.clone())
}
_ => return Err(err("unknown condition property")),
})
}
@@ -959,14 +964,10 @@ pub trait ItemEnv {
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
0.0
}
/// Additional component predicate types supplied by the host.
/// `ItemStack.getMaxStackSize` when the prototype has no component.
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
false
}
/// Membership in the level's enchantment registry tags.
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
false
}
fn is_keybind_down(&self, _keybind: &str) -> bool {
false
}
@@ -1334,8 +1335,7 @@ impl Resolve<'_> {
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
ConditionProperty::Component(predicate) => {
component_predicate::matches(predicate, self.components, self.env)
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
self.env.component_matches(self.stack, predicate)
}
}
}
@@ -1,177 +0,0 @@
//! Component conditions used by enchantment-dependent item models.
use super::*;
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
/// (`predicate`) and its wrapped codec's separate `value` field.
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
let id = component_type(required(m, "predicate")?)?;
let mut value = required(m, "value")?.clone();
if matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
let mut entries = Vec::new();
for entry in list(&value)? {
let mut entry = object(entry)?.clone();
if let Some(selector) = get(&entry, "enchantments") {
let selector = match selector {
Value::Array(ids) => Value::Array(
ids.iter()
.map(ident)
.map(|r| r.map(Value::String))
.collect::<Result<_>>()?,
),
Value::String(name) if name.starts_with('#') => {
Value::String(format!("#{}", identifier(&name[1..])?))
}
_ => Value::String(ident(selector)?),
};
entry.insert("enchantments".into(), selector);
}
if let Some(levels) = get(&entry, "levels") {
let levels = if levels.is_object() {
let mut bounds = Map::new();
for key in ["min", "max"] {
if let Some(n) = get(object(levels)?, key) {
bounds.insert(key.into(), Value::from(int(n)?));
}
}
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
if bounds
.get("min")
.and_then(Value::as_i64)
.zip(bounds.get("max").and_then(Value::as_i64))
.is_some_and(|(min, max)| min > max)
{
return Err(err("minimum exceeds maximum"));
}
Value::Object(bounds)
} else {
Value::from(int(levels)?)
};
entry.insert("levels".into(), levels);
}
entries.push(Value::Object(entry));
}
value = Value::Array(entries);
}
Ok(Value::Object(Map::from_iter([(id, value)])))
}
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
/// all entries must match; each entry needs one selected, present enchantment
/// with a matching level. An empty predicate list still needs the component.
pub(super) fn matches(
predicate: &Value,
components: &Components,
env: &dyn ItemEnv,
) -> Option<bool> {
let (id, expected) = predicate.as_object()?.iter().next()?;
if !matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
return None;
}
let Some(have) = components.get(id).and_then(Value::as_object) else {
return Some(false);
};
Some(expected.as_array()?.iter().all(|entry| {
let Some(entry) = entry.as_object() else {
return false;
};
have.iter().any(|(id, level)| {
let level = level.as_i64().unwrap_or(0);
let selector = get(entry, "enchantments");
let selected = match selector {
None => true,
Some(Value::String(s)) => s
.strip_prefix('#')
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
_ => false,
};
selected
&& if selector.is_some() {
matches_enchantment(level, get(entry, "levels"))
} else {
level_matches(level, get(entry, "levels"))
}
})
}))
}
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
/// nonzero level even when its bounds accept zero.
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
level != 0 && level_matches(level, bounds)
}
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
match bounds {
None => true,
Some(Value::Number(n)) => n.as_i64() == Some(level),
Some(Value::Object(bounds)) => {
get(bounds, "min")
.and_then(Value::as_i64)
.is_none_or(|min| level >= min)
&& get(bounds, "max")
.and_then(Value::as_i64)
.is_none_or(|max| level <= max)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct Tags;
impl ItemEnv for Tags {
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
enchantment == "minecraft:density" && tag == "horizon:mace"
}
}
#[test]
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
let components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":0}),
)]);
assert_eq!(
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
Some(true)
);
assert_eq!(
matches(
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
&components,
&NoEnv
),
Some(false)
);
}
#[test]
fn enchantment_selectors_use_the_hosts_registry_tags() {
let condition = json!({"predicate":"enchantments", "value":[
{"enchantments":"#horizon:mace", "levels":{"min":3}}
]});
let predicate = decode(condition.as_object().unwrap()).unwrap();
let mut components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":3}),
)]);
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
components.insert(
"minecraft:enchantments".into(),
json!({"minecraft:density":2}),
);
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
}
}
-216
View File
@@ -335,177 +335,6 @@ pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
}
/// A JOML `Matrix3f`: `m[column][row]`.
type Mat3 = [[f32; 3]; 3];
/// `Matrix3f.mul`: `a * b`.
fn mul3(a: &Mat3, b: &Mat3) -> Mat3 {
std::array::from_fn(|c| {
std::array::from_fn(|r| fma(a[0][r], b[c][0], fma(a[1][r], b[c][1], a[2][r] * b[c][2])))
})
}
/// `Matrix3f.transpose`.
fn transpose3(m: &Mat3) -> Mat3 {
std::array::from_fn(|c| std::array::from_fn(|r| m[r][c]))
}
/// `Matrix3f.rotate(Quaternionfc)`: `m * R(q)`, without fma.
fn rotate3(m: &Mat3, q: [f32; 4]) -> Mat3 {
let [x, y, z, w] = q;
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
let (zw, xy, xz, yw, yz, xw) = (z * w, x * y, x * z, y * w, y * z, x * w);
let (zw, xy, xz, yw, yz, xw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
let rotation = [
[w2 + x2 - z2 - y2, xy + zw, xz - yw],
[xy - zw, y2 - z2 + w2 - x2, yz + xw],
[yw + xz, yz - xw, z2 - y2 - x2 + w2],
];
std::array::from_fn(|c| {
let rc = rotation[c];
std::array::from_fn(|r| m[0][r] * rc[0] + m[1][r] * rc[1] + m[2][r] * rc[2])
})
}
/// `Quaternionf.mul`: `a * b`.
fn quaternion_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
let ([x, y, z, w], [qx, qy, qz, qw]) = (a, b);
[
fma(w, qx, fma(x, qw, fma(y, qz, -z * qy))),
fma(w, qy, fma(-x, qz, fma(y, qw, z * qx))),
fma(w, qz, fma(x, qy, fma(-y, qx, z * qw))),
fma(w, qw, fma(-x, qx, fma(-y, qy, -z * qz))),
]
}
/// `GivensParameters`: the half angle's sine and cosine.
#[derive(Clone, Copy)]
struct Givens(f32, f32);
impl Givens {
/// `GivensParameters.fromUnnormalized`.
fn unnormalized(sin_half: f32, cos_half: f32) -> Self {
let inv = 1.0 / (sin_half * sin_half + cos_half * cos_half).sqrt();
Self(inv * sin_half, inv * cos_half)
}
/// `MatrixUtil.PI_4`: `fromPositiveAngle(π / 4)`.
fn quarter_pi() -> Self {
let half = std::f32::consts::FRAC_PI_4 / 2.0;
let sin = joml_sin(half);
Self(sin, cos_from_sin(sin, half))
}
fn inverse(self) -> Self {
Self(-self.0, self.1)
}
fn cos(self) -> f32 {
self.1 * self.1 - self.0 * self.0
}
fn sin(self) -> f32 {
2.0 * self.0 * self.1
}
/// `aroundX/Y/Z(Quaternionf)` (axis 0, 1, 2).
fn quaternion(self, axis: usize) -> [f32; 4] {
let mut q = [0.0, 0.0, 0.0, self.1];
q[axis] = self.0;
q
}
/// `aroundX/Y/Z(Matrix3f)` (axis 0, 1, 2).
fn matrix(self, axis: usize) -> Mat3 {
let (cos, sin) = (self.cos(), self.sin());
let (a, b) = ((axis + 1) % 3, (axis + 2) % 3);
let mut m = [[0.0; 3]; 3];
m[axis][axis] = 1.0;
m[a][a] = cos;
m[b][b] = cos;
m[a][b] = sin;
m[b][a] = -sin;
m
}
}
/// `MatrixUtil.approxGivensQuat`.
fn approx_givens(a11: f32, a12: f32, a22: f32) -> Givens {
let g = 3.0 + 2.0 * 2.0f32.sqrt();
let (cos_half, sin_half) = (2.0 * (a11 - a22), a12);
if g * sin_half * sin_half < cos_half * cos_half {
Givens::unnormalized(sin_half, cos_half)
} else {
Givens::quarter_pi()
}
}
/// `MatrixUtil.qrGivensQuat`.
fn qr_givens(a1: f32, a2: f32) -> Givens {
let p = f64::from(a1).hypot(f64::from(a2)) as f32;
let mut sin_half = if p > 1.0e-6 { a2 } else { 0.0 };
let mut cos_half = a1.abs() + if p > 1.0e-6 { p } else { 1.0e-6 };
if a1 < 0.0 {
std::mem::swap(&mut sin_half, &mut cos_half);
}
Givens::unnormalized(sin_half, cos_half)
}
/// `MatrixUtil.stepJacobi`: the xy, xz then yz pairs, each by
/// `similarityTransform` (`R^T * m * R`).
fn step_jacobi(m: &mut Mat3, q: &mut [f32; 4]) {
let mut rotate = |m: &mut Mat3, g: Givens, axis: usize| {
*q = quaternion_mul(*q, g.quaternion(axis));
let r = g.matrix(axis);
*m = mul3(&transpose3(&r), &mul3(m, &r));
};
if m[0][1] * m[0][1] + m[1][0] * m[1][0] > 1.0e-6 {
let g = approx_givens(m[0][0], 0.5 * (m[0][1] + m[1][0]), m[1][1]);
rotate(m, g, 2);
}
if m[0][2] * m[0][2] + m[2][0] * m[2][0] > 1.0e-6 {
let g = approx_givens(m[0][0], 0.5 * (m[0][2] + m[2][0]), m[2][2]).inverse();
rotate(m, g, 1);
}
if m[1][2] * m[1][2] + m[2][1] * m[2][1] > 1.0e-6 {
let g = approx_givens(m[1][1], 0.5 * (m[1][2] + m[2][1]), m[2][2]);
rotate(m, g, 0);
}
}
/// `MatrixUtil.svdDecompose(Matrix4fc, ...)`, how `new Transformation(matrix)`
/// splits a matrix: translation, left rotation, scale, right rotation. The
/// right rotation diagonalizes `a^T * a` (`eigenvalueJacobi`, 5 steps), then
/// QR Givens rotations about z, y and x bring `a * R(right)` to the scale.
pub fn svd_decompose(m: &Mat4) -> ([f32; 3], [f32; 4], [f32; 3], [f32; 4]) {
let inv = 1.0 / m.0[3][3];
let a: Mat3 = std::array::from_fn(|c| std::array::from_fn(|r| m.0[c][r] * inv));
let mut b = mul3(&transpose3(&a), &a);
let mut right = [0.0, 0.0, 0.0, 1.0];
for _ in 0..5 {
step_jacobi(&mut b, &mut right);
}
right = normalize_quaternion(right);
let (x_small, y_small) = (f64::from(b[0][0]) < 1.0e-6, f64::from(b[1][1]) < 1.0e-6);
let mut left = [0.0, 0.0, 0.0, 1.0];
let mut r = rotate3(&a, right);
let mut qr = |r: &mut Mat3, g: Givens, axis: usize| {
left = quaternion_mul(left, g.quaternion(axis));
*r = mul3(&transpose3(&g.matrix(axis)), r);
};
let g = if x_small {
qr_givens(r[1][1], -r[1][0])
} else {
qr_givens(r[0][0], r[0][1])
};
qr(&mut r, g, 2);
let g = if x_small {
qr_givens(r[2][2], -r[2][0])
} else {
qr_givens(r[0][0], r[0][2])
};
qr(&mut r, g.inverse(), 1);
let g = if y_small {
qr_givens(r[2][2], -r[2][1])
} else {
qr_givens(r[1][1], r[1][2])
};
qr(&mut r, g, 0);
let scale = [r[0][0], r[1][1], r[2][2]];
let translation = std::array::from_fn(|i| m.0[3][i] * inv);
let [x, y, z, w] = right;
(translation, left, scale, [-x, -y, -z, w])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -526,51 +355,6 @@ mod tests {
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
}
#[test]
fn svd_decomposes_like_matrix_util() {
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
let parts = |m: [f32; 16]| {
let (t, l, s, r) = svd_decompose(&Mat4::from_row_major(m));
[bits(&t), bits(&l), bits(&s), bits(&r)]
};
// Values from MatrixUtil.svdDecompose on the 26.3 jar.
let scaled = [
2.0, 0.0, 0.0, 1.0, 0.0, 3.0, 0.0, -2.0, 0.0, 0.0, 0.5, 0.25, 0.0, 0.0, 0.0, 1.0,
];
assert_eq!(
parts(scaled),
[
vec![0x3f80_0000u32, 0xc000_0000, 0x3e80_0000],
vec![0u32, 0, 0, 0x3f80_0000],
vec![0x4000_0000u32, 0x4040_0000, 0x3f00_0000],
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
]
);
let quarter_turn = [
0.0, -1.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
];
assert_eq!(
parts(quarter_turn),
[
vec![0x3f00_0000u32, 0, 0],
vec![0u32, 0, 0x3f35_04f3, 0x3f35_04f3],
vec![0x3f7f_ffffu32, 0x3f7f_ffff, 0x3f80_0000],
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
]
);
let sheared = [
1.0, 0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.25, 0.0, 2.0, 3.0, 0.0, 0.0, 0.0, 2.0,
];
assert_eq!(
parts(sheared),
[
vec![0u32, 0, 0x3fc0_0000],
vec![0x3c03_7010u32, 0x3d38_d722, 0x3ea8_ef95, 0x3f71_60d9],
vec![0x3f23_2630u32, 0x3ec6_bdde, 0x3f81_5b1f],
vec![0xbcc5_7e11u32, 0xbda8_1e36, 0xbee2_916d, 0x3f64_860a],
]
);
}
#[test]
fn half_turn_has_exact_zeros_like_joml() {
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
-275
View File
@@ -102,74 +102,10 @@ impl ModelLibrary {
missing,
})
}
/// 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),
})
}
/// 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_chain(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 visited = BTreeSet::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_chain(name, Some(child))
}
fn resolve_chain(
&self,
name: &str,
child: Option<&BTreeMap<String, TextureSlot>>,
) -> Result<ResolvedCuboidModel> {
let mut name = identifier(name)?;
let mut visited = BTreeSet::new();
let mut chain = Vec::new();
@@ -220,9 +156,6 @@ impl ModelLibrary {
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 mut textures = BTreeMap::<String, Material>::new();
let mut unresolved_textures = BTreeSet::<String>::new();
for start in slots.keys() {
@@ -341,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();
@@ -549,23 +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"));
}
}
+6 -16
View File
@@ -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());
}
}
File diff suppressed because it is too large. Load diff
-15
View File
@@ -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
-63
View File
@@ -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*
-472
View File
@@ -1,472 +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),
};
f.allowed.push((normalise::word(word), 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.
pub until: i64,
}
#[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,
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
}
/// 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;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
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.until > 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}")
}
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;
-255
View File
@@ -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', "źżžƶẑẓẕᴢᶻ"),
];
-253
View File
@@ -1,253 +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_until(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_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(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_until(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[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());
}
@@ -494,11 +494,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
_ => string("minecraft:air"),
},
);
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
f.put(
"facing",
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
);
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
f.float("progress", 0.0);
f.flag("extending", false);
f.flag("source", false);
@@ -877,23 +873,6 @@ mod tests {
);
}
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
#[test]
fn moving_piston_fields_match_vanilla() {
let out = canon(
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
facing:5,progress:0.5f,extending:1b,source:0b}",
);
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
}
#[test]
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
let out = canon(
+93 -366
View File
@@ -1,6 +1,6 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{InlineObject, Style, Text, named_color};
use crate::text::{Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
@@ -11,18 +11,13 @@ pub fn parse_snbt(input: &str) -> Result<(Value, usize), String> {
let mut p = Parser {
s: input.as_bytes(),
i: 0,
depth: 0,
};
let v = p.value()?;
Ok((v, p.i))
}
/// Nesting deeper than NBT's own limit (`NbtAccounter`'s 512) is refused
/// rather than overflowing the stack.
const SNBT_MAX_DEPTH: usize = 512;
struct Parser<'a> {
s: &'a [u8],
i: usize,
depth: usize,
}
impl Parser<'_> {
fn ws(&mut self) {
@@ -46,22 +41,57 @@ impl Parser<'_> {
self.ws();
match self.peek() {
Some(b'{') => {
if self.depth >= SNBT_MAX_DEPTH {
return Err(format!("tag nesting too deep at {}", self.i));
self.i += 1;
let mut map = Map::new();
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
loop {
self.ws();
let key = self.string()?;
self.expect(b':')?;
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
}
_ => return Err("expected ',' or '}'".into()),
}
}
self.depth += 1;
let v = self.object();
self.depth -= 1;
v
}
Some(b'[') => {
if self.depth >= SNBT_MAX_DEPTH {
return Err(format!("tag nesting too deep at {}", self.i));
self.i += 1;
let mut list = Vec::new();
self.ws();
// Typed arrays [B;...], [I;...], [L;...].
if matches!(
self.s.get(self.i..self.i + 2),
Some([b'B' | b'I' | b'L', b';'])
) {
self.i += 2;
}
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
loop {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
}
_ => return Err("expected ',' or ']'".into()),
}
}
self.depth += 1;
let v = self.array();
self.depth -= 1;
v
}
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
Some(_) => {
@@ -75,20 +105,10 @@ impl Parser<'_> {
_ => {
let digits = word
.trim_end_matches(['b', 'B', 's', 'S', 'l', 'L', 'f', 'F', 'd', 'D']);
// Only digits (and an exponent) make a number: `nan`
// or `Infinity`, which Rust's parse would take, stays
// a string.
if digits
.bytes()
.any(|c| c.is_ascii_alphabetic() && c != b'e' && c != b'E')
{
Value::String(word)
} else {
match (digits.parse::<i64>(), digits.parse::<f64>()) {
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
_ => Value::String(word),
}
match (digits.parse::<i64>(), digits.parse::<f64>()) {
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
_ => Value::String(word),
}
}
})
@@ -96,59 +116,6 @@ impl Parser<'_> {
None => Err("expected a value".into()),
}
}
fn object(&mut self) -> Result<Value, String> {
self.i += 1;
let mut map = Map::new();
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
loop {
self.ws();
let key = self.string()?;
self.expect(b':')?;
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
}
_ => return Err("expected ',' or '}'".into()),
}
}
}
fn array(&mut self) -> Result<Value, String> {
self.i += 1;
let mut list = Vec::new();
self.ws();
// Typed arrays [B;...], [I;...], [L;...].
if matches!(
self.s.get(self.i..self.i + 2),
Some([b'B' | b'I' | b'L', b';'])
) {
self.i += 2;
}
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
loop {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
}
_ => return Err("expected ',' or ']'".into()),
}
}
}
fn unquoted(&mut self) -> String {
let start = self.i;
while let Some(c) = self.peek() {
@@ -186,73 +153,6 @@ impl Parser<'_> {
Err("unterminated string".into())
}
}
/// `click_event` / `hover_event`, or the pre-datafixer `clickEvent` /
/// `hoverEvent` names SNBT and older chat JSON still use.
fn style_event_object<'a>(
o: &'a serde_json::Map<String, Value>,
modern: &str,
legacy: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
o.get(modern)
.or_else(|| o.get(legacy))
.and_then(Value::as_object)
}
/// Style flags from JSON bools, or SNBT `1b`/`0b` which parse as numbers.
fn style_flag(v: Option<&Value>) -> Option<bool> {
match v {
Some(Value::Bool(b)) => Some(*b),
Some(Value::Number(n)) => n.as_i64().map(|i| i != 0),
_ => None,
}
}
fn untrusted_http_url(url: &str) -> bool {
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
// https only, scheme case-insensitive.
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
}
/// 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);
let legacy = || text("value");
match e.get("action").and_then(Value::as_str) {
Some("open_url") => text("url")
.or_else(legacy)
.filter(|u| untrusted_http_url(u))
.map(crate::text::ClickEvent::OpenUrl),
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.or_else(|| e.get("value").and_then(Value::as_i64))
.or_else(|| {
e.get("value")
.and_then(Value::as_str)
.and_then(|s| s.parse().ok())
})
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command")
.or_else(legacy)
.map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command")
.or_else(legacy)
.map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => text("value").map(crate::text::ClickEvent::CopyToClipboard),
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
}
}
/// The style fields of a component object.
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
@@ -262,37 +162,58 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
style.bold = style_flag(o.get("bold"));
style.italic = style_flag(o.get("italic"));
style.underlined = style_flag(o.get("underlined"));
style.strikethrough = style_flag(o.get("strikethrough"));
style.obfuscated = style_flag(o.get("obfuscated"));
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style.font = o.get("font").and_then(crate::text::font_id);
// ClickEvent.CODEC: the actions a chat line acts on here. A component
// from a server never opens a file.
if let Some(e) = style_event_object(o, "click_event", "clickEvent") {
style.click = parse_click_event(e);
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
Some("open_url") => text("url")
.filter(|u| {
u.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
})
.map(crate::text::ClickEvent::OpenUrl),
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
};
}
// HoverEvent.ShowText, ShowItem and ShowEntity.
if let Some(e) = style_event_object(o, "hover_event", "hoverEvent") {
// HoverEvent.ShowText and ShowItem.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
// Modern `value`, or the older `contents` / nested `text`.
let value = e
.get("value")
.or_else(|| e.get("contents"))
.or_else(|| e.get("text"));
if let Some(value) = value {
if let Some(value) = e.get("value") {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
Some("show_entity") if e.get("id").is_some_and(Value::is_string) => {
style.hover_entity = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
}
@@ -302,28 +223,6 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
.map(str::to_owned);
style
}
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
if let Some(packed) = value.as_i64() {
return i32::try_from(packed).ok().map(|n| n as u32);
}
let rgba = value.as_array()?;
if rgba.len() != 4 {
return None;
}
let channels = rgba
.iter()
.map(|channel| {
// ExtraCodecs.ARGB_COLOR_CODEC reads floats; ARGB.as8BitChannel
// floors each normalized channel multiplied by 255.
let value = channel.as_f64()? as f32;
(value.is_finite() && (0.0..=1.0).contains(&value))
.then_some((value * 255.0_f32).floor() as u32)
})
.collect::<Option<Vec<_>>>()?;
Some(channels[3] << 24 | channels[0] << 16 | channels[1] << 8 | channels[2])
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
@@ -364,10 +263,6 @@ pub fn to_text_with(
let style = object_style(o);
let content = if let Some(t) = contents(o) {
t
} else if let Some(object) = inline_object(o, resolve, contents) {
let mut text = Text::literal("\u{fffc}");
text.spans[0].style.inline_object = Some(object);
text
} else if let Some(t) = o.get("text") {
match t {
Value::String(s) => Text::literal(s.clone()),
@@ -404,74 +299,12 @@ pub fn to_text_with(
{
out.push(to_text_with(extra, resolve, contents).with_style(&style));
}
if o.contains_key("object") || o.contains_key("player") || o.contains_key("sprite") {
out.with_component(Value::Object(o.clone()))
} else {
out
}
out
}
Value::Null => Text::empty(),
}
}
fn inline_object(
object: &serde_json::Map<String, Value>,
resolve: &mut dyn FnMut(&str) -> Text,
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
) -> Option<InlineObject> {
let kind = object.get("object").and_then(Value::as_str);
let fallback = object
.get("fallback")
.map(|value| to_text_with(value, resolve, contents).plain());
match kind {
Some("player") | None if object.contains_key("player") => {
let profile = object.get("player")?.clone();
// PlayerSprite.defaultFallback.
let fallback = fallback.unwrap_or_else(|| {
match profile
.as_str()
.or_else(|| profile.get("name").and_then(Value::as_str))
{
Some(name) => format!("[{name} head]"),
None => "[unknown player head]".to_owned(),
}
});
Some(InlineObject::Player {
profile,
hat: object.get("hat").and_then(Value::as_bool).unwrap_or(true),
fallback,
})
}
Some("atlas") | None if object.contains_key("sprite") => {
let atlas = object
.get("atlas")
.and_then(Value::as_str)
.unwrap_or("minecraft:blocks")
.to_owned();
let sprite = object.get("sprite")?.as_str()?.to_owned();
// AtlasSprite.defaultFallback: short names, the atlas unless blocks.
let fallback = fallback.unwrap_or_else(|| {
let short = |id: &str| match id.split_once(':') {
Some(("minecraft", path)) => path.to_owned(),
Some(_) => id.to_owned(),
None => id.to_owned(),
};
if short(&atlas) == "blocks" {
format!("[{}]", short(&sprite))
} else {
format!("[{}@{}]", short(&sprite), short(&atlas))
}
});
Some(InlineObject::Atlas {
atlas,
sprite,
fallback,
})
}
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
@@ -485,61 +318,6 @@ pub fn name_text(tag: &Tag) -> Text {
mod tests {
use super::*;
#[test]
fn shadow_color_accepts_packed_and_rgba_styles() {
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
let text = to_text(&packed, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.shadow_color, Some(0xff00ff00));
assert_eq!(text.component()["shadow_color"], -16711936);
let rgba = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 1]});
let text = to_text(&rgba, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.shadow_color, Some(0xffff007f));
let translucent = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 0.5]});
let text = to_text(&translucent, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.shadow_color, Some(0x7fff007f));
let no_shadow = serde_json::json!({"text": "A", "shadow_color": 0});
let text = to_text(&no_shadow, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.shadow_color, Some(0));
}
#[test]
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
let component = serde_json::json!({
"text": "<",
"extra": [
{"object": "player", "player": {"name": "elyapluggin"}, "hat": false,
"fallback": "[head]"},
{"text": " >", "color": "gold"}
]
});
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(text.plain(), "<[head] >");
assert!(matches!(
&text.spans[1].style.inline_object,
Some(InlineObject::Player { hat: false, .. })
));
assert!(text.spans[2].style.inline_object.is_none());
assert_eq!(text.component(), component);
let sprite = serde_json::json!({"object": "atlas", "sprite": "minecraft:item/porkchop"});
let text = to_text(&sprite, &mut |_| Text::empty());
assert!(matches!(
&text.spans[0].style.inline_object,
Some(InlineObject::Atlas { sprite, .. }) if sprite == "minecraft:item/porkchop"
));
// ObjectContents.visit without a fallback: the sprite's defaultFallback.
assert_eq!(text.plain(), "[item/porkchop]");
let head = serde_json::json!({"object": "player", "player": {"name": "Steve"}});
assert_eq!(
to_text(&head, &mut |_| Text::empty()).plain(),
"[Steve head]"
);
let gui = serde_json::json!({"atlas": "gui", "sprite": "hud/heart/full"});
assert_eq!(
to_text(&gui, &mut |_| Text::empty()).plain(),
"[hud/heart/full@gui]"
);
}
#[test]
fn snbt_and_json_components() {
let input = "{text:'Hi ',color:red,extra:[{text:\"there\",bold:1b}]} rest";
@@ -548,43 +326,10 @@ mod tests {
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.plain(), "Hi there");
assert_eq!(t.spans[1].style.color, named_color("red"));
// SNBT `1b` is a number in JSON; style flags still take it as true.
assert_eq!(t.spans[1].style.bold, Some(true));
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
}
#[test]
fn legacy_click_event_open_url_uses_value_field() {
use crate::text::ClickEvent;
let modern = serde_json::json!({
"text": "site",
"click_event": {"action": "open_url", "url": "https://example.com"},
"underlined": true
});
let legacy = serde_json::json!({
"text": "site",
"clickEvent": {"action": "open_url", "value": "https://example.com"},
"underlined": 1
});
for component in [modern, legacy] {
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(
text.spans[0].style.click,
Some(ClickEvent::OpenUrl("https://example.com".into()))
);
assert_eq!(text.spans[0].style.underlined, Some(true));
}
let snbt = r#"{text:"site",underlined:1b,clickEvent:{action:open_url,value:"https://example.com"}}"#;
let (v, _) = parse_snbt(snbt).unwrap();
let text = to_text(&v, &mut |_| Text::empty());
assert_eq!(
text.spans[0].style.click,
Some(ClickEvent::OpenUrl("https://example.com".into()))
);
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
@@ -606,22 +351,4 @@ mod tests {
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.spans[0].style.font, None);
}
#[test]
fn snbt_nesting_is_bounded() {
let nest = |n: usize| format!("{}1{}", "[".repeat(n), "]".repeat(n));
assert!(parse_snbt(&nest(512)).is_ok());
assert!(parse_snbt(&nest(513)).is_err());
assert!(parse_snbt(&nest(100_000)).is_err());
}
#[test]
fn bare_words_that_look_numeric_stay_strings() {
let (v, _) = parse_snbt("{text:nan}").unwrap();
assert_eq!(v["text"], Value::String("nan".into()));
let (v, _) = parse_snbt("{text:Infinity}").unwrap();
assert_eq!(v["text"], Value::String("Infinity".into()));
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
}
+18 -87
View File
@@ -1,68 +1,32 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
use std::{collections::BTreeMap, sync::RwLock};
/// The keys that write a symbol, with what they write on a US layout.
const SYMBOLS: [(&str, &str); 11] = [
("key.keyboard.minus", "-"),
("key.keyboard.equal", "="),
("key.keyboard.left.bracket", "["),
("key.keyboard.right.bracket", "]"),
("key.keyboard.backslash", "\\"),
("key.keyboard.semicolon", ";"),
("key.keyboard.apostrophe", "'"),
("key.keyboard.grave.accent", "`"),
("key.keyboard.comma", ","),
("key.keyboard.period", "."),
("key.keyboard.slash", "/"),
];
/// What the player's keyboard layout writes on each key that writes
/// something, by key name, as the window reported it (`learn_layout`).
static LAYOUT: RwLock<BTreeMap<String, String>> = RwLock::new(BTreeMap::new());
/// The keyboard layout writes `text` on the key `key` (`key.keyboard.q`):
/// its name from now on, as `SDL_GetKeyFromScancode` names it with SDL's
/// default keycode options. Only letter and symbol keys take it; a letter
/// key keeps its Latin letter on a non-Latin layout (`latin_letters`), and
/// the number row keeps its digits (`french_numbers`).
pub fn learn_layout(key: &str, text: &str) {
let Some(rest) = key.strip_prefix("key.keyboard.") else {
return;
};
let mut chars = text.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
return;
};
let letter_key = rest.len() == 1 && rest.as_bytes()[0].is_ascii_lowercase();
let latin = c.is_ascii() || ('\u{C0}'..='\u{24F}').contains(&c);
if c.is_control()
|| c.is_whitespace()
|| !(letter_key || SYMBOLS.iter().any(|(k, _)| *k == key))
|| (letter_key && c.is_alphabetic() && !latin)
{
return;
}
if let Ok(mut layout) = LAYOUT.write() {
layout.insert(key.to_owned(), c.to_uppercase().collect());
}
}
/// InputConstants.Key.getDisplayName: what the layout writes on the key,
/// upper-case, else the US layout's character, else by translation.
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(label) = LAYOUT.read().ok().and_then(|l| l.get(key).cloned()) {
return Text::literal(label);
}
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
if let Some((_, s)) = SYMBOLS.iter().find(|(k, _)| *k == key) {
return Text::literal(*s);
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
@@ -72,36 +36,3 @@ pub fn display_name(key: &str) -> Text {
}
Text::translate(key, &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_show_what_the_layout_writes() {
// The layout table is shared: these keys are this test's alone.
assert_eq!(display_name("key.keyboard.q").plain(), "Q");
// AZERTY: A on the Q key, M on the semicolon key, a dead ^ on the
// left bracket, a comma on the M key.
learn_layout("key.keyboard.q", "a");
learn_layout("key.keyboard.semicolon", "m");
learn_layout("key.keyboard.left.bracket", "^");
learn_layout("key.keyboard.m", ",");
assert_eq!(display_name("key.keyboard.q").plain(), "A");
assert_eq!(display_name("key.keyboard.semicolon").plain(), "M");
assert_eq!(display_name("key.keyboard.left.bracket").plain(), "^");
assert_eq!(display_name("key.keyboard.m").plain(), ",");
// The number row keeps its digits; a Cyrillic letter, nothing
// printable and keys that write nothing are left alone.
learn_layout("key.keyboard.1", "&");
learn_layout("key.keyboard.f", "а");
learn_layout("key.keyboard.g", "\u{8}");
learn_layout("key.keyboard.tab", "\t");
learn_layout("key.keyboard.h", "ab");
assert_eq!(display_name("key.keyboard.1").plain(), "1");
assert_eq!(display_name("key.keyboard.f").plain(), "F");
assert_eq!(display_name("key.keyboard.g").plain(), "G");
assert_eq!(display_name("key.keyboard.h").plain(), "H");
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
}
}
+3 -360
View File
@@ -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,25 +34,12 @@ 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);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
fn read(&self, path: &str) -> Option<Vec<u8>>;
/// Whether `path` is there, without reading it where the source can tell.
fn exists(&self, path: &str) -> bool {
self.read(path).is_some()
}
/// The namespaces under `assets/` (resource packs may add their own).
fn namespaces(&self) -> Vec<String> {
vec!["minecraft".into()]
@@ -67,43 +53,9 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
"assets/horizon/lang/zh_cn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/zh_cn.json")
}
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
"assets/horizon/lang/pl_pl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pl_pl.json")
}
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.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"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
@@ -116,9 +68,6 @@ impl ResourceSource for std::path::PathBuf {
fn read(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(self.join(path)).ok()
}
fn exists(&self, path: &str) -> bool {
self.join(path).is_file()
}
fn namespaces(&self) -> Vec<String> {
std::fs::read_dir(self.join("assets"))
.into_iter()
@@ -241,7 +190,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
for (code, info) in value["language"].as_object().into_iter().flatten() {
// Only languages whose file exists somewhere are selectable.
let file = format!("assets/minecraft/lang/{code}.json");
if code != DEFAULT && !sources.iter().any(|s| s.exists(&file)) {
if code != DEFAULT && !sources.iter().any(|s| s.read(&file).is_some()) {
continue;
}
found.insert(
@@ -264,50 +213,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)
@@ -319,19 +226,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())
@@ -358,242 +259,6 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
let zh = Language::load(&[&empty], "zh_cn");
assert_eq!(zh.get("horizon.options.controller"), Some("手柄"));
assert_eq!(zh.get("horizon.pages.mods"), Some("模组"));
assert_eq!(zh.get("Preset"), Some("预设"));
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
// Not every bundle is complete: the en_us layer is in every map, so a
// missing key falls back to English (`horizon.mods.not_built` is not
// in ru_ru either).
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
let pl = Language::load(&[&empty], "pl_pl");
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
let uk = Language::load(&[&empty], "uk_ua");
assert_eq!(
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
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"),
Some("Teclado en pantalla")
);
}
#[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]
@@ -619,26 +284,4 @@ mod tests {
);
assert_eq!(langs[1].label(), "Français (France)");
}
#[test]
fn languages_are_listed_without_reading_their_files() {
struct NoLangReads;
impl ResourceSource for NoLangReads {
fn read(&self, path: &str) -> Option<Vec<u8>> {
assert!(!path.contains("/lang/"), "read {path}");
(path == "assets/pack.mcmeta").then(|| {
br#"{"language":{"fr_fr":{"name":"French"},"de_de":{"name":"German"}}}"#
.to_vec()
})
}
fn exists(&self, path: &str) -> bool {
path == "assets/minecraft/lang/fr_fr.json"
}
}
let langs = available(&[&NoLangReads]);
assert_eq!(
langs.iter().map(|l| l.code.as_str()).collect::<Vec<_>>(),
["en_us", "fr_fr"]
);
}
}
@@ -55,12 +55,6 @@ particle_types! {
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
NoxiousGas = "noxious_gas",
NoxiousGasCloud = "noxious_gas_cloud",
Geyser = "geyser",
GeyserBase = "geyser_base",
GeyserPoof = "geyser_poof",
GeyserPlume = "geyser_plume",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
@@ -155,10 +149,6 @@ impl ParticleType {
matches!(
self,
Self::BlockMarker
| Self::Geyser
| Self::GeyserBase
| Self::GeyserPoof
| Self::GeyserPlume
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
@@ -201,8 +191,6 @@ impl ParticleType {
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// NoxiousGasParticle.
| Self::NoxiousGas
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
+7 -140
View File
@@ -9,8 +9,7 @@ pub enum Category {
Harmful,
Neutral,
}
/// A `MobEffect`: id (vanilla's without namespace, a mod's `<ns>:<path>`),
/// colour, category, instantaneous.
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
@@ -85,83 +84,13 @@ pub const EFFECTS: &[EffectDef] = &[
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
/// The effects content mods declare, registered after vanilla's.
static MOD_EFFECTS: OnceLock<Vec<EffectDef>> = OnceLock::new();
/// Registers the content mods' effects, `<ns>:<path>` ids in registry order.
/// The list is set once per process: false when it already was, and the
/// effects stay those of the first call until the game restarts.
pub fn register_mod_effects(effects: impl IntoIterator<Item = (String, u32, Category)>) -> bool {
if MOD_EFFECTS.get().is_some() {
return false;
}
let defs: Vec<EffectDef> = effects
.into_iter()
.map(|(id, color, category)| {
debug_assert!(id.contains(':'), "{id} has no namespace");
fx(Box::leak(id.into_boxed_str()), color, category)
})
.collect();
MOD_EFFECTS.set(defs).is_ok()
}
/// The content mods' effects, in registry order.
pub fn mod_effects() -> &'static [EffectDef] {
MOD_EFFECTS.get().map_or(&[], Vec::as_slice)
}
/// Every registered effect: vanilla's, then the content mods'.
pub fn all_effects() -> impl Iterator<Item = &'static EffectDef> {
EFFECTS.iter().chain(mod_effects())
}
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let short = id.trim_start_matches("minecraft:");
match EFFECTS.iter().find(|e| e.id == short) {
Some(e) => Some(e),
None => mod_effect(id),
}
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == 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)]
fn mod_effect(id: &str) -> Option<&'static EffectDef> {
mod_effects().iter().find(|e| e.id == id)
}
/// `MobEffect.getDescriptionId` (`Util.makeDescriptionId("effect", key)`):
/// `effect.<namespace>.<path>`, `/` in the path as `.`.
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.
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let key = id.strip_prefix("minecraft:").unwrap_or(id);
IDS.get_or_init(|| {
EFFECTS
.iter()
@@ -173,7 +102,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
})
.collect()
})
.get(key)
.get(id)
.copied()
.unwrap_or(id)
}
@@ -300,15 +229,10 @@ pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
effects_color(def.effects.iter().map(|(id, _, amp)| (*id, *amp)))
}
/// `PotionContents.getColorOptional` of some effects, `(id, amplifier)`, all visible: the mix of
/// their colours weighted by amplifier + 1, else the base potion colour.
pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, amp) in effects {
for (e, _, amp) in def.effects {
let Some(e) = effect(e) else { continue };
let w = amp as u32 + 1;
let w = *amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
@@ -323,60 +247,3 @@ pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
_ => BASE_POTION_COLOR,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_ids_and_sprites_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
/// amplifier + 1, the channels averaged by integer division; none gives the base colour.
#[test]
fn effects_mix_their_colours_by_amplifier() {
let c = |id: &str| effect(id).unwrap().color;
assert_eq!(effects_color([("poison", 0)].into_iter()), c("poison"));
let (p, s) = (c("poison"), c("speed"));
let mix = |shift: u32| (((p >> shift & 255) + 3 * (s >> shift & 255)) / 4) << shift;
assert_eq!(
effects_color([("poison", 0), ("speed", 2)].into_iter()),
mix(16) | mix(8) | mix(0)
);
assert_eq!(effects_color(std::iter::empty()), BASE_POTION_COLOR);
}
/// The only test that registers: the registry is set once per process.
#[test]
fn mod_effects_follow_vanilla_once_registered() {
assert!(register_mod_effects([(
"ruby:glow".to_owned(),
0xFF0000,
Category::Neutral
)]));
assert!(!register_mod_effects([(
"ruby:late".to_owned(),
1,
Category::Harmful
)]));
let glow = effect("ruby:glow").unwrap();
assert_eq!(
(glow.color, glow.category, glow.instant),
(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();
assert_eq!(ids.len(), EFFECTS.len() + 1);
assert_eq!(ids.last(), Some(&"ruby:glow"));
}
}
@@ -28,16 +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.
pub custom_completions: std::collections::BTreeSet<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
@@ -64,12 +54,6 @@ pub struct Registries {
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
/// entity under the crosshair (`Entity.getStringUUID`), if any.
pub selected_entities: Vec<String>,
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
pub target_point: Option<[String; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
@@ -97,55 +81,7 @@ pub struct RegistryIds {
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
/// ClientboundCustomChatCompletionsPacket.Action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomCompletionAction {
Add,
Remove,
Set,
}
impl CustomCompletionAction {
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
/// so an unknown id is `ADD`.
pub fn from_id(id: i32) -> Self {
match id {
1 => Self::Remove,
2 => Self::Set,
_ => Self::Add,
}
}
}
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
/// removes or replaces the extra chat completions.
pub fn modify_custom_completions(
set: &mut std::collections::BTreeSet<String>,
action: CustomCompletionAction,
entries: &[String],
) {
match action {
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
CustomCompletionAction::Remove => {
for e in entries {
set.remove(e);
}
}
CustomCompletionAction::Set => {
set.clear();
set.extend(entries.iter().cloned());
}
}
}
impl Registries {
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
/// command) completes, the online players' names with the server's custom
/// completions, each once.
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
self.players
.iter()
.chain(&self.custom_completions)
.map(String::as_str)
.collect()
}
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
@@ -213,48 +149,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);
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) {
+4 -63
View File
@@ -112,7 +112,6 @@ pub fn style_from_json(v: &Value) -> Style {
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
s.bold = flag("bold");
s.italic = flag("italic");
@@ -663,22 +662,10 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
}
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
}
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
let Some(team) = board.players_team(dying) else {
return true;
};
let same_team = board
.players_team(other)
.is_some_and(|t| t.name == team.name);
match team.death_message_visibility {
Visibility::Always => true,
Visibility::Never => false,
Visibility::HideForOtherTeams => same_team && other != dying,
Visibility::HideForOwnTeam => !same_team,
}
/// `ServerPlayer.die`: whether a player's death message reaches the player
/// themselves (the team broadcasts leave out the dying player).
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
}
// ---- objectives and scores ---------------------------------------------
@@ -1564,49 +1551,3 @@ mod tests {
);
}
}
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
/// name gets the `<id>.item` message, with the item's name as a third
/// argument.
pub fn death_message_args(
key: &str,
victim: &Text,
cause: &Text,
item: Option<&Text>,
) -> (String, Vec<Text>) {
match item {
Some(item) => (
format!("{key}.item"),
vec![victim.clone(), cause.clone(), item.clone()],
),
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
}
}
#[cfg(test)]
mod death_message_tests {
use super::*;
#[test]
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
let victim = Text::literal("Steve");
let cause = Text::literal("Zombie");
// DamageSource.getLocalizedDeathMessage: the item variant, with the
// item's name as the third argument.
let (key, args) = death_message_args(
"death.attack.arrow",
&victim,
&cause,
Some(&Text::literal("My Sword")),
);
assert_eq!(key, "death.attack.arrow.item");
assert_eq!(args.len(), 3);
assert_eq!(args[0].plain(), "Steve");
assert_eq!(args[1].plain(), "Zombie");
assert_eq!(args[2].plain(), "My Sword");
// Without a custom name the message keeps its two arguments.
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
assert_eq!(key, "death.attack.arrow");
assert_eq!(args.len(), 2);
}
}
+173 -2
View File
@@ -3,8 +3,179 @@
//! number formatting.
use crate::text::{self, Style, Text};
use horizon_world::nbt::Tag;
use horizon_world::snbt_text::string_of;
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
/// `Double.toString`.
pub fn java_double(v: f64) -> String {
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
}
/// `Float.toString`.
pub fn java_float(v: f32) -> String {
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
}
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
if v.is_nan() {
return "NaN".into();
}
if v.is_infinite() {
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
}
let a = v.abs();
if (1.0e-3..1.0e7).contains(&a) {
if plain.contains('.') {
plain.to_owned()
} else {
format!("{plain}.0")
}
} else {
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
let mantissa = if mantissa.contains('.') {
mantissa.to_owned()
} else {
format!("{mantissa}.0")
};
format!("{mantissa}E{exponent}")
}
}
/// `SnbtGrammar.escapeControlCharacters`.
fn escape_control(c: char) -> Option<&'static str> {
Some(match c {
'\u{8}' => "b",
'\u{c}' => "f",
'\n' => "n",
'\r' => "r",
'\t' => "t",
_ => return None,
})
}
/// `StringTag.quoteAndEscape`.
pub fn quote_and_escape(input: &str) -> String {
let mut out = String::from(" ");
let mut quote = '\0';
for c in input.chars() {
if c == '\\' {
out.push_str("\\\\");
} else if c != '"' && c != '\'' {
match escape_control(c) {
Some(e) => {
out.push('\\');
out.push_str(e);
}
None => out.push(c),
}
} else {
if quote == '\0' {
quote = if c == '"' { '\'' } else { '"' };
}
if quote == c {
out.push('\\');
}
out.push(c);
}
}
if quote == '\0' {
quote = '"';
}
out.replace_range(0..1, &quote.to_string());
out.push(quote);
out
}
fn unquoted_key(key: &str) -> bool {
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
return false;
}
let mut chars = key.chars();
let first_ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
}
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
t.to_string_lossy()
}
/// `Tag.toString` (StringTagVisitor).
pub fn to_snbt(tag: &Tag) -> String {
let mut out = String::new();
visit(tag, &mut out);
out
}
fn visit(tag: &Tag, out: &mut String) {
match tag {
Tag::End => out.push_str("END"),
Tag::Byte(v) => out.push_str(&format!("{v}b")),
Tag::Short(v) => out.push_str(&format!("{v}s")),
Tag::Int(v) => out.push_str(&v.to_string()),
Tag::Long(v) => out.push_str(&format!("{v}L")),
Tag::Float(v) => {
out.push_str(&java_float(f32::from_bits(*v)));
out.push('f');
}
Tag::Double(v) => {
out.push_str(&java_double(f64::from_bits(*v)));
out.push('d');
}
Tag::String(s) => out.push_str(&quote_and_escape(&string_of(s))),
Tag::ByteArray(a) => {
out.push_str("[B;");
out.push_str(
&a.iter()
.map(|v| format!("{}B", *v as i8))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::IntArray(a) => {
out.push_str("[I;");
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
out.push(']');
}
Tag::LongArray(a) => {
out.push_str("[L;");
out.push_str(
&a.iter()
.map(|v| format!("{v}L"))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::List(l) => {
out.push('[');
for (i, t) in l.iter().enumerate() {
if i > 0 {
out.push(',');
}
visit(t, out);
}
out.push(']');
}
Tag::Compound(c) => {
out.push('{');
let mut entries: Vec<(String, &Tag)> =
c.iter().map(|(k, v)| (string_of(k), v)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
if unquoted_key(k) {
out.push_str(k);
} else {
out.push_str(&quote_and_escape(k));
}
out.push(':');
visit(v, out);
}
out.push('}');
}
}
}
// ---- the coloured form -----------------------------------------------------
+30 -79
View File
@@ -58,34 +58,10 @@ pub enum ClickEvent {
OpenFile(String),
}
/// A non-character `minecraft:object` embedded in a text component.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InlineObject {
Player {
profile: Value,
hat: bool,
fallback: String,
},
Atlas {
atlas: String,
sprite: String,
fallback: String,
},
}
impl InlineObject {
pub fn fallback(&self) -> &str {
match self {
Self::Player { fallback, .. } | Self::Atlas { fallback, .. } => fallback,
}
}
}
/// Text style; `None` fields inherit from the enclosing style.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Style {
pub color: Option<u32>,
/// Text component `shadow_color` as packed ARGB. Zero suppresses shadows.
pub shadow_color: Option<u32>,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underlined: Option<bool>,
@@ -96,15 +72,11 @@ pub struct Style {
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object.
pub hover_entity: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
/// Content metadata for a single U+FFFC object span; never inherited.
pub inline_object: Option<InlineObject>,
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
@@ -120,7 +92,6 @@ impl Style {
/// Fill unset fields from `base` (Style.applyTo).
pub fn inherit(&mut self, base: &Style) {
self.color = self.color.or(base.color);
self.shadow_color = self.shadow_color.or(base.shadow_color);
self.bold = self.bold.or(base.bold);
self.italic = self.italic.or(base.italic);
self.underlined = self.underlined.or(base.underlined);
@@ -129,12 +100,11 @@ impl Style {
if self.click.is_none() {
self.click.clone_from(&base.click);
}
// One HoverEvent: its three kinds are inherited together, so a style
// with its own event never picks up another kind from `base`.
if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() {
if self.hover.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
self.hover_entity.clone_from(&base.hover_entity);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
@@ -367,13 +337,7 @@ impl Text {
}
/// Component.getString: the plain characters.
pub fn plain(&self) -> String {
self.spans
.iter()
.map(|s| match &s.style.inline_object {
Some(object) if s.text == "\u{fffc}" => object.fallback(),
_ => s.text.as_str(),
})
.collect()
self.spans.iter().map(|s| s.text.as_str()).collect()
}
pub fn is_empty(&self) -> bool {
self.spans.iter().all(|s| s.text.is_empty())
@@ -439,7 +403,31 @@ impl Text {
continue;
}
let Some(code) = chars.next() else { break };
let next = legacy_format(&style, code, &span.style);
let code = code.to_ascii_lowercase();
let next = if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
// Style.applyLegacyFormat: a colour clears the decorations.
Style {
color: Some(c.2),
bold: Some(false),
italic: Some(false),
underlined: Some(false),
strikethrough: Some(false),
obfuscated: Some(false),
..style.clone()
}
} else {
let mut s = style.clone();
match code {
'k' => s.obfuscated = Some(true),
'l' => s.bold = Some(true),
'm' => s.strikethrough = Some(true),
'n' => s.underlined = Some(true),
'o' => s.italic = Some(true),
'r' => s = span.style.clone(),
_ => {}
}
s
};
if next != style {
if !current.is_empty() {
out.spans.push(Span {
@@ -526,9 +514,8 @@ impl Text {
}
/// The style fields of a component's JSON.
const STYLE_KEYS: [&str; 11] = [
const STYLE_KEYS: [&str; 10] = [
"color",
"shadow_color",
"bold",
"italic",
"underlined",
@@ -549,9 +536,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
.map_or_else(|| format!("#{c:06X}"), |n| n.1.to_owned());
out.push(("color".to_owned(), name.into()));
}
if let Some(shadow) = style.shadow_color {
out.push(("shadow_color".to_owned(), Value::from(shadow as i32)));
}
for (key, flag) in [
("bold", style.bold),
("italic", style.italic),
@@ -594,10 +578,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
} else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() {
let mut event = entity.clone();
event.insert("action".into(), "show_entity".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
@@ -608,35 +588,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
out
}
/// `Style.applyLegacyFormat` for the code after a `§`, `§r` going back to
/// `reset` (`StringDecomposer.iterateFormatted`); an unknown code changes nothing.
pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
let code = code.to_ascii_lowercase();
if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
// A colour clears the decorations.
return Style {
color: Some(c.2),
bold: Some(false),
italic: Some(false),
underlined: Some(false),
strikethrough: Some(false),
obfuscated: Some(false),
..style.clone()
};
}
let mut s = style.clone();
match code {
'k' => s.obfuscated = Some(true),
'l' => s.bold = Some(true),
'm' => s.strikethrough = Some(true),
'n' => s.underlined = Some(true),
'o' => s.italic = Some(true),
'r' => s = reset.clone(),
_ => {}
}
s
}
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
/// shown verbatim.
@@ -60,19 +60,6 @@ pub fn edit_shortcut(
})
}
/// Windows reports AltGr as Ctrl plus Alt: the system presses a left Control
/// in front of the right Alt.
pub const ALTGR_IS_CTRL_ALT: bool = cfg!(windows);
/// Whether a key's text reaches the screen (`KeyboardHandler.textInput`, then
/// `charTyped` per code point). The edit modifier's shortcuts type nothing,
/// but `textInput` takes no modifiers: with AltGr held (Ctrl plus Alt where
/// `altgr_is_ctrl_alt`) the layout's third-level characters are text, as `/`
/// on ABNT2, `@`, `{` and `[` on German or the Polish letters.
pub fn types_text(edit_modifier: bool, alt: bool, altgr_is_ctrl_alt: bool) -> bool {
!edit_modifier || (altgr_is_ctrl_alt && alt)
}
/// `StringUtil.isAllowedChatCharacter`.
pub fn is_allowed_chat_character(c: char) -> bool {
c != '§' && c >= ' ' && c != '\u{7f}'
@@ -316,22 +303,6 @@ pub fn append_limited(value: &mut String, text: &str, max_chars: usize) -> bool
mod tests {
use super::*;
#[test]
fn altgr_types_text_where_it_is_ctrl_alt() {
// Plain keys and Shift type.
assert!(types_text(false, false, true));
assert!(types_text(false, false, false));
// Ctrl (Cmd) shortcuts do not.
assert!(!types_text(true, false, true));
assert!(!types_text(true, false, false));
// AltGr on Windows is Ctrl+Alt: ABNT2 `/`, German `@` type.
assert!(types_text(true, true, true));
// Where AltGr is its own modifier, Ctrl+Alt is a shortcut.
assert!(!types_text(true, true, false));
// Alt alone (macOS Option, Linux AltGr) types.
assert!(types_text(false, true, false));
}
#[test]
fn shortcuts_need_the_edit_modifier_alone() {
let c = Some('c');
@@ -567,34 +567,6 @@ fn length(v: [f32; 2]) -> f32 {
(v[0] * v[0] + v[1] * v[1]).sqrt()
}
/// 8BitDo Ultimate 2 Wireless (`2dc8:6012`). gilrs' bundled SDL DB only has a
/// Windows row for it; on Linux/Bluetooth the right stick is on ABS_Z/ABS_RX
/// (`LeftZ`/`RightStickX`) until an SDL mapping matches.
pub const BITDO_ULTIMATE2_VID_PID: (u16, u16) = (0x2dc8, 0x6012);
/// Stick axes after correcting pads whose right stick gilrs still exposes on
/// `LeftZ`/`RightStickX` (no matching SDL row for this device version).
/// `sdl_mapped` is true when gilrs already applied an SDL gamecontroller row.
pub fn stick_axes(
left: [f32; 2],
right: [f32; 2],
left_z: f32,
vendor: Option<u16>,
product: Option<u16>,
sdl_mapped: bool,
) -> ([f32; 2], [f32; 2]) {
if !sdl_mapped
&& vendor == Some(BITDO_ULTIMATE2_VID_PID.0)
&& product == Some(BITDO_ULTIMATE2_VID_PID.1)
{
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
// is the right trigger and must not drive look.
(left, [left_z, right[0]])
} else {
(left, right)
}
}
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
/// clipped and the first movement past the deadzone is small.
@@ -1206,38 +1178,6 @@ mod tests {
assert_eq!(odd, PadSettings::default());
}
#[test]
fn ultimate2_right_stick_on_z_without_sdl() {
let (left, right) = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
false,
);
assert_eq!(left, [0.2, 0.3]);
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
let kept = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
true,
);
assert_eq!(kept, ([0.2, 0.3], [0.7, -0.9]), "SDL row already remapped");
let other = stick_axes(
[0.0, 0.0],
[1.0, 0.0],
0.5,
Some(0x045e),
Some(0x028e),
false,
);
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
-45
View File
@@ -2,9 +2,6 @@
//! Item-use and sneak multipliers come from the player's current state; this
//! module does not implement sprint eligibility, poses or the input lifecycle.
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
fn length(v: [f32; 2]) -> f32 {
f64::from(v[0] * v[0] + v[1] * v[1]).sqrt() as f32
}
@@ -68,45 +65,3 @@ fn square_movement(v: [f32; 2]) -> [f32; 2] {
},
)
}
/// `InputQuirks.isQuitShortcutDown`: checks whether the platform's window-closing
/// keyboard shortcut is held.
/// - On macOS: Command (`super_key()`) + `Q`.
/// - On Windows / Linux / other: Alt (`alt_key()`) + `F4`.
pub fn is_quit_shortcut_down(modifiers: ModifiersState, keys: &HashSet<KeyCode>) -> bool {
if cfg!(target_os = "macos") {
modifiers.super_key() && keys.contains(&KeyCode::KeyQ)
} else {
modifiers.alt_key() && keys.contains(&KeyCode::F4)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quit_shortcut_follows_platform_modifiers_and_keys() {
let mut keys = HashSet::new();
let alt = ModifiersState::ALT;
let super_key = ModifiersState::SUPER;
assert!(!is_quit_shortcut_down(ModifiersState::empty(), &keys));
keys.insert(KeyCode::F4);
if cfg!(target_os = "macos") {
assert!(!is_quit_shortcut_down(alt, &keys));
keys.remove(&KeyCode::F4);
keys.insert(KeyCode::KeyQ);
assert!(is_quit_shortcut_down(super_key, &keys));
assert!(!is_quit_shortcut_down(alt, &keys));
} else {
assert!(is_quit_shortcut_down(alt, &keys));
assert!(!is_quit_shortcut_down(super_key, &keys));
keys.remove(&KeyCode::F4);
keys.insert(KeyCode::KeyQ);
assert!(!is_quit_shortcut_down(alt, &keys));
assert!(!is_quit_shortcut_down(super_key, &keys));
}
}
}
@@ -13,12 +13,6 @@ pub enum Button {
}
pub trait InteractionWorld {
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
/// every click of the tick reads, even after one of them changed the
/// block it names (a creative break, then a use that replaces it).
fn pick_hit_result(&mut self);
/// The tick's clicks are done: later picks follow the crosshair again.
fn release_hit_result(&mut self);
fn using_item(&self) -> bool;
fn release_using(&mut self);
fn start_attack(&mut self) -> bool;
@@ -51,14 +45,9 @@ 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);
world.pick_hit_result();
let mut instant = false;
if world.using_item() {
if !use_down {
@@ -81,20 +70,6 @@ impl InteractionInput {
world.start_use();
}
world.continue_attack(!instant && attack_down);
world.release_hit_result();
}
}
/// 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();
}
}
@@ -111,8 +86,6 @@ mod tests {
events: Vec<&'static str>,
}
impl InteractionWorld for World {
fn pick_hit_result(&mut self) {}
fn release_hit_result(&mut self) {}
fn using_item(&self) -> bool {
self.using
}
@@ -210,67 +183,4 @@ mod tests {
input.tick(&mut world, false, false);
assert_eq!(world.events, ["release", "continue"]);
}
/// A floating block in front of the crosshair, nothing behind it, and a
/// block item in the hand of a creative player.
#[derive(Default)]
struct Column {
block: bool,
hit: Option<Option<()>>,
placed: bool,
}
impl Column {
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
fn target(&self) -> Option<()> {
self.hit.unwrap_or(self.block.then_some(()))
}
}
impl InteractionWorld for Column {
fn pick_hit_result(&mut self) {
self.hit = Some(self.target());
}
fn release_hit_result(&mut self) {
self.hit = None;
}
fn using_item(&self) -> bool {
false
}
fn release_using(&mut self) {}
fn start_attack(&mut self) -> bool {
// MultiPlayerGameMode.startDestroyBlock in creative: the
// predicted break leaves air.
if self.target().is_some() {
self.block = false;
}
!self.block
}
fn start_use(&mut self) {
// BlockItem.place: the clicked block, now air, can be replaced.
if self.target().is_some() && !self.block {
self.block = true;
self.placed = true;
}
}
fn pick(&mut self) {}
fn use_delay(&self) -> u32 {
0
}
fn continue_attack(&mut self, _: bool) {}
}
#[test]
fn attack_and_use_in_one_tick_share_the_hit_result() {
// #3006: both buttons at once replace the block the crosshair is on.
let mut input = InteractionInput::default();
let mut world = Column {
block: true,
..Default::default()
};
input.press(Button::Attack);
input.press(Button::Use);
input.tick(&mut world, false, false);
assert!(world.placed, "the use acts on the block the attack broke");
assert!(world.block);
assert!(world.hit.is_none(), "the next pick follows the crosshair");
}
}
+13 -106
View File
@@ -284,26 +284,6 @@ pub fn mouse_button(name: &str) -> Option<MouseButton> {
}
pub use horizon_client_core::key_display::display_name;
/// A key event: what the layout writes on its key, without modifiers, names
/// that key from now on (`key_display::learn_layout`), so an AZERTY player's
/// Q key shows A.
pub fn learn_layout(event: &winit::event::KeyEvent) {
use winit::keyboard::{Key, PhysicalKey};
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
let Some(name) = key_name(code) else {
return;
};
let learn = horizon_client_core::key_display::learn_layout;
match event.key_without_modifiers() {
Key::Character(text) => learn(name, &text),
Key::Dead(Some(c)) => learn(name, c.encode_utf8(&mut [0; 4])),
_ => {}
}
}
/// Current bindings by mapping name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyBinds {
@@ -320,8 +300,9 @@ impl Default for KeyBinds {
}
}
/// Each default key once, in order: two mappings that share a default key
/// (Pick Block and the spectator hotbar both on the middle button) are one
/// press of it, not two.
/// (Advancements and the debug profiler both on L) are one press of it, not
/// two (the second would close the screen the first opened). Vanilla clicks
/// both mappings, but the profiler one only acts with F3 held.
fn distinct<T: PartialEq>(keys: Vec<T>) -> Vec<T> {
let mut out = Vec::with_capacity(keys.len());
for k in keys {
@@ -387,25 +368,6 @@ impl KeyBinds {
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
/// The mappings bound to `key` that [`KeyBinds::translate_all`] follows:
/// all but the debug keys. Those act only with F3 held
/// (`KeyboardHandler.handleDebugKeys`, `debug_keys.rs` reads the physical
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
/// would otherwise turn the key back into its old gameplay meaning.
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
MAPPINGS
.iter()
.filter(|m| m.2 != "debug")
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
/// Whether a mapping (other than a debug key) whose default is `key` was
/// bound elsewhere.
fn default_moved(&self, key: &str) -> bool {
MAPPINGS
.iter()
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
}
/// Translate a physical key into the default key of the mapping it is
/// bound to, so game code written against vanilla defaults follows the
/// player's bindings. `None` means the key now does nothing; mouse
@@ -422,7 +384,7 @@ impl KeyBinds {
return vec![Ok(key)];
};
let bound: Vec<_> = self
.translated_mappings(name)
.mappings_for(name)
.filter_map(|mapping| {
let default = Self::default_of(mapping);
match key_code(default) {
@@ -435,7 +397,10 @@ impl KeyBinds {
return distinct(bound);
}
// An unbound key whose default meaning was moved elsewhere is inert.
if self.default_moved(name) {
if MAPPINGS
.iter()
.any(|(n, d, _)| *d == name && !self.is_default(n))
{
return Vec::new();
}
vec![Ok(key)]
@@ -448,7 +413,7 @@ impl KeyBinds {
pub fn translate_mouse_all(&self, button: MouseButton) -> Vec<Result<KeyCode, MouseButton>> {
let name = mouse_name(button);
let bound: Vec<_> = self
.translated_mappings(&name)
.mappings_for(&name)
.filter_map(|mapping| {
let default = Self::default_of(mapping);
match mouse_button(default) {
@@ -460,7 +425,10 @@ impl KeyBinds {
if !bound.is_empty() {
return distinct(bound);
}
if self.default_moved(&name) {
if MAPPINGS
.iter()
.any(|(n, d, _)| *d == name && !self.is_default(n))
{
return Vec::new();
}
vec![Err(button)]
@@ -483,21 +451,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 +487,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();
@@ -578,29 +508,6 @@ mod tests {
assert_eq!(k.conflicts("key.forward"), ["key.jump"]);
}
#[test]
fn a_moved_key_keeps_no_default_action() {
// #3158: the debug keys (F3+A, F3+1...) share these keys' defaults,
// and must not hand them back their gameplay meaning.
let mut k = KeyBinds::default();
k.set("key.hotbar.1", "key.keyboard.q");
assert_eq!(k.translate_all(KeyCode::Digit1), []);
assert_eq!(
k.translate_all(KeyCode::KeyQ),
[Ok(KeyCode::KeyQ), Ok(KeyCode::Digit1)]
);
k.set("key.inventory", "key.keyboard.a");
k.set("key.left", "key.keyboard.e");
assert_eq!(k.translate_all(KeyCode::KeyA), [Ok(KeyCode::KeyE)]);
assert_eq!(k.translate_all(KeyCode::KeyE), [Ok(KeyCode::KeyA)]);
k.set("key.back", "key.keyboard.down");
assert_eq!(k.translate_all(KeyCode::KeyS), []);
// Keys left alone still act, and a moved debug key changes nothing.
k.set("key.debug.fpsCharts", "key.keyboard.9");
assert_eq!(k.translate_all(KeyCode::Digit2), [Ok(KeyCode::Digit2)]);
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
}
#[test]
fn display_names() {
assert_eq!(display_name("key.keyboard.w").plain(), "W");
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
+4 -103
View File
@@ -10,10 +10,6 @@ pub struct KeyState {
released_by_screen_when_down: bool,
toggle: Option<bool>,
keyboard: bool,
/// Held down by a controller's toggle (Horizon's pad toggle-sneak, which
/// vanilla has no option for): until it lets go, the mapping toggles as
/// if its vanilla option were on, so a screen releases and restores it.
pad_toggled: bool,
}
impl KeyState {
@@ -24,7 +20,6 @@ impl KeyState {
released_by_screen_when_down: false,
toggle: None,
keyboard: true,
pad_toggled: false,
}
}
/// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key.
@@ -48,28 +43,16 @@ impl KeyState {
true
}
}
/// The mapping toggles: its option is on, or a pad toggled it down.
fn needs_toggle(&self, option: bool) -> bool {
option || self.pad_toggled
}
/// `setDown`; `needs_toggle` is the mapping's option value.
pub fn set_down(&mut self, down: bool, needs_toggle: bool) {
if self.toggle.is_some() && self.needs_toggle(needs_toggle) {
if self.toggle.is_some() && needs_toggle {
if down {
self.is_down = !self.is_down;
// Toggled back up: the pad's toggle is over.
self.pad_toggled &= self.is_down;
}
} else {
self.is_down = down;
}
}
/// A controller's toggle button: flips the mapping like `setDown(true)`
/// with its option on, whatever the option says.
pub fn pad_toggle(&mut self) {
self.is_down = !self.is_down;
self.pad_toggled = self.is_down;
}
/// `shouldSetOnIngameFocus` for a keyboard-bound, bound key.
pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool {
self.keyboard && (self.toggle.is_none() || !needs_toggle)
@@ -78,8 +61,7 @@ impl KeyState {
pub fn release(&mut self, needs_toggle: bool) {
if self.toggle.is_some() {
// ToggleKeyMapping.release does not clear pending clicks.
if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down
{
if needs_toggle && self.is_down || self.released_by_screen_when_down {
self.released_by_screen_when_down = true;
}
self.reset();
@@ -88,8 +70,7 @@ impl KeyState {
self.set_down(false, needs_toggle);
}
}
/// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen
/// that released it restores it.
/// `ToggleKeyMapping.reset`.
pub fn reset(&mut self) {
self.is_down = false;
}
@@ -102,10 +83,7 @@ impl KeyState {
let Some(restore) = self.toggle else {
return false;
};
let result = restore
&& self.needs_toggle(needs_toggle)
&& self.keyboard
&& self.released_by_screen_when_down;
let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down;
self.released_by_screen_when_down = false;
result
}
@@ -160,15 +138,6 @@ impl ToggleKeys {
_ => {}
}
}
/// A controller's toggle button bound to `slot` (the pad's toggle-sneak):
/// a press with no screen open flips the mapping and clicks it, as a
/// key press does with the mapping's toggle option on.
pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) {
if !screen_open {
self.0[slot].pad_toggle();
self.0[slot].click();
}
}
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
/// screen or overlay.
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
@@ -198,29 +167,12 @@ impl ToggleKeys {
pub fn respawn(&mut self) {
for key in &mut self.0 {
key.reset();
// As with the vanilla option, the death screen closing after
// this still restores a toggle it released.
key.pad_toggled &= key.released_by_screen_when_down;
}
}
}
#[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() {
@@ -245,57 +197,6 @@ mod tests {
keys.respawn();
assert!(!keys.is_down(SPRINT));
}
/// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes
/// back after a screen, as the vanilla option's toggle does; a
/// keyboard shift-click in the inventory does not drop it.
#[test]
fn pad_toggle_is_restored_after_a_screen() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
assert!(keys.is_down(SNEAK));
keys.screen_opened(off);
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, true, false);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(keys.is_down(SNEAK), "the pad's toggle is restored");
// Toggled back up, it stays up through the next screen.
keys.pad_toggle(SNEAK, false);
assert!(!keys.is_down(SNEAK));
keys.screen_opened(off);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
// A held key is not restored, as before.
keys.key(SNEAK, KeyAction::Press, false, false);
keys.screen_opened(off);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
}
/// After the pad's toggle is over (toggled up, or reset by a respawn
/// with no screen to restore it), the shift key holds again.
#[test]
fn pad_toggle_ends_with_its_release() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
keys.respawn();
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, false, false);
keys.key(SNEAK, KeyAction::Release, false, false);
assert!(!keys.is_down(SNEAK), "held, not toggled");
// The death screen releases it and closes after the respawn: the
// toggle comes back, as with the vanilla option.
keys.pad_toggle(SNEAK, false);
keys.screen_opened(off);
keys.respawn();
keys.screen_closed(off);
assert!(keys.is_down(SNEAK));
// A press with a screen open does nothing.
keys.pad_toggle(SNEAK, true);
assert!(keys.is_down(SNEAK));
}
#[test]
fn screen_release_restores_toggled_state() {
let mut k = KeyState::toggle(true, true);
-1
View File
@@ -10,6 +10,5 @@ pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
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));
}
}
-39
View File
@@ -342,28 +342,6 @@ pub fn mouse_position_after_grab_change(
}
}
/// `MacosUtil.setCtrlClickEmulatesRightClick`: with the option on (and only on
/// macOS), a left click made with Control held is a right click, and so is
/// its release, whatever Control does in between.
#[derive(Clone, Copy, Debug, Default)]
pub struct RightClickEmulation {
emulating: bool,
}
impl RightClickEmulation {
/// Whether the left button's `pressed` or released event is a right click.
/// `enabled` is the option on macOS.
pub fn is_right_click(&mut self, pressed: bool, control: bool, enabled: bool) -> bool {
if pressed {
self.emulating = enabled && control;
}
let right = self.emulating;
if !pressed {
self.emulating = false;
}
right
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -548,21 +526,4 @@ mod tests {
None
);
}
#[test]
fn control_click_is_a_right_click_until_released() {
let mut e = RightClickEmulation::default();
// The option off, or no Control: a left click stays one.
assert!(!e.is_right_click(true, true, false));
assert!(!e.is_right_click(false, true, false));
assert!(!e.is_right_click(true, false, true));
assert!(!e.is_right_click(false, true, true));
// Control held at the press: the press and the release are right clicks,
// even when Control was let go before the release.
assert!(e.is_right_click(true, true, true));
assert!(e.is_right_click(false, false, true));
// The next left click is its own.
assert!(!e.is_right_click(true, false, true));
assert!(!e.is_right_click(false, false, true));
}
}
+1 -307
View File
@@ -284,18 +284,12 @@ impl FramerateLimiter {
/// (`Window.updateFullscreenState` -> `Minecraft.fullscreenStateChanged`), and
/// the window is only asked to change when the option itself changes
/// (`Window.updateFullscreenIfChanged`), never re-forced to the option.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FullscreenState {
/// The state the window system last reported (`Window.fullscreen`).
actual: bool,
/// The state last asked of the window system, or adopted from it.
requested: bool,
/// `Window.exclusiveFullscreen`.
exclusive: bool,
/// `Window.preferredFullscreenVideoMode`.
mode: Option<VideoMode>,
/// `Window.dirty`: the preferred mode changed and the screen has not been left yet.
mode_dirty: bool,
}
impl FullscreenState {
@@ -304,70 +298,9 @@ impl FullscreenState {
Self {
actual: false,
requested,
exclusive: false,
mode: None,
mode_dirty: false,
}
}
/// `Window.updateFullscreenIfChanged` with the exclusive option and preferred mode: asks the
/// window to enter, leave or change its fullscreen only when one of them changed.
/// `in_video_screen` holds a changed mode back until the Video Settings screen closes
/// (`changeFullscreenVideoMode`).
pub fn apply(
&mut self,
window: &winit::window::Window,
wanted: bool,
exclusive: bool,
preferred: Option<VideoMode>,
in_video_screen: bool,
) {
let request = self.request(wanted);
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None => {}
}
}
/// `Window.setExclusiveFullscreen`: true when the option changed while fullscreen is
/// requested, so the window has to enter the other kind of fullscreen at once.
pub fn set_exclusive(&mut self, exclusive: bool) -> bool {
let changed = self.exclusive != exclusive;
self.exclusive = exclusive;
changed && self.requested
}
/// `Window.setPreferredFullscreenVideoMode`: a changed mode waits for `take_mode_change`.
pub fn set_preferred_mode(&mut self, mode: Option<VideoMode>) {
if self.mode != mode {
self.mode = mode;
self.mode_dirty = true;
}
}
/// `Window.changeFullscreenVideoMode` (the Video Settings screen closing): true once when
/// a changed mode has to be applied to a fullscreen window.
pub fn take_mode_change(&mut self) -> bool {
let apply = self.requested && self.mode_dirty;
if apply {
self.mode_dirty = false;
}
apply
}
/// `Window.updateFullscreenIfChanged`: the state to ask of the window
/// when the option `wanted` differs from what was last asked or adopted.
pub fn request(&mut self, wanted: bool) -> Option<bool> {
@@ -388,168 +321,10 @@ impl FullscreenState {
}
}
/// `VideoMode`: one display mode of a monitor, as `fullscreenResolution` in options.txt keeps it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VideoMode {
pub width: i32,
pub height: i32,
pub red_bits: i32,
pub green_bits: i32,
pub blue_bits: i32,
pub refresh_rate: f32,
}
impl VideoMode {
/// `VideoMode.read`: the JSON object `write` makes; any other string is None.
pub fn read(text: &str) -> Option<Self> {
let number = |key: &str| -> Option<f64> {
let at = text.find(&format!("\"{key}\""))? + key.len() + 2;
let rest = text[at..].trim_start().strip_prefix(':')?.trim_start();
let end = rest
.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')))
.unwrap_or(rest.len());
rest[..end].parse().ok()
};
if !text.trim_start().starts_with('{') {
return None;
}
Some(Self {
width: number("width")? as i32,
height: number("height")? as i32,
red_bits: number("red_bits")? as i32,
green_bits: number("green_bits")? as i32,
blue_bits: number("blue_bits")? as i32,
refresh_rate: number("refresh_rate")? as f32,
})
}
/// `VideoMode.write`: the Codec's JSON through Gson (a float prints as `60.0`).
pub fn write(&self) -> String {
let rate = if self.refresh_rate == self.refresh_rate.trunc() {
format!("{:.1}", self.refresh_rate)
} else {
self.refresh_rate.to_string()
};
format!(
"{{\"width\":{},\"height\":{},\"red_bits\":{},\"green_bits\":{},\"blue_bits\":{},\"refresh_rate\":{rate}}}",
self.width, self.height, self.red_bits, self.green_bits, self.blue_bits
)
}
/// `VideoMode.refreshRateLabel`: a whole rate without decimals, any other with two.
pub fn refresh_rate_label(&self) -> String {
if self.refresh_rate == self.refresh_rate.round_ties_even() {
(self.refresh_rate as i32).to_string()
} else {
format!("{:.2}", self.refresh_rate)
}
}
/// The colour depth `options.fullscreen.entry` shows.
pub fn bits(&self) -> i32 {
self.red_bits + self.green_bits + self.blue_bits
}
/// A winit mode: its depth is the total, split evenly over the channels.
pub fn from_winit(mode: &winit::monitor::VideoModeHandle) -> Self {
let depth = i32::from(mode.bit_depth());
let size = mode.size();
Self {
width: size.width as i32,
height: size.height as i32,
red_bits: depth / 3,
green_bits: depth / 3,
blue_bits: depth - 2 * (depth / 3),
refresh_rate: mode.refresh_rate_millihertz() as f32 / 1000.0,
}
}
}
/// `Monitor.videoModes`: the modes a monitor offers.
pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<VideoMode> {
monitor
.video_modes()
.map(|m| VideoMode::from_winit(&m))
.collect()
}
/// The video modes of the window's monitor, listed once per monitor. Listing them takes tens of
/// milliseconds on Windows, and settings apply at every step of a dragged slider: listing them
/// each time made the slider stutter.
#[derive(Debug)]
pub struct VideoModeList<M = winit::monitor::MonitorHandle> {
listed: Option<M>,
}
impl<M> Default for VideoModeList<M> {
fn default() -> Self {
Self { listed: None }
}
}
impl<M: PartialEq> VideoModeList<M> {
/// The modes of `monitor` when it is not the monitor listed last, else `None` (the list
/// stands). No monitor lists none.
pub fn refresh(
&mut self,
monitor: Option<M>,
list: impl FnOnce(&M) -> Vec<VideoMode>,
) -> Option<Vec<VideoMode>> {
if monitor.is_some() && monitor == self.listed {
return None;
}
let modes = monitor.as_ref().map(list).unwrap_or_default();
self.listed = monitor;
Some(modes)
}
}
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
/// the monitor's own size with the highest refresh rate and depth.
fn monitor_current_mode(
monitor: &winit::monitor::MonitorHandle,
) -> Option<winit::monitor::VideoModeHandle> {
let size = monitor.size();
monitor
.video_modes()
.filter(|m| m.size() == size)
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
}
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
pub fn fullscreen_request(
window: &winit::window::Window,
exclusive: bool,
preferred: Option<&VideoMode>,
) -> winit::window::Fullscreen {
use winit::window::Fullscreen;
if !exclusive {
return Fullscreen::Borderless(None);
}
let Some(monitor) = window
.current_monitor()
.or_else(|| window.primary_monitor())
else {
return Fullscreen::Borderless(None);
};
let wanted = preferred.and_then(|p| {
monitor
.video_modes()
.find(|m| VideoMode::from_winit(m) == *p)
});
match wanted.or_else(|| monitor_current_mode(&monitor)) {
Some(mode) => Fullscreen::Exclusive(mode),
None => Fullscreen::Borderless(None),
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let mut bytes = vec![0; reader.output_buffer_size()?];
@@ -565,63 +340,11 @@ pub fn icon() -> Option<winit::window::Icon> {
mod tests {
use super::*;
#[test]
fn video_mode_round_trips_the_options_json() {
let mode = VideoMode {
width: 1920,
height: 1080,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: 60.0,
};
let text = mode.write();
assert_eq!(
text,
r#"{"width":1920,"height":1080,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#
);
assert_eq!(VideoMode::read(&text), Some(mode));
assert_eq!(VideoMode::read("1920x1080"), None);
assert_eq!(VideoMode::read(r#"{"width":1}"#), None);
}
#[test]
fn refresh_rate_label_is_whole_or_two_decimals() {
let mut mode = VideoMode::read(
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":144.0}"#,
)
.unwrap();
assert_eq!(mode.refresh_rate_label(), "144");
mode.refresh_rate = 59.94;
assert_eq!(mode.refresh_rate_label(), "59.94");
assert_eq!(mode.bits(), 24);
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
}
#[test]
fn exclusive_fullscreen_reapplies_only_while_fullscreen_is_requested() {
let mut f = FullscreenState::new(false);
assert!(!f.set_exclusive(true));
assert!(!f.set_exclusive(true));
assert_eq!(f.request(true), Some(true));
assert!(f.set_exclusive(false));
assert!(!f.set_exclusive(false));
}
#[test]
fn a_changed_video_mode_applies_once_when_fullscreen() {
let mode = VideoMode::read(
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#,
);
let mut f = FullscreenState::new(false);
f.set_preferred_mode(mode);
assert!(!f.take_mode_change());
f.request(true);
f.set_preferred_mode(mode);
assert!(f.take_mode_change());
assert!(!f.take_mode_change());
}
#[test]
fn fullscreen_follows_the_option_only_when_it_changes() {
let mut f = FullscreenState::new(false);
@@ -705,11 +428,6 @@ mod tests {
vsync_present_mode(&[PresentMode::Fifo], false),
Some(PresentMode::Fifo)
);
// Regression guard: GLES capabilities report [Fifo, Immediate] without Mailbox.
assert_eq!(
vsync_present_mode(&[PresentMode::Fifo, PresentMode::Immediate], false),
Some(PresentMode::Immediate)
);
}
#[test]
fn vsync_on_waits_for_the_display_whatever_the_present_mode() {
@@ -730,28 +448,4 @@ mod tests {
Some(Immediate)
);
}
#[test]
fn video_modes_are_listed_again_only_for_another_monitor() {
let mode = |width| VideoMode {
width,
height: 1080,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: 60.0,
};
let mut list = VideoModeList::default();
let mut listings = 0;
let mut modes = |monitor: &i32| {
listings += 1;
vec![mode(*monitor)]
};
assert_eq!(list.refresh(Some(1), &mut modes), Some(vec![mode(1)]));
assert_eq!(list.refresh(Some(1), &mut modes), None);
assert_eq!(list.refresh(Some(2), &mut modes), Some(vec![mode(2)]));
assert_eq!(list.refresh(None, &mut modes), Some(vec![]));
assert_eq!(list.refresh(Some(2), &mut modes), Some(vec![mode(2)]));
assert_eq!(listings, 3);
}
}
-221
View File
@@ -1,221 +0,0 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
-76
View File
@@ -1,76 +0,0 @@
[package]
name = "horizon-client-render"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
base64 = "0.22"
bytemuck = { version = "1.24", features = ["derive"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
half = "2"
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha1 = "0.10"
sha2 = "0.10.9"
smallvec = { version = "1", features = ["const_new"] }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
# Sound output: horizon-client's `audio` feature.
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[lints]
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
@@ -1,151 +0,0 @@
//! Two offscreen frames presented in one window, without game resources or player data.
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
use horizon_client_render::gfx::compositor::Compositor;
use std::sync::Arc;
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
struct Frames {
window: Arc<Window>,
compositor: Compositor,
textures: [wgpu::Texture; 2],
}
impl Frames {
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
let window = Arc::new(
event_loop.create_window(
Window::default_attributes()
.with_title("Compositor: red and blue views")
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
)?,
);
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
Box::new(window.clone()),
));
let surface = instance.create_surface(window.clone())?;
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface),
..Default::default()
}))?;
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
let size = wgpu::Extent3d {
width: 320,
height: 360,
depth_or_array_layers: 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Offscreen view"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
&pixel.repeat(320 * 360),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(320 * 4),
rows_per_image: Some(360),
},
size,
);
texture
});
let compositor = Compositor::new(
surface,
&adapter,
&device,
&queue,
window.inner_size().into(),
)?;
Ok(Self {
window,
compositor,
textures,
})
}
}
#[derive(Default)]
struct Demo {
frames: Option<Frames>,
error: Option<String>,
presented: usize,
smoke: bool,
}
impl ApplicationHandler for Demo {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.frames.is_none() {
match Frames::new(event_loop) {
Ok(frames) => self.frames = Some(frames),
Err(error) => {
self.error = Some(error.to_string());
event_loop.exit();
}
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
let Some(f) = &mut self.frames else { return };
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
// Uncovered pixels are black; resize smaller to exercise clipping.
WindowEvent::RedrawRequested
if f.compositor
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
{
self.presented += 1;
if self.smoke && self.presented == 10 {
f.compositor.set_vsync(false);
f.compositor.resize(0, 0);
assert!(!f.compositor.present(&[]));
f.compositor.resize(u32::MAX, u32::MAX);
assert!(!f.compositor.present(&[]));
f.compositor.resize(400, 240);
let _ = f
.window
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
}
if self.smoke && self.presented == 20 {
println!("compositor: 20 frames, resize, suspension and clipping passed");
event_loop.exit();
}
}
_ => {}
}
}
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
if let Some(f) = &self.frames {
f.window.request_redraw();
}
}
}
fn main() -> horizon_client_render::Result<()> {
let mut demo = Demo {
smoke: std::env::args().any(|a| a == "--smoke"),
..Default::default()
};
EventLoop::new()?.run_app(&mut demo)?;
if let Some(error) = demo.error {
return Err(error.into());
}
if demo.smoke && demo.presented < 20 {
return Err("smoke test closed early".into());
}
Ok(())
}
@@ -1,436 +0,0 @@
//! How long a big resource pack takes to load and reload, on generated files only (no game
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
//! is printed as a Markdown table.
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
use horizon_client_render::{
hud::HudAtlas,
model_library::ParsedModels,
packs::{self, Pack},
sprites::Sprites,
};
use std::{
fs,
path::{Path, PathBuf},
time::Instant,
};
/// A small deterministic generator: the same files on every run and every branch.
struct Noise(u64);
impl Noise {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
}
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
/// about as well as real textures do.
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<u8> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, side, side * frames);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize);
let mut colour = [0u8; 4];
for i in 0..side * side * frames {
if i % 3 == 0 {
let n = noise.next();
colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255];
if alpha && n >> 40 & 3 == 0 {
colour[3] = 0;
}
}
pixels.extend_from_slice(&colour);
}
writer.write_image_data(&pixels).unwrap();
}
out
}
fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec<u8> {
let n = noise.next() % 4;
format!(
r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##,
h = 4 + n * 4
)
.into_bytes()
}
fn write(root: &Path, name: &str, bytes: &[u8]) {
let path = root.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535
/// entries.
#[derive(Default)]
struct ZipOut {
out: Vec<u8>,
central: Vec<u8>,
count: u16,
}
impl ZipOut {
fn put(&mut self, name: &str, body: &[u8]) {
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
let offset = u32::try_from(self.out.len()).unwrap();
let sizes = [data.len() as u32, body.len() as u32];
self.out.extend(0x0403_4b50u32.to_le_bytes());
self.out.extend([20, 0, 0, 0, 8, 0]);
self.out.extend([0; 8]);
sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes()));
self.out.extend((name.len() as u16).to_le_bytes());
self.out.extend([0; 2]);
self.out.extend(name.as_bytes());
self.out.extend(&data);
self.central.extend(0x0201_4b50u32.to_le_bytes());
self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]);
self.central.extend([0; 8]);
sizes
.iter()
.for_each(|s| self.central.extend(s.to_le_bytes()));
self.central.extend((name.len() as u16).to_le_bytes());
self.central.extend([0; 12]);
self.central.extend(offset.to_le_bytes());
self.central.extend(name.as_bytes());
self.count = self.count.checked_add(1).expect("too many zip entries");
}
fn finish(mut self, path: &Path) {
let start = self.out.len() as u32;
self.out.extend(&self.central);
self.out.extend(0x0605_4b50u32.to_le_bytes());
self.out.extend([0; 4]);
self.out.extend(self.count.to_le_bytes());
self.out.extend(self.count.to_le_bytes());
self.out.extend((self.central.len() as u32).to_le_bytes());
self.out.extend(start.to_le_bytes());
self.out.extend([0; 2]);
fs::write(path, self.out).unwrap();
}
}
const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#;
/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON
/// files (lang, sounds, fonts, atlases) every reload merges.
fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec<String> {
let mut blocks = Vec::new();
for i in 0..1100 * scale {
let name = format!("block/b{i}");
let frames = if i % 50 == 0 { 8 } else { 1 };
write(
root,
&format!("assets/minecraft/textures/{name}.png"),
&png(noise, 16, frames, i % 7 == 0),
);
if frames > 1 {
write(
root,
&format!("assets/minecraft/textures/{name}.png.mcmeta"),
br#"{"animation":{"frametime":2}}"#,
);
}
write(
root,
&format!("assets/minecraft/models/block/b{i}.json"),
&model(noise, "minecraft", &name),
);
blocks.push(format!("minecraft:{name}"));
}
for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] {
for i in 0..count * scale {
let sub = if folder == "gui" { "sprites/" } else { "" };
write(
root,
&format!(
"assets/minecraft/textures/{folder}/{sub}{}/t{i}.png",
i % 20
),
&png(noise, side, 1, true),
);
}
}
for i in 0..1500 * scale {
write(
root,
&format!("assets/minecraft/models/item/i{i}.json"),
&model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)),
);
}
let lang: String = (0..4000)
.map(|i| format!("\"key.{i}\":\"Value {i}\""))
.collect::<Vec<_>>()
.join(",");
write(
root,
"assets/minecraft/lang/en_us.json",
format!("{{{lang}}}").as_bytes(),
);
write(
root,
"assets/minecraft/sounds.json",
br#"{"a":{"sounds":["a"]}}"#,
);
write(
root,
"assets/minecraft/atlases/blocks.json",
br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#,
);
write(root, "pack.mcmeta", META);
blocks
}
/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each
/// with its own items' textures and models, its sounds and its lang files.
fn server_zip(path: &Path, scale: usize, noise: &mut Noise) {
let mut zip = ZipOut::default();
zip.put("pack.mcmeta", META);
for n in 0..190 {
let ns = format!("plugin{n}");
for i in 0..50 * scale {
let texture = format!("item/{}/t{i}", i % 5);
zip.put(
&format!("assets/{ns}/textures/{texture}.png"),
&png(noise, 32, 1, true),
);
zip.put(
&format!("assets/{ns}/models/item/{}/m{i}.json", i % 5),
&model(noise, &ns, &texture),
);
if i % 10 == 0 {
zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]);
}
}
zip.put(
&format!("assets/{ns}/lang/en_us.json"),
br#"{"item.custom":"Custom"}"#,
);
}
let lang: String = (0..2000)
.map(|i| format!("\"server.{i}\":\"Server {i}\""))
.collect::<Vec<_>>()
.join(",");
zip.put(
"assets/minecraft/lang/en_us.json",
format!("{{{lang}}}").as_bytes(),
);
zip.finish(path);
}
/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style
/// tiles beside them: what the big OptiFine packs look like on disk.
fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) {
let mut zip = ZipOut::default();
zip.put("pack.mcmeta", META);
for i in 0..1100 * scale {
zip.put(
&format!("assets/minecraft/textures/block/b{i}.png"),
&png(noise, 64, 1, i % 7 == 0),
);
if i % 4 == 0 {
for tile in 0..12 {
zip.put(
&format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"),
&png(noise, 64, 1, false),
);
}
zip.put(
&format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"),
format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(),
);
}
}
for i in 0..750 * scale {
zip.put(
&format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20),
&png(noise, 64, 1, true),
);
}
zip.finish(path);
}
fn median(mut ms: Vec<f64>) -> f64 {
ms.sort_by(f64::total_cmp);
ms[ms.len() / 2]
}
/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run.
fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 {
median(
(0..runs)
.map(|_| {
before();
let started = Instant::now();
f();
started.elapsed().as_secs_f64() * 1000.0
})
.collect(),
)
}
fn zip_pack(id: &str, path: PathBuf) -> Pack {
// Read the way `packs::folder` reads a `resourcepacks/` zip.
let dir = path.parent().unwrap().join(format!("{id}-dir"));
fs::create_dir_all(&dir).unwrap();
let at = dir.join(path.file_name().unwrap());
fs::rename(&path, &at).unwrap();
packs::folder(&dir, packs::RESOURCE_FORMAT)
.into_iter()
.next()
.unwrap_or_else(|| panic!("{id} is not a pack"))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let scale = args
.iter()
.position(|a| a == "--scale")
.and_then(|i| args.get(i + 1))
.map_or(1, |s| s.parse().unwrap());
let runs = 3;
let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
let mut noise = Noise(0x9e37_79b9_7f4a_7c15);
let vanilla_root = scratch.join("vanilla");
let blocks = vanilla(&vanilla_root, scale, &mut noise);
server_zip(&scratch.join("server.zip"), scale, &mut noise);
hd_zip(&scratch.join("hd.zip"), scale, &mut noise);
let server = zip_pack("server", scratch.join("server.zip"));
let hd = zip_pack("hd", scratch.join("hd.zip"));
let cache = scratch.join("cache");
let both = [hd.clone(), server.clone()];
let mut rows: Vec<(String, f64)> = Vec::new();
let mut row = |name: &str, ms: f64| {
eprintln!("{name}: {ms:.1} ms");
rows.push((name.to_owned(), ms));
};
let fresh = || {
let _ = fs::remove_dir_all(&cache);
};
row(
"overlay: build, both packs (cold)",
time(runs, fresh, || {
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
}),
);
let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap();
row(
"overlay: reuse, both packs (cache hit)",
time(
runs,
|| {},
|| {
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
},
),
);
// The pack screen: one pack turned off, then on again.
row(
"overlay: toggle the server pack off (new selection)",
time(
runs,
|| {
let only =
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
fs::remove_dir_all(&only).unwrap();
},
|| {
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
},
),
);
row(
"models: parse every model",
time(
runs,
|| {},
|| {
std::hint::black_box(ParsedModels::load(&root));
},
),
);
let sprite_cache = root.join("horizon-sprites.cache");
// OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does.
let tiles: Vec<String> = (0..1100 * scale)
.step_by(4)
.flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png")))
.collect();
let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| {
time(
runs,
|| {
if cold {
let _ = fs::remove_file(&sprite_cache);
}
},
|| {
let mut sprites = Sprites::with_disk_cache(&root);
if prefetch {
sprites.prefetch(&root, names.iter().map(String::as_str));
}
for name in names {
if files {
sprites.insert_file(&root, name).unwrap();
} else {
sprites.insert(&root, name).unwrap();
}
}
sprites.save_disk_cache();
sprites.finish();
std::hint::black_box(&sprites.pixels);
},
)
};
row(
"block sprites: one by one (no cache)",
sprites(true, false, &blocks, false),
);
row(
"block sprites: prefetched (no cache)",
sprites(true, true, &blocks, false),
);
row(
"block sprites: from the disk cache",
sprites(false, false, &blocks, false),
);
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
row(
"GUI atlas: decode and stitch, both packs",
time(
runs,
|| {},
|| {
if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) {
eprintln!("GUI atlas failed: {e}");
}
},
),
);
row(
"GUI atlas: vanilla alone",
time(
runs,
|| {},
|| {
let _ = HudAtlas::build(&vanilla_root);
},
),
);
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |");
println!("|---|---:|");
for (name, ms) in &rows {
println!("| {name} | {ms:.0} |");
}
let _ = fs::remove_dir_all(&scratch);
}
@@ -1,373 +0,0 @@
//! The ban notices `Gui.addInitialScreens` queues before the title screen:
//! `Minecraft.multiplayerBan()` (the account's MULTIPLAYER ban from the services
//! attributes call), the forced name change and the banned skin
//! (`ProfileResult.actions()`), and the text `BanNoticeScreens` shows for each.
//! The network calls are in `auth.rs`; this file parses what they return.
use crate::text::Text;
/// `CommonLinks.SUSPENSION_HELP`.
pub const SUSPENSION_HELP: &str = "https://aka.ms/mcjavamoderation";
/// authlib's `BanDetails` (the id is never shown).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BanDetails {
/// Epoch milliseconds the ban ends; `None` is permanent.
pub expires_ms: Option<i64>,
pub reason: Option<String>,
pub reason_message: Option<String>,
}
/// authlib's `UserProperties`, as far as the title screen reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserProperties {
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
pub servers_allowed: bool,
/// `bannedScopes.get("MULTIPLAYER")`.
pub multiplayer_ban: Option<BanDetails>,
}
impl Default for UserProperties {
/// `UserApiService.OFFLINE_PROPERTIES`: what a failed fetch falls back to.
fn default() -> Self {
Self {
servers_allowed: true,
multiplayer_ban: None,
}
}
}
/// authlib's `ProfileResult.actions()`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProfileActions {
pub forced_name_change: bool,
pub using_banned_skin: bool,
}
/// What the title screen learns about the account.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AccountStatus {
pub properties: UserProperties,
pub actions: ProfileActions,
}
impl AccountStatus {
/// `Minecraft.allowsMultiplayer` (without the `--disableMultiplayer` flag).
pub fn allows_multiplayer(&self) -> bool {
self.properties.servers_allowed
&& self.properties.multiplayer_ban.is_none()
&& !self.actions.forced_name_change
}
}
/// One of the screens `Gui.addInitialScreens` adds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Notice {
/// `BanNoticeScreens.create`.
Multiplayer(BanDetails),
/// `BanNoticeScreens.createNameBan` with the profile's name.
Name(String),
/// `BanNoticeScreens.createSkinBan`.
Skin,
}
/// `Gui.addInitialScreens`' ban part, in its order: the multiplayer ban, the
/// name ban, the skin ban.
pub fn initial_notices(status: &AccountStatus, profile_name: &str) -> Vec<Notice> {
let mut notices = Vec::new();
if let Some(ban) = &status.properties.multiplayer_ban {
notices.push(Notice::Multiplayer(ban.clone()));
}
if status.actions.forced_name_change {
notices.push(Notice::Name(profile_name.to_owned()));
}
if status.actions.using_banned_skin {
notices.push(Notice::Skin);
}
notices
}
impl Notice {
/// The screen's title (bold).
pub fn title(&self) -> Text {
let key = match self {
Self::Multiplayer(ban) if ban.expires_ms.is_some() => "gui.banned.title.temporary",
Self::Multiplayer(_) => "gui.banned.title.permanent",
Self::Name(_) => "gui.banned.name.title",
Self::Skin => "gui.banned.skin.title",
};
Text::translate(key, &[]).bold()
}
/// The screen's message; `now_ms` is `Instant.now()`.
pub fn message(&self, now_ms: i64) -> Text {
let link = || Text::literal(SUSPENSION_HELP);
match self {
Self::Multiplayer(ban) => Text::translate(
"gui.banned.description",
&[reason_text(ban), status_text(ban, now_ms), link()],
),
Self::Name(name) => Text::translate(
"gui.banned.name.description",
&[Text::colored(name.clone(), YELLOW), link()],
),
Self::Skin => Text::translate("gui.banned.skin.description", &[link()]),
}
}
}
/// `ChatFormatting.YELLOW`.
const YELLOW: u32 = 0xFFFF55;
/// `BanReason.byId`: the translation key suffix.
fn ban_reason(id: i32) -> Option<&'static str> {
Some(match id {
2 => "false_reporting",
5 => "hate_speech",
16 | 25 => "hate_terrorism_notorious_figure",
17 | 19 | 23 | 31 => "generic_violation",
21 => "harassment_or_bullying",
27 => "defamation_impersonation_false_information",
28 => "drugs",
29 => "fraud",
30 => "spam_or_advertising",
32 => "nudity_or_pornography",
33 | 35 | 36 => "sexually_inappropriate",
34 => "extreme_violence_or_gore",
53 => "imminent_harm_to_person_or_property",
_ => return None,
})
}
/// `BanNoticeScreens.getBanReasonText`.
fn reason_text(ban: &BanDetails) -> Text {
let numeric = ban
.reason
.as_deref()
.filter(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()));
let Some(reason) = numeric else {
return Text::translate("gui.banned.description.unknownreason", &[]);
};
// Integer.parseInt: past i32 the vanilla screen would throw; show the code as is.
let id = reason.parse::<i32>().ok();
let text = if let Some(key) = id.and_then(ban_reason) {
Text::translate(&format!("gui.banned.reason.{key}"), &[]).bold()
} else if let Some(message) = &ban.reason_message {
Text::translate(
"gui.banned.description.reason_id_message",
&[Text::literal(reason), Text::literal(message.clone())],
)
.bold()
} else {
Text::translate("gui.banned.description.reason_id", &[Text::literal(reason)]).bold()
};
Text::translate("gui.banned.description.reason", &[text])
}
/// `BanNoticeScreens.getBanStatusText`.
fn status_text(ban: &BanDetails, now_ms: i64) -> Text {
match ban.expires_ms {
Some(expires) => {
let duration = Text::translate(
"gui.banned.description.temporary.duration",
&[duration_text(expires - now_ms)],
)
.bold();
Text::translate("gui.banned.description.temporary", &[duration])
}
None => Text::translate("gui.banned.description.permanent", &[]).bold(),
}
}
/// `BanNoticeScreens.getBanDurationText`: days past 72 hours, else hours,
/// else minutes (`Duration.toHours` and friends truncate toward zero).
fn duration_text(millis: i64) -> Text {
let count = |n: i64| Text::literal(n.to_string());
let hours = millis / 3_600_000;
if hours > 72 {
Text::translate("gui.days", &[count(millis / 86_400_000)])
} else if hours < 1 {
Text::translate("gui.minutes", &[count(millis / 60_000)])
} else {
Text::translate("gui.hours", &[count(hours)])
}
}
/// `Instant` as authlib's gson adapter reads it: an ISO-8601 string, or epoch milliseconds.
fn parse_instant(value: &serde_json::Value) -> Option<i64> {
match value {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|t| t.timestamp_millis())
.or_else(|| s.parse().ok()),
_ => None,
}
}
/// `UserAttributesResponse` into `UserProperties` (`fetchProperties`). Bad JSON
/// is an error, which vanilla answers with the offline properties.
pub fn parse_properties(body: &str) -> Option<UserProperties> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let servers_allowed = v
.pointer("/privileges/multiplayerServer/enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let multiplayer_ban = v
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
.filter(|b| b.is_object())
.map(|b| {
let text = |k: &str| b.get(k).and_then(|s| s.as_str()).map(str::to_owned);
BanDetails {
expires_ms: b.get("expires").and_then(parse_instant),
reason: text("reason"),
reason_message: text("reasonMessage"),
}
});
Some(UserProperties {
servers_allowed,
multiplayer_ban,
})
}
/// `MinecraftProfilePropertiesResponse.profileActions` into `ProfileActionType`s.
pub fn parse_profile_actions(body: &str) -> ProfileActions {
let mut actions = ProfileActions::default();
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
for action in v
.get("profileActions")
.and_then(|a| a.as_array())
.into_iter()
.flatten()
{
match action.get("type").and_then(|t| t.as_str()) {
Some("FORCED_NAME_CHANGE") => actions.forced_name_change = true,
Some("USING_BANNED_SKIN") => actions.using_banned_skin = true,
_ => {}
}
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR: i64 = 3_600_000;
/// 2026-11-01T00:00:00Z.
const EXPIRES: i64 = 1_793_491_200_000;
#[test]
fn parses_a_multiplayer_ban() {
let body = r#"{"privileges":{"multiplayerServer":{"enabled":false}},
"banStatus":{"bannedScopes":{"MULTIPLAYER":{"banId":"6b0f0a0e-0000-4000-8000-000000000000",
"expires":"2026-11-01T00:00:00Z","reason":"21","reasonMessage":"x"}}}}"#;
let p = parse_properties(body).unwrap();
assert!(!p.servers_allowed);
let ban = p.multiplayer_ban.unwrap();
assert_eq!(ban.expires_ms, Some(EXPIRES));
assert_eq!(ban.reason.as_deref(), Some("21"));
let numeric =
parse_properties(r#"{"banStatus":{"bannedScopes":{"MULTIPLAYER":{"expires":5}}}}"#)
.unwrap();
assert_eq!(numeric.multiplayer_ban.unwrap().expires_ms, Some(5));
assert!(parse_properties("nope").is_none());
let clean = parse_properties(
r#"{"privileges":{"multiplayerServer":{"enabled":true}},"banStatus":{"bannedScopes":{}}}"#,
)
.unwrap();
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
}
#[test]
fn parses_profile_actions() {
let a = parse_profile_actions(
r#"{"id":"x","profileActions":[{"type":"FORCED_NAME_CHANGE"},{"type":"OTHER"}]}"#,
);
assert!(a.forced_name_change && !a.using_banned_skin);
let a = parse_profile_actions(r#"{"profileActions":[{"type":"USING_BANNED_SKIN"}]}"#);
assert!(!a.forced_name_change && a.using_banned_skin);
assert_eq!(parse_profile_actions("{}"), ProfileActions::default());
}
#[test]
fn queues_in_vanilla_order() {
let ban = BanDetails {
expires_ms: None,
reason: None,
reason_message: None,
};
let status = AccountStatus {
properties: UserProperties {
servers_allowed: true,
multiplayer_ban: Some(ban.clone()),
},
actions: ProfileActions {
forced_name_change: true,
using_banned_skin: true,
},
};
assert_eq!(
initial_notices(&status, "Steve"),
vec![
Notice::Multiplayer(ban),
Notice::Name("Steve".into()),
Notice::Skin
]
);
assert!(!status.allows_multiplayer());
assert!(initial_notices(&AccountStatus::default(), "Steve").is_empty());
assert!(AccountStatus::default().allows_multiplayer());
let no_servers = AccountStatus {
properties: UserProperties {
servers_allowed: false,
multiplayer_ban: None,
},
..Default::default()
};
assert!(!no_servers.allows_multiplayer());
}
/// The message's component JSON: the keys and arguments, whatever language is loaded.
fn json(notice: &Notice, now: i64) -> String {
notice.message(now).component().to_string()
}
#[test]
fn ban_text_follows_banned_screens() {
let temp = |reason: Option<&str>, message: Option<&str>| {
Notice::Multiplayer(BanDetails {
expires_ms: Some(100 * HOUR),
reason: reason.map(str::to_owned),
reason_message: message.map(str::to_owned),
})
};
assert_eq!(
temp(None, None).title().translation_key(),
Some("gui.banned.title.temporary")
);
let perm = Notice::Multiplayer(BanDetails {
expires_ms: None,
reason: Some("5".into()),
reason_message: None,
});
assert_eq!(
perm.title().translation_key(),
Some("gui.banned.title.permanent")
);
let text = json(&perm, 0);
assert!(text.contains("gui.banned.reason.hate_speech"));
assert!(text.contains("gui.banned.description.permanent"));
assert!(text.contains(SUSPENSION_HELP));
// Unknown ids show their code, with the message when there is one.
let with_message = json(&temp(Some("999"), Some("why")), 0);
assert!(with_message.contains("gui.banned.description.reason_id_message"));
assert!(with_message.contains("why"));
assert!(json(&temp(Some("999"), None), 0).contains("gui.banned.description.reason_id\""));
assert!(json(&temp(Some("abc"), None), 0).contains("gui.banned.description.unknownreason"));
// 100 hours left is past 72: days (4); less is hours; under an hour is minutes.
let duration = |now| json(&temp(None, None), now);
assert!(duration(0).contains("gui.days") && duration(0).contains("[4]"));
assert!(duration(60 * HOUR).contains("gui.hours") && duration(60 * HOUR).contains("[40]"));
let minutes = duration(100 * HOUR - 90_000);
assert!(minutes.contains("gui.minutes") && minutes.contains("[1]"));
assert!(json(&Notice::Name("Bob".into()), 0).contains("Bob"));
assert!(json(&Notice::Skin, 0).contains(SUSPENSION_HELP));
}
}
-691
View File
@@ -1,691 +0,0 @@
//! Biome colours from the pinned biome definitions and colormaps
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
use crate::Result;
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrassModifier {
None,
DarkForest,
Swamp,
}
#[derive(Clone, Debug)]
pub struct SpawnEntry {
pub entity: String,
pub weight: u32,
pub min: u32,
pub max: u32,
/// A `ConstantInt` count (sampling draws nothing).
pub constant: bool,
}
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
list.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let count = &e["count"];
let (min, max, constant) = match count {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c if c["type"].as_str() == Some("minecraft:constant") => {
let n = c["value"].as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
false,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
constant,
})
})
.collect()
}
/// `MobSpawnSettings.MobSpawnCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpawnCost {
pub energy_budget: f64,
pub charge: f64,
}
/// The `spawn_costs` of a `natural_mob_spawns` value.
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
costs
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| {
(
k.clone(),
SpawnCost {
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
charge: c["charge"].as_f64().unwrap_or(0.0),
},
)
})
.collect()
}
#[derive(Clone, Debug)]
pub struct BiomeInfo {
/// `natural_mob_spawns` by category (creature, monster, ...).
pub spawns: HashMap<String, Vec<SpawnEntry>>,
/// `natural_mob_spawns` spawn costs by entity type.
pub spawn_costs: HashMap<String, SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
/// mushroom fields).
pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
/// `temperature_modifier: frozen` (the frozen oceans).
pub frozen: bool,
pub downfall: f32,
pub precipitation: bool,
pub grass: u32,
pub modifier: GrassModifier,
pub foliage: u32,
pub dry_foliage: u32,
pub water: u32,
pub sky: Option<u32>,
/// The biome's own fog colour attribute; otherwise the dimension's applies.
pub fog: Option<u32>,
pub water_fog: u32,
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
/// with height above `sea_level + 17`.
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
use horizon_world::worldgen::biome_temperature::{
BiomeTemperatureSettings, TemperatureModifier,
};
BiomeTemperatureSettings {
base_temperature: self.temperature,
modifier: if self.frozen {
TemperatureModifier::Frozen
} else {
TemperatureModifier::None
},
has_precipitation: self.precipitation,
downfall: self.downfall,
}
.height_adjusted(pos, sea_level)
}
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
/// without precipitation, else whether it falls as snow
/// (`getHeightAdjustedTemperature` below 0.15).
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
self.precipitation
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
/// file order, and keep their ids whatever a world's data packs do; a pack's
/// new biomes follow them (`with_packs`). Those are the client's own ids:
/// the network registry's are `registry_order`'s.
#[derive(Clone)]
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
info_noise: SimplexNoise,
colormaps: Arc<Colormaps>,
/// The built-in biomes, the first of `list` unless packs replaced them.
builtin: Arc<Vec<BiomeInfo>>,
/// The data packs' biome files (new biomes and replaced built-in ones),
/// for the tables that read more of a biome than `BiomeInfo` keeps.
packs: BTreeMap<String, Arc<Value>>,
}
/// The grass, foliage and dry foliage colormaps.
struct Colormaps {
grass: Vec<u32>,
foliage: Vec<u32>,
dry: Vec<u32>,
}
fn color(v: &Value) -> Option<u32> {
match v {
Value::String(s) => u32::from_str_radix(s.trim_start_matches('#'), 16)
.ok()
.map(|c| c & 0xFF_FFFF),
Value::Number(n) => n.as_i64().map(|c| c as u32 & 0xFF_FFFF),
_ => None,
}
}
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
let info = reader.next_frame(&mut bytes)?;
let channels = info.color_type.samples();
Ok(bytes[..info.buffer_size()]
.chunks_exact(channels)
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect())
}
/// `GrassColor.get`/`FoliageColor.get`: temperature and downfall index a 256x256 map.
fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
let temperature = f64::from(temperature.clamp(0.0, 1.0));
let downfall = f64::from(downfall.clamp(0.0, 1.0)) * temperature;
let x = ((1.0 - temperature) * 255.0) as i32;
let y = ((1.0 - downfall) * 255.0) as i32;
let index = (y << 8 | x) as usize;
map.get(index).copied().unwrap_or(fallback)
}
impl BiomeInfo {
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
/// leaves out sampled from the colormaps.
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
features: v["features"]
.as_array()
.into_iter()
.flatten()
.map(|step| {
step.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.collect(),
name,
}
}
}
/// `Identifier.compareTo`: the path first, then the namespace.
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
fn split(id: &str) -> (&str, &str) {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
(path, ns)
}
split(a).cmp(&split(b))
}
impl Biomes {
pub fn load(root: &Path) -> Result<Self> {
let maps = Colormaps {
grass: colormap(root, "grass")?,
foliage: colormap(root, "foliage")?,
dry: colormap(root, "dry_foliage")?,
};
let mut list = Vec::new();
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
names.sort();
for path in names {
let name = format!(
"minecraft:{}",
path.file_stem()
.and_then(|s| s.to_str())
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
list.push(BiomeInfo::from_json(name, &v, &maps));
}
let index = list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect();
Ok(Self {
builtin: Arc::new(list.clone()),
list,
index,
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
})
}
/// The built-in biomes with a world's data packs' biome files (id →
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
/// for a built-in biome replaces it, keeping its id, and new biomes join
/// after the built-in ones, in registry order. Starts from the built-in
/// table whatever packs `self` has.
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
let mut out = self.builtin_only();
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
files.sort_by(|a, b| identifier_order(a.0, b.0));
for (name, json) in files {
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
match out.index.get(name) {
Some(&id) => out.list[usize::from(id)] = info,
None if out.list.len() < usize::from(u16::MAX) => {
out.index.insert(name.to_owned(), out.list.len() as u16);
out.list.push(info);
}
None => continue,
}
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
}
out
}
/// The built-in biomes alone: this table without what packs gave it.
pub fn builtin_only(&self) -> Self {
let mut out = self.clone();
if self.packs.is_empty() {
return out;
}
out.list = self.builtin.to_vec();
let builtin = out.list.len();
out.index.retain(|_, id| usize::from(*id) < builtin);
out.packs.clear();
out
}
/// The data pack file a world's biome `name` came from, if a pack added
/// or replaced it.
pub fn pack_file(&self, name: &str) -> Option<&Value> {
self.packs.get(name).map(|v| &**v)
}
/// Whether data packs added or replaced any biome.
pub fn has_packs(&self) -> bool {
!self.packs.is_empty()
}
/// The biome names in the network registry's order: `RegistryDataLoader`
/// sorts a registry's files by `Identifier` (path, then namespace).
pub fn registry_order(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
names.sort_by(|a, b| identifier_order(a, b));
names
}
pub fn id(&self, name: &str) -> u16 {
self.index
.get(name)
.or_else(|| self.index.get("minecraft:plains"))
.copied()
.unwrap_or(0)
}
/// Whether the game has the biome `name`.
pub fn contains(&self, name: &str) -> bool {
self.index.contains_key(name)
}
pub fn get(&self, id: u16) -> &BiomeInfo {
&self.list[usize::from(id).min(self.list.len() - 1)]
}
pub fn grass(&self, id: u16, x: f64, z: f64) -> u32 {
let b = self.get(id);
match b.modifier {
GrassModifier::None => b.grass,
GrassModifier::DarkForest => ((b.grass & 0xFE_FEFE) + 0x28_340A) >> 1,
GrassModifier::Swamp => {
if f64::from(self.info_noise.get2(x * 0.0225, z * 0.0225)) < -0.1 {
0x4C_763C
} else {
0x6A_7039
}
}
}
}
}
/// A biome's colours as a mod may change them (`native_biome_colors`), each
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
/// water.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BiomeColors {
pub grass: u32,
pub foliage: u32,
pub dry_foliage: u32,
pub water: u32,
}
impl Biomes {
/// This table with each biome's colours passed through `f`, given the
/// biome's name (`minecraft:plains`) and its colours; None when `f`
/// changed none. Bits past 0xFFFFFF are dropped.
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
let mut out = self.clone();
let mut changed = false;
for b in &mut out.list {
let before = BiomeColors {
grass: b.grass,
foliage: b.foliage,
dry_foliage: b.dry_foliage,
water: b.water,
};
let mut colors = before;
f(&b.name, &mut colors);
let after = BiomeColors {
grass: colors.grass & 0xFF_FFFF,
foliage: colors.foliage & 0xFF_FFFF,
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
water: colors.water & 0xFF_FFFF,
};
if after != before {
changed = true;
b.grass = after.grass;
b.foliage = after.foliage;
b.dry_foliage = after.dry_foliage;
b.water = after.water;
}
}
changed.then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
pub fn rgb(c: u32) -> [f32; 3] {
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
}
/// `GaussianSampler.GAUSSIAN_SAMPLE_KERNEL`.
const GAUSSIAN_KERNEL: [f64; 7] = [0.0, 1.0, 4.0, 6.0, 4.0, 1.0, 0.0];
/// `EnvironmentAttributeProbe.tick`: `GaussianSampler.sample` over the noise
/// biomes (quart cells) around `position`, in blocks. Each biome comes once
/// with its summed weight, in the order it was first sampled, as
/// `SpatialAttributeInterpolator` keeps them (zero weights included).
pub fn gaussian_weights(
position: [f64; 3],
mut biome_at_quart: impl FnMut(i32, i32, i32) -> u16,
) -> Vec<(u16, f64)> {
let p = position.map(|c| c * 0.25 - 0.5);
let integral = p.map(|c| c.floor() as i32);
let relative: [f64; 3] = std::array::from_fn(|i| p[i] - f64::from(integral[i]));
let weight = |axis: usize, i: usize| {
let (start, end) = (GAUSSIAN_KERNEL[i + 1], GAUSSIAN_KERNEL[i]);
start + relative[axis] * (end - start)
};
let mut weights: Vec<(u16, f64)> = Vec::new();
for z in 0..6 {
let wz = weight(2, z);
for x in 0..6 {
let wx = weight(0, x);
for y in 0..6 {
let wy = weight(1, y);
let biome = biome_at_quart(
integral[0] - 2 + x as i32,
integral[1] - 2 + y as i32,
integral[2] - 2 + z as i32,
);
let w = wx * wy * wz;
match weights.iter_mut().find(|(b, _)| *b == biome) {
Some((_, total)) => *total += w,
None => weights.push((biome, w)),
}
}
}
}
weights
}
/// `SpatialAttributeInterpolator.applyAttributeLayer` for an RGB colour
/// (`ARGB.srgbLerp`: a component-wise lerp of the sRGB values).
pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3]) -> [f32; 3] {
let mut result: Option<[f32; 3]> = None;
let mut accumulated = 0.0;
for &(biome, weight) in weights {
let v = value(biome);
accumulated += weight;
result = Some(match result {
None => v,
Some(r) => {
let t = (weight / accumulated) as f32;
std::array::from_fn(|i| r[i] + (v[i] - r[i]) * t)
}
});
}
result.unwrap_or([0.0; 3])
}
#[cfg(test)]
mod pack_tests {
use super::*;
use serde_json::json;
/// A built-in table of `names` (no colormaps: sampled colours fall back).
fn builtin(names: &[&str]) -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<BiomeInfo> = names
.iter()
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
.collect();
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn registry_order_is_path_then_namespace() {
use std::cmp::Ordering::*;
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
assert_eq!(identifier_order("z:a", "a:b"), Less);
assert_eq!(identifier_order("b:same", "a:same"), Greater);
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
}
#[test]
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
let hot = json!({"effects": {"grass_color": 0x123456}});
let files = [
("pack:zeta", &hot),
("minecraft:molten_peaks", &hot),
("minecraft:river", &red),
];
let world = base.with_packs(files);
// The built-in ids stay; the river takes the pack's values.
assert_eq!(world.id("minecraft:plains"), 1);
assert_eq!(world.id("minecraft:river"), 2);
assert_eq!(world.get(2).water, 0xFF0000);
assert_eq!(world.get(2).temperature, 2.0);
// New biomes follow, in registry order.
assert_eq!(world.id("minecraft:molten_peaks"), 3);
assert_eq!(world.id("pack:zeta"), 4);
assert_eq!(world.get(3).grass, 0x123456);
assert!(world.pack_file("minecraft:river").is_some());
assert!(world.pack_file("minecraft:plains").is_none());
// The network registry sorts every name by path, then namespace.
assert_eq!(
world.registry_order(),
[
"minecraft:meadow",
"minecraft:molten_peaks",
"minecraft:plains",
"minecraft:river",
"pack:zeta"
]
);
// Another world starts again from the built-in biomes.
let other = world.with_packs([]);
assert_eq!(other.list.len(), 3);
assert!(!other.contains("minecraft:molten_peaks"));
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
}
#[cfg(test)]
mod color_tests {
use super::*;
use serde_json::json;
fn biomes() -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let effects = |grass: u32, modifier: &str| {
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
"grass_color_modifier": modifier}})
};
let list = vec![
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
BiomeInfo::from_json(
"minecraft:dark_forest".into(),
&effects(0x91BD59, "dark_forest"),
&maps,
),
];
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn a_change_reaches_the_tints_the_mesher_reads() {
let autumn = biomes()
.with_colors(|name, c| {
c.foliage = 0xD2691E;
if name == "minecraft:plains" {
c.grass = 0xBFA055;
}
})
.expect("changed");
let plains = autumn.id("minecraft:plains");
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
assert_eq!(autumn.get(plains).water, 0x3F76E4);
// The dark forest's modifier still darkens its (unchanged) grass:
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
let dark = autumn.id("minecraft:dark_forest");
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
}
#[test]
fn no_change_gives_none_and_alpha_bits_are_dropped() {
assert!(biomes().with_colors(|_, _| {}).is_none());
assert!(
biomes()
.with_colors(|_, c| c.water |= 0xFF00_0000)
.is_none()
);
let blue = biomes()
.with_colors(|_, c| c.water = 0xFF00_00FF)
.expect("changed");
assert_eq!(blue.get(0).water, 0x0000FF);
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
#[test]
fn one_biome_keeps_its_colour() {
let weights = gaussian_weights([10.3, 64.0, -7.9], |_, _, _| 4);
assert_eq!(weights.len(), 1);
// The kernel sums to 16 per axis whatever the offset.
assert!((weights[0].1 - 16f64.powi(3)).abs() < 1e-9);
assert_eq!(blend_rgb(&weights, |_| [0.2, 0.4, 0.6]), [0.2, 0.4, 0.6]);
}
#[test]
fn colour_changes_gradually_across_a_boundary() {
// Biome 1 for quart x >= 0 (block x >= 0), biome 0 below.
let at = |x: f64| {
let w = gaussian_weights([x, 40.0, 0.0], |qx, _, _| u16::from(qx >= 0));
blend_rgb(&w, |b| if b == 1 { [1.0, 0.0, 0.0] } else { [0.0; 3] })[0]
};
assert_eq!(at(-12.0), 0.0);
assert_eq!(at(12.0), 1.0);
// Halfway at the boundary, rising steadily through it.
assert!((at(0.0) - 0.5).abs() < 1e-5, "{}", at(0.0));
let mut last = 0.0;
for i in -16..=16 {
let v = at(f64::from(i) * 0.5);
assert!(v >= last - 1e-6, "{v} < {last} at {i}");
last = v;
}
assert!(at(-2.0) > 0.0 && at(-2.0) < 0.5);
}
}
@@ -1,306 +0,0 @@
//! Bulk block edits: runs of blocks up columns written straight into the
//! resident sections, a section unpacked and packed once however many of its
//! blocks change, instead of a `World::set` per block. Nothing round them is
//! told (as `fill ... strict`); the light of each chunk changed is worked out
//! again whole, off the game thread, rather than block by block, and each
//! section changed is remeshed once.
use crate::chunk::index;
use crate::fast_hash::FxHashMap;
use crate::world::World;
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
/// `state` where filter `only` (if any) passes the block there.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Run {
pub x: i32,
pub z: i32,
pub y0: i32,
pub y1: i32,
pub state: u32,
pub only: Option<u16>,
}
/// What an edit changed.
#[derive(Debug, Default)]
pub struct Outcome {
/// Blocks changed.
pub changed: u64,
/// Chunks with a change.
pub chunks: usize,
/// Changes of the states `tracked` asked for (block entities, watched
/// blocks): position, previous and new state.
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
}
/// Per state answers, worked out once each.
struct Memo(Vec<u8>);
impl Memo {
#[inline]
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
let i = state as usize;
if i >= self.0.len() {
self.0.resize(i + 1, 0);
}
if self.0[i] == 0 {
self.0[i] = 1 + u8::from(ask(state));
}
self.0[i] == 2
}
}
impl World {
/// Make `runs` in order, each seeing the changes of those before it.
/// `matches(filter, state)` says whether a filter passes a state, and
/// `tracked(state)` whether its changes are reported; each is asked once
/// per state. Runs in unloaded chunks and the parts of runs outside each
/// chunk's own column (`Chunk::layout`) are left out.
///
/// Unlike `World::set`, this records nothing per block: no
/// `block_entities.state_changed` (a caller adds or removes block
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
/// for those states through `tracked`), no `block_log` entry (the chunk
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
/// paths are not recomputed for these blocks) and no `light_edits` (the
/// chunk and its neighbours are lit again whole, through `dirty_light`).
pub fn edit_runs(
&mut self,
runs: &[Run],
mut matches: impl FnMut(u16, u32) -> bool,
mut tracked: impl FnMut(u32) -> bool,
) -> Outcome {
let mut out = Outcome::default();
// the runs of each chunk, in order, and the chunks in the order they were first named
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
let mut order = Vec::new();
for (i, r) in runs.iter().enumerate() {
if r.y1 < r.y0 {
continue;
}
let key = (r.x >> 4, r.z >> 4);
by_chunk
.entry(key)
.or_insert_with(|| {
order.push(key);
Vec::new()
})
.push(i as u32);
}
let mut filter_memo: Vec<Memo> = Vec::new();
let mut tracked_memo = Memo(Vec::new());
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
let mut changed: Vec<bool> = Vec::new();
for key in order {
let Some(chunk) = self.chunks.get(&key) else {
continue;
};
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
let max_y = chunk.max_y();
let list = &by_chunk[&key];
changed.clear();
changed.resize(sections, false);
values.clear();
values.resize_with(sections, || None);
let mut any = 0u64;
for &i in list {
let r = runs[i as usize];
let (lx, lz) = (r.x & 15, r.z & 15);
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
let mut y = y0;
while y <= y1 {
let s = ((y - min_y) >> 4) as usize;
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
let vals = values[s].get_or_insert_with(|| {
let mut v = Box::new([0u32; 4096]);
chunk.sections[s].unpack(&mut v);
v
});
for yy in y..=top {
let at = index(lx, yy, lz);
let old = vals[at];
if old == r.state {
continue;
}
if let Some(f) = r.only {
let f = f as usize;
if filter_memo.len() <= f {
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
}
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
continue;
}
}
vals[at] = r.state;
changed[s] = true;
any += 1;
if tracked_memo.get(old, &mut tracked)
|| tracked_memo.get(r.state, &mut tracked)
{
out.tracked.push(((r.x, yy, r.z), old, r.state));
}
}
y = top + 1;
}
}
if any == 0 {
continue;
}
out.changed += any;
out.chunks += 1;
let chunk =
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
for s in 0..sections {
if changed[s]
&& let Some(v) = &values[s]
{
chunk.replace_blocks(s, v);
}
}
self.revision = self.revision.wrapping_add(1);
self.modified.insert(key);
if self.block_log.is_some() {
self.bulk_log.insert(key);
}
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
for ds in -1..=1 {
let sy = s as i32 + ds;
if !(0..sections as i32).contains(&sy) {
continue;
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
}
}
}
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::Chunk;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
fn world() -> World {
let mut w = World::new(BlockStateId(0), 0);
for cz in -1..=1 {
for cx in -1..=1 {
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
}
}
// stone up to y 63
for z in -16..32 {
for x in -16..32 {
for y in -64..64 {
w.set(x, y, z, BlockStateId(1)).unwrap();
}
}
}
w.dirty_light.clear();
w.dirty_sections.clear();
w.block_log = Some(Vec::new());
w
}
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
Run {
x,
z,
y0,
y1,
state,
only,
}
}
#[test]
fn runs_change_their_blocks_in_order() {
let mut w = world();
let runs = [
run(3, 4, 10, 40, 0, None),
run(3, 4, 10, 12, 5, None),
// only where it is air: over the stone, and the three blocks of 5 stay
run(3, 4, 0, 80, 7, Some(0)),
run(-1, 20, -70, 400, 9, None),
];
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
assert_eq!(w.block(3, 9, 4).0, 1);
assert_eq!(w.block(3, 11, 4).0, 5);
assert_eq!(w.block(3, 13, 4).0, 7);
assert_eq!(w.block(3, 70, 4).0, 7);
assert_eq!(w.block(3, 81, 4).0, 0);
assert_eq!(w.block(3, 0, 4).0, 1);
assert_eq!(w.block(-1, -64, 20).0, 9);
assert_eq!(w.block(-1, 319, 20).0, 9);
assert_eq!(w.block(0, 30, 20).0, 1);
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
assert_eq!(out.changed, 31 + 3 + 45 + 384);
assert_eq!(out.chunks, 2);
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
assert!(w.dirty_sections.contains(&(1, 4, 1)));
// the per-block log is left to the blocks set one at a time
assert!(w.block_log.as_ref().unwrap().is_empty());
}
#[test]
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
let mut w = world();
let out = w.edit_runs(
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|_, _| true,
|_| false,
);
assert_eq!(out.changed, 0);
assert_eq!(out.chunks, 0);
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
}
#[test]
fn tracked_states_are_reported() {
let mut w = world();
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
assert_eq!(out.tracked.len(), 6);
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
}
#[test]
fn heightmaps_follow_the_edit() {
let mut w = world();
let air = |s: u32| s == 0;
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
}
#[test]
fn runs_follow_each_chunks_own_layout() {
use crate::chunk::ColumnLayout;
// a data pack's taller dimension: -128..=511
let tall = ColumnLayout::new(-128, 640).unwrap();
let mut w = World::new(BlockStateId(0), 0);
w.chunks
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
assert_eq!(out.changed, 640);
let chunk = &w.chunks[&(0, 0)];
assert_eq!(chunk.block(2, -128, 3).0, 4);
assert_eq!(chunk.block(2, -65, 3).0, 4);
assert_eq!(chunk.block(2, 320, 3).0, 4);
assert_eq!(chunk.block(2, 511, 3).0, 4);
assert!(w.dirty_sections.contains(&(0, 39, 0)));
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
}
}
@@ -1,736 +0,0 @@
//! The world's block entities and the order they tick in: vanilla's
//! `Level.blockEntityTickers`, kept beside the block entity map so a tick
//! visits only the block entities that tick, in vanilla's order, instead of
//! scanning the whole map once per kind.
//!
//! The list follows `LevelChunk.updateBlockEntityTicker` and
//! `Level.tickBlockEntities`:
//! - a block entity whose block state has a ticker gets a slot at the end of
//! the list (through `pendingBlockEntityTickers`, so a slot made during a
//! tick first ticks on the next one);
//! - replacing the block entity at a position (`setBlockEntity`), or changing
//! its block state to another state with a ticker, keeps its slot
//! (`RebindableTickingBlockEntityWrapper.rebind`);
//! - removing it (`removeBlockEntity`), unloading its chunk
//! (`clearAllBlockEntities`) or changing its state to one without a ticker
//! (a jukebox losing its record) frees the slot; a later ticker there is
//! appended again;
//! - a loaded chunk's block entities are added in the iteration order of its
//! `Object2ObjectOpenHashMap<BlockPos, BlockEntity>`
//! (`registerAllBlockEntitiesAfterLevelLoad`), filled in the order the chunk
//! listed them.
//!
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
pub type Pos = (i32, i32, i32);
/// What a block state's block entity does each tick (`BlockState.getTicker`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Ticking {
/// No ticker, and the block has no block entity that could have one.
#[default]
No,
/// A ticker.
Yes,
/// The block's block entity, but this state has no ticker (a jukebox
/// without a record): a state change to it frees the slot.
Idle,
}
#[derive(Default)]
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
attachments: HashMap<Pos, horizon_world::attachments::Attachments>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
live: FxHashMap<Pos, u32>,
/// Positions that may need a slot, in the order vanilla would have made
/// it (`pendingBlockEntityTickers`), each with a token; decided at the
/// next promotion from the block state then.
added: Vec<(Pos, u32)>,
/// The token of each position's pending entry: an entry whose token is
/// not here any more was cancelled.
pending: FxHashMap<Pos, u32>,
next_slot: u32,
/// Whether a slot was freed since the list was last swept: otherwise
/// every slot in it is still live, unless its block entity is `out`.
freed: bool,
/// Positions whose block entity `take` took and `put` has not brought
/// back.
out: FxHashSet<Pos>,
/// Positions a loaded chunk gave a new block entity over an old one: at
/// the next tick their slot stays only if their block state ticks
/// (`updateBlockEntityTicker`: rebind, or remove without a ticker).
replaced: FxHashSet<Pos>,
/// `Ticking` by block state id; empty until the game builds it.
states: Vec<Ticking>,
/// The containers of mod blocks' block entities, by block state id;
/// built with `states`.
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
/// for the renderer: the mesher lists block entities by their template,
/// which leaves out a mod block whose template has none. One taken out
/// (`take`) stays listed until `put` or `remove`.
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
/// The positions of the block entities that draw a bounding box (structure and test
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
}
/// A structure or test instance block: the block entities whose box the frame may draw.
fn shows_box(entity: &BlockEntity) -> bool {
matches!(
entity,
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
)
}
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
(x >> 4, z >> 4)
}
/// Takes `pos` out of its chunk's mod block entity set.
fn forget_mod(mods: &mut FxHashMap<(i32, i32), FxHashSet<Pos>>, pos: Pos) {
let chunk = chunk_of(pos);
if let Some(set) = mods.get_mut(&chunk) {
set.remove(&pos);
if set.is_empty() {
mods.remove(&chunk);
}
}
}
impl std::ops::Deref for BlockEntities {
type Target = HashMap<Pos, BlockEntity>;
fn deref(&self) -> &Self::Target {
&self.map
}
}
impl<'a> IntoIterator for &'a BlockEntities {
type Item = (&'a Pos, &'a BlockEntity);
type IntoIter = std::collections::hash_map::Iter<'a, Pos, BlockEntity>;
fn into_iter(self) -> Self::IntoIter {
self.map.iter()
}
}
/// `Vec3i.hashCode`.
fn java_hash((x, y, z): Pos) -> i32 {
y.wrapping_add(z.wrapping_mul(31))
.wrapping_mul(31)
.wrapping_add(x)
}
/// `HashCommon.mix`.
fn mix(x: i32) -> usize {
let h = (x as u32).wrapping_mul(0x9E37_79B9);
(h ^ (h >> 16)) as usize
}
/// The order `values().forEach` visits a `new Object2ObjectOpenHashMap<>()`
/// keyed by these positions, put in this order (repeats keep their first
/// slot): open addressing with linear probing, a rehash to twice the size
/// past three quarters full, and iteration from the last slot down.
pub fn chunk_map_order(positions: impl IntoIterator<Item = Pos>) -> Vec<Pos> {
// HashCommon.arraySize(16, .75f), and maxFill of each size.
let mut slots: Vec<Option<Pos>> = vec![None; 32];
let max_fill = |n: usize| (n * 3).div_ceil(4).min(n - 1);
let mut size = 0;
for p in positions {
let mask = slots.len() - 1;
let mut at = mix(java_hash(p)) & mask;
let mut known = false;
while let Some(k) = slots[at] {
if k == p {
known = true;
break;
}
at = (at + 1) & mask;
}
if known {
continue;
}
slots[at] = Some(p);
// `if (size++ >= maxFill) rehash(arraySize(size + 1, f))`, which
// puts the keys back from the last slot down.
size += 1;
if size > max_fill(slots.len()) {
let n = ((size + 1) * 4).div_ceil(3).next_power_of_two();
let old = std::mem::replace(&mut slots, vec![None; n]);
for k in old.into_iter().rev().flatten() {
let mut at = mix(java_hash(k)) & (n - 1);
while slots[at].is_some() {
at = (at + 1) & (n - 1);
}
slots[at] = Some(k);
}
}
}
slots.into_iter().rev().flatten().collect()
}
impl BlockEntities {
/// Whether the ticking table has been built.
pub fn has_states(&self) -> bool {
!self.states.is_empty()
}
pub fn set_states(&mut self, states: Vec<Ticking>) {
self.states = states;
}
pub fn ticking(&self, state: u32) -> Ticking {
self.states.get(state as usize).copied().unwrap_or_default()
}
pub fn set_mod_containers(&mut self, containers: FxHashMap<u32, Arc<ModContainer>>) {
self.mod_containers = containers;
}
/// The container of a mod block's block entity in block state `state`.
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
self.mod_containers.get(&state)
}
pub fn set_mod_fluids(&mut self, fluids: FxHashMap<u32, Arc<ModFluidTanks>>) {
self.mod_fluids = fluids;
}
/// The fluid tanks of a mod block's block entity in block state `state`.
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
/// The energy buffer of a mod block's block entity in block state `state`.
pub fn mod_energy(&self, state: u32) -> Option<&Arc<ModEnergyBuffer>> {
self.mod_energy.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
self.next_slot = n.wrapping_add(1);
n
}
/// A ticker may be made for `pos` now (`updateBlockEntityTicker`).
fn add(&mut self, pos: Pos) {
let token = self.next();
self.pending.insert(pos, token);
self.added.push((pos, token));
}
/// `removeBlockEntityTicker`: no slot, made or pending.
fn cancel(&mut self, pos: &Pos) {
self.freed |= self.live.remove(pos).is_some();
self.pending.remove(pos);
}
/// Attachment map for a live block entity.
pub fn attachments_mut(
&mut self,
pos: Pos,
) -> Option<&mut horizon_world::attachments::Attachments> {
if !self.map.contains_key(&pos) && !self.out.contains(&pos) {
return None;
}
Some(self.attachments.entry(pos).or_default())
}
/// Attachment maps in a loaded chunk, for a save snapshot.
pub fn chunk_attachments(
&self,
x: i32,
z: i32,
) -> Vec<(Pos, horizon_world::attachments::Attachments)> {
self.attachments
.iter()
.filter(|(p, _)| p.0 >> 4 == x && p.2 >> 4 == z)
.map(|(p, data)| (*p, data.clone()))
.collect()
}
/// Restore a loaded block entity's attachment map.
pub fn restore_attachments(&mut self, pos: Pos, data: horizon_world::attachments::Attachments) {
if self.map.contains_key(&pos) {
self.attachments.insert(pos, data);
}
}
/// Whether any mod block entity is in the map: until one is, a block change
/// never looks one up (`LevelAccess::has_mod_block_entity`).
pub fn has_mods(&self) -> bool {
!self.mods.is_empty()
}
/// The structure and test instance blocks (a frame draws their boxes), in no order.
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
self.boxes
.iter()
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
}
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
if entity.is_some_and(shows_box) {
self.boxes.insert(pos);
} else {
self.boxes.remove(&pos);
}
}
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
/// far ones are skipped whole; the caller checks each distance).
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
let far = move |c: i32, at: f64| {
let (lo, hi) = (f64::from(c * 16), f64::from(c * 16 + 16));
at < lo - radius || at > hi + radius
};
self.mods
.iter()
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.map.get_mut(pos)
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&Pos, &mut BlockEntity)> {
self.map.iter_mut()
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut BlockEntity> {
self.map.values_mut()
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
/// `/data merge block`), where vanilla keeps the object, so its
/// attachments stay; any other replacement starts them over.
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
let same_type = |old: &BlockEntity| match (old, &entity) {
(BlockEntity::Mod(old), BlockEntity::Mod(new)) => old.id == new.id,
(old, new) => std::mem::discriminant(old) == std::mem::discriminant(new),
};
if !self.map.get(&pos).is_some_and(same_type) {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
}
if new_box {
self.boxes.insert(pos);
} else if old.as_ref().is_some_and(shows_box) {
self.boxes.remove(&pos);
}
if new_is_mod {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, pos);
}
old
}
/// The block entity at `pos`, made by `make` when there is none.
pub fn get_or_insert_with(
&mut self,
pos: Pos,
make: impl FnOnce() -> BlockEntity,
) -> &mut BlockEntity {
let absent = !self.map.contains_key(&pos);
if absent {
self.add(pos);
}
let entity = self.map.entry(pos).or_insert_with(make);
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
if absent && shows_box(entity) {
self.boxes.insert(pos);
}
entity
}
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.cancel(pos);
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, *pos);
}
self.boxes.remove(pos);
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
/// for `put` to bring it back (a tick working on it).
pub fn take(&mut self, pos: &Pos) -> Option<BlockEntity> {
let taken = self.map.remove(pos);
if taken.is_some() {
self.out.insert(*pos);
}
taken
}
/// Brings back what `take` took, in its slot.
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
self.out.remove(&pos);
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
self.note_box(pos, Some(&entity));
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
{
self.add(pos);
}
}
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
let (live, pending, freed, attachments) = (
&mut self.live,
&mut self.pending,
&mut self.freed,
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
attachments.remove(p);
*freed |= live.remove(p).is_some();
pending.remove(p);
forget_mod(mods, *p);
boxes.remove(p);
}
kept
});
}
pub fn clear(&mut self) {
self.attachments.clear();
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
self.pending.clear();
self.out.clear();
self.replaced.clear();
self.freed = false;
}
/// A loaded or generated chunk's block entities, in the order the chunk
/// lists them; they tick in its block entity map's order
/// (`registerAllBlockEntitiesAfterLevelLoad`). One replacing an entity
/// already at its position (`LevelChunk.setBlockEntity`) drops that one;
/// its slot is decided by the block state at the next tick.
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.attachments.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
}
if new_is_mod {
self.mods.entry(chunk_of(p)).or_default().insert(p);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, p);
}
}
for p in order {
self.add(p);
}
}
/// `LevelChunk.setBlockState` on a block entity's position, from state
/// `old` to `new`: a ticker made, kept or dropped.
pub fn state_changed(&mut self, pos: Pos, old: u32, new: u32) {
if self.states.is_empty() || !self.map.contains_key(&pos) {
return;
}
match self.ticking(new) {
// A new ticker, unless one was made or is pending (rebound).
Ticking::Yes => {
if self.ticking(old) != Ticking::Yes && !self.live.contains_key(&pos) {
self.add(pos);
}
}
Ticking::Idle => self.cancel(&pos),
// Another block: the game removes or replaces the block entity.
Ticking::No => {}
}
}
/// The start of `Level.tickBlockEntities`: pending slots join the list
/// and freed ones leave it. `state` reads the block state at a position.
/// Returns the slots to tick, in order; check each with `is_live` before
/// ticking it, as the ticks before it can free it.
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
for (pos, token) in std::mem::take(&mut self.added) {
if self.pending.get(&pos) != Some(&token) {
continue;
}
self.pending.remove(&pos);
if self.live.contains_key(&pos)
|| !self.map.contains_key(&pos)
|| self.ticking(state(pos)) != Ticking::Yes
{
continue;
}
let slot = self.next();
self.live.insert(pos, slot);
Arc::make_mut(&mut self.tickers).push((pos, slot));
}
for pos in std::mem::take(&mut self.replaced) {
if self.live.contains_key(&pos) && self.ticking(state(pos)) != Ticking::Yes {
self.cancel(&pos);
}
}
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
return Arc::clone(&self.tickers);
}
self.out.clear();
let (live, map) = (&mut self.live, &self.map);
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
let mine = live.get(p) == Some(slot);
if mine && !map.contains_key(p) {
live.remove(p);
}
mine && map.contains_key(p)
});
Arc::clone(&self.tickers)
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
}
/// Frees the slot at `pos` (the block entity stays).
pub fn forget(&mut self, pos: &Pos) {
self.cancel(pos);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hopper() -> BlockEntity {
BlockEntity::Chest(vec![None; 5])
}
/// States: 0 air, 1 ticks, 2 idle.
fn level() -> BlockEntities {
let mut b = BlockEntities::default();
b.set_states(vec![Ticking::No, Ticking::Yes, Ticking::Idle]);
b
}
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
.iter()
.map(|(p, _)| *p)
.collect()
}
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
found.sort();
found
}
#[test]
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
let mut b = level();
let modded = || BlockEntity::Mod(Box::default());
b.insert((1, 64, 1), modded());
b.insert((200, 64, 1), modded());
b.insert((2, 64, 2), hopper());
b.get_or_insert_with((3, 64, 3), modded);
b.load_chunk(vec![((4, 64, 4), modded()), ((5, 64, 5), hopper())]);
// Only mods, and only chunks within reach.
assert_eq!(
near(&b, 0.0, 0.0, 64.0),
[(1, 64, 1), (3, 64, 3), (4, 64, 4)]
);
assert_eq!(near(&b, 200.0, 0.0, 8.0), [(200, 64, 1)]);
// Taken out for a tick, still listed; removed or replaced, gone.
let taken = b.take(&(1, 64, 1)).unwrap();
assert_eq!(near(&b, 0.0, 0.0, 64.0).len(), 3);
b.put((1, 64, 1), taken);
b.remove(&(3, 64, 3));
b.insert((4, 64, 4), hopper());
assert_eq!(near(&b, 0.0, 0.0, 64.0), [(1, 64, 1)]);
b.retain(|p, _| p.0 != 1);
assert!(near(&b, 0.0, 0.0, 64.0).is_empty());
b.clear();
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
}
#[test]
fn attachments_follow_block_entity_lifetime() {
use horizon_world::attachments::AttachmentType;
let ty = AttachmentType {
namespace: "test",
name: "value",
default: || 0,
codec: None,
copy_on_death: false,
};
let p = (1, 64, 2);
let mut entities = level();
assert!(entities.attachments_mut(p).is_none());
entities.insert(p, hopper());
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
let held = entities.take(&p).unwrap();
entities.put(p, held);
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
let machine = |id: &str| {
BlockEntity::Mod(Box::new(
crate::chunk::block_entity::ModBlockEntityFields {
id: id.into(),
..Default::default()
}
.into(),
))
};
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, machine("test:b"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.remove(&p);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 5).unwrap();
entities.retain(|_, _| false);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
}
#[test]
fn slots_follow_vanilla_lifecycle() {
let mut b = level();
let mut s = HashMap::new();
for p in [(0, 0, 0), (1, 0, 0), (2, 0, 0)] {
s.insert(p, 1);
b.insert(p, hopper());
}
assert_eq!(order(&mut b, &s), [(0, 0, 0), (1, 0, 0), (2, 0, 0)]);
// Replacing keeps the slot; removing and placing again appends.
b.insert((0, 0, 0), hopper());
b.remove(&(1, 0, 0));
b.insert((1, 0, 0), hopper());
assert_eq!(order(&mut b, &s), [(0, 0, 0), (2, 0, 0), (1, 0, 0)]);
// Take and put keep it; an idle state frees it, ticking again appends.
let e = b.take(&(0, 0, 0)).unwrap();
b.put((0, 0, 0), e);
assert_eq!(order(&mut b, &s), [(0, 0, 0), (2, 0, 0), (1, 0, 0)]);
s.insert((2, 0, 0), 2);
b.state_changed((2, 0, 0), 1, 2);
assert_eq!(order(&mut b, &s), [(0, 0, 0), (1, 0, 0)]);
s.insert((2, 0, 0), 1);
b.state_changed((2, 0, 0), 2, 1);
assert_eq!(order(&mut b, &s), [(0, 0, 0), (1, 0, 0), (2, 0, 0)]);
// A slot freed during a tick is not live any more.
let slots = b.tickers(|p| s.get(&p).copied().unwrap_or(0));
b.retain(|p, _| *p != (1, 0, 0));
assert!(b.is_live(slots[0]) && !b.is_live(slots[1]));
// A block entity taken and never brought back loses its slot.
b.take(&(0, 0, 0));
assert_eq!(order(&mut b, &s), [(2, 0, 0)]);
assert_eq!(order(&mut b, &s), [(2, 0, 0)]);
}
#[test]
fn chunk_loads_in_open_hash_map_order() {
// A few keys: their mixed hashes over 32 slots, read from the top.
let positions = [
(0, 64, 0),
(1, 64, 0),
(0, 64, 1),
(15, -64, 15),
(3, 70, 9),
];
let slot = |p: &Pos| mix(java_hash(*p)) & 31;
let mut expected = positions.to_vec();
expected.sort_by_key(|p| std::cmp::Reverse(slot(p)));
let distinct: std::collections::HashSet<usize> = positions.iter().map(slot).collect();
assert_eq!(distinct.len(), positions.len());
assert_eq!(chunk_map_order(positions), expected);
// Past 24 keys the table doubles; every key stays, once.
let many: Vec<Pos> = (0..100).map(|i| (i % 16, i / 16, 3)).collect();
let order = chunk_map_order(many.iter().copied().chain(many.iter().copied()));
assert_eq!(order.len(), 100);
}
/// A chunk loaded over a block entity already there: the new one replaces
/// it, and keeps the old slot only while its block state ticks.
#[test]
fn loaded_chunk_replaces_block_entity_by_state() {
let mut b = level();
let mut s = HashMap::new();
let (p, q) = ((0, 0, 0), (1, 0, 0));
for pos in [p, q] {
s.insert(pos, 1);
}
// Two chunks, so the order is the order they load in.
b.load_chunk(vec![(p, hopper())]);
b.load_chunk(vec![(q, hopper())]);
assert_eq!(order(&mut b, &s), [p, q]);
// Replaced where the state ticks: the slot stays in its place.
b.load_chunk(vec![(p, hopper())]);
assert_eq!(order(&mut b, &s), [p, q]);
// Replaced where the state has no ticker: the slot is freed, and the
// new block entity is the one in the map.
s.insert(p, 2);
b.load_chunk(vec![(p, BlockEntity::Chest(vec![None; 27]))]);
assert_eq!(order(&mut b, &s), [q]);
assert!(matches!(b.get(&p), Some(BlockEntity::Chest(items)) if items.len() == 27));
// Ticking again later appends it, as any new ticker does.
s.insert(p, 1);
b.state_changed(p, 2, 1);
assert_eq!(order(&mut b, &s), [q, p]);
}
#[test]
fn boxes_list_exactly_the_structure_and_test_blocks() {
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
let listed = |b: &BlockEntities| {
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
v.sort();
v
};
let mut b = level();
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
b.insert(p, test_block());
b.insert(q, hopper());
assert_eq!(listed(&b), [p]);
// Replaced by another kind, and back.
b.insert(p, hopper());
assert_eq!(listed(&b), []);
b.insert(q, test_block());
b.get_or_insert_with(r, test_block);
assert_eq!(listed(&b), [q, r]);
// Taken out: not shown until put back; put back as another kind: gone.
let taken = b.take(&q).unwrap();
assert_eq!(listed(&b), [r]);
b.put(q, taken);
assert_eq!(listed(&b), [q, r]);
let taken = b.take(&r).unwrap();
drop(taken);
b.put(r, hopper());
assert_eq!(listed(&b), [q]);
b.remove(&q);
assert_eq!(listed(&b), []);
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
assert_eq!(listed(&b), [p]);
b.retain(|pos, _| *pos != p);
assert_eq!(listed(&b), []);
b.insert(r, test_block());
b.clear();
assert!(b.boxes.is_empty());
}
}
Loaded 100 of 2174 files, more files were not shown because too many files have changed in this diff. Show more