mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38307d15e2 | ||
|
|
096ca2e71a | ||
|
|
a729696c0d | ||
|
|
70618f3de8 | ||
|
|
7a27a549d5 | ||
|
|
80de7205ab | ||
|
|
052f9ff7f2 | ||
|
|
c1c655c4a6 | ||
|
|
1c23593656 | ||
|
|
80da69bf5b | ||
|
|
5c0d2e58c2 | ||
|
|
7c993ea789 | ||
|
|
c23588f086 | ||
|
|
c097666071 | ||
|
|
8033ef296f | ||
|
|
6a498c60bf | ||
|
|
58cd5da9c7 | ||
|
|
89c7b94af8 | ||
|
|
9200a5e623 | ||
|
|
2bed6eeaf7 |
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -2,6 +2,7 @@
|
||||
target/
|
||||
.local/
|
||||
**/node_modules/
|
||||
website/
|
||||
benchmarks/
|
||||
packs/
|
||||
mods/
|
||||
@@ -1,6 +0,0 @@
|
||||
# Generated files that tests compare byte for byte with what they generate: LF on every OS, also
|
||||
# where Git for Windows' core.autocrlf would check them out with CRLF.
|
||||
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
|
||||
+1
-10
@@ -1,11 +1,2 @@
|
||||
#!/bin/sh
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
exec python3 tools/check_public_tree.py
|
||||
+1
-10
@@ -1,11 +1,2 @@
|
||||
#!/bin/sh
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py --pre-push
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
exec python3 tools/check_public_tree.py --history
|
||||
@@ -6,6 +6,7 @@
|
||||
/.github/ @ludovic111
|
||||
/tools/ci/ @ludovic111
|
||||
/tools/release_train.py @ludovic111
|
||||
/tools/release_channels.py @ludovic111
|
||||
/crates/horizon-release-sign/ @ludovic111
|
||||
/crates/horizon-server/src/update.rs @ludovic111
|
||||
/crates/horizon-server/Cargo.toml @ludovic111
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
0.2
|
||||
@@ -1,10 +0,0 @@
|
||||
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
|
||||
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
|
||||
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
|
||||
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
|
||||
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
|
||||
kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
|
||||
@@ -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
|
||||
+104
-117
@@ -3,8 +3,7 @@ name: checks
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. A fork's pull
|
||||
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
|
||||
# never start this workflow. See docs/release-flow.md.
|
||||
on:
|
||||
@@ -19,28 +18,15 @@ concurrency:
|
||||
# A new push to a pull request replaces its running checks; main's runs all finish (releases follow them).
|
||||
group: checks-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
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 +36,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' }}
|
||||
@@ -87,95 +60,15 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request change the launcher or a crate it builds?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: launcher
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
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
|
||||
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'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check the launcher's formatting, lint and tests
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
working-directory: launcher
|
||||
run: |
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
parity-scope:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
run: ${{ steps.scope.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- id: scope
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
|
||||
run: |
|
||||
changed=$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)
|
||||
harness=$(grep -E '^(tools/test_[a-z0-9_]+_parity\.py|tools/(parity[a-z_]*|lab)\.py|tools/parity_hosts\.json|mods/horizon/src/test/|crates/[^/]+/examples/|crates/horizon-client/src/game/[a-z_]*(fixture|harness)\.rs|\.github/workflows/parity[a-z-]*\.yml)' <<< "$changed" || true)
|
||||
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "The pull request runs its harnesses: ${harness:-label run-parity}"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No harness, oracle, probe or runner change: parity-main checks this on main"
|
||||
fi
|
||||
run: cargo +stable test --workspace
|
||||
parity:
|
||||
needs: parity-scope
|
||||
if: needs.parity-scope.outputs.run == 'true'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/parity.yml
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
|
||||
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
|
||||
# (label horizon-game) holding the maintainer's own copy: the official launcher's
|
||||
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
|
||||
@@ -528,7 +421,7 @@ jobs:
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_float_log_parity.py
|
||||
jfr-analysis:
|
||||
portable-mod:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
@@ -540,7 +433,101 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Compile original mod and test metrics
|
||||
run: python tools/build_mod.py
|
||||
- name: Verify streaming JFR analysis against the JDK exporter
|
||||
run: python tools/test_jfr.py
|
||||
- name: Verify ZGC pause and allocation event interpretation
|
||||
run: python tools/test_jfr.py --gc zgc
|
||||
- name: Build pinned portable packs
|
||||
run: python tools/build_pack.py
|
||||
- name: Measure isolated worker pacing on this platform
|
||||
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
|
||||
- name: Measure isolated debounce queue polling
|
||||
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
|
||||
- name: Measure wakeable idle queue latency
|
||||
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
|
||||
- name: Measure object allocations with compact headers off and on
|
||||
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
|
||||
- name: Test pack integrity and comparisons
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
python tools/test_client_graphics.py --native-display
|
||||
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
|
||||
python tools/test_client_graphics.py --native-display --world
|
||||
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
|
||||
- name: Compile the private dispatch fixture
|
||||
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
|
||||
- name: Compile the private integrated-server graphics fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
|
||||
- name: Compile the private tick-manager state fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
|
||||
- name: Compile the private terrain tick membership fixture
|
||||
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
|
||||
- name: Compile the private culling whitelist fixture
|
||||
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
|
||||
- name: Compile the private culling ray geometry fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
|
||||
- name: Compile the private terrain position lookup fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
|
||||
- name: Compile the private terrain quad reuse fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
|
||||
- name: Compile the private object-size agent and terrain quad fixture
|
||||
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
|
||||
- name: Compile the private terrain read shutdown fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
|
||||
- name: Install headless graphics libraries for applied-hook checks
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
|
||||
- name: Verify integrated-server graphics report against the actual device
|
||||
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
|
||||
- name: Exercise the applied dispatcher hooks and upstream rejection path
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_dispatch.py
|
||||
- name: Verify applied tick membership transitions and allocation
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_tick_dedup.py
|
||||
- name: Verify live culling whitelist registration and empty iteration
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_culling_empty.py
|
||||
- name: Compare applied culling ray geometry and provider queries
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_culling_rays.py
|
||||
- name: Verify actual full-data position lookup equivalence
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_position.py
|
||||
- name: Verify actual terrain quad geometry and allocation
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_quad_reuse.py
|
||||
- name: Measure actual builder and quad shallow sizes in both header modes
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_quad_sizes.py
|
||||
- name: Verify interrupted DH reads and preserved shared futures
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_read.py
|
||||
- name: Upload original mod and pack manifests only
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-linux
|
||||
path: |
|
||||
dist/*.mrpack
|
||||
dist/horizon-*.jar
|
||||
dist/worker-pacing-benchmark.txt
|
||||
dist/debounce-queue-benchmark.txt
|
||||
dist/idle-queue-benchmark.txt
|
||||
dist/object-header-benchmark.txt
|
||||
dist/dispatch-integration.json
|
||||
dist/tick-dedup-integration.json
|
||||
dist/culling-empty-integration.json
|
||||
dist/terrain-read-integration.json
|
||||
dist/culling-rays-integration.json
|
||||
dist/terrain-position-integration.json
|
||||
dist/quad-reuse-integration.json
|
||||
dist/quad-size-integration.json
|
||||
dist/server-graphics-smoke.json
|
||||
dist/client-graphics-smoke.json
|
||||
dist/client-graphics-macos-title.json
|
||||
dist/client-graphics-macos-world.json
|
||||
retention-days: 3
|
||||
@@ -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
|
||||
@@ -1,303 +0,0 @@
|
||||
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
|
||||
# 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.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: The commit to build
|
||||
type: string
|
||||
required: true
|
||||
version:
|
||||
description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28
|
||||
type: string
|
||||
required: true
|
||||
nightly:
|
||||
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
|
||||
type: string
|
||||
default: ''
|
||||
trusted:
|
||||
# Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose
|
||||
# build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an
|
||||
# untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway),
|
||||
# and a trusted one never reads the machine's shared sccache, which pull requests' jobs write
|
||||
# (tests/test_nightly.py pins all of it).
|
||||
description: The source is main (a release); false for anything else (a nightly)
|
||||
type: boolean
|
||||
default: false
|
||||
developer_id:
|
||||
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
|
||||
type: boolean
|
||||
default: true
|
||||
permissions:
|
||||
contents: read
|
||||
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"]') }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
env:
|
||||
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
|
||||
- run: bash tools/ci/vanilla-data.sh
|
||||
- name: Compile and validate original exporters against the pinned jars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
|
||||
# prepares afresh.
|
||||
cargo build --release --locked -p horizon-prepare
|
||||
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
|
||||
# write those (vanilla-data.sh links .local/exporters to them).
|
||||
rm -rf .local/exporters
|
||||
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
|
||||
mkdir -p exporters
|
||||
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
|
||||
# Only our classes and their manifest leave this runner.
|
||||
stray=$(find exporters -type f ! -path exporters/manifest.json \
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: exporters
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
# The server build too: packaging/server/install.sh installs it on Linux.
|
||||
server_dir: horizon-server-linux-x86_64
|
||||
server_asset: horizon-server-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
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 }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
|
||||
# telemetry say it. No version-bump commits.
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
|
||||
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
|
||||
# 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:
|
||||
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
|
||||
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
|
||||
save-if: ${{ inputs.trusted }}
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux'
|
||||
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
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: packaging/macos/keychain.sh unlock
|
||||
- name: Signed with Developer ID and the hardened runtime
|
||||
# Before the checks below, so they run the programs as players will.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
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"
|
||||
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 }}"
|
||||
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; }
|
||||
else
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=$BIN
|
||||
"$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'
|
||||
# The version is the release's.
|
||||
for binary in horizon-prepare horizon-server; do
|
||||
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
|
||||
echo "$out"
|
||||
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
|
||||
done
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
env:
|
||||
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The game runs (offscreen, a new world, 1500 frames)
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
|
||||
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"
|
||||
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 \
|
||||
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
|
||||
python3 - "$home/smoke.json" <<'EOF'
|
||||
import json, sys
|
||||
report = json.load(open(sys.argv[1]))
|
||||
print(json.dumps(report))
|
||||
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
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
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
|
||||
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
|
||||
else
|
||||
# Without resource forks and extended attributes: plain ditto adds an
|
||||
# AppleDouble `._` file beside every file that has any.
|
||||
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
|
||||
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
|
||||
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- name: Notarized
|
||||
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
|
||||
done
|
||||
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The server build (the game without sound or controllers, as horizon-engine)
|
||||
if: matrix.server_asset
|
||||
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
|
||||
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"
|
||||
"$folder/horizon-engine" --help | grep -q -- '--resources'
|
||||
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
|
||||
cd dist
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
path: dist/${{ matrix.server_asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The signing keychain, locked again
|
||||
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
run: packaging/macos/keychain.sh lock
|
||||
|
||||
@@ -8,9 +8,6 @@ name: launcher
|
||||
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
|
||||
# commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
|
||||
# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
|
||||
# itself instead (launcher/src/backend.rs).
|
||||
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
@@ -22,7 +19,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 +29,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 * * * *'
|
||||
@@ -55,10 +50,6 @@ concurrency:
|
||||
group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
decide:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -166,7 +157,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.
|
||||
@@ -207,28 +198,22 @@ jobs:
|
||||
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
|
||||
"$launcher" menu-style classic
|
||||
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
|
||||
"$launcher" menu-style horizon
|
||||
# A new platform's launcher can come out before any stable game release has its build (Intel Macs, #1161):
|
||||
# then there is nothing to install yet.
|
||||
stable_build='import json, sys; sys.exit(not any(r["available_here"] and r["channel"] == "stable" for r in json.load(sys.stdin)))'
|
||||
if ! retry "$launcher" --json versions | ${{ runner.os == 'Windows' && 'python' || 'python3' }} -c "$stable_build"; then
|
||||
echo "::warning::No stable game release has a build for ${{ matrix.name }} yet: install not tested"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
exit 0
|
||||
fi
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
[[ -f "$client" ]] || { echo "::error::no game installed under versions/"; exit 1; }
|
||||
# The game's Linux build is the game's to keep portable (release.yml checks it): a
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
@@ -259,13 +244,6 @@ jobs:
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
if [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
# The .tar.gz below is what the launcher updates itself from; the AppImage is what a
|
||||
# player downloads and double-clicks, and it replaces itself instead
|
||||
# (packaging/linux/appimage.sh, launcher/src/backend.rs).
|
||||
../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
|
||||
fi
|
||||
mkdir -p dist
|
||||
cp "$BIN/${{ matrix.binary }}" dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
name: mod-registry
|
||||
# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build
|
||||
# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-mod-registry/**'
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-mod-registry/**'
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/horizon-mod-registry
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates/horizon-mod-registry
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo +stable fmt --check
|
||||
- run: cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo +stable test --locked
|
||||
@@ -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' || '' }}
|
||||
@@ -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.'
|
||||
@@ -1,280 +0,0 @@
|
||||
name: nightly build
|
||||
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
|
||||
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
|
||||
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
|
||||
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
|
||||
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
|
||||
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
|
||||
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
|
||||
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
|
||||
#
|
||||
# Why the signing key never meets the pull requests' code:
|
||||
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
|
||||
# so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull
|
||||
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
|
||||
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
|
||||
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
|
||||
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
|
||||
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
|
||||
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
|
||||
# never saved; and releases don't read the machine's shared sccache.
|
||||
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
|
||||
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
|
||||
# which stays locked for nightlies).
|
||||
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
|
||||
# runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message
|
||||
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
|
||||
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
|
||||
# the tag. Like sign, it never executes what build produced.
|
||||
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
|
||||
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
|
||||
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
|
||||
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
|
||||
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
|
||||
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: Build and publish even if tonight's pick is the commit already published
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: Build and sign with a throwaway key, publish nothing
|
||||
type: boolean
|
||||
default: false
|
||||
issue:
|
||||
description: The issue or pull request whose `/nightly` comment asked for this build (answered there)
|
||||
type: string
|
||||
default: ''
|
||||
request:
|
||||
description: The command's id, in the run's name so it can link the run
|
||||
type: string
|
||||
default: ''
|
||||
prs:
|
||||
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
|
||||
type: string
|
||||
default: ''
|
||||
base:
|
||||
description: The commit of main to build on (default main's newest with green checks and parity-main)
|
||||
type: string
|
||||
default: ''
|
||||
urgent:
|
||||
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
# One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick
|
||||
# is lost.
|
||||
group: nightly-build
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
pick:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
|
||||
issues: write # says in the thread when the picked pull requests conflict
|
||||
pull-requests: write
|
||||
actions: read # main's newest green checks and parity-main runs (the base)
|
||||
outputs:
|
||||
build: ${{ steps.pick.outputs.build }}
|
||||
sha: ${{ steps.pick.outputs.sha }}
|
||||
short: ${{ steps.pick.outputs.short }}
|
||||
source: ${{ steps.pick.outputs.source }}
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- id: mode
|
||||
# A dry run never publishes; a hand-started run off main is always one.
|
||||
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
|
||||
- id: pick
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
|
||||
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
|
||||
NIGHTLY_ISSUE: ${{ inputs.issue }}
|
||||
NIGHTLY_PRS: ${{ inputs.prs }}
|
||||
NIGHTLY_BASE: ${{ inputs.base }}
|
||||
NIGHTLY_URGENT: ${{ inputs.urgent }}
|
||||
run: |
|
||||
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Old nightly/build-* branches deleted (scheduled runs only)
|
||||
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
|
||||
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: The source of the build (horizon-source.tar.gz)
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
path: nightly-notes.md
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: pick
|
||||
if: needs.pick.outputs.build == 'true'
|
||||
uses: ./.github/workflows/game-build.yml
|
||||
with:
|
||||
ref: ${{ needs.pick.outputs.sha }}
|
||||
version: ${{ needs.pick.outputs.version }}
|
||||
nightly: ${{ needs.pick.outputs.nightly }}
|
||||
developer_id: false
|
||||
trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner
|
||||
|
||||
sign:
|
||||
needs: [pick, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
|
||||
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
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
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [pick, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # the open release-blocker issues (gate)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
|
||||
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
|
||||
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
|
||||
# only reports what a real run would do (that is how a blocker's fix is tried).
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish as the `nightly` pre-release
|
||||
if: needs.pick.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ needs.pick.outputs.sha }}
|
||||
DATE: ${{ needs.pick.outputs.date }}
|
||||
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"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
|
||||
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
|
||||
ls -l dist
|
||||
|
||||
answer:
|
||||
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
|
||||
needs: [pick, build, sign, publish]
|
||||
if: always() && inputs.issue != '' && needs.pick.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ISSUE: ${{ inputs.issue }}
|
||||
BUILD: ${{ needs.pick.outputs.build }}
|
||||
PUBLISHED: ${{ needs.publish.result }}
|
||||
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
|
||||
NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }}
|
||||
RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
if [[ "$BUILD" != true ]]; then
|
||||
text="nothing new: the published Nightly is already this build."
|
||||
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
|
||||
text="the dry run built and signed $NAME; nothing was published. $RUN"
|
||||
elif [[ "$PUBLISHED" == success ]]; then
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
|
||||
else
|
||||
text="the build failed, nothing was published: $RUN"
|
||||
fi
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"
|
||||
@@ -1,46 +0,0 @@
|
||||
name: nightly command
|
||||
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
|
||||
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
|
||||
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` always runs this file from main, whoever comments and wherever; a pull request's own copy of it
|
||||
# is never read.
|
||||
# - It checks out main and runs main's nightly_set.py. The comment's text and author reach it as env vars, never in
|
||||
# a shell line.
|
||||
# - It never checks out, fetches or merges a pull request: it reads their file lists through the API. The merge
|
||||
# happens in nightly-build.yml's `pick`, with git objects only; their code runs in its `build`, without secrets.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts.
|
||||
if: startsWith(github.event.comment.body, '/nightly') && github.event.comment.user.type != 'Bot'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write # the nightly-state branch (main is protected)
|
||||
actions: write # starts `nightly build`
|
||||
issues: write # answers in the thread
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/CODEOWNERS
|
||||
.github/trusted-reviewers.txt
|
||||
tools/ci/nightly_set.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_URL: ${{ github.event.comment.html_url }}
|
||||
run: python3 tools/ci/nightly_set.py command
|
||||
@@ -8,7 +8,7 @@ name: nightly audit
|
||||
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
|
||||
on:
|
||||
schedule:
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:07 in Paris today.
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
|
||||
- cron: '7 1 * * *'
|
||||
- cron: '7 2 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -23,27 +23,17 @@ permissions:
|
||||
concurrency:
|
||||
group: nightly-audit
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
# The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
|
||||
runs-on: [self-hosted, horizon-game, macOS]
|
||||
timeout-minutes: 240 # every harness (~80 min) plus lan_integration (~40 min an attempt under load, retried)
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: 03:07 in Paris
|
||||
- name: 03:00 in Paris
|
||||
id: when
|
||||
env:
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
# Which cron is 03:07 in Paris, from Paris's offset today: GitHub can start a schedule hours late, so the
|
||||
# clock at start time can't tell (on 2026-10-26 both started after 04:00 Paris and both skipped).
|
||||
offset=$(TZ=Europe/Paris date +%z)
|
||||
ours=$([ "$offset" = +0200 ] && echo '7 1 * * *' || echo '7 2 * * *')
|
||||
if [[ "${{ github.event_name }}" == schedule && "$SCHEDULE" != "$ours" ]]; then
|
||||
echo "Paris is at UTC${offset} today: the '$ours' schedule runs the audit."
|
||||
hour=$(TZ=Europe/Paris date +%H)
|
||||
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
|
||||
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -71,8 +61,7 @@ jobs:
|
||||
run: |
|
||||
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
|
||||
set +e
|
||||
# lan_integration (the whole lan_e2e suite) takes ~2,400 s under load, past the usual 1,800.
|
||||
python3 tools/parity.py --all --timeout 3000 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Issues for the harnesses that regressed
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -1,77 +0,0 @@
|
||||
name: parity-main
|
||||
# The vanilla harnesses on main, a batch of merges at a time (docs/release-flow.md#parity-on-main), on the Mac mini's
|
||||
# runner. One run at a time: merges that land while a run goes on wait in one pending run (GitHub keeps only the
|
||||
# newest), which checks every merge since main's last green parity-main (tools/parity_main.py base). On a failure it
|
||||
# bisects the failing harnesses over those merges and comments on the pull request that broke them. Releases ship
|
||||
# only a commit whose parity-main passed (tools/release_train.py), so nothing a harness catches reaches players.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
# The comment on the merge that broke a harness.
|
||||
pull-requests: write
|
||||
# tools/parity_main.py base reads earlier runs.
|
||||
actions: read
|
||||
concurrency:
|
||||
group: parity-main
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
parity-main:
|
||||
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
|
||||
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs
|
||||
# make a core-crate run several times longer, and a failure here bisects and comments on merges.
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: Main's last green parity-main
|
||||
id: base
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
|
||||
- name: The harnesses the merges since then can affect
|
||||
id: harnesses
|
||||
# A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
|
||||
continue-on-error: true
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: |
|
||||
echo "Merges since ${BASE_SHA}:"
|
||||
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
|
||||
- name: A flake, or the merge that broke them
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: |
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -1,6 +1,6 @@
|
||||
name: parity
|
||||
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
|
||||
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's
|
||||
# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
|
||||
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -13,79 +13,32 @@ on:
|
||||
description: The commit to check out and test
|
||||
type: string
|
||||
required: true
|
||||
full:
|
||||
description: >-
|
||||
The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it
|
||||
runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is
|
||||
busy the job waits for it.
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }}
|
||||
# A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4
|
||||
# harnesses at a time) up to a few hours, and any pull request's job may land there.
|
||||
timeout-minutes: ${{ inputs.full && 360 || 180 }}
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
|
||||
# main into it.
|
||||
# The commit the event names: a fork's later pushes need another run-parity label.
|
||||
ref: ${{ inputs.head }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
|
||||
clean: false
|
||||
- name: The head merged into main (what merging it would land)
|
||||
id: merge
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
HEAD_SHA: ${{ inputs.head }}
|
||||
run: |
|
||||
# Harness fixes and game fixes that reached main since the branch was made then apply here too: a head
|
||||
# tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before
|
||||
# #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull
|
||||
# request's own changes. A head that conflicts with main is tested alone.
|
||||
set -euo pipefail
|
||||
git merge --abort 2>/dev/null || true
|
||||
git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
main=$(git rev-parse origin/main)
|
||||
if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then
|
||||
echo "base=$main" >> "$GITHUB_OUTPUT"
|
||||
echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
git merge --abort
|
||||
echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
|
||||
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
|
||||
env:
|
||||
BASE_SHA: ${{ steps.merge.outputs.base }}
|
||||
run: |
|
||||
# A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number.
|
||||
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
|
||||
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
- name: The version constants are the pinned jar's
|
||||
run: python3 tools/gen_version_consts.py --check
|
||||
- name: The harnesses this change can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.merge.outputs.base }}
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,52 @@
|
||||
name: promote
|
||||
# Beta game releases become stable (docs/release-flow.md, "Channels"): every 30 minutes tools/release_channels.py
|
||||
# promotes the newest beta that has been out 6 hours, passed its release smoke test and isn't covered by an issue
|
||||
# labelled release-blocker. By hand, from GitHub's app too (Actions > promote > Run workflow):
|
||||
# gh workflow run promote -f tag=v2026.10.19.03 # that beta now, without the wait
|
||||
# gh workflow run promote -f tag=v2026.10.19.03 -f force=true # even blocked or without its smoke test
|
||||
# gh workflow run promote -f dry_run=true # say what would be promoted
|
||||
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
|
||||
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
|
||||
# When the oldest waiting beta is 24 hours old, an issue "Promotion to stable has stalled" says so; it closes itself
|
||||
# after the next promotion.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '13,43 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: The beta to promote now (empty = the newest beta that is due)
|
||||
type: string
|
||||
default: ''
|
||||
force:
|
||||
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: Say what would be promoted, change nothing
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
concurrency:
|
||||
group: promote
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The notes of a promoted release cover every pull request since the last stable one.
|
||||
fetch-depth: 0
|
||||
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
|
||||
persist-credentials: false
|
||||
- name: Promote
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
|
||||
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
|
||||
+329
-40
@@ -1,19 +1,22 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). Horizon ships only the Nightly since 2026-10-28 (Ludovic): this
|
||||
# workflow is the batch train that decides when a new Nightly goes out, and `nightly build` (nightly-build.yml) builds,
|
||||
# signs and publishes it. After every green `checks` run on main, every 10 minutes (so a batch waiting for merges to
|
||||
# settle goes out without another merge) and by hand, tools/release_train.py decides, for a commit whose checks and
|
||||
# parity-main (the vanilla harnesses on main) both passed: a Nightly when 5 pull requests merged since the published
|
||||
# Nightly changed what players run and the newest merged 5 minutes ago, or when the oldest has waited 6 hours, or at
|
||||
# once for a pull request labelled release-now. RELEASES_PAUSED=1 (repository variable) stops it; so does an open issue
|
||||
# labelled release-blocker, forced runs too (nightly-build.yml checks again before publishing). One Nightly build at a
|
||||
# time, at most 12 a day (release-now and force=true pass the cap). No `vX.Y.Z` game release is made any more.
|
||||
# gh workflow run release --ref <branch> # dry run: decides, starts nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: a Nightly now, whatever the rules say
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 15 minutes
|
||||
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub): Apple Silicon, and
|
||||
# Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161), then
|
||||
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
|
||||
# stable after 6 hours. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
|
||||
# (packaging/macos/README.md); unset (forks), ad hoc.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
|
||||
on:
|
||||
workflow_run:
|
||||
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
|
||||
workflows: [checks, parity-main]
|
||||
workflows: [checks]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
schedule:
|
||||
@@ -21,24 +24,17 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Decide only; start no Nightly build
|
||||
description: Build everything and write the notes, but publish nothing
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: A Nightly even without player-facing changes, before merges settle, while paused or past the day's cap
|
||||
description: Release even without player-facing changes, before merges settle or while paused
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
# The checks, parity-main and nightly build runs (decide).
|
||||
actions: read
|
||||
concurrency:
|
||||
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
|
||||
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
|
||||
# cancelled it (#2779's dry run, 2026-10-27), which happens all day while releases go out in batches.
|
||||
group: game-release${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '-dry-run' || '' }}
|
||||
group: game-release
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
decide:
|
||||
@@ -49,8 +45,9 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
urgent: ${{ steps.decide.outputs.urgent }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -63,28 +60,320 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
|
||||
# The commit whose checks or parity-main just passed; a dry run builds the branch it was started on;
|
||||
# otherwise main's newest commit with green checks and parity-main.
|
||||
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
|
||||
# main's newest commit with green checks.
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
# The version the programs are built with (docs/versioning.md): the tag without its v.
|
||||
tag=$(jq -r '.tag' decision.json)
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
nightly:
|
||||
# The Nightly is the release: nightly-build.yml builds the decided commit plus the reviewers' picks, signs it for
|
||||
# the Nightly channel and publishes it once its smoke test passed and no release-blocker is open.
|
||||
build:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true' && needs.decide.outputs.dry_run != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write # starts `nightly build`
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
# The server build too: packaging/server/install.sh installs it on Linux.
|
||||
server_dir: horizon-server-linux-x86_64
|
||||
server_asset: horizon-server-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
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 && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
|
||||
# telemetry say it. No version-bump commits.
|
||||
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
|
||||
# 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
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ needs.decide.outputs.head }}
|
||||
URGENT: ${{ needs.decide.outputs.urgent }}
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
|
||||
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
|
||||
# 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:
|
||||
shared-key: release-${{ matrix.name }}
|
||||
- name: ALSA and udev headers
|
||||
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 --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: |
|
||||
gh workflow run nightly-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f base="$HEAD_SHA" -f urgent="$URGENT"
|
||||
echo "### Started \`nightly build\` on ${HEAD_SHA:0:9}" >> "$GITHUB_STEP_SUMMARY"
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: packaging/macos/keychain.sh unlock
|
||||
- name: Signed with Developer ID and the hardened runtime
|
||||
# Before the checks below, so they run the programs as players will.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
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"
|
||||
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 }}"
|
||||
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; }
|
||||
else
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=$BIN
|
||||
"$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'
|
||||
# The version is the release's.
|
||||
for binary in horizon-prepare horizon-server; do
|
||||
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
|
||||
echo "$out"
|
||||
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
|
||||
done
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The game runs (offscreen, a new world, 1500 frames)
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
|
||||
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"
|
||||
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 \
|
||||
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
|
||||
python3 - "$home/smoke.json" <<'EOF'
|
||||
import json, sys
|
||||
report = json.load(open(sys.argv[1]))
|
||||
print(json.dumps(report))
|
||||
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
# One smoke-test.json per release, the Apple Silicon build's; the Intel build must pass its own run too.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted' && !matrix.target
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
|
||||
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
|
||||
else
|
||||
# Without resource forks and extended attributes: plain ditto adds an
|
||||
# AppleDouble `._` file beside every file that has any.
|
||||
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
|
||||
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
|
||||
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- name: Notarized
|
||||
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
|
||||
done
|
||||
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The server build (the game without sound or controllers, as horizon-engine)
|
||||
if: matrix.server_asset
|
||||
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
|
||||
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"
|
||||
"$folder/horizon-engine" --help | grep -q -- '--resources'
|
||||
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
|
||||
cd dist
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
path: dist/${{ matrix.server_asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The signing keychain, locked again
|
||||
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
run: packaging/macos/keychain.sh lock
|
||||
|
||||
sign:
|
||||
needs: [decide, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
|
||||
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
|
||||
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
|
||||
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh "$TAG" 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
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [decide, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
|
||||
# A beta (pre-release): promote.yml makes it stable and latest after 6 hours (docs/release-flow.md).
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --prerelease --latest=false 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/SHA256SUMS "${signature[@]}" "${smoke[@]}"
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-notes
|
||||
path: notes/
|
||||
retention-days: 3
|
||||
@@ -5,20 +5,12 @@ 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).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
if: github.event.label.name == 'run-parity'
|
||||
@@ -26,4 +18,3 @@ jobs:
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
|
||||
@@ -1,48 +0,0 @@
|
||||
name: triage command
|
||||
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
|
||||
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
|
||||
# tools/ci/triage_command.py).
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` always runs 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.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts.
|
||||
if: >-
|
||||
github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/close') ||
|
||||
startsWith(github.event.comment.body, '/reopen') ||
|
||||
startsWith(github.event.comment.body, '/label') ||
|
||||
startsWith(github.event.comment.body, '/unlabel'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: 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/triage_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 }}
|
||||
REPO: ${{ github.repository }}
|
||||
COMMENT_URL: ${{ github.event.comment.html_url }}
|
||||
run: python3 tools/ci/triage_command.py command
|
||||
@@ -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
|
||||
@@ -32,6 +31,3 @@
|
||||
/.horizon-cache/
|
||||
/crash-reports/
|
||||
/debug/
|
||||
|
||||
# Release-only compiled original exporter bundle.
|
||||
/exporters/
|
||||
@@ -1,212 +0,0 @@
|
||||
# Horizon: a map for agents
|
||||
|
||||
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
|
||||
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
|
||||
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Rules that bite
|
||||
|
||||
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
|
||||
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
|
||||
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.
|
||||
- **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
|
||||
`🤖 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
|
||||
they would change the vanilla look.
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
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
|
||||
|
||||
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
|
||||
|
||||
| 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-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` |
|
||||
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
|
||||
| `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-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 |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`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 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/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
|
||||
harnesses for CI.
|
||||
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- 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.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
|
||||
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
|
||||
|
||||
## 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`
|
||||
([docs/decompilation-status.md](docs/decompilation-status.md)).
|
||||
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
|
||||
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
|
||||
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
|
||||
random draws must happen in vanilla's order.
|
||||
|
||||
## 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.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| 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`) |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
- `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.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
## Docs worth knowing
|
||||
|
||||
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
|
||||
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
|
||||
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
|
||||
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
|
||||
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
|
||||
+7
-65
@@ -2,15 +2,10 @@
|
||||
|
||||
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.
|
||||
|
||||
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
|
||||
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
## Build it
|
||||
|
||||
@@ -44,34 +39,6 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
|
||||
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
|
||||
them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code, or code whose license allows it
|
||||
|
||||
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.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
@@ -91,20 +58,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,21 +66,10 @@ 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
|
||||
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
|
||||
|
||||
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
|
||||
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
|
||||
|
||||
@@ -140,5 +82,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/ludovic111/horizon/issues)
|
||||
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
|
||||
Generated
+1164
-905
File diff suppressed because it is too large.
Load diff
+4
-46
@@ -1,41 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
|
||||
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",
|
||||
"crates/horizon-protocol",
|
||||
"crates/horizon-theme",
|
||||
"crates/horizon-prepare",
|
||||
"crates/horizon-telemetry",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mods",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"xtask",
|
||||
]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
@@ -54,17 +19,10 @@ large_enum_variant = "allow"
|
||||
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
|
||||
# pool with no pool sizes while it creates the device, which Vulkan allows only
|
||||
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
|
||||
# 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.
|
||||
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
|
||||
# folder once a wgpu release has the fix.
|
||||
[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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
Copyright (c) 2026 Ludovic
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<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">
|
||||
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
||||
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
||||
<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>
|
||||
@@ -16,48 +15,49 @@
|
||||
<a href="https://playhorizon.dev"><b>Website</b></a> ·
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
|
||||
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
|
||||
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="#project-status"><b>Project status</b></a> ·
|
||||
<a href="#contributing"><b>Contributing</b></a>
|
||||
</p>
|
||||
|
||||
# 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/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon (Intel Macs from the next release) or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or a Mac on macOS 11 or later. 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, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. 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>
|
||||
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
|
||||
<td width="50%"><img src="docs/media/menu-title.webp" alt="Horizon's title screen over a veiled snowy landscape"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
|
||||
@@ -67,15 +67,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,74 +86,80 @@ 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 never downloads them. 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
|
||||
git clone https://github.com/ludovic111/horizon.git
|
||||
cd horizon
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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).
|
||||
|
||||
## 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.
|
||||
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); 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
|
||||
|
||||
|
||||
-916
@@ -1,916 +0,0 @@
|
||||
# Horizon roadmap
|
||||
|
||||
<!-- Generated by tools/roadmap.py from roadmap/roadmap.toml: edit that file, then run `python3 tools/roadmap.py build`. -->
|
||||
|
||||
What is being done on Horizon, what comes next and where you can help. There are no dates: things are Now, Next or Later. Each item is said in plain words, with the technical picture folded under it. The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on [playhorizon.dev/hub](https://playhorizon.dev/hub). Pull requests carry the `roadmap:<id>` label of their item.
|
||||
|
||||
## At a glance
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it runs faster on the same computer. Single player, LAN, servers, resource packs and Horizon's own mods work today, on your own copy of the game.
|
||||
|
||||
**Being done now**
|
||||
|
||||
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Vanilla parity: the player-visible gaps, closed**: Every method of the Java game is tracked with where Horizon does it, and the differences players can see are being closed, list by list: mobs, blocks, items, screens, rendering, servers.
|
||||
- **Mods**: Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.
|
||||
|
||||
**Next**: Fresh benchmarks against vanilla; Faster world creation and entry on small CPUs; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; One single-player path: remove the in-frame world; Faster block lookups; Playing on servers: everything shows as it should; Shader packs, written again from scratch; A new launcher design; Splitscreen.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
**Developers**: Read CONTRIBUTING.md, pick a Help wanted item or a To do card, check vanilla's code for the behaviour, and paste the block `python3 tools/pr_ready.py` prints in your pull request.
|
||||
|
||||
## Now
|
||||
|
||||
### Performance
|
||||
|
||||
#### Steady frames at long render distances
|
||||
|
||||
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3909](https://github.com/horizon-rust/horizon/issues/3909)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: no main-thread tick stutter at high render distances ([#3827](https://github.com/horizon-rust/horizon/issues/3827)), each proto chunk's light view built once ([#3815](https://github.com/horizon-rust/horizon/issues/3815)), video memory handed back by the vertex arena ([#3466](https://github.com/horizon-rust/horizon/issues/3466)), chunk batches assembled on helper threads ([#3036](https://github.com/horizon-rust/horizon/issues/3036)), free structure starts in the generation workers ([#1760](https://github.com/horizon-rust/horizon/issues/1760)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Servers that hold 20 TPS with 50 to 100 players
|
||||
|
||||
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3910](https://github.com/horizon-rust/horizon/issues/3910)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
|
||||
|
||||
More: [#3847](https://github.com/horizon-rust/horizon/issues/3847) · [#2608](https://github.com/horizon-rust/horizon/issues/2608)
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Modpacks' world generation, natively
|
||||
|
||||
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
|
||||
Owner: [ThatCakeIsAPie](https://github.com/ThatCakeIsAPie) · Tracking: [#3913](https://github.com/horizon-rust/horizon/issues/3913)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-port-pack` carries a pack's data-driven content over, and 26.2 data packs are upgraded to 26.3's format ([#3369](https://github.com/horizon-rust/horizon/issues/3369)). In review: worlds taller than 32 sections and each level's shape from its dimension type ([#3480](https://github.com/horizon-rust/horizon/issues/3480), [#3488](https://github.com/horizon-rust/horizon/issues/3488)), data-pack biomes in local and remote worlds ([#3491](https://github.com/horizon-rust/horizon/issues/3491)), block state providers ([#3802](https://github.com/horizon-rust/horizon/issues/3802)), surface rules by biome id ([#3366](https://github.com/horizon-rust/horizon/issues/3366)), Lithostitched's pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods
|
||||
|
||||
Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.
|
||||
|
||||
Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://github.com/horizon-rust/horizon/issues/3911)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Single player as a server: the last differences
|
||||
|
||||
Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://github.com/horizon-rust/horizon/issues/3914)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Minecraft Java instances in the launcher
|
||||
|
||||
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3915](https://github.com/horizon-rust/horizon/issues/3915)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
|
||||
|
||||
More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### A leaner codebase that builds faster
|
||||
|
||||
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3916](https://github.com/horizon-rust/horizon/issues/3916)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
|
||||
</details>
|
||||
|
||||
#### Vanilla parity: the player-visible gaps, closed
|
||||
|
||||
Every method of the Java game is tracked with where Horizon does it, and the differences players can see are being closed, list by list: mobs, blocks, items, screens, rendering, servers.
|
||||
|
||||
Owner: [kacpherek](https://github.com/kacpherek) · Tracking: [#3912](https://github.com/horizon-rust/horizon/issues/3912)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The port ledger ([tools/port_ledger.py](tools/port_ledger.py), tools/port_map/, [#3139](https://github.com/horizon-rust/horizon/issues/3139)) gives every 26.3 method a verdict and links the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)). The player-visible gaps are ten lists, one per area ([#3512](https://github.com/horizon-rust/horizon/issues/3512) to [#3521](https://github.com/horizon-rust/horizon/issues/3521)); a fix translates the vanilla method, ticks its row and carries the `parity` label. Start from .claude/skills/vanilla-parity.
|
||||
|
||||
More: [#3513](https://github.com/horizon-rust/horizon/issues/3513) · [#3514](https://github.com/horizon-rust/horizon/issues/3514) · [#3515](https://github.com/horizon-rust/horizon/issues/3515) · [#3516](https://github.com/horizon-rust/horizon/issues/3516) · [#3517](https://github.com/horizon-rust/horizon/issues/3517) · [#3518](https://github.com/horizon-rust/horizon/issues/3518) · [#3519](https://github.com/horizon-rust/horizon/issues/3519) · [#3520](https://github.com/horizon-rust/horizon/issues/3520)
|
||||
|
||||
</details>
|
||||
|
||||
## Next
|
||||
|
||||
### Performance
|
||||
|
||||
#### Fresh benchmarks against vanilla
|
||||
|
||||
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/horizon-rust/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/horizon-rust/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/horizon-rust/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster world creation and entry on small CPUs
|
||||
|
||||
Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/horizon-rust/horizon/issues/1760) and [#1778](https://github.com/horizon-rust/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Low-end computers: Steam Deck, old Macs, 8 GB
|
||||
|
||||
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
|
||||
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native upscaling
|
||||
|
||||
Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
|
||||
|
||||
More: [#3762](https://github.com/horizon-rust/horizon/issues/3762) · [#3763](https://github.com/horizon-rust/horizon/issues/3763) · [#3764](https://github.com/horizon-rust/horizon/issues/3764)
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster block lookups
|
||||
|
||||
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Server console, bans and a full admin API
|
||||
|
||||
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Hosting polish: Windows service, safe updates
|
||||
|
||||
Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/horizon-rust/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
|
||||
|
||||
</details>
|
||||
|
||||
#### Playing on servers: everything shows as it should
|
||||
|
||||
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Splitscreen
|
||||
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
Owner: [martipops](https://github.com/martipops)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054), built by martipops as a series: up to 16 local players on the server thread's multiplayer ([#3541](https://github.com/horizon-rust/horizon/issues/3541)), each controller driving its own player ([#3794](https://github.com/horizon-rust/horizon/issues/3794)), joining with a controller ([#3801](https://github.com/horizon-rust/horizon/issues/3801)), players in windows of their own ([#3825](https://github.com/horizon-rust/horizon/issues/3825)) and the profile page ([#3826](https://github.com/horizon-rust/horizon/issues/3826)).
|
||||
|
||||
More: [#3777](https://github.com/horizon-rust/horizon/issues/3777)
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
|
||||
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### A new launcher design
|
||||
|
||||
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
|
||||
|
||||
More: [#3635](https://github.com/horizon-rust/horizon/issues/3635) · [#3636](https://github.com/horizon-rust/horizon/issues/3636) · [#3637](https://github.com/horizon-rust/horizon/issues/3637)
|
||||
|
||||
</details>
|
||||
|
||||
## Later
|
||||
|
||||
### Performance
|
||||
|
||||
#### Exact far terrain from real chunks
|
||||
|
||||
Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
|
||||
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Older Fabric and Forge mods
|
||||
|
||||
Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A modpack's own resource and shader packs
|
||||
|
||||
Imported modpacks carry their resource packs into a profile of their own. Shader-pack conversion is still pending.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Resource-pack carry-over is implemented locally: Modrinth common/client overrides, CurseForge's override folder, installed instances (ZIP and directory packs), and hash-checked indexed ZIP downloads. The launcher keeps profile edits and lets resource-only profiles play; packs are enabled in Resource Packs. Synthetic import and profile tests cover the file pipeline; in-game rendering has not been verified. Shader-pack conversion is still pending, so this combined item remains partial ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Whole modpacks, such as ATM10, rewritten
|
||||
|
||||
Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part ([docs/modpacks.md](docs/modpacks.md)). It depends on 'Mods with code' and the native mod API.
|
||||
|
||||
</details>
|
||||
|
||||
#### Running Java mods that aren't rewritten
|
||||
|
||||
Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Option to look at again: a bridge for simple Fabric mods.
|
||||
|
||||
</details>
|
||||
|
||||
#### CurseForge in the mod browser (coming soon)
|
||||
|
||||
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A Convert to Rust button for what isn't rewritten yet
|
||||
|
||||
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods with code: machines, magic, mobs
|
||||
|
||||
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now ([docs/native-mods.md](docs/native-mods.md)): each port is a horizon-mixins bundle. The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Data packs in the mod browser
|
||||
|
||||
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Ely.by and other sign-in providers
|
||||
|
||||
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.
|
||||
|
||||
More: [#3023](https://github.com/horizon-rust/horizon/issues/3023)
|
||||
|
||||
</details>
|
||||
|
||||
#### Online-mode checks for Play with Friends
|
||||
|
||||
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Play with Friends joins over an encrypted tunnel with an invite code ([docs/play-with-friends.md](docs/play-with-friends.md), 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.
|
||||
|
||||
</details>
|
||||
|
||||
#### Prepare a version without Java
|
||||
|
||||
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Guided hosting with Tailscale and an invite button
|
||||
|
||||
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
|
||||
|
||||
More: [docs/hosting.md](docs/hosting.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### An official Docker image for servers
|
||||
|
||||
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
|
||||
|
||||
More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### A server you install with one command
|
||||
|
||||
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Scripts for mods
|
||||
|
||||
Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: a script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A native port (a horizon-mixins bundle, docs/native-mods.md) of [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.
|
||||
|
||||
</details>
|
||||
|
||||
#### Native versions of popular client mods
|
||||
|
||||
Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.
|
||||
|
||||
More: [docs/extras.md](docs/extras.md) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Create-style physics as a native mod
|
||||
|
||||
Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
|
||||
|
||||
</details>
|
||||
|
||||
#### Proximity voice chat as a native mod
|
||||
|
||||
Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A native mod ([docs/native-mods.md](docs/native-mods.md)). It needs a way to carry voice between players and an audio path in the game before anything can be built.
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Every Minecraft version, prepared automatically
|
||||
|
||||
Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
|
||||
|
||||
</details>
|
||||
|
||||
#### Controllers: tested on real pads, and in the launcher
|
||||
|
||||
Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/horizon-rust/horizon/issues/562) asked for the F3 editor).
|
||||
|
||||
</details>
|
||||
|
||||
#### Pistons push players and mobs
|
||||
|
||||
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
#### Villages and combat, exactly as in vanilla
|
||||
|
||||
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Choose the audio output device
|
||||
|
||||
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
|
||||
</details>
|
||||
|
||||
#### Play in your language
|
||||
|
||||
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn.
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### A light default shader pack
|
||||
|
||||
A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Optional ray tracing
|
||||
|
||||
An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/horizon-rust/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
|
||||
|
||||
</details>
|
||||
|
||||
#### HDR monitors
|
||||
|
||||
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
#### MSAA, anisotropic filtering, exclusive fullscreen
|
||||
|
||||
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.
|
||||
|
||||
</details>
|
||||
|
||||
#### Well-known shader looks, rewritten for Horizon
|
||||
|
||||
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Lean installs that use less disk
|
||||
|
||||
Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started.
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Linux ARM64
|
||||
|
||||
Game and launcher builds for 64-bit ARM Linux computers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Windows ARM64
|
||||
|
||||
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.
|
||||
|
||||
More: [docs/release-flow.md](docs/release-flow.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
#### Wayland and Hyprland polish
|
||||
|
||||
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
|
||||
</details>
|
||||
|
||||
#### A Nix flake
|
||||
|
||||
Install Horizon on NixOS through a Nix flake.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). The launcher's AppImage ships with each launcher release ([packaging/linux/appimage.sh](packaging/linux/appimage.sh)): one file with the libraries GPUI needs, which replaces itself when it updates. A flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA) is still missing, the way horizon-parity's NixOS setup runs them today.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### A complete video studio inside the game
|
||||
|
||||
Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
|
||||
|
||||
</details>
|
||||
|
||||
#### Game menus in the launcher's style
|
||||
|
||||
An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following [docs/design-system.md](docs/design-system.md); off by default because it changes the vanilla look.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### An optional AI teammate
|
||||
|
||||
An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/horizon-rust/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/horizon-rust/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
|
||||
|
||||
</details>
|
||||
|
||||
#### A versioned agent-control protocol
|
||||
|
||||
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
|
||||
|
||||
More: [docs/api/stability.md](docs/api/stability.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Port mods with your own AI agent
|
||||
|
||||
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### A video series about Horizon
|
||||
|
||||
A series of videos about how Horizon is made and what is new, with each upload announced on Discord.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video.
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on X (coming soon)
|
||||
|
||||
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### Pipeline protection and a GitHub App for agents
|
||||
|
||||
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Signed updates everywhere, installers included
|
||||
|
||||
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
## Help wanted
|
||||
|
||||
### Performance
|
||||
|
||||
#### Benchmark your computer
|
||||
|
||||
Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/horizon-rust/horizon/issues/1711).
|
||||
|
||||
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### End city banners keep their pattern
|
||||
|
||||
Banners in End cities show as plain magenta instead of with their pattern.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
|
||||
|
||||
</details>
|
||||
|
||||
#### Enchanted loot in End cities
|
||||
|
||||
Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Chickens flap while they fall** (v0.2.67): Chickens should flap their wings while they fall, not stand still in the air. [#1706](https://github.com/horizon-rust/horizon/issues/1706) · [#1892](https://github.com/horizon-rust/horizon/issues/1892)
|
||||
- **Ctrl+Backspace and scroll bars in menus** (v0.2.67): Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. [#1764](https://github.com/horizon-rust/horizon/issues/1764) · [#1782](https://github.com/horizon-rust/horizon/issues/1782) · [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
|
||||
- **A pointing hand over buttons** (v0.2.67): The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. [#1347](https://github.com/horizon-rust/horizon/issues/1347) · [#1897](https://github.com/horizon-rust/horizon/issues/1897)
|
||||
- **Chat settings and team prefixes** (v0.2.67): The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat. [#1376](https://github.com/horizon-rust/horizon/issues/1376) · [#1378](https://github.com/horizon-rust/horizon/issues/1378) · [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
|
||||
- **Cooldowns drawn on items** (v0.2.67): After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla. [#1763](https://github.com/horizon-rust/horizon/issues/1763) · [#1933](https://github.com/horizon-rust/horizon/issues/1933)
|
||||
- **Pre-generate an area with /pregen** (v0.2.67): A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore. [#1805](https://github.com/horizon-rust/horizon/issues/1805)
|
||||
- **Horizon's own shader format and kit** (v0.2.70): Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them. [#1820](https://github.com/horizon-rust/horizon/issues/1820) · [#1822](https://github.com/horizon-rust/horizon/issues/1822) · [#1861](https://github.com/horizon-rust/horizon/issues/1861) · [#1863](https://github.com/horizon-rust/horizon/issues/1863) · [#1855](https://github.com/horizon-rust/horizon/issues/1855) · [docs/shaders.md](docs/shaders.md) · [docs/api/shaders.md](docs/api/shaders.md)
|
||||
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
|
||||
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/horizon-rust/horizon/issues/1434)
|
||||
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/horizon-rust/horizon/issues/1239)
|
||||
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/horizon-rust/horizon/issues/1199) · [#1029](https://github.com/horizon-rust/horizon/issues/1029)
|
||||
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/horizon-rust/horizon/issues/1154) · [#1242](https://github.com/horizon-rust/horizon/issues/1242) · [#1243](https://github.com/horizon-rust/horizon/issues/1243) · [#1246](https://github.com/horizon-rust/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
|
||||
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/horizon-rust/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
|
||||
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/horizon-rust/horizon/issues/1162)
|
||||
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/horizon-rust/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
|
||||
+32
-87
@@ -1,93 +1,38 @@
|
||||
# Third-party notices
|
||||
|
||||
## CullLeaves
|
||||
|
||||
The optional leaf and mangrove-root face-culling behavior is adapted from
|
||||
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
|
||||
Its MIT license is reproduced below.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 MidnightDust
|
||||
|
||||
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.
|
||||
```
|
||||
|
||||
## 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)
|
||||
by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1.1
|
||||
(`crates/horizon-theme/fonts/OFL-PixelifySans.txt`).
|
||||
|
||||
## Pixelarticons
|
||||
|
||||
`crates/horizon-theme/icons/pixel/*.svg` are icons from
|
||||
[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.
|
||||
-457
@@ -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,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,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.
|
||||
@@ -119,7 +119,7 @@ machines during the runs.
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
|
||||
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
|
||||
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
|
||||
from its cache. Same Java and heap.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
Same Java and heap.
|
||||
|
||||
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
|
||||
@@ -148,7 +148,7 @@ early, wrote no report. Its lines come from its output.
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
@@ -103,7 +103,7 @@ the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
|
||||
|
||||
## Reproduce
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
|
||||
cargo build --release -p horizon-bots # on the machine that runs the bots
|
||||
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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: 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"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
//! `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.
|
||||
use horizon_mods::{
|
||||
Package,
|
||||
index::{self, Facts, Kind},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn json(&self) -> Value {
|
||||
let requires: Vec<Value> = self
|
||||
.facts
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
|
||||
.collect();
|
||||
json!({
|
||||
"kind": self.kind.name(),
|
||||
"id": self.facts.id,
|
||||
"version": self.facts.version,
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"api_level": self.facts.api_level,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
let f = &self.facts;
|
||||
let mut out = format!(
|
||||
"{} {} ({}",
|
||||
f.id.as_deref().unwrap_or("resource pack"),
|
||||
f.version.as_deref().unwrap_or(""),
|
||||
self.kind.name()
|
||||
);
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if self.kind == Kind::Shader {
|
||||
out += &format!(", shader API {}", f.api);
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
if let Some(game) = &f.game {
|
||||
out += &format!(", for {game}");
|
||||
}
|
||||
let signed = if self.signed {
|
||||
"signature verified"
|
||||
} else {
|
||||
"unsigned"
|
||||
};
|
||||
out += &format!(
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
if let Some(level) = f.api_level {
|
||||
out += &format!(" needs mod API level {level} or newer\n");
|
||||
}
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
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 {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind a file's extension says.
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the file at `path` as `kind`; `key` and `signature` (the
|
||||
/// `.minisig` text) check its signature.
|
||||
pub fn check(
|
||||
path: &Path,
|
||||
kind: Kind,
|
||||
key: Option<&str>,
|
||||
signature: Option<&str>,
|
||||
) -> Result<Report, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size > index::MAX_FILE {
|
||||
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
|
||||
}
|
||||
index::open(&kind)?;
|
||||
let facts = index::inspect(&kind, path)?;
|
||||
let signed = match (key, signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
index::verify_signature(key, sig, path)?;
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
if kind == Kind::Mod {
|
||||
content_only(path)?;
|
||||
}
|
||||
Ok(Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-mods/tests/index")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_each_kind_its_own_way() {
|
||||
let pack = fixture("cozy-1.0.0.hpack");
|
||||
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
|
||||
assert_eq!(report.facts.requires[0].id, "coords_hud");
|
||||
assert!(
|
||||
report
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// A mod with WebAssembly code is refused: code mods are native.
|
||||
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();
|
||||
let key = key.lines().last().unwrap();
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("native mod"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
Some(key),
|
||||
Some("untrusted comment: x\nRUQ=\n"),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("horizon-shaders.toml"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
//! `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> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`, `.hshader`).
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{Capability, Content, Manifest, Package, manifest::MANIFEST};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
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/ludovic111/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 +24,11 @@ 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("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> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -73,63 +69,32 @@ 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);
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
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 files = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
),
|
||||
(
|
||||
"data/{id}/horizon/block/glowing_amethyst.json",
|
||||
include_str!("../template/content/block.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/blockstates/glowing_amethyst.json",
|
||||
include_str!("../template/content/blockstate.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/models/block/glowing_amethyst.json",
|
||||
include_str!("../template/content/model.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/items/glowing_amethyst.json",
|
||||
include_str!("../template/content/item.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/lang/en_us.json",
|
||||
include_str!("../template/content/lang.json"),
|
||||
),
|
||||
(
|
||||
"data/{id}/loot_table/blocks/glowing_amethyst.json",
|
||||
include_str!("../template/content/loot.json"),
|
||||
),
|
||||
(
|
||||
"data/{id}/recipe/glowing_amethyst.json",
|
||||
include_str!("../template/content/recipe.json"),
|
||||
),
|
||||
(
|
||||
"data/minecraft/tags/block/mineable/pickaxe.json",
|
||||
include_str!("../template/content/pickaxe.json"),
|
||||
),
|
||||
("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")),
|
||||
];
|
||||
if flag(args, "--template").is_some_and(|t| t != "content") {
|
||||
return Err(
|
||||
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
|
||||
);
|
||||
}
|
||||
for (file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
Content::read(dir, &manifest)?;
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
println!(
|
||||
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
|
||||
dir.display()
|
||||
@@ -137,82 +102,65 @@ 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("."))
|
||||
}
|
||||
|
||||
fn build_in(root: &Path) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(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());
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
if !manifest.capabilities.contains(&Capability::Registries) {
|
||||
let out = cmd
|
||||
.stderr(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"a content mod has capability registries in {MANIFEST}"
|
||||
"cargo build failed (is the target installed? rustup target add {TARGET})"
|
||||
));
|
||||
}
|
||||
// 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()?;
|
||||
let mut files = Vec::with_capacity(content.files.len());
|
||||
for name in &content.files {
|
||||
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 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())?)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
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)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
let file = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or("cargo horizon-mod check <file>")?;
|
||||
let path = Path::new(file);
|
||||
let kind = match flag(args, "--kind") {
|
||||
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
|
||||
None => check::kind_of(path)
|
||||
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
|
||||
};
|
||||
let signature = flag(args, "--signature")
|
||||
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
|
||||
.transpose()?;
|
||||
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
let json = match &report {
|
||||
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
|
||||
Err(e) => serde_json::json!({"ok": false, "error": e}),
|
||||
};
|
||||
println!("{json}");
|
||||
}
|
||||
let report = report?;
|
||||
if !args.iter().any(|a| a == "--json") {
|
||||
print!("{}", report.text());
|
||||
}
|
||||
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)
|
||||
@@ -232,43 +180,21 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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()));
|
||||
let path = dir.join("gems");
|
||||
let new_args = [
|
||||
path.display().to_string(),
|
||||
"--template".into(),
|
||||
"content".into(),
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
let content = Content::read(&hmod, &package.manifest).unwrap();
|
||||
assert!(
|
||||
content
|
||||
.files
|
||||
.contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
|
||||
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")
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
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"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into()
|
||||
])
|
||||
.is_err()
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
|
||||
//! (`horizon-rust/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! workflow downloads the files and publishes what this writes.
|
||||
use horizon_mods::index::{
|
||||
FILE_HOST,
|
||||
registry::{self, Sources},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the registry's site is published (its icons).
|
||||
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>";
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
super::flag(args, name)
|
||||
}
|
||||
|
||||
fn positional(args: &[String]) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = false;
|
||||
for a in args {
|
||||
if skip {
|
||||
skip = false;
|
||||
} else if a == "--json" {
|
||||
} else if a.starts_with("--") {
|
||||
skip = true;
|
||||
} else {
|
||||
out.push(a.as_str());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lines(errors: Vec<String>) -> String {
|
||||
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
|
||||
}
|
||||
|
||||
pub fn main(args: &[String]) -> Result<(), String> {
|
||||
let rest = args.get(1..).unwrap_or_default();
|
||||
let pos = positional(rest);
|
||||
match args.first().map(String::as_str) {
|
||||
Some("build") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
|
||||
let (Some(files), Some(out)) = (files, out) else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let generated = flag(rest, "--generated")
|
||||
.map(str::to_owned)
|
||||
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
|
||||
.unwrap_or_default();
|
||||
let index = registry::build(&Sources {
|
||||
registry: Path::new(checkout),
|
||||
files: Path::new(files),
|
||||
release: flag(rest, "--release").unwrap_or(FILE_HOST),
|
||||
site: flag(rest, "--site").unwrap_or(SITE),
|
||||
generated,
|
||||
})
|
||||
.map_err(lines)?;
|
||||
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
|
||||
println!(
|
||||
"Wrote {out}: {} project(s), {} taken down",
|
||||
index.projects.len(),
|
||||
index.revoked.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("review") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let author = flag(rest, "--author").ok_or(USAGE)?;
|
||||
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
|
||||
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review(&base, &head, author);
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
|
||||
println!("{json}");
|
||||
} else {
|
||||
for n in &review.notes {
|
||||
println!("note: {n}");
|
||||
}
|
||||
for e in &review.errors {
|
||||
println!("error: {e}");
|
||||
}
|
||||
}
|
||||
if review.errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} change(s) the registry refuses",
|
||||
review.errors.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
// `check` (and its trial run) on each file `head` lists and `base`
|
||||
// doesn't: a pull request's new files.
|
||||
Some("check") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
|
||||
let old: Vec<String> = match registry::load(Path::new(base)) {
|
||||
Ok((entries, _)) => entries
|
||||
.iter()
|
||||
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
|
||||
.collect(),
|
||||
// The first pull request has no entries to compare with.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let mut failed = 0;
|
||||
for e in &entries {
|
||||
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
|
||||
println!("{} {} ({}):", e.id, v.version, v.file);
|
||||
let report = crate::check::check(
|
||||
&files.join(&v.sha256),
|
||||
e.kind.clone(),
|
||||
e.key.as_deref(),
|
||||
v.signature.as_deref(),
|
||||
);
|
||||
match report {
|
||||
Ok(r) => print!("{}", r.text()),
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
println!(" error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{failed} file(s) failed their check"))
|
||||
}
|
||||
}
|
||||
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
|
||||
Some("list") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
|
||||
for e in &entries {
|
||||
for v in &e.versions {
|
||||
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
|
||||
}
|
||||
}
|
||||
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,
|
||||
®istry::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
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"template": "minecraft:amethyst_block",
|
||||
"properties": { "hardness": 2.0, "light": 7 }
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"variants": {
|
||||
"": { "model": "{id}:block/glowing_amethyst" }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
id = "{id}"
|
||||
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).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks, items, mob effects and entity types to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"block.{id}.glowing_amethyst": "Glowing Amethyst"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"condition": { "type": "minecraft:survives_explosion" },
|
||||
"entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
|
||||
"rolls": 1
|
||||
}
|
||||
],
|
||||
"random_sequence": "{id}:blocks/glowing_amethyst"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": { "all": "minecraft:block/amethyst_block" }
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"values": ["{id}:glowing_amethyst"]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shapeless",
|
||||
"category": "building",
|
||||
"ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
|
||||
"result": { "id": "{id}:glowing_amethyst" }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Choice<T> {
|
||||
Single(T),
|
||||
enum Choice {
|
||||
Single(Variant),
|
||||
Weighted {
|
||||
variants: Vec<T>,
|
||||
variants: Vec<Variant>,
|
||||
ends: Vec<i32>,
|
||||
total: i32,
|
||||
},
|
||||
}
|
||||
impl Choice<Variant> {
|
||||
impl Choice {
|
||||
fn new(value: &VariantSet) -> Result<Self> {
|
||||
match value {
|
||||
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
|
||||
@@ -41,23 +41,7 @@ impl Choice<Variant> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T> Choice<T> {
|
||||
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
|
||||
match self {
|
||||
Self::Single(value) => Choice::Single(f(value)),
|
||||
Self::Weighted {
|
||||
variants,
|
||||
ends,
|
||||
total,
|
||||
} => Choice::Weighted {
|
||||
variants: variants.iter().map(&mut *f).collect(),
|
||||
ends: ends.clone(),
|
||||
total: *total,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &T {
|
||||
fn select(&self, random: &mut impl RandomSource) -> &Variant {
|
||||
match self {
|
||||
Self::Single(value) => value,
|
||||
Self::Weighted {
|
||||
@@ -76,11 +60,10 @@ impl<T> Choice<T> {
|
||||
}
|
||||
/// Compile once per definition and share across its bound block states. Prefix
|
||||
/// sums avoid rebuilding weight totals or scanning all entries during selection.
|
||||
/// `map` swaps each variant for what it stands for (its baked model).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompiledChoices<T = Variant> {
|
||||
variants: Vec<Choice<T>>,
|
||||
multipart: Vec<Choice<T>>,
|
||||
pub struct CompiledChoices {
|
||||
variants: Vec<Choice>,
|
||||
multipart: Vec<Choice>,
|
||||
}
|
||||
impl CompiledChoices {
|
||||
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
|
||||
@@ -99,19 +82,10 @@ impl CompiledChoices {
|
||||
.collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<T> CompiledChoices<T> {
|
||||
/// The same choices, with `f` of each variant in its place.
|
||||
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
|
||||
CompiledChoices {
|
||||
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
|
||||
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
|
||||
}
|
||||
}
|
||||
/// The variant `particleMaterial` comes from: the first entry of a weighted
|
||||
/// list (`WeightedVariants`) and, for a multipart root, of its first
|
||||
/// selector whether or not that selector applies (`MultiPartModel`).
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
|
||||
let choice = match selected {
|
||||
SelectedModels::Variant(index) => self.variants.get(*index)?,
|
||||
SelectedModels::Multipart(_) => self.multipart.first()?,
|
||||
@@ -121,23 +95,14 @@ impl<T> CompiledChoices<T> {
|
||||
Choice::Weighted { variants, .. } => variants.first()?,
|
||||
})
|
||||
}
|
||||
/// Append selected variants without allocating intermediate part lists.
|
||||
/// Append selected variants without allocating intermediate part lists. A
|
||||
/// multipart root consumes nextLong even for an empty selected subset, then
|
||||
/// resets the same random source to that seed before each selected part.
|
||||
pub fn collect<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
output: &mut Vec<&'a T>,
|
||||
) -> Result<()> {
|
||||
self.for_each(selected, random, |v| output.push(v))
|
||||
}
|
||||
/// Each selected variant, in order. A multipart root consumes nextLong
|
||||
/// even for an empty selected subset, then resets the same random source
|
||||
/// to that seed before each selected part.
|
||||
pub fn for_each<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
mut f: impl FnMut(&'a T),
|
||||
output: &mut Vec<&'a Variant>,
|
||||
) -> Result<()> {
|
||||
match selected {
|
||||
SelectedModels::Variant(index) => {
|
||||
@@ -145,7 +110,7 @@ impl<T> CompiledChoices<T> {
|
||||
.variants
|
||||
.get(*index)
|
||||
.ok_or_else(|| error("invalid variant selection"))?;
|
||||
f(choice.select(random));
|
||||
output.push(choice.select(random));
|
||||
}
|
||||
SelectedModels::Multipart(indices) => {
|
||||
if indices.iter().any(|index| *index >= self.multipart.len()) {
|
||||
@@ -154,7 +119,7 @@ impl<T> CompiledChoices<T> {
|
||||
let seed = random.next_long();
|
||||
for index in indices {
|
||||
random.set_seed(seed);
|
||||
f(self.multipart[*index].select(random));
|
||||
output.push(self.multipart[*index].select(random));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1030,22 +1031,19 @@ pub struct ItemModels {
|
||||
}
|
||||
|
||||
impl ItemModels {
|
||||
/// The stack's components: its item's defaults with the stack's patch on top. A stack
|
||||
/// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
|
||||
pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
|
||||
static NONE: Components = BTreeMap::new();
|
||||
let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
|
||||
if stack.patch.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(prototype);
|
||||
}
|
||||
let mut out = prototype.clone();
|
||||
pub fn components(&self, stack: &StackView) -> Components {
|
||||
let mut out = self
|
||||
.prototypes
|
||||
.get(&stack.item)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for (key, value) in &stack.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
out
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
|
||||
@@ -1334,8 +1332,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));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -8,5 +8,3 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -15,22 +16,8 @@ pub struct Hardware {
|
||||
pub cpu: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_threads: Option<usize>,
|
||||
/// Physical cores.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_cores: Option<u16>,
|
||||
/// Base and top boost clock, where the OS gives them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_base_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_boost_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ram_mb: Option<u64>,
|
||||
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub storage: Option<String>,
|
||||
/// `laptop` or `desktop`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub form: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu: Option<Gpu>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -46,9 +33,6 @@ pub struct Gpu {
|
||||
pub backend: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
|
||||
pub kind: String,
|
||||
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vram_mb: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -63,50 +47,17 @@ pub struct Display {
|
||||
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
let probes = probes();
|
||||
Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
|
||||
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
|
||||
Hardware {
|
||||
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
|
||||
..Hardware::detect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the telemetry probes read (`horizon_telemetry::system`).
|
||||
struct Probes {
|
||||
cores: Option<u16>,
|
||||
base: Option<u32>,
|
||||
boost: Option<u32>,
|
||||
form: Option<String>,
|
||||
}
|
||||
|
||||
fn probes() -> Probes {
|
||||
let s = horizon_telemetry::payload::System::detect();
|
||||
let some = |n: u32| (n > 0).then_some(n);
|
||||
Probes {
|
||||
cores: (s.cores > 0).then_some(s.cores),
|
||||
base: some(s.cpu_base_mhz),
|
||||
boost: some(s.cpu_boost_mhz),
|
||||
form: (!s.form.is_empty()).then_some(s.form),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
@@ -131,7 +82,25 @@ pub fn load_average() -> Option<f64> {
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
if cfg!(target_os = "macos") {
|
||||
let batt = output("pmset", &["-g", "batt"])?;
|
||||
return Some(if batt.contains("'Battery Power'") {
|
||||
"battery"
|
||||
} else {
|
||||
"ac"
|
||||
});
|
||||
}
|
||||
let mut on_battery = None;
|
||||
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
|
||||
let read =
|
||||
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
|
||||
match read("type").trim() {
|
||||
"Mains" if read("online").trim() == "1" => return Some("ac"),
|
||||
"Battery" => on_battery = Some("battery"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
on_battery
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
@@ -172,6 +141,12 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
fn output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
@@ -202,8 +177,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 +202,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 +256,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());
|
||||
}
|
||||
}
|
||||
@@ -245,19 +245,13 @@ pub struct Report {
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Self::with_hardware(kind, app, Hardware::detect())
|
||||
}
|
||||
|
||||
/// Starts a report using supplied hardware, without running system probes.
|
||||
/// Interactive apps can collect the remaining machine details after their first frame.
|
||||
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
|
||||
Report {
|
||||
format: FORMAT.to_owned(),
|
||||
version: VERSION,
|
||||
kind,
|
||||
created: utc_now(),
|
||||
app,
|
||||
hardware,
|
||||
hardware: Hardware::detect(),
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
|
||||
+59
-964
File diff suppressed because it is too large.
Load diff
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.88"
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,63 +0,0 @@
|
||||
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
|
||||
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
|
||||
# words are written here only so the filter can recognise them, because players spammed them on the
|
||||
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
|
||||
# aren't slurs out of it (moderators handle those).
|
||||
#
|
||||
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
|
||||
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
|
||||
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
|
||||
# doubled in the text: "niger", the river and the country, isn't "nigger".
|
||||
# word matches inside a longer word too
|
||||
# =word only as a whole word ("spic", not "suspicious")
|
||||
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
|
||||
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
|
||||
|
||||
~nigger
|
||||
~nigga
|
||||
nigguh
|
||||
niggah
|
||||
niqqa
|
||||
niqqer
|
||||
nlgger
|
||||
nlgga
|
||||
kneegrow
|
||||
=nig
|
||||
=nigs
|
||||
=niglet
|
||||
=niglets
|
||||
=nibba
|
||||
=nibbas
|
||||
=nibber
|
||||
=nibbers
|
||||
jigaboo
|
||||
porchmonkey
|
||||
=coon
|
||||
=coons
|
||||
~faggot
|
||||
=fag
|
||||
=fags
|
||||
=faggy
|
||||
=fagot
|
||||
=fagots
|
||||
=tranny
|
||||
=trannies
|
||||
=kike
|
||||
=kikes
|
||||
=chink
|
||||
=chinks
|
||||
=gook
|
||||
=gooks
|
||||
=spic
|
||||
=spics
|
||||
wetback
|
||||
=beaner
|
||||
=beaners
|
||||
raghead
|
||||
towelhead
|
||||
zipperhead
|
||||
=paki
|
||||
=pakis
|
||||
|
||||
!snigger*
|
||||
!niggard*
|
||||
@@ -1,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;
|
||||
@@ -1,255 +0,0 @@
|
||||
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
|
||||
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
|
||||
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
|
||||
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
|
||||
|
||||
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
|
||||
const OTHER: u8 = 26;
|
||||
|
||||
/// One letter and how many times it repeats (`gg` is `g` twice).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Run {
|
||||
/// 0..=25 for `a`..`z`, 26 for any other letter.
|
||||
pub letter: u8,
|
||||
pub count: u8,
|
||||
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
|
||||
pub weak: bool,
|
||||
}
|
||||
|
||||
/// A whole message, normalised.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Text {
|
||||
pub runs: Vec<Run>,
|
||||
/// Each word's runs, `runs[start..end]`.
|
||||
pub words: Vec<(usize, usize)>,
|
||||
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
|
||||
/// without the punctuation they end with (`fag!`).
|
||||
pub spaced: Vec<(usize, usize)>,
|
||||
/// Every word one after the other, a letter that ends one and starts the next merged.
|
||||
pub joined: Vec<Run>,
|
||||
/// Where each word starts in `joined`.
|
||||
pub joined_at: Vec<usize>,
|
||||
/// The last word each run of `joined` belongs to.
|
||||
joined_last: Vec<usize>,
|
||||
}
|
||||
|
||||
enum Fold {
|
||||
Letter(u8, bool),
|
||||
Separator,
|
||||
Space,
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn new(text: &str) -> Self {
|
||||
let mut t = Text::default();
|
||||
let mut start = 0;
|
||||
for c in text.chars() {
|
||||
match fold(c) {
|
||||
Fold::Letter(letter, weak) => {
|
||||
let in_word = t.runs.len() > start;
|
||||
match t.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == letter => {
|
||||
last.count = last.count.saturating_add(1);
|
||||
last.weak &= weak;
|
||||
}
|
||||
_ => t.runs.push(Run {
|
||||
letter,
|
||||
count: 1,
|
||||
weak,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Fold::Space => {
|
||||
t.end_word(start);
|
||||
start = t.runs.len();
|
||||
}
|
||||
Fold::Separator | Fold::Skip => {}
|
||||
}
|
||||
}
|
||||
t.end_word(start);
|
||||
t.add_spaced();
|
||||
t.join();
|
||||
t
|
||||
}
|
||||
|
||||
fn end_word(&mut self, start: usize) {
|
||||
if self.runs.len() > start {
|
||||
self.words.push((start, self.runs.len()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
|
||||
/// copied after the real words').
|
||||
fn add_spaced(&mut self) {
|
||||
let words = self.words.len();
|
||||
let mut w = 0;
|
||||
while w < words {
|
||||
let mut end = w;
|
||||
while end < words && self.words[end].1 - self.words[end].0 == 1 {
|
||||
end += 1;
|
||||
}
|
||||
if end - w >= 2 {
|
||||
let first = self.runs.len();
|
||||
for k in w..end {
|
||||
let run = self.runs[self.words[k].0];
|
||||
let in_word = self.runs.len() > first;
|
||||
match self.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
}
|
||||
_ => self.runs.push(run),
|
||||
}
|
||||
}
|
||||
self.spaced.push((first, self.runs.len()));
|
||||
w = end;
|
||||
} else {
|
||||
w += 1;
|
||||
}
|
||||
}
|
||||
for k in 0..words {
|
||||
let (start, mut end) = self.words[k];
|
||||
while end > start + 1 && self.runs[end - 1].weak {
|
||||
end -= 1;
|
||||
}
|
||||
if end < self.words[k].1 {
|
||||
self.spaced.push((start, end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&mut self) {
|
||||
for (w, &(start, end)) in self.words.iter().enumerate() {
|
||||
let mut first = true;
|
||||
for run in &self.runs[start..end] {
|
||||
match self.joined.last_mut() {
|
||||
Some(last) if first && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
self.joined_at.push(self.joined.len() - 1);
|
||||
*self.joined_last.last_mut().expect("beside joined") = w;
|
||||
}
|
||||
_ => {
|
||||
if first {
|
||||
self.joined_at.push(self.joined.len());
|
||||
}
|
||||
self.joined.push(*run);
|
||||
self.joined_last.push(w);
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
|
||||
pub fn spans_words(&self, w: usize, len: usize) -> bool {
|
||||
self.joined_last
|
||||
.get(self.joined_at[w] + len - 1)
|
||||
.is_some_and(|&last| last > w)
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry of the list, normalised the same way (one word, whitespace ignored).
|
||||
pub fn word(text: &str) -> Vec<Run> {
|
||||
let t = Text::new(&text.replace(char::is_whitespace, ""));
|
||||
t.words
|
||||
.first()
|
||||
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
|
||||
}
|
||||
|
||||
fn letter(c: char) -> Fold {
|
||||
Fold::Letter(c as u8 - b'a', false)
|
||||
}
|
||||
|
||||
fn fold(c: char) -> Fold {
|
||||
if c.is_ascii_alphabetic() {
|
||||
return letter(c.to_ascii_lowercase());
|
||||
}
|
||||
if c.is_ascii() {
|
||||
return match c {
|
||||
'0' => letter('o'),
|
||||
'1' => letter('i'),
|
||||
'3' => letter('e'),
|
||||
'4' => letter('a'),
|
||||
'5' => letter('s'),
|
||||
'6' | '9' => letter('g'),
|
||||
'7' => letter('t'),
|
||||
'8' => letter('b'),
|
||||
'!' | '|' => Fold::Letter(b'i' - b'a', true),
|
||||
'@' => Fold::Letter(0, true),
|
||||
'$' => Fold::Letter(b's' - b'a', true),
|
||||
c if c.is_ascii_whitespace() => Fold::Space,
|
||||
_ => Fold::Separator,
|
||||
};
|
||||
}
|
||||
let u = c as u32;
|
||||
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
|
||||
let mapped = match u {
|
||||
// Zero-width characters, soft hyphen, combining marks, variation selectors.
|
||||
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
|
||||
0x300..=0x36F
|
||||
| 0x1AB0..=0x1AFF
|
||||
| 0x1DC0..=0x1DFF
|
||||
| 0x20D0..=0x20FF
|
||||
| 0xFE00..=0xFE0F
|
||||
| 0xFE20..=0xFE2F
|
||||
| 0xE0100..=0xE01EF => return Fold::Skip,
|
||||
// Full-width Latin and digits.
|
||||
0xFF21..=0xFF3A => from(0xFF21, 26),
|
||||
0xFF41..=0xFF5A => from(0xFF41, 26),
|
||||
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
|
||||
// Mathematical alphanumerics: 52 letters a style, then digits.
|
||||
0x1D400..=0x1D6A3 => from(0x1D400, 26),
|
||||
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
|
||||
// Circled, parenthesised, squared and negative letters, regional indicators.
|
||||
0x24B6..=0x24CF => from(0x24B6, 26),
|
||||
0x24D0..=0x24E9 => from(0x24D0, 26),
|
||||
0x249C..=0x24B5 => from(0x249C, 26),
|
||||
0x1F130..=0x1F149 => from(0x1F130, 26),
|
||||
0x1F150..=0x1F169 => from(0x1F150, 26),
|
||||
0x1F170..=0x1F189 => from(0x1F170, 26),
|
||||
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
|
||||
_ => {
|
||||
if c.is_whitespace() {
|
||||
return Fold::Space;
|
||||
}
|
||||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||||
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
|
||||
Some((base, _)) => *base,
|
||||
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
|
||||
None => return Fold::Separator,
|
||||
}
|
||||
}
|
||||
};
|
||||
letter(mapped)
|
||||
}
|
||||
|
||||
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
|
||||
const LOOK_ALIKES: &[(char, &str)] = &[
|
||||
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
|
||||
('b', "ƀɓḃḅḇвьъβʙᵇ"),
|
||||
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
|
||||
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
|
||||
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
|
||||
('f', "ƒḟꜰᶠ"),
|
||||
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
|
||||
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
|
||||
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
|
||||
('j', "ĵǰјᴊʲ"),
|
||||
('k', "ķĸǩḱḳḵкκᴋᵏ"),
|
||||
('l', "ĺļľŀłḷḹḻḽʟˡ"),
|
||||
('m', "мᴍᵐ"),
|
||||
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
|
||||
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
|
||||
('p', "ṕṗрρᴘᵖ"),
|
||||
('q', "ԛ"),
|
||||
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
|
||||
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
|
||||
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
|
||||
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
|
||||
('v', "νᴠᵛ"),
|
||||
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
|
||||
('x', "хχˣ"),
|
||||
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
|
||||
('z', "źżžƶẑẓẕᴢᶻ"),
|
||||
];
|
||||
@@ -1,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());
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-client-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
|
||||
|
||||
[dependencies]
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,627 +0,0 @@
|
||||
//! 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 horizon_world::nbt::Tag;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
|
||||
/// unquoted strings, numbers (with type suffixes) and booleans. Returns the
|
||||
/// value and the bytes consumed.
|
||||
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) {
|
||||
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.s.get(self.i).copied()
|
||||
}
|
||||
fn expect(&mut self, c: u8) -> Result<(), String> {
|
||||
self.ws();
|
||||
if self.peek() == Some(c) {
|
||||
self.i += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("expected '{}'", c as char))
|
||||
}
|
||||
}
|
||||
fn value(&mut self) -> Result<Value, String> {
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b'{') => {
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
}
|
||||
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.depth += 1;
|
||||
let v = self.array();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
|
||||
Some(_) => {
|
||||
let word = self.unquoted();
|
||||
if word.is_empty() {
|
||||
return Err("expected a value".into());
|
||||
}
|
||||
Ok(match word.as_str() {
|
||||
"true" => Value::Bool(true),
|
||||
"false" => Value::Bool(false),
|
||||
_ => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
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() {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.' | b'+') {
|
||||
self.i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&self.s[start..self.i]).into_owned()
|
||||
}
|
||||
fn string(&mut self) -> Result<String, String> {
|
||||
self.ws();
|
||||
let Some(q @ (b'"' | b'\'')) = self.peek() else {
|
||||
return Ok(self.unquoted());
|
||||
};
|
||||
self.i += 1;
|
||||
let mut out = Vec::new();
|
||||
while let Some(c) = self.peek() {
|
||||
self.i += 1;
|
||||
match c {
|
||||
b'\\' => {
|
||||
let Some(e) = self.peek() else { break };
|
||||
self.i += 1;
|
||||
out.push(match e {
|
||||
b'n' => b'\n',
|
||||
b't' => b'\t',
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
c if c == q => return Ok(String::from_utf8_lossy(&out).into_owned()),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
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();
|
||||
if let Some(c) = o.get("color").and_then(Value::as_str) {
|
||||
style.color = named_color(c).or_else(|| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
|
||||
style.bold = style_flag(o.get("bold"));
|
||||
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"));
|
||||
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);
|
||||
}
|
||||
// HoverEvent.ShowText, ShowItem and ShowEntity.
|
||||
if let Some(e) = style_event_object(o, "hover_event", "hoverEvent") {
|
||||
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 {
|
||||
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())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
style.insertion = o
|
||||
.get("insertion")
|
||||
.and_then(Value::as_str)
|
||||
.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 {
|
||||
to_text_with(v, resolve, &mut |_| None)
|
||||
}
|
||||
/// `to_text` with a hook resolving other contents (`nbt`) from the object.
|
||||
pub fn to_text_with(
|
||||
v: &Value,
|
||||
resolve: &mut dyn FnMut(&str) -> Text,
|
||||
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
|
||||
) -> Text {
|
||||
match v {
|
||||
Value::String(s) => Text::literal(s.clone()),
|
||||
Value::Number(n) => Text::literal(n.to_string()),
|
||||
Value::Bool(b) => Text::literal(b.to_string()),
|
||||
// A list is its first element with the rest as siblings.
|
||||
Value::Array(list) => {
|
||||
let Some((first, rest)) = list.split_first() else {
|
||||
return Text::empty();
|
||||
};
|
||||
let mut base = to_text_with(first, resolve, contents);
|
||||
// The siblings take the first component's own style, even when
|
||||
// its text is empty.
|
||||
let style = match first {
|
||||
Value::Object(o) => object_style(o),
|
||||
_ => base
|
||||
.spans
|
||||
.first()
|
||||
.map(|s| s.style.clone())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
for sibling in rest {
|
||||
base.push(to_text_with(sibling, resolve, contents).with_style(&style));
|
||||
}
|
||||
base
|
||||
}
|
||||
Value::Object(o) => {
|
||||
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()),
|
||||
other => Text::literal(other.to_string()),
|
||||
}
|
||||
} else if let Some(key) = o.get("translate").and_then(Value::as_str) {
|
||||
let args: Vec<Text> = o
|
||||
.get("with")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|x| to_text_with(x, resolve, contents))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let translated = Text::translate(key, &args);
|
||||
match o.get("fallback").and_then(Value::as_str) {
|
||||
Some(fallback) if translated.plain() == key => Text::translate(fallback, &args),
|
||||
_ => translated,
|
||||
}
|
||||
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
|
||||
resolve(sel)
|
||||
} else if let Some(key) = o.get("keybind").and_then(Value::as_str) {
|
||||
crate::key_display::display_name(key)
|
||||
} else {
|
||||
Text::empty()
|
||||
};
|
||||
let mut out = content.with_style(&style);
|
||||
for extra in o
|
||||
.get("extra")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
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 {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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";
|
||||
let (v, n) = parse_snbt(input).unwrap();
|
||||
assert_eq!(&input[n..], " rest");
|
||||
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, _) =
|
||||
parse_snbt(r#"{text:"a",font:"alt",extra:["b",{text:"c",font:"minecraft:uniform"}]}"#)
|
||||
.unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
let fonts: Vec<_> = t.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
let (alt, uniform) = (
|
||||
Some("minecraft:alt".into()),
|
||||
Some("minecraft:uniform".into()),
|
||||
);
|
||||
assert_eq!(fonts, [alt.clone(), alt, uniform]);
|
||||
// Back to JSON with the field, which a re-read keeps.
|
||||
let again = to_text(&t.component(), &mut |_| Text::empty());
|
||||
let kept: Vec<_> = again.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
assert_eq!(kept, fonts);
|
||||
// Not an identifier: no font.
|
||||
let (v, _) = parse_snbt(r#"{text:"a",font:"Bad Font"}"#).unwrap();
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! Resource identifiers (`Identifier`).
|
||||
|
||||
/// Identifier.parse: `ns:path` or `path`, validated.
|
||||
pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
let (ns, path) = match raw.split_once(':') {
|
||||
Some(("", path)) => ("minecraft", path),
|
||||
Some((ns, path)) => (ns, path),
|
||||
None => ("minecraft", raw),
|
||||
};
|
||||
let ns_ok = ns != ".."
|
||||
&& ns
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
|
||||
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
|
||||
/// characters, `minecraft` when the namespace is left out.
|
||||
pub fn normalize_id(s: &str) -> Option<String> {
|
||||
let (ns, path) = match s.split_once(':') {
|
||||
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
|
||||
None => ("minecraft", s),
|
||||
};
|
||||
let ok = |c: char, slash: bool| {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| matches!(c, '_' | '-' | '.')
|
||||
|| slash && c == '/'
|
||||
};
|
||||
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
|
||||
.then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//! 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.
|
||||
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);
|
||||
}
|
||||
if let Some(n) = key.strip_prefix("key.mouse.")
|
||||
&& n.parse::<u32>().is_ok()
|
||||
&& !crate::lang::has(key)
|
||||
{
|
||||
return Text::translate("key.mouse", &[Text::literal(n)]);
|
||||
}
|
||||
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(), "/");
|
||||
}
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
//! Languages (ClientLanguage / LanguageManager): `lang/<code>.json` from the
|
||||
//! resource root and enabled packs layered over `en_us`, the language list
|
||||
//! from `pack.mcmeta` `language` sections, and a process-wide current
|
||||
//! language used by every screen.
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LanguageInfo {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub region: String,
|
||||
pub bidirectional: bool,
|
||||
}
|
||||
impl LanguageInfo {
|
||||
/// LanguageInfo.toComponent: "Name (Region)".
|
||||
pub fn label(&self) -> String {
|
||||
format!("{} ({})", self.name, self.region)
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Language {
|
||||
pub code: String,
|
||||
/// `LanguageInfo.bidirectional`: text without a strong character reads
|
||||
/// right to left (`ClientLanguage.defaultRightToLeft`).
|
||||
bidirectional: bool,
|
||||
map: HashMap<String, String>,
|
||||
}
|
||||
pub const DEFAULT: &str = "en_us";
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::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()]
|
||||
}
|
||||
}
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
"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())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
vec!["horizon".into()]
|
||||
}
|
||||
}
|
||||
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()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// DeprecatedTranslationsInfo.applyToMap: drop removed keys, then move each
|
||||
/// renamed key's text to its new key (a missing old key removes the new one).
|
||||
pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
|
||||
let Ok(info) = serde_json::from_slice::<serde_json::Value>(json) else {
|
||||
return;
|
||||
};
|
||||
for key in info["removed"].as_array().into_iter().flatten() {
|
||||
if let Some(k) = key.as_str() {
|
||||
map.remove(k);
|
||||
}
|
||||
}
|
||||
for (from, to) in info["renamed"].as_object().into_iter().flatten() {
|
||||
let Some(to) = to.as_str() else { continue };
|
||||
match map.remove(from) {
|
||||
Some(value) => {
|
||||
map.insert(to.to_owned(), value);
|
||||
}
|
||||
None => {
|
||||
map.remove(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// ClientLanguage.loadFrom: en_us first, then the selected language, each
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
/// override).
|
||||
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
let mut codes = vec![DEFAULT];
|
||||
if code != DEFAULT {
|
||||
codes.push(code);
|
||||
}
|
||||
// Horizon's own strings first, so a pack or the prepared root can still
|
||||
// override them. Vanilla has no key for "Controller vibration".
|
||||
let bundled = Bundled;
|
||||
let mut all: Vec<&dyn ResourceSource> = vec![&bundled];
|
||||
all.extend_from_slice(sources);
|
||||
let sources = all.as_slice();
|
||||
let namespaces: std::collections::BTreeSet<String> =
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
for namespace in &namespaces {
|
||||
for source in sources {
|
||||
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(&bytes) {
|
||||
map.extend(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// DeprecatedTranslationsInfo.loadFromDefaultResource().applyToMap: the
|
||||
// game's own deprecated.json, not a resource pack's.
|
||||
if let Some(bytes) = sources
|
||||
.iter()
|
||||
.find_map(|s| s.read("assets/minecraft/lang/deprecated.json"))
|
||||
{
|
||||
apply_deprecated(&mut map, &bytes);
|
||||
}
|
||||
let bidirectional = available(sources)
|
||||
.iter()
|
||||
.any(|info| info.code == code && info.bidirectional);
|
||||
Self {
|
||||
code: code.to_owned(),
|
||||
bidirectional,
|
||||
map,
|
||||
}
|
||||
}
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.map.get(key).map(String::as_str)
|
||||
}
|
||||
/// The resolved map, for `horizon_protocol::text::plain`: a server's
|
||||
/// components carry keys, and the network layer resolves them with this.
|
||||
pub fn map(&self) -> &HashMap<String, String> {
|
||||
&self.map
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Languages declared by `pack.mcmeta` files (the vanilla asset root first).
|
||||
pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
|
||||
let mut found: BTreeMap<String, LanguageInfo> = BTreeMap::new();
|
||||
found.insert(
|
||||
DEFAULT.into(),
|
||||
LanguageInfo {
|
||||
code: DEFAULT.into(),
|
||||
name: "English".into(),
|
||||
region: "US".into(),
|
||||
bidirectional: false,
|
||||
},
|
||||
);
|
||||
for source in sources {
|
||||
for path in ["assets/pack.mcmeta", "pack.mcmeta"] {
|
||||
let Some(bytes) = source.read(path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
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)) {
|
||||
continue;
|
||||
}
|
||||
found.insert(
|
||||
code.clone(),
|
||||
LanguageInfo {
|
||||
code: code.clone(),
|
||||
name: info["name"].as_str().unwrap_or(code).to_owned(),
|
||||
region: info["region"].as_str().unwrap_or("").to_owned(),
|
||||
bidirectional: info["bidirectional"].as_bool().unwrap_or(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.into_values().collect()
|
||||
}
|
||||
|
||||
pub fn set(language: Language) {
|
||||
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
/// `ClientLanguage.defaultRightToLeft` of the current language.
|
||||
pub fn bidirectional() -> bool {
|
||||
BIDIRECTIONAL.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn code() -> String {
|
||||
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
|
||||
}
|
||||
/// Language.getOrDefault: the translation, or the key itself.
|
||||
pub fn get(key: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::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(|| fallback.to_owned())
|
||||
}
|
||||
pub fn has(key: &str) -> bool {
|
||||
current().is_some_and(|l| l.get(key).is_some())
|
||||
}
|
||||
/// I18n.get with plain string arguments.
|
||||
pub fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
crate::text::Text::tr(key, args).plain()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
struct Mem(HashMap<String, Vec<u8>>);
|
||||
impl ResourceSource for Mem {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.get(path).cloned()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bundled_horizon_strings_follow_the_language() {
|
||||
// No prepared resources at all: the namespace is compiled in.
|
||||
let empty = Mem(HashMap::new());
|
||||
let en = Language::load(&[&empty], "en_us");
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
body.lines()
|
||||
.filter(|line| line.starts_with(" \""))
|
||||
.map(|line| {
|
||||
serde_json::Deserializer::from_str(line.trim_start())
|
||||
.into_iter::<String>()
|
||||
.next()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_else(|| panic!("no key on line {line}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
|
||||
/// percent sign that starts none of them ("100% is") is plain text.
|
||||
fn placeholders(text: &str) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find('%') {
|
||||
rest = &rest[at + 1..];
|
||||
let spec = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
|
||||
.unwrap_or(rest.len());
|
||||
let after = rest[spec..].chars().next();
|
||||
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
|
||||
let end = spec + 1;
|
||||
found.push(format!("%{}", &rest[..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
};
|
||||
seen += 1;
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
let map: HashMap<String, String> = serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
|
||||
let keys = file_keys(&body);
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
map.len(),
|
||||
"{name} repeats a key or has a multi-line entry"
|
||||
);
|
||||
for (key, value) in &map {
|
||||
// Stale: the code no longer asks for it, or it was renamed in en_us only.
|
||||
let english = en
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
|
||||
// A translation keeps the fields the code fills in.
|
||||
assert_eq!(
|
||||
placeholders(value),
|
||||
placeholders(english),
|
||||
"{code}: {key} changes the placeholders of the English text"
|
||||
);
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_horizon_key_the_code_names_is_in_en_us() {
|
||||
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
"horizon-client",
|
||||
"horizon-client-render",
|
||||
"horizon-client-core",
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
let text = std::fs::read_to_string(&file).unwrap();
|
||||
for (at, _) in text.match_indices("\"horizon.") {
|
||||
let rest = &text[at + 1..];
|
||||
let Some(end) = rest.find('"') else { continue };
|
||||
let key = &rest[..end];
|
||||
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
|
||||
// between the dots. A key built at run time has a brace; a file name
|
||||
// has one dot; the server addresses that look like keys are listed.
|
||||
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
let key_like = key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
|
||||
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
assert!(
|
||||
en.contains_key(key),
|
||||
"{} names {key}, which is not in en_us.json",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(found > 50, "found only {found} key names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
("assets/minecraft/lang/en_us.json".into(), br#"{"a":"Apple","b":"Banana"}"#.to_vec()),
|
||||
("assets/minecraft/lang/fr_fr.json".into(), br#"{"a":"Pomme"}"#.to_vec()),
|
||||
("assets/pack.mcmeta".into(), r#"{"language":{"fr_fr":{"name":"Français","region":"France"},"de_de":{"name":"Deutsch","region":"Deutschland"}}}"#.as_bytes().to_vec()),
|
||||
]));
|
||||
let pack = Mem(HashMap::from([(
|
||||
"assets/minecraft/lang/fr_fr.json".into(),
|
||||
br#"{"b":"Banane"}"#.to_vec(),
|
||||
)]));
|
||||
let sources: [&dyn ResourceSource; 2] = [&base, &pack];
|
||||
let fr = Language::load(&sources, "fr_fr");
|
||||
assert_eq!(fr.get("a"), Some("Pomme"));
|
||||
assert_eq!(fr.get("b"), Some("Banane"));
|
||||
let langs = available(&sources);
|
||||
// de_de has no file, so only English and French are offered.
|
||||
assert_eq!(
|
||||
langs.iter().map(|l| l.code.as_str()).collect::<Vec<_>>(),
|
||||
["en_us", "fr_fr"]
|
||||
);
|
||||
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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//! The client's lowest layer, below commands and gameplay: chat text and
|
||||
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
|
||||
//! bars, game rules, block entity NBT tables, and the registry, particle and
|
||||
//! sound source tables commands name, and the `pack.mcmeta` filter regex.
|
||||
//! horizon-client re-exports each module under its old path
|
||||
//! (`horizon_client::text`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod block_entity_nbt;
|
||||
pub mod bossbar;
|
||||
pub mod component;
|
||||
pub mod fastutil_order;
|
||||
pub mod gamerules;
|
||||
pub mod identifier;
|
||||
pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod nbt_value;
|
||||
pub mod particle_type;
|
||||
pub mod pattern;
|
||||
pub mod potions;
|
||||
pub mod registries;
|
||||
pub mod scoreboard;
|
||||
pub mod snbt;
|
||||
pub mod sound_source;
|
||||
pub mod text;
|
||||
pub mod uuid;
|
||||
pub mod who;
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
@@ -1,32 +0,0 @@
|
||||
//! Reading and writing single NBT values the way vanilla's codecs accept them.
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
pub fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
pub fn text(tag: Option<&Tag>) -> Option<String> {
|
||||
match tag {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
|
||||
pub fn number(tag: Option<&Tag>) -> Option<i64> {
|
||||
Some(match tag? {
|
||||
Tag::Byte(v) => i64::from(*v),
|
||||
Tag::Short(v) => i64::from(*v),
|
||||
Tag::Int(v) => i64::from(*v),
|
||||
Tag::Long(v) => *v,
|
||||
Tag::Float(b) => f32::from_bits(*b) as i64,
|
||||
Tag::Double(b) => f64::from_bits(*b) as i64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Any numeric tag as f64, keeping a float's or double's fraction.
|
||||
pub fn float(tag: Option<&Tag>) -> Option<f64> {
|
||||
Some(match tag? {
|
||||
Tag::Float(b) => f64::from(f32::from_bits(*b)),
|
||||
Tag::Double(b) => f64::from_bits(*b),
|
||||
other => number(Some(other))? as f64,
|
||||
})
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
//! Particle types (`ParticleTypes`): the vanilla particle types with a client
|
||||
//! implementation, by registry name, and their per-type flags.
|
||||
|
||||
macro_rules! particle_types {
|
||||
($($variant:ident = $name:literal),* $(,)?) => {
|
||||
/// Vanilla particle types with a client implementation here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ParticleType { $($variant),* }
|
||||
impl ParticleType {
|
||||
pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*];
|
||||
/// Registry path (`minecraft:<name>`), also the sprite-set file name.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self { $(ParticleType::$variant => $name),* }
|
||||
}
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
match name { $($name => Some(ParticleType::$variant),)* _ => None }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
particle_types! {
|
||||
Block = "block",
|
||||
BlockCrumble = "block_crumble",
|
||||
Trail = "trail",
|
||||
DustPillar = "dust_pillar",
|
||||
FallingDust = "falling_dust",
|
||||
Smoke = "smoke",
|
||||
LargeSmoke = "large_smoke",
|
||||
WhiteSmoke = "white_smoke",
|
||||
Flame = "flame",
|
||||
SoulFireFlame = "soul_fire_flame",
|
||||
CopperFireFlame = "copper_fire_flame",
|
||||
SmallFlame = "small_flame",
|
||||
Lava = "lava",
|
||||
DrippingWater = "dripping_water",
|
||||
FallingWater = "falling_water",
|
||||
DrippingLava = "dripping_lava",
|
||||
FallingLava = "falling_lava",
|
||||
LandingLava = "landing_lava",
|
||||
DrippingHoney = "dripping_honey",
|
||||
FallingHoney = "falling_honey",
|
||||
LandingHoney = "landing_honey",
|
||||
FallingNectar = "falling_nectar",
|
||||
FallingSporeBlossom = "falling_spore_blossom",
|
||||
SporeBlossomAir = "spore_blossom_air",
|
||||
DrippingObsidianTear = "dripping_obsidian_tear",
|
||||
FallingObsidianTear = "falling_obsidian_tear",
|
||||
LandingObsidianTear = "landing_obsidian_tear",
|
||||
DrippingDripstoneWater = "dripping_dripstone_water",
|
||||
FallingDripstoneWater = "falling_dripstone_water",
|
||||
DrippingDripstoneLava = "dripping_dripstone_lava",
|
||||
FallingDripstoneLava = "falling_dripstone_lava",
|
||||
Splash = "splash",
|
||||
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",
|
||||
Underwater = "underwater",
|
||||
Portal = "portal",
|
||||
ReversePortal = "reverse_portal",
|
||||
Enchant = "enchant",
|
||||
Nautilus = "nautilus",
|
||||
VaultConnection = "vault_connection",
|
||||
Crit = "crit",
|
||||
EnchantedHit = "enchanted_hit",
|
||||
DamageIndicator = "damage_indicator",
|
||||
Heart = "heart",
|
||||
AngryVillager = "angry_villager",
|
||||
HappyVillager = "happy_villager",
|
||||
Composter = "composter",
|
||||
Mycelium = "mycelium",
|
||||
Dolphin = "dolphin",
|
||||
EggCrack = "egg_crack",
|
||||
Poof = "poof",
|
||||
Explosion = "explosion",
|
||||
ExplosionEmitter = "explosion_emitter",
|
||||
CampfireCosySmoke = "campfire_cosy_smoke",
|
||||
CampfireSignalSmoke = "campfire_signal_smoke",
|
||||
Note = "note",
|
||||
Dust = "dust",
|
||||
TotemOfUndying = "totem_of_undying",
|
||||
Firework = "firework",
|
||||
EndRod = "end_rod",
|
||||
Ash = "ash",
|
||||
WhiteAsh = "white_ash",
|
||||
CrimsonSpore = "crimson_spore",
|
||||
WarpedSpore = "warped_spore",
|
||||
CherryLeaves = "cherry_leaves",
|
||||
PaleOakLeaves = "pale_oak_leaves",
|
||||
TintedLeaves = "tinted_leaves",
|
||||
Effect = "effect",
|
||||
InstantEffect = "instant_effect",
|
||||
EntityEffect = "entity_effect",
|
||||
Witch = "witch",
|
||||
Infested = "infested",
|
||||
Cloud = "cloud",
|
||||
Sneeze = "sneeze",
|
||||
Snowflake = "snowflake",
|
||||
Soul = "soul",
|
||||
SculkSoul = "sculk_soul",
|
||||
DragonBreath = "dragon_breath",
|
||||
Gust = "gust",
|
||||
SmallGust = "small_gust",
|
||||
GustEmitterLarge = "gust_emitter_large",
|
||||
GustEmitterSmall = "gust_emitter_small",
|
||||
DustColorTransition = "dust_color_transition",
|
||||
SculkCharge = "sculk_charge",
|
||||
SculkChargePop = "sculk_charge_pop",
|
||||
Shriek = "shriek",
|
||||
SonicBoom = "sonic_boom",
|
||||
Vibration = "vibration",
|
||||
SweepAttack = "sweep_attack",
|
||||
Glow = "glow",
|
||||
WaxOn = "wax_on",
|
||||
WaxOff = "wax_off",
|
||||
ElectricSpark = "electric_spark",
|
||||
Scrape = "scrape",
|
||||
SquidInk = "squid_ink",
|
||||
GlowSquidInk = "glow_squid_ink",
|
||||
Item = "item",
|
||||
ItemSlime = "item_slime",
|
||||
ItemSnowball = "item_snowball",
|
||||
ItemCobweb = "item_cobweb",
|
||||
SulfurCubeGoo = "sulfur_cube_goo",
|
||||
Flash = "flash",
|
||||
Firefly = "firefly",
|
||||
TrialSpawnerDetection = "trial_spawner_detection",
|
||||
TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous",
|
||||
TrialOmen = "trial_omen",
|
||||
RaidOmen = "raid_omen",
|
||||
OminousSpawning = "ominous_spawning",
|
||||
Spit = "spit",
|
||||
Fishing = "fishing",
|
||||
DustPlume = "dust_plume",
|
||||
RedPoplarLeaves = "red_poplar_leaves",
|
||||
OrangePoplarLeaves = "orange_poplar_leaves",
|
||||
YellowPoplarLeaves = "yellow_poplar_leaves",
|
||||
PauseMobGrowth = "pause_mob_growth",
|
||||
ResetMobGrowth = "reset_mob_growth",
|
||||
BlockMarker = "block_marker",
|
||||
}
|
||||
impl ParticleType {
|
||||
/// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`):
|
||||
/// spawned regardless of distance.
|
||||
pub fn override_limiter(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::BlockMarker
|
||||
| Self::Geyser
|
||||
| Self::GeyserBase
|
||||
| Self::GeyserPoof
|
||||
| Self::GeyserPlume
|
||||
| Self::DamageIndicator
|
||||
| Self::ExplosionEmitter
|
||||
| Self::Explosion
|
||||
| Self::Gust
|
||||
| Self::GustEmitterLarge
|
||||
| Self::GustEmitterSmall
|
||||
| Self::SonicBoom
|
||||
| Self::SculkCharge
|
||||
| Self::SculkChargePop
|
||||
| Self::Vibration
|
||||
| Self::Poof
|
||||
| Self::Spit
|
||||
| Self::SquidInk
|
||||
| Self::SweepAttack
|
||||
| Self::CampfireCosySmoke
|
||||
| Self::CampfireSignalSmoke
|
||||
| Self::GlowSquidInk
|
||||
| Self::Glow
|
||||
| Self::WaxOn
|
||||
| Self::WaxOff
|
||||
| Self::ElectricSpark
|
||||
| Self::Scrape
|
||||
| Self::TrialSpawnerDetection
|
||||
| Self::TrialSpawnerDetectionOminous
|
||||
| Self::VaultConnection
|
||||
| Self::OminousSpawning
|
||||
)
|
||||
}
|
||||
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
|
||||
/// hardcodes the translucent layer, drawn after translucent terrain.
|
||||
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
|
||||
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
|
||||
/// sprite set on the engine.
|
||||
pub fn translucent_layer(self) -> bool {
|
||||
match self {
|
||||
// CampfireSmokeParticle.
|
||||
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
|
||||
// PlayerCloudParticle.
|
||||
| Self::Cloud | Self::Sneeze
|
||||
// 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,
|
||||
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
|
||||
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
|
||||
| Self::Vibration | Self::Firefly | Self::Flash
|
||||
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
|
||||
// SparkParticle (firework), SquidInkParticle, TotemParticle.
|
||||
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
|
||||
| Self::TotemOfUndying
|
||||
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
|
||||
| Self::VaultConnection => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
|
||||
//! tables): each effect's colour and category, each potion's effects, and the
|
||||
//! potion colour mix. horizon-client re-exports them from `brewing`.
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Category {
|
||||
Beneficial,
|
||||
Harmful,
|
||||
Neutral,
|
||||
}
|
||||
/// A `MobEffect`: id (vanilla's without namespace, a mod's `<ns>:<path>`),
|
||||
/// colour, category, instantaneous.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EffectDef {
|
||||
pub id: &'static str,
|
||||
pub color: u32,
|
||||
pub category: Category,
|
||||
pub instant: bool,
|
||||
}
|
||||
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
|
||||
EffectDef {
|
||||
id,
|
||||
color,
|
||||
category,
|
||||
instant: false,
|
||||
}
|
||||
}
|
||||
use Category::{Beneficial as B, Harmful as H, Neutral as N};
|
||||
/// `MobEffects` in registration order.
|
||||
pub const EFFECTS: &[EffectDef] = &[
|
||||
fx("speed", 3402751, B),
|
||||
fx("slowness", 9154528, H),
|
||||
fx("haste", 14270531, B),
|
||||
fx("mining_fatigue", 4866583, H),
|
||||
fx("strength", 16762624, B),
|
||||
EffectDef {
|
||||
id: "instant_health",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
EffectDef {
|
||||
id: "instant_damage",
|
||||
color: 11101546,
|
||||
category: H,
|
||||
instant: true,
|
||||
},
|
||||
fx("jump_boost", 16646020, B),
|
||||
fx("nausea", 5578058, H),
|
||||
fx("regeneration", 13458603, B),
|
||||
fx("resistance", 9520880, B),
|
||||
fx("fire_resistance", 16750848, B),
|
||||
fx("water_breathing", 10017472, B),
|
||||
fx("invisibility", 16185078, B),
|
||||
fx("blindness", 2039587, H),
|
||||
fx("night_vision", 12779366, B),
|
||||
fx("hunger", 5797459, H),
|
||||
fx("weakness", 4738376, H),
|
||||
fx("poison", 8889187, H),
|
||||
fx("wither", 7561558, H),
|
||||
fx("health_boost", 16284963, B),
|
||||
fx("absorption", 2445989, B),
|
||||
EffectDef {
|
||||
id: "saturation",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
fx("glowing", 9740385, N),
|
||||
fx("levitation", 13565951, H),
|
||||
fx("luck", 5882118, B),
|
||||
fx("unluck", 12624973, H),
|
||||
fx("slow_falling", 15978425, B),
|
||||
fx("conduit_power", 1950417, B),
|
||||
fx("dolphins_grace", 8954814, B),
|
||||
fx("bad_omen", 745784, N),
|
||||
fx("hero_of_the_village", 4521796, B),
|
||||
fx("darkness", 2696993, H),
|
||||
fx("trial_omen", 1484454, N),
|
||||
fx("raid_omen", 14565464, N),
|
||||
fx("wind_charged", 12438015, H),
|
||||
fx("weaving", 7891290, H),
|
||||
fx("oozing", 10092451, H),
|
||||
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),
|
||||
}
|
||||
}
|
||||
/// 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.
|
||||
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()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
|
||||
/// effects as (effect, duration ticks, amplifier).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PotionDef {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub effects: &'static [(&'static str, i32, i32)],
|
||||
}
|
||||
const fn p(
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
) -> PotionDef {
|
||||
PotionDef { id, name, effects }
|
||||
}
|
||||
/// `Potions` in registration order.
|
||||
pub const POTIONS: &[PotionDef] = &[
|
||||
p("water", "water", &[]),
|
||||
p("mundane", "mundane", &[]),
|
||||
p("thick", "thick", &[]),
|
||||
p("awkward", "awkward", &[]),
|
||||
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
|
||||
p(
|
||||
"long_night_vision",
|
||||
"night_vision",
|
||||
&[("night_vision", 9600, 0)],
|
||||
),
|
||||
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
|
||||
p(
|
||||
"long_invisibility",
|
||||
"invisibility",
|
||||
&[("invisibility", 9600, 0)],
|
||||
),
|
||||
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
|
||||
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
|
||||
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
|
||||
p(
|
||||
"fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 9600, 0)],
|
||||
),
|
||||
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
|
||||
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
|
||||
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
|
||||
p("slowness", "slowness", &[("slowness", 1800, 0)]),
|
||||
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
|
||||
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
|
||||
p(
|
||||
"turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 3), ("resistance", 400, 2)],
|
||||
),
|
||||
p(
|
||||
"long_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 800, 3), ("resistance", 800, 2)],
|
||||
),
|
||||
p(
|
||||
"strong_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 5), ("resistance", 400, 3)],
|
||||
),
|
||||
p(
|
||||
"water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 9600, 0)],
|
||||
),
|
||||
p("healing", "healing", &[("instant_health", 1, 0)]),
|
||||
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
|
||||
p("harming", "harming", &[("instant_damage", 1, 0)]),
|
||||
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
|
||||
p("poison", "poison", &[("poison", 900, 0)]),
|
||||
p("long_poison", "poison", &[("poison", 1800, 0)]),
|
||||
p("strong_poison", "poison", &[("poison", 432, 1)]),
|
||||
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
|
||||
p(
|
||||
"long_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 1800, 0)],
|
||||
),
|
||||
p(
|
||||
"strong_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 450, 1)],
|
||||
),
|
||||
p("strength", "strength", &[("strength", 3600, 0)]),
|
||||
p("long_strength", "strength", &[("strength", 9600, 0)]),
|
||||
p("strong_strength", "strength", &[("strength", 1800, 1)]),
|
||||
p("weakness", "weakness", &[("weakness", 1800, 0)]),
|
||||
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
|
||||
p("luck", "luck", &[("luck", 6000, 0)]),
|
||||
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
|
||||
p(
|
||||
"long_slow_falling",
|
||||
"slow_falling",
|
||||
&[("slow_falling", 4800, 0)],
|
||||
),
|
||||
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
|
||||
p("weaving", "weaving", &[("weaving", 3600, 0)]),
|
||||
p("oozing", "oozing", &[("oozing", 3600, 0)]),
|
||||
p("infested", "infested", &[("infested", 3600, 0)]),
|
||||
];
|
||||
pub fn potion(id: &str) -> Option<&'static PotionDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
POTIONS.iter().find(|p| p.id == id)
|
||||
}
|
||||
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
|
||||
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
|
||||
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
|
||||
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 {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
total += w;
|
||||
}
|
||||
match (
|
||||
r.checked_div(total),
|
||||
g.checked_div(total),
|
||||
b.checked_div(total),
|
||||
) {
|
||||
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
|
||||
_ => 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
//! Registry contents and source state that commands validate and complete
|
||||
//! arguments against (what the server's registry access holds).
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Registry contents and source state used to validate and complete arguments.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Registries {
|
||||
/// Permission level of the command source.
|
||||
pub permission: u8,
|
||||
pub items: Vec<String>,
|
||||
pub item_tags: Vec<String>,
|
||||
/// Block id -> (property, allowed values).
|
||||
pub blocks: BTreeMap<String, Vec<(String, Vec<String>)>>,
|
||||
pub block_tags: Vec<String>,
|
||||
/// Entity types that `summon` can create.
|
||||
pub summonable: Vec<String>,
|
||||
/// Every entity type (selector `type=`).
|
||||
pub entity_types: Vec<String>,
|
||||
pub effects: Vec<String>,
|
||||
pub enchantments: Vec<String>,
|
||||
pub structures: Vec<String>,
|
||||
pub biomes: Vec<String>,
|
||||
pub dimensions: Vec<String>,
|
||||
pub clocks: Vec<String>,
|
||||
pub timelines: Vec<String>,
|
||||
pub time_markers: Vec<String>,
|
||||
pub advancements: Vec<String>,
|
||||
/// 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>,
|
||||
pub tracked: Vec<String>,
|
||||
/// Loaded datapack functions (`ns:path`) and function tags (`ns:path`).
|
||||
pub functions: Vec<String>,
|
||||
/// `/data ... storage` ids.
|
||||
pub storages: Vec<String>,
|
||||
/// Boss bar ids and datapack predicate ids.
|
||||
pub bossbars: Vec<String>,
|
||||
pub predicates: Vec<String>,
|
||||
pub item_modifiers: Vec<String>,
|
||||
pub schedules: Vec<String>,
|
||||
pub stopwatches: Vec<String>,
|
||||
pub recipes: Vec<String>,
|
||||
pub sounds: Vec<String>,
|
||||
pub damage_types: Vec<String>,
|
||||
pub attributes: Vec<String>,
|
||||
pub loot_tables: Vec<String>,
|
||||
pub templates: Vec<String>,
|
||||
pub random_sequences: Vec<String>,
|
||||
pub datapacks_enabled: Vec<String>,
|
||||
pub datapacks_disabled: Vec<String>,
|
||||
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>,
|
||||
/// Blocks whose default state has a block entity.
|
||||
pub block_entities: std::collections::BTreeSet<String>,
|
||||
/// Block tag -> member blocks.
|
||||
pub block_tag_members: BTreeMap<String, Vec<String>>,
|
||||
/// Data component types with a codec (not transient).
|
||||
pub persistent_components: Vec<String>,
|
||||
/// Recipes `/recipe` can give (not special).
|
||||
pub learnable_recipes: Vec<String>,
|
||||
/// Advancement id -> criterion names.
|
||||
pub advancement_criteria: BTreeMap<String, Vec<String>>,
|
||||
/// World clock -> time markers shown in commands.
|
||||
pub clock_time_markers: BTreeMap<String, Vec<String>>,
|
||||
/// Trigger objectives the source may use (`/trigger` suggestions).
|
||||
pub trigger_objectives: Vec<String>,
|
||||
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
|
||||
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
|
||||
}
|
||||
/// The ids and tag ids of one registry.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RegistryIds {
|
||||
pub ids: Vec<String>,
|
||||
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
|
||||
/// mob effects, data component types and dialogs, block entities and tag
|
||||
/// members. Registries that data packs reload stay with the game.
|
||||
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
|
||||
fn strings(v: &serde_json::Value) -> Vec<String> {
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
let fill = |field: &mut Vec<String>, key: &str| {
|
||||
if field.is_empty() {
|
||||
*field = strings(&r[key]);
|
||||
}
|
||||
};
|
||||
fill(&mut self.effects, "effects");
|
||||
fill(&mut self.enchantments, "enchantments");
|
||||
fill(&mut self.attributes, "attributes");
|
||||
fill(&mut self.damage_types, "damage_types");
|
||||
fill(&mut self.templates, "templates");
|
||||
fill(&mut self.persistent_components, "persistent_components");
|
||||
fill(&mut self.learnable_recipes, "learnable_recipes");
|
||||
const RELOADED: [&str; 5] = [
|
||||
"minecraft:advancement",
|
||||
"minecraft:recipe",
|
||||
"minecraft:loot_table",
|
||||
"minecraft:predicate",
|
||||
"minecraft:item_modifier",
|
||||
];
|
||||
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
|
||||
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
self.by_registry.insert(
|
||||
name.clone(),
|
||||
RegistryIds {
|
||||
ids: strings(&entry["ids"]),
|
||||
tags: strings(&entry["tags"]),
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.block_entities.is_empty() {
|
||||
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
|
||||
}
|
||||
if self.block_tag_members.is_empty() {
|
||||
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
|
||||
self.block_tag_members.insert(tag.clone(), strings(members));
|
||||
}
|
||||
}
|
||||
if self.clock_time_markers.is_empty() {
|
||||
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
|
||||
self.clock_time_markers
|
||||
.insert(clock.clone(), strings(markers));
|
||||
}
|
||||
}
|
||||
if self.advancement_criteria.is_empty() {
|
||||
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
|
||||
self.advancement_criteria
|
||||
.insert(id.clone(), strings(criteria));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
return &r.ids;
|
||||
}
|
||||
match registry {
|
||||
"minecraft:item" => &self.items,
|
||||
"minecraft:entity_type" => &self.entity_types,
|
||||
"minecraft:mob_effect" => &self.effects,
|
||||
"minecraft:enchantment" => &self.enchantments,
|
||||
"minecraft:worldgen/structure" => &self.structures,
|
||||
"minecraft:worldgen/biome" => &self.biomes,
|
||||
"minecraft:world_clock" => &self.clocks,
|
||||
"minecraft:timeline" => &self.timelines,
|
||||
"minecraft:attribute" => &self.attributes,
|
||||
"minecraft:damage_type" => &self.damage_types,
|
||||
"minecraft:sound_event" => &self.sounds,
|
||||
"minecraft:predicate" => &self.predicates,
|
||||
"minecraft:item_modifier" => &self.item_modifiers,
|
||||
"minecraft:loot_table" => &self.loot_tables,
|
||||
"minecraft:recipe" => &self.recipes,
|
||||
"minecraft:advancement" => &self.advancements,
|
||||
_ => EMPTY,
|
||||
}
|
||||
}
|
||||
/// The tag ids of a registry (sorted).
|
||||
pub fn tags(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
return &r.tags;
|
||||
}
|
||||
match registry {
|
||||
"minecraft:item" => &self.item_tags,
|
||||
"minecraft:block" => &self.block_tags,
|
||||
_ => EMPTY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//! Sound sources (`SoundSource`), by name.
|
||||
|
||||
/// SoundSource names in vanilla order.
|
||||
pub const SOUND_SOURCES: [&str; 11] = [
|
||||
"master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient",
|
||||
"voice", "ui",
|
||||
];
|
||||
@@ -1,24 +0,0 @@
|
||||
//! UUIDs as vanilla's four-int form, to and from their text form.
|
||||
|
||||
pub fn uuid_text(u: [i32; 4]) -> String {
|
||||
let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect();
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&hex[0..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..32]
|
||||
)
|
||||
}
|
||||
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
|
||||
let hex: String = s.chars().filter(|c| *c != '-').collect();
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0i32; 4];
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
Loaded 100 of 3201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user