Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 3be9ca142a horizon-bots: report each idle bot's first full view time
view_after_ms: from asking to join to the last chunk new to the bot.
For a bot that stands still, that is when its whole first view arrived.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:38:40 +01:00
1362 changed files with 16501 additions and 314920 deletions

No files matched your search

-6
View File
@@ -21,12 +21,6 @@
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
## Other projects' code
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". -->
## Self-check
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
-1
View File
@@ -7,4 +7,3 @@ kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
+1 -3
View File
@@ -9,9 +9,7 @@ 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:
+11 -40
View File
@@ -24,23 +24,14 @@ env:
HORIZON_TELEMETRY_URL: "off"
jobs:
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
#
# Where: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
# Python and the Rust cache steps are GitHub's only.
verify:
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
- name: Does this pull request need Rust checks?
@@ -50,33 +41,20 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
- name: Keep game artifacts out of Git
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python tools/check_public_tree.py
if [ "$EVENT" = pull_request ]; then
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
else
python tools/check_public_tree.py --history
fi
python tools/check_public_tree.py --history
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- 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' }}
@@ -89,8 +67,6 @@ jobs:
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: |
@@ -103,8 +79,8 @@ jobs:
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
@@ -118,16 +94,12 @@ jobs:
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust and the headers GPUI links against
if: steps.launcher.outputs.required != 'false'
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
fi
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
if: steps.launcher.outputs.required != 'false'
with:
workspaces: launcher
shared-key: launcher
@@ -144,8 +116,7 @@ jobs:
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
parity-scope:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
+2 -4
View File
@@ -29,8 +29,7 @@ jobs:
# 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' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
@@ -56,8 +55,7 @@ jobs:
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' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+10 -23
View File
@@ -1,7 +1,6 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
# which never runs what this one built. See docs/release-flow.md.
@@ -105,13 +104,6 @@ jobs:
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
# programs under Rosetta 2.
- name: macos-x64
mac: true
target: x86_64-apple-darwin
dir: horizon-macos-x64
asset: horizon-macos-x64.zip
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
@@ -122,8 +114,6 @@ jobs:
HORIZON_VERSION: ${{ inputs.version }}
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
HORIZON_NIGHTLY: ${{ inputs.nightly }}
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
defaults:
run:
shell: bash
@@ -138,8 +128,6 @@ jobs:
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -150,11 +138,11 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
@@ -169,15 +157,15 @@ jobs:
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
codesign --verify --strict --verbose=2 "$BIN/$binary"
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="$BIN/$binary${{ matrix.exe }}"
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
@@ -188,7 +176,7 @@ jobs:
- name: Every program starts
run: |
set -euo pipefail
bin=$BIN
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
@@ -214,7 +202,7 @@ jobs:
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/$BIN/horizon-client"
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
@@ -229,9 +217,8 @@ jobs:
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted' && !matrix.target
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
@@ -244,7 +231,7 @@ jobs:
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
+11 -50
View File
@@ -8,11 +8,7 @@ name: launcher
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
# commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
# itself instead (launcher/src/backend.rs).
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
@@ -22,7 +18,7 @@ on:
push:
branches: [main, launcher, 'launcher-*']
paths:
# Everything the launcher is built and packaged from, as tools/release_train.py's LAUNCHER_PATHS.
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
@@ -32,8 +28,6 @@ on:
- 'crates/horizon-release-sign/**'
- 'crates/horizon-telemetry/**'
- 'crates/horizon-theme/**'
- 'packaging/linux/**'
- 'packaging/icons/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
@@ -111,13 +105,6 @@ jobs:
mac: true
asset: horizon-launcher-macos-arm64.zip
binary: horizon-launcher
mac_arch: arm64
- name: macos-x64
mac: true
target: x86_64-apple-darwin
asset: horizon-launcher-macos-x64.zip
binary: horizon-launcher
mac_arch: x64
- name: windows
os: windows-latest
asset: horizon-launcher-windows-x64.zip
@@ -130,8 +117,6 @@ jobs:
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
# variable, not a secret. Unset, the launcher says sign-in is not configured.
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
@@ -155,8 +140,6 @@ jobs:
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -166,7 +149,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev patchelf
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
- name: Metal shaders compiled now, or at run time without the Metal toolchain
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
@@ -177,17 +160,17 @@ jobs:
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
out=$(target/release/${{ matrix.binary }} --version)
echo "$out"
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
- name: Only system libraries
run: |
file="$BIN/${{ matrix.binary }}"
file=target/release/${{ matrix.binary }}
case "$RUNNER_OS" in
Windows) python ../tools/check_imports.py "$file" ;;
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
@@ -202,28 +185,13 @@ jobs:
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
rm -rf "$HORIZON_LAUNCHER_HOME"
launcher="$PWD/$BIN/${{ matrix.binary }}"
launcher="$PWD/target/release/${{ matrix.binary }}"
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
retry "$launcher" versions
"$launcher" links --json
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
# (exit 3); a launcher or a script that fails still fails the step.
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
versions=$(retry "$launcher" --json versions)
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
status=0
python3 -c "$no_build" <<<"$versions" || status=$?
if [[ $status == 3 ]]; then
echo "::warning::No game build has an asset for ${{ matrix.name }} yet: install not tested"
rm -rf "$HORIZON_LAUNCHER_HOME"
exit 0
fi
[[ $status == 0 ]] || exit $status
fi
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
client="${clients[0]}"
@@ -256,18 +224,11 @@ jobs:
# Developer ID): packaging/macos.
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
rm -rf out/Horizon.app
else
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
if [[ "$RUNNER_OS" == "Linux" ]]; then
# The .tar.gz below is what the launcher updates itself from; the AppImage is what a
# player downloads and double-clicks, and it replaces itself instead
# (packaging/linux/appimage.sh, launcher/src/backend.rs).
../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
fi
mkdir -p dist
cp "$BIN/${{ matrix.binary }}" dist/
cp target/release/${{ matrix.binary }} dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
else
-55
View File
@@ -1,55 +0,0 @@
name: native hook stamp
# Opens a PR that numbers pending fragments after they merge. Only pushes to
# main run main's own tool; fragments are read as data, not PR code.
on:
push:
branches: [main]
paths:
- 'tools/native-hook-fragments/*.json'
permissions:
contents: read
concurrency:
group: native-hook-stamp
cancel-in-progress: false
jobs:
stamp:
if: github.repository == 'horizon-rust/horizon'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Open a stamp pull request
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
branch=automation/native-hook-stamp
if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
echo "A stamp pull request is already open."
exit 0
fi
python3 tools/stamp_native_hooks.py
if git diff --quiet; then
echo "No fragments to stamp."
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git checkout -b "$branch"
git add -u
git commit -m 'Stamp native hook API fragments'
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "$branch"
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
+4 -6
View File
@@ -172,8 +172,7 @@ jobs:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -234,10 +233,9 @@ jobs:
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
+1 -3
View File
@@ -19,9 +19,7 @@ jobs:
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' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+1 -3
View File
@@ -16,9 +16,7 @@ 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' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
-1
View File
@@ -21,7 +21,6 @@
.DS_Store
# What the client writes to its working directory when run from the repo root.
/options.txt
/debug-profile.json
/horizon.txt
/horizon-graphics.txt
/horizon-controller.txt
+1 -12
View File
@@ -52,7 +52,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
@@ -68,16 +67,6 @@ unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Write tests with signal
- Test observable behavior, not words in docs, comments or source. Keep checks for machine-consumed contracts.
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
- Make slow tests faster without removing cases or weakening their coverage.
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
## Build and test fast
```sh
@@ -146,7 +135,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
+10 -32
View File
@@ -2,7 +2,7 @@
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/playhorizon).
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
@@ -47,31 +47,14 @@ them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code, or code whose license allows it
## Only original code
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
another project's source may also be read and used, **only when that project's license allows it**:
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
at your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
another project's source; they come back only as a clean-room rewrite from a written description of the pack
format.
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
had been written from another project's source; they come back only as a clean-room rewrite from a written
description of the pack format.
## Before asking for review
@@ -99,8 +82,7 @@ claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the
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.
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. 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.
@@ -113,10 +95,6 @@ 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.
@@ -140,5 +118,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)
[Discord](https://discord.gg/HcTeMZxe5E) 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)).
Generated
+33 -580
View File
File diff suppressed because it is too large. Load diff
+2 -10
View File
@@ -1,9 +1,8 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-narrator",
"crates/horizon-threads",
"crates/horizon-hdr",
"crates/horizon-world",
@@ -14,8 +13,6 @@ members = [
"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",
@@ -55,14 +52,9 @@ large_enum_variant = "allow"
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# And gfx-rs/wgpu#10714: on Windows, Vulkan waits for the acquire fence only in
# FIFO (VSync), where it was meant to pace frames; with VSync off it made every
# frame wait for the GPU to finish the one before (#4604).
# Drop this and the folder once a wgpu release has these fixes.
# Drop this and the folder once a wgpu release has both fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
naga = { path = "third_party/naga" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
+56 -37
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
</p>
<p align="center">
@@ -24,32 +24,34 @@
# Horizon
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
</tr>
</table>
## What works today
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
The [documentation](https://playhorizon.dev/docs/) covers each of these.
In the Rust client, with vanilla assets and no mods or shaders:
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
<table>
<tr>
@@ -67,15 +69,15 @@ In the Rust client, with vanilla assets and no mods or shaders:
## Host a server
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Modpacks
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
## Agent control
AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, reads its state as JSON and acts like a player; `horizon-ctl mcp` serves the same commands over MCP.
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
```sh
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
@@ -86,14 +88,16 @@ horizon-ctl break -1 65 -3 # mines it; replies with
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
```
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
## Build from source
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
```sh
git clone https://github.com/horizon-rust/horizon.git
@@ -103,57 +107,72 @@ cargo run --release -p horizon-prepare -- sounds # optional: sounds from your
cargo run --release -p horizon-client
```
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
## Roadmap
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
## Contributing
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
```sh
git config core.hooksPath .githooks
python3 tools/pr_ready.py
```
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
### Java parity: 100%
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
and verified against vanilla by differential tests). The percentage is the
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
2026-10-13 against the harness results: all 281 harnesses pass on Linux, each
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
them too (Rust uses Java's own float maths on each platform).
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
| Window, input and game loop | 1 | None: game loop, options, camera, auto-jump, toggle sprint/sneak and frame limiter match |
| Assets and resources | 1 | None: item models match over 196,290 stacks; the resource pack screen over 3,000 sessions |
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server matches vanilla's files, login, configuration, play and command tree; LAN discovery over 462 cases (offline mode only) |
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
| Assets and resources | 1 | None: item models match vanilla over 196,290 stacks and the client draws every item through them; the resource pack screen matches vanilla's drawing over 3,000 sessions of clicks, scrolling and searching, its incompatibility confirmation included |
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server (`--dedicated`) matches vanilla's files, status ping, login, configuration, play, chunks, chat, recipes, advancements and command tree, a client built from vanilla's packet code joins it, Horizon joins a vanilla server, and LAN discovery matches over 462 cases (offline mode only) |
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
| Rendering | 1 | None: entity models, block entity renderers (407 cases) and animations (296), HUD overlays (2,200 states), the recipe book (600), player list, subtitles and world border match |
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
| Region files and saves | 1 | None: saves round-trip with vanilla; worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do |
| Entities and physics | 1 | None: attributes, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900), `finalizeSpawn` (24,000) and mob AI (92 scenarios) match |
| Interaction and inventory | 1 | None: menu clicks over 8,000 sequences; recipe book placement (6,000), buttons (1,200), saved book (1,500), loom and crafter screens (1,600) |
| Menus, HUD and chat | 1 | None: every command (758 over 39,685 inputs), scoreboards (81,000 operations), the player list (872,000 draw calls), chat and its suggestions (3,000 sessions) match |
| Region files and saves | 1 | None: saves round-trip with vanilla, and worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do, superflat worlds included; old options.txt and hotbar.nbt files are upgraded too |
| Entities and physics | 1 | None: attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases), spawned mobs' gear, enchantments and state (`finalizeSpawn`, 24,000 cases) and mob AI (92 scenarios: villager breeding and gossip, the warden, ghastlings and copper golems included) match vanilla |
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
| Menus, HUD and chat | 1 | None: every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), the player list matches over 872,000 draw calls with heads drawn from players' skins (authlib's textures, SkinManager's lookup, legacy skin conversion), and the chat matches focused and scrolled; the resource pack screen (selection over 4,000 cases, drawing and its incompatibility confirmation over 3,000 sessions) and the chat input with its command suggestions, usage lines, Tab completion and history (3,000 sessions on vanilla's client command tree) match vanilla |
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Licence
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
## Author
+9 -9
View File
@@ -76,9 +76,9 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
<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).
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. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [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)
More: [#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>
@@ -326,11 +326,11 @@ A mapping table from intermediary and SRG names so pre-26.1 jars can be classifi
#### 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.
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
<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)).
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -490,7 +490,7 @@ 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.
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
More: [docs/native-mods.md](docs/native-mods.md)
@@ -718,7 +718,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
<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.
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 WebAssembly 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>
@@ -742,13 +742,13 @@ Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayla
</details>
#### A Nix flake
#### AppImage and a Nix flake
Install Horizon on NixOS through a Nix flake.
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). The launcher's AppImage ships with each launcher release ([packaging/linux/appimage.sh](packaging/linux/appimage.sh)): one file with the libraries GPUI needs, which replaces itself when it updates. A flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA) is still missing, the way horizon-parity's NixOS setup runs them today.
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
</details>
+2 -49
View File
@@ -3,17 +3,10 @@
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
developers, with one upstream fix applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## naga
`third_party/naga` is [naga](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs developers, with
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
@@ -26,43 +19,3 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
ports to WGSL of the optical flow and frame interpolation shaders of the
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
The launcher shows the logo of the mod loader an instance plays with.
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
unmodified (the launcher scales it up by whole pixels).
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
fetches them from their own sites when an instance needs them
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
the project endorses Horizon.
## Sign in with Microsoft
The launcher's sign-in buttons follow Microsoft's published branding guidelines for apps that sign in
with Microsoft accounts
([Sign in with Microsoft branding](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps)).
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
-457
View File
@@ -1,457 +0,0 @@
#!/usr/bin/env python3
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50
python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50
Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the
server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds:
- TPS: `time query gametime` at the start and the end of the window, as game ticks per wall-clock second;
- MSPT: `tick query` every 5 s (average, P50, P95, P99 over the last 100 ticks), averaged over the window;
- RSS and CPU of the server's whole process tree, once a second (needs `ps`: Linux and macOS);
- bandwidth: `bytes_in`/`bytes_out` from the bots' report, per player and second online (each bot's whole time
on the server, its join included), when the bots report them (they do not yet: the field is null until S-6); join time: the bots' `joined_after_ms`.
It writes one JSON report (--out) and prints the Markdown table of the design (docs/design.md, section 1).
Vanilla is the owner's own server.jar (--jar): nothing of Mojang's is fetched or committed. Pumpkin is a released
binary run as a black box (--exe); its Bedrock listeners, LAN broadcast and telemetry are turned off in the
config this script writes (--pumpkin-config adds a TOML file of your own on top if a release names a key
differently). `--pre-generated DIR` copies a finished world into every run's scratch folder as `world`, so runs
repeat on the same terrain. Build the bots first: cargo build --release -p horizon-bots
"""
import argparse
import datetime
import json
import os
from pathlib import Path
import platform
import re
import shutil
import socket
import statistics
import subprocess
import sys
import tempfile
import threading
import time
ROOT = Path(__file__).resolve().parent.parent
EXE = '.exe' if os.name == 'nt' else ''
SERVERS = ('horizon', 'vanilla', 'pumpkin')
PROPERTIES = {
'level-seed': '12345',
'view-distance': '10',
'simulation-distance': '10',
'online-mode': 'false',
'white-list': 'false',
'gamemode': 'creative',
'force-gamemode': 'true',
'spawn-protection': '0',
'max-players': '200',
'difficulty': 'easy',
'server-ip': '127.0.0.1',
'enable-query': 'false',
'enable-rcon': 'false',
}
# Pumpkin's two config files. Bedrock, LAN broadcast and telemetry (plugins' included) are off.
PUMPKIN_CONFIGURATION = '''\
server_address = "127.0.0.1:{port}"
seed = "{seed}"
max_players = 200
view_distance = {view}
simulation_distance = {view}
online_mode = false
default_difficulty = "Easy"
allow_chat_reports = false
encryption = false
lan_broadcast = false
[networking.bedrock]
enabled = false
[networking.lan_broadcast]
enabled = false
[networking.query]
enabled = false
[networking.rcon]
enabled = false
'''
PUMPKIN_FEATURES = '''\
[telemetry]
enabled = false
[plugins]
enabled = false
'''
DONE = re.compile(r'Done|listening|Server started', re.IGNORECASE)
TICK_AVERAGE = re.compile(r'Average time per tick: ([0-9.]+) ?ms')
TICK_PERCENTILES = re.compile(r'P50: ([0-9.]+) ?ms P95: ([0-9.]+) ?ms P99: ([0-9.]+) ?ms')
GAMETIME = re.compile(r'The time is (-?\d+)')
def free_port():
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]
def parse_tick_query(lines):
"""MSPT numbers from the lines `tick query` printed, or None until both lines are there."""
average = percentiles = None
for line in lines:
if (m := TICK_AVERAGE.search(line)) and average is None:
average = float(m.group(1))
if (m := TICK_PERCENTILES.search(line)) and percentiles is None:
percentiles = tuple(float(g) for g in m.groups())
if average is None or percentiles is None:
return None
return {'average': average, 'p50': percentiles[0], 'p95': percentiles[1], 'p99': percentiles[2]}
def parse_gametime(lines):
"""The game time `time query gametime` printed, or None."""
for line in lines:
if m := GAMETIME.search(line):
return int(m.group(1))
return None
def tps(start_ticks, end_ticks, seconds):
"""Game ticks per wall-clock second over the window, or None when a query went unanswered."""
if start_ticks is None or end_ticks is None or seconds <= 0:
return None
return round((end_ticks - start_ticks) / seconds, 2)
def percentile(values, p):
if not values:
return None
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(p / 100 * len(ordered)))]
def cpu_seconds(text):
"""ps's cumulative CPU time: [[DD-]HH:]MM:SS[.ss]."""
days, _, rest = text.rpartition('-')
seconds = 0.0
for part in rest.split(':'):
seconds = seconds * 60 + float(part)
return seconds + (float(days) * 86400 if days else 0)
def tree_usage(root):
"""RSS (bytes) and CPU seconds of a process and all its descendants; None without `ps`."""
try:
out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], capture_output=True, text=True).stdout
except OSError:
return None
rows = {}
for line in out.splitlines():
parts = line.split()
if len(parts) == 4:
rows[int(parts[0])] = (int(parts[1]), int(parts[2]) * 1024, cpu_seconds(parts[3]))
tree, frontier = {root}, [root]
while frontier:
parent = frontier.pop()
for pid, (ppid, _, _) in rows.items():
if ppid == parent and pid not in tree:
tree.add(pid)
frontier.append(pid)
return sum(rows[p][1] for p in tree if p in rows), sum(rows[p][2] for p in tree if p in rows)
def prepare(kind, directory, port, args):
"""Writes the server's settings into its scratch folder and returns the command that starts it."""
directory = Path(directory)
if args.pre_generated:
shutil.copytree(args.pre_generated, directory / 'world')
props = dict(PROPERTIES, **{'server-port': str(port), 'level-seed': str(args.seed),
'view-distance': str(args.view_distance),
'simulation-distance': str(args.view_distance)})
if kind == 'pumpkin':
config = directory / 'config'
config.mkdir()
(config / 'configuration.toml').write_text(PUMPKIN_CONFIGURATION.format(
port=port, seed=args.seed, view=args.view_distance))
(config / 'features.toml').write_text(PUMPKIN_FEATURES)
if args.pumpkin_config:
with (config / 'configuration.toml').open('a') as f:
f.write('\n' + Path(args.pumpkin_config).read_text())
return [Path(args.exe).resolve()]
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(directory / 'eula.txt').write_text('eula=true\n')
if kind == 'vanilla':
return ['java', f'-Xms{args.heap}', f'-Xmx{args.heap}', '-jar', Path(args.jar).resolve(), 'nogui']
server = Path(args.exe).resolve()
subprocess.run([server, '--dir', directory, 'import', directory / 'server.properties'], check=True,
capture_output=True)
toml = directory / 'server.toml'
# No web panel in a bench.
toml.write_text(re.sub(r'(?m)^bind = "127\.0\.0\.1:8130"', 'bind = ""', toml.read_text()))
return [server, '--dir', directory, 'run']
class Server:
"""A server with its console on stdin and its output collected."""
def __init__(self, command, directory, log_path):
self.lines = []
self.lock = threading.Lock()
self.log = log_path.open('w', encoding='utf-8')
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'),
HORIZON_LAUNCHER_NO_SELF_UPDATE='1')
self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
encoding='utf-8', errors='replace', env=env)
threading.Thread(target=self._read, daemon=True).start()
def _read(self):
for line in self.process.stdout:
with self.lock:
self.lines.append(line.rstrip('\n'))
self.log.write(line)
self.log.flush()
def count(self):
with self.lock:
return len(self.lines)
def since(self, n):
with self.lock:
return self.lines[n:]
def wait_for(self, regex, timeout):
deadline = time.time() + timeout
while time.time() < deadline:
if any(regex.search(line) for line in self.since(0)):
return
if self.process.poll() is not None:
raise RuntimeError('the server exited:\n' + '\n'.join(self.since(0)[-20:]))
time.sleep(0.1)
raise RuntimeError(f'the server was not up within {timeout} s')
def ask(self, command, parse, timeout=5):
"""Sends a console command and returns parse(its output lines), or None if nothing parsed in time."""
start = self.count()
try:
self.process.stdin.write(command + '\n')
self.process.stdin.flush()
except (BrokenPipeError, OSError):
return None
deadline = time.time() + timeout
while time.time() < deadline:
if (value := parse(self.since(start))) is not None:
return value
time.sleep(0.05)
return None
def stop(self):
if self.process.poll() is None:
try:
self.process.stdin.write('stop\n')
self.process.stdin.flush()
self.process.wait(timeout=60)
except (BrokenPipeError, OSError, subprocess.TimeoutExpired):
self.process.kill()
self.process.wait()
self.log.close()
def bots_command(args, port, total):
return [str(args.bots_bin), '--server', f'127.0.0.1:{port}', '--bots', str(args.bots),
'--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)),
'--seed', str(args.seed), '--view-distance', str(args.view_distance), '--report', '-']
def split_bot_output(lines):
"""The per-second samples (JSON lines with a `t`) and the final summary the bots printed."""
samples, rest = [], []
for line in lines:
try:
sample = json.loads(line)
except ValueError:
sample = None
if isinstance(sample, dict) and 't' in sample:
samples.append(sample)
else:
rest.append(line)
text = ''.join(rest)
return samples, (json.loads(text) if text.strip() else {})
def bytes_per_player_second(per_bot, run_seconds, join_every):
"""The bots' bytes over the seconds they were connected: bot i connects i * join_every after the first and
stays to the end of the run. So it is each bot's average from its login on, its join included, not the
measuring window's alone (the bots report one total at the end). None until the bots count bytes (S-6)."""
counted = [(i, b) for i, b in enumerate(per_bot) if 'bytes_in' in b and not b.get('error')]
online = sum(max(run_seconds - i * join_every, 0) for i, _ in counted)
if not counted or online <= 0:
return None
return {k: round(sum(b.get(k, 0) for _, b in counted) / online) for k in ('bytes_in', 'bytes_out')}
def summarize(kind, args, ticks, rss, cpu, gametime, window_seconds, summary, start_seconds, run_seconds):
mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None
joins = [b['joined_after_ms'] for b in summary.get('per_bot', []) if b.get('joined_after_ms', 0) > 0]
bandwidth = bytes_per_player_second(summary.get('per_bot', []), run_seconds, args.join_every)
return {
'server': kind,
'bots': args.bots,
'behaviour': args.behaviour,
'start_seconds': start_seconds,
'tps': tps(*gametime, window_seconds),
'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')},
'mspt_worst_p99': max((t['p99'] for t in ticks), default=None),
'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None},
'cpu_percent': mean(cpu),
'bytes_per_player_second': bandwidth,
'join_ms': {'p50': percentile(joins, 50), 'worst': max(joins, default=None)},
'bots_joined': summary.get('joined'),
'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')],
'chunks_total': summary.get('chunks'),
'tick_queries': len(ticks),
}
def run_once(kind, args, out):
scratch = Path(tempfile.mkdtemp(prefix=f'horizon-bench-{kind}-'))
result = {'server': kind, 'bots': args.bots, 'behaviour': args.behaviour}
server = None
try:
port = free_port()
command = prepare(kind, scratch, port, args)
started = time.time()
server = Server(command, scratch, out / f'{kind}-{args.bots}.log')
server.wait_for(DONE, args.start_timeout)
start_seconds = round(time.time() - started, 2)
total = args.bots * args.join_every + args.warmup + args.seconds
bot_process = subprocess.Popen(bots_command(args, port, total), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
bot_lines = []
threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start()
time.sleep(args.bots * args.join_every + args.warmup)
first_time = server.ask('time query gametime', parse_gametime)
began = time.time()
rss, cpu, ticks = [], [], []
usage = tree_usage(server.process.pid)
last_cpu, last_time = (usage[1] if usage else 0), time.time()
next_query = time.time()
while time.time() - began < args.seconds:
time.sleep(1)
now = time.time()
if usage and (u := tree_usage(server.process.pid)):
rss.append(u[0])
cpu.append((u[1] - last_cpu) / (now - last_time) * 100)
last_cpu, last_time = u[1], now
if now >= next_query:
if (t := server.ask('tick query', parse_tick_query)) is not None:
ticks.append(t)
next_query = now + 5
window = time.time() - began
last = server.ask('time query gametime', parse_gametime)
bot_process.wait(timeout=total + 120)
time.sleep(0.5)
_, summary = split_bot_output(list(bot_lines))
result.update(summarize(kind, args, ticks, rss, cpu, (first_time, last), window, summary, start_seconds,
total))
except Exception as e: # a failed run is reported, and the next one still runs
result['error'] = str(e)
finally:
if server:
server.stop()
shutil.rmtree(scratch, ignore_errors=True)
return result
def table(runs):
"""The Markdown table of the design: one column per server, one row per measure."""
ok = [r for r in runs if 'error' not in r]
head = '| | ' + ' | '.join(r['server'] for r in ok) + ' |\n|---|' + '---|' * len(ok) + '\n'
def cell(r, path):
value = r
for key in path:
value = value.get(key) if isinstance(value, dict) else None
return '-' if value is None else str(value)
rows = [
('Players', ('bots',)),
('TPS', ('tps',)),
('MSPT average', ('mspt', 'average')),
('MSPT p99 (mean of 5 s queries)', ('mspt', 'p99')),
('MSPT p99 (worst query)', ('mspt_worst_p99',)),
('RSS mean MB', ('rss_mb', 'mean')),
('CPU %', ('cpu_percent',)),
('Bytes in per player and s online', ('bytes_per_player_second', 'bytes_in')),
('Join p50 ms', ('join_ms', 'p50')),
('Join worst ms', ('join_ms', 'worst')),
('Start s', ('start_seconds',)),
]
body = ''.join(f'| {name} | ' + ' | '.join(cell(r, path) for r in ok) + ' |\n' for name, path in rows)
failed = ''.join(f'\n{r["server"]} failed: {r["error"].splitlines()[0]}\n' for r in runs if 'error' in r)
return head + body + failed
def check(args):
for kind in args.server:
if kind not in SERVERS:
sys.exit(f'--server: {kind!r} is not one of {", ".join(SERVERS)}')
if 'vanilla' in args.server and not args.jar:
sys.exit('vanilla needs --jar, your own server.jar')
if any(k != 'vanilla' for k in args.server) and not args.exe:
sys.exit('horizon and pumpkin need --exe, the server binary')
if not Path(args.bots_bin).exists():
sys.exit(f'{args.bots_bin} is missing: cargo build --release -p horizon-bots')
if args.pre_generated and not Path(args.pre_generated).is_dir():
sys.exit(f'--pre-generated {args.pre_generated} is not a folder')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
parser.add_argument('--server', type=lambda s: s.split(','), default=['horizon'],
help='horizon, vanilla or pumpkin (comma-separated, one run each)')
parser.add_argument('--exe', help='the horizon-server or Pumpkin binary')
parser.add_argument('--jar', help="vanilla's server.jar (your own copy)")
parser.add_argument('--bots-bin', default=str(ROOT / 'target/release' / f'horizon-bots{EXE}'))
parser.add_argument('--bots', type=int, default=50)
parser.add_argument('--behaviour', default='mixed')
parser.add_argument('--join-every', type=float, default=0.5)
parser.add_argument('--warmup', type=int, default=30)
parser.add_argument('--seconds', type=int, default=60)
parser.add_argument('--seed', type=int, default=12345)
parser.add_argument('--view-distance', type=int, default=10)
parser.add_argument('--heap', default='4g', help='the Java heap for vanilla')
parser.add_argument('--pre-generated', help='a finished world folder, copied into every run as `world`')
parser.add_argument('--pumpkin-config', help='extra TOML appended to Pumpkin\'s configuration.toml')
parser.add_argument('--start-timeout', type=int, default=600)
parser.add_argument('--out', type=Path, default=ROOT / '.local/bench', help='the folder for report.json and logs')
args = parser.parse_args(argv)
check(args)
args.out.mkdir(parents=True, exist_ok=True)
runs = []
for kind in args.server:
print(f'{kind}, {args.bots} {args.behaviour} bots...', flush=True)
runs.append(run_once(kind, args, args.out))
print(json.dumps(runs[-1]), flush=True)
report = {
'kind': 'horizon-bench',
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
'machine': {'system': platform.system(), 'machine': platform.machine(), 'cpus': os.cpu_count()},
'settings': {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
'runs': runs,
}
path = args.out / 'report.json'
path.write_text(json.dumps(report, indent=2) + '\n')
print('\n' + table(runs) + f'\nWrote {path}')
return 1 if any('error' in r for r in runs) else 0
if __name__ == '__main__':
sys.exit(main())
File diff suppressed because it is too large. Load diff
+1 -71
View File
@@ -13,9 +13,7 @@ const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>
cargo horizon-mod registry release <checkout> <id> <file> --tag <tag> --published <YYYY-MM-DD> [--notes <file>] [--signature <file>] [--game <version>]
cargo horizon-mod registry page <checkout> <id> <horizon-registry.toml>";
cargo horizon-mod registry list <checkout>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
@@ -156,74 +154,6 @@ pub fn main(args: &[String]) -> Result<(), String> {
}
Ok(())
}
// A release of a project's repository, downloaded: the checks of a submission, then the
// version as the index lists it (JSON on stdout), for the registry's release sync.
Some("release") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let (tag, published) = (
flag(rest, "--tag").ok_or(USAGE)?,
flag(rest, "--published").ok_or(USAGE)?,
);
let read = |name: &str| {
flag(rest, name)
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()
};
let (notes, signature) = (read("--notes")?, read("--signature")?);
let entry = entry_of(checkout, id)?;
let path = Path::new(file);
let cache = registry::Cache::read(Path::new(checkout), id)?;
let entry = entry.resolved(&cache.page);
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
crate::check::check(
path,
entry.kind.clone(),
entry.key.as_deref(),
signature.as_deref(),
)?;
let version = registry::release_version(
&entry,
&registry::Release {
tag,
published,
notes: notes.as_deref().unwrap_or(""),
asset: flag(rest, "--name").unwrap_or(name),
game: flag(rest, "--game").unwrap_or("26.3"),
signature: signature.as_deref(),
},
path,
)?;
println!(
"{}",
serde_json::to_string(&version).map_err(|e| e.to_string())?
);
Ok(())
}
// The page file of a project's repository, checked against its entry: the page as JSON.
Some("page") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let entry = entry_of(checkout, id)?;
let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;
let page = registry::page(&entry, &text)?;
println!(
"{}",
serde_json::to_string(&page).map_err(|e| e.to_string())?
);
Ok(())
}
_ => Err(USAGE.into()),
}
}
/// `mods/<id>.toml` of the checkout, which must name a repository.
fn entry_of(checkout: &str, id: &str) -> Result<registry::Entry, String> {
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
entries
.into_iter()
.find(|e| e.id == id && e.repo.is_some())
.ok_or_else(|| format!("{id}: no entry with a repo"))
}
@@ -3,8 +3,8 @@ name = "{name}"
version = "0.1.0"
description = ""
authors = []
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks, items and mob effects to your worlds".
# registries: "May add blocks and items to your worlds".
capabilities = ["registries"]
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "horizon-anticheat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.85"
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "1.1"
[lints]
workspace = true
-99
View File
@@ -1,99 +0,0 @@
//! Combat: attacks vanilla's client can't send (docs/anticheat.md).
//!
//! Vanilla's client sends ServerboundAttackPacket from one place only, `Minecraft.startAttack`, run from
//! `handleKeybinds` once per client tick. It attacks the entity of the tick's `hitResult`, which
//! `Minecraft.tick` picks once before the keys are handled, so every attack of one client tick is on the
//! same entity. And each attack swings the arm (ServerboundPunchPacket) in the same call, so a client
//! tick never has more attacks than swings: a tick that does comes from a kill aura hiding its swing.
//! The swing is counted before or after the attack, as older clients behind a version proxy swing
//! first; a swing without an attack (a miss, a block) is fine.
/// What breaks vanilla's way of attacking.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Foul {
/// A client tick with more attacks than swings: how many more.
NoSwing(u32),
/// A second entity attacked in the same client tick: the first one and this one.
TwoTargets(i32, i32),
}
/// The attacks of the client tick being heard.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Attacks {
/// The entity this tick attacked first.
target: Option<i32>,
attacks: u32,
swings: u32,
}
impl Attacks {
/// ServerboundAttackPacket on entity `target`.
pub fn attack(&mut self, target: i32) -> Option<Foul> {
self.attacks = self.attacks.saturating_add(1);
let first = *self.target.get_or_insert(target);
(first != target).then_some(Foul::TwoTargets(first, target))
}
/// ServerboundPunchPacket: the arm swung.
pub fn swing(&mut self) {
self.swings = self.swings.saturating_add(1);
}
/// ServerboundClientTickEndPacket: the tick's attacks are counted, and the next tick picks its
/// target again.
pub fn tick_end(&mut self) -> Option<Foul> {
let unswung = self.attacks.saturating_sub(self.swings);
*self = Self::default();
(unswung > 0).then_some(Foul::NoSwing(unswung))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_vanilla_tick_is_clean() {
let mut a = Attacks::default();
// Two clicks in one tick on the same entity, each swung for.
for _ in 0..2 {
assert_eq!(a.attack(7), None);
a.swing();
}
assert_eq!(a.tick_end(), None);
// The next tick may attack another entity, swinging first (an older client).
a.swing();
assert_eq!(a.attack(8), None);
assert_eq!(a.tick_end(), None);
// A swing on its own (a miss, a block) is fine.
a.swing();
assert_eq!(a.tick_end(), None);
}
#[test]
fn an_attack_not_swung_for_is_caught() {
let mut a = Attacks::default();
a.attack(7);
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
// A swing in the tick before doesn't count for this one.
a.swing();
a.tick_end();
a.attack(7);
a.attack(7);
a.swing();
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
}
#[test]
fn two_entities_in_one_tick_are_caught() {
let mut a = Attacks::default();
a.attack(7);
a.swing();
assert_eq!(a.attack(8), Some(Foul::TwoTargets(7, 8)));
a.swing();
// Still against the tick's first target.
assert_eq!(a.attack(9), Some(Foul::TwoTargets(7, 9)));
a.swing();
assert_eq!(a.tick_end(), None);
}
}
-419
View File
@@ -1,419 +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>>,
/// 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);
}
}
-411
View File
@@ -1,411 +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> },
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
/// are the server's: they reach the client as attributes.)
Effects,
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
/// tick played before the client had them may be off.
Abilities,
}
/// A teleport the client accepted: the replay starts again from it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Restart {
/// Where it put the player.
pub at: [f64; 3],
/// It kept the player's motion (an End gateway's).
pub kept_motion: bool,
/// Client ticks ended and changes heard before it: the ones after are the replay's.
pub ended: u64,
pub changed: u64,
}
/// One client tick.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ClientTick {
/// The keys held (ServerboundPlayerInputPacket's flags: forward, backward, left, right, jump,
/// shift, sprint), as last sent.
pub input: u8,
/// Sprinting, as the player's commands left it (ServerboundPlayerCommandPacket).
pub sprinting: bool,
/// The rotation the client last sent.
pub yaw: f32,
pub pitch: f32,
/// Where the tick's move said the player is, if it said: a move with a position.
pub position: Option<[f64; 3]>,
/// The tick's move packet's flags (1: on the ground, 2: against a wall), if it sent one.
pub flags: Option<u8>,
/// The last server tick whose packets the client had handled (its pong) before this tick ended.
pub seen: Option<u64>,
}
/// A player's last client ticks.
#[derive(Clone, Debug, Default)]
pub struct Record {
/// The tick being heard.
current: ClientTick,
/// The ended ones, oldest first, at most [`KEPT`].
ticks: VecDeque<ClientTick>,
/// The last server tick the client answered for.
seen: Option<u64>,
/// Client ticks ended since the record began.
ended: u64,
/// Where an accepted teleport put the player, until the replay starts again from there.
restart: Option<Restart>,
/// What the server changed, with the server tick that sent it, oldest first.
changes: VecDeque<(u64, Change)>,
/// Changes heard since the record began.
changed: u64,
/// The blocks the server changed near the player (None: an entity it stopped showing the player
/// from near it, which counts wherever the player has gone since), with the server tick that sent
/// each, until every kept tick has seen them.
near: VecDeque<(u64, Option<[i32; 3]>)>,
/// The server tick of the last of them dropped early ([`NEAR_KEPT`] came after it).
near_dropped: Option<u64>,
}
impl Record {
/// The tick's move: its position if it has one, the rotation if it has one, and its flags.
pub fn moved(&mut self, position: Option<[f64; 3]>, rotation: Option<(f32, f32)>, flags: u8) {
if position.is_some() {
self.current.position = position;
}
if let Some((yaw, pitch)) = rotation {
(self.current.yaw, self.current.pitch) = (yaw, pitch);
}
self.current.flags = Some(flags);
}
/// ServerboundPongPacket for the ping sent at the end of `server_tick`. False for an answer to a
/// ping that was never sent, or one older than an answer already heard: a vanilla client answers
/// every ping, once, in order.
pub fn pong(&mut self, server_tick: u64, sent_up_to: u64) -> bool {
if server_tick > sent_up_to || self.seen.is_some_and(|seen| server_tick <= seen) {
return false;
}
self.seen = Some(server_tick);
true
}
/// ServerboundClientTickEndPacket: the tick ends with these keys held and this sprinting. The
/// next tick starts with the same keys and rotation, until the client says otherwise.
pub fn tick_end(&mut self, input: u8, sprinting: bool) -> &ClientTick {
self.current.input = input;
self.current.sprinting = sprinting;
self.current.seen = self.seen;
let next = ClientTick {
yaw: self.current.yaw,
pitch: self.current.pitch,
..ClientTick::default()
};
let ended = std::mem::replace(&mut self.current, next);
if self.ticks.len() == KEPT {
self.ticks.pop_front();
}
self.ticks.push_back(ended);
self.ended += 1;
// What the oldest tick kept had seen, every later one had too.
if let Some(seen) = self.ticks.front().and_then(|t| t.seen) {
while self.near.front().is_some_and(|&(sent, _)| sent <= seen) {
self.near.pop_front();
}
self.near_dropped = self.near_dropped.filter(|&sent| sent > seen);
}
self.ticks.back().expect("just pushed")
}
/// The ended ticks, oldest first.
pub fn ticks(&self) -> impl DoubleEndedIterator<Item = &ClientTick> + ExactSizeIterator {
self.ticks.iter()
}
/// The last server tick the client answered for.
pub fn seen(&self) -> Option<u64> {
self.seen
}
/// Client ticks ended since the record began.
pub fn ended(&self) -> u64 {
self.ended
}
/// The ticks ended after the first `played`, oldest first; None when some of them are no longer
/// kept.
pub fn since(&self, played: u64) -> Option<impl Iterator<Item = &ClientTick>> {
let new = self.ended.checked_sub(played)?;
let new = usize::try_from(new)
.ok()
.filter(|&n| n <= self.ticks.len())?;
Some(self.ticks.iter().skip(self.ticks.len() - new))
}
/// `change`, sent during server tick `server_tick` (before that tick's ping).
pub fn changed(&mut self, server_tick: u64, change: Change) {
if self.changes.len() == CHANGES_KEPT {
self.changes.pop_front();
}
self.changes.push_back((server_tick, change));
self.changed += 1;
}
/// Changes heard since the record began.
pub fn changes(&self) -> u64 {
self.changed
}
/// The changes after the first `applied`, oldest first; None when some of them are no longer kept.
pub fn changes_since(
&self,
applied: u64,
) -> Option<impl Iterator<Item = &(u64, Change)> + Clone> {
let new = self.changed.checked_sub(applied)?;
let new = usize::try_from(new)
.ok()
.filter(|&n| n <= self.changes.len())?;
Some(self.changes.iter().skip(self.changes.len() - new))
}
/// During server tick `server_tick` (before that tick's ping) the server sent the client the block
/// at `pos`.
pub fn changed_near(&mut self, server_tick: u64, pos: [i32; 3]) {
self.note_near(server_tick, Some(pos));
}
/// During server tick `server_tick` the server stopped showing the client an entity near it (a
/// firework it flew with, a mob that pushed it): the client may still have it, wherever it goes.
pub fn entity_gone(&mut self, server_tick: u64) {
if self.near.back() != Some(&(server_tick, None)) {
self.note_near(server_tick, None);
}
}
fn note_near(&mut self, server_tick: u64, pos: Option<[i32; 3]>) {
if self.near.len() == NEAR_KEPT {
self.near_dropped = self.near.pop_front().map(|(sent, _)| sent);
}
self.near.push_back((server_tick, pos));
}
/// Whether a client tick that had `seen` up to that server tick (its [`ClientTick::seen`]) may
/// not have had a change at a position for which `near` holds: it still had the old block, or the
/// entity, where the server no longer has it.
pub fn unseen_near(&self, seen: Option<u64>, near: impl Fn([i32; 3]) -> bool) -> bool {
let unseen = |sent: u64| seen.is_none_or(|seen| sent > seen);
self.near_dropped.is_some_and(unseen)
|| self
.near
.iter()
.any(|&(sent, pos)| unseen(sent) && pos.is_none_or(&near))
}
/// The client accepted a teleport to `at` (ServerboundAcceptTeleportationPacket): it goes on from
/// there, standing still unless the teleport `kept_motion` (an End gateway's). The ticks it ended
/// before stay, for a replay that hasn't played them yet; the one under way starts again.
pub fn teleported(&mut self, at: [f64; 3], kept_motion: bool) {
self.restart_tick();
self.restart = Some(Restart {
at,
kept_motion,
ended: self.ended,
changed: self.changed,
});
}
/// The last teleport the client accepted, once.
pub fn take_restart(&mut self) -> Option<Restart> {
self.restart.take()
}
/// The ticks before now say nothing about the ones after.
pub fn clear(&mut self) {
self.ticks.clear();
self.restart_tick();
}
fn restart_tick(&mut self) {
self.current = ClientTick {
yaw: self.current.yaw,
pitch: self.current.pitch,
..ClientTick::default()
};
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_change_near_is_unseen_by_the_ticks_before_its_pong() {
let mut r = Record::default();
r.changed_near(4, [1, 2, 3]);
r.changed_near(5, [7, 2, 3]);
let near = |p: [i32; 3]| p[0] < 5;
assert!(r.unseen_near(None, near));
assert!(r.unseen_near(Some(3), near));
assert!(!r.unseen_near(Some(4), near));
assert!(r.unseen_near(Some(4), |_| true));
// Kept until the oldest tick kept had seen it: one that ended before the pong hadn't.
r.tick_end(0, false);
assert!(r.pong(5, 5));
r.tick_end(0, false);
assert!(r.unseen_near(Some(3), near));
for _ in 0..KEPT {
r.tick_end(0, false);
}
assert!(!r.unseen_near(None, |_| true));
// An entity gone counts anywhere.
r.entity_gone(5);
r.entity_gone(5);
assert!(r.unseen_near(Some(4), |_| false));
assert!(!r.unseen_near(Some(5), |_| false));
// One dropped early counts for the ticks that hadn't seen it.
for i in 0..=NEAR_KEPT as u64 {
r.changed_near(6 + i, [100, 0, 0]);
}
assert!(
r.unseen_near(Some(5), near),
"a change dropped unseen counts"
);
assert!(!r.unseen_near(Some(6), near));
}
#[test]
fn a_tick_is_the_packets_between_two_tick_ends() {
let mut r = Record::default();
r.moved(Some([1.0, 2.0, 3.0]), Some((90.0, 10.0)), 1);
let t = *r.tick_end(0b1001, true);
assert_eq!(t.position, Some([1.0, 2.0, 3.0]));
assert_eq!((t.yaw, t.pitch, t.flags), (90.0, 10.0, Some(1)));
assert_eq!((t.input, t.sprinting, t.seen), (0b1001, true, None));
// A tick without a move keeps the rotation and has no position.
let t = *r.tick_end(0, false);
assert_eq!((t.position, t.flags, t.yaw), (None, None, 90.0));
assert_eq!(r.ticks().len(), 2);
}
#[test]
fn pongs_say_which_server_ticks_a_client_tick_had_seen() {
let mut r = Record::default();
assert!(r.pong(5, 6));
r.tick_end(0, false);
assert!(r.pong(6, 6));
r.tick_end(0, false);
r.tick_end(0, false);
let seen: Vec<_> = r.ticks().map(|t| t.seen).collect();
assert_eq!(seen, [Some(5), Some(6), Some(6)]);
// Never sent, answered twice, or out of order.
assert!(!r.pong(9, 6));
assert!(!r.pong(6, 6));
assert!(!r.pong(4, 6));
assert_eq!(r.seen(), Some(6));
}
#[test]
fn only_the_last_two_seconds_are_kept() {
let mut r = Record::default();
for i in 0..KEPT + 5 {
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
}
assert_eq!(r.ticks().len(), KEPT);
assert_eq!(r.ticks().next().unwrap().position, Some([5.0, 0.0, 0.0]));
r.clear();
assert_eq!(r.ticks().len(), 0);
}
#[test]
fn the_replay_takes_the_ticks_it_has_not_played() {
let mut r = Record::default();
for i in 0..3 {
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
}
fn x(r: &Record, played: u64) -> Option<Vec<f64>> {
r.since(played)
.map(|t| t.map(|t| t.position.unwrap()[0]).collect())
}
assert_eq!(x(&r, 1), Some(vec![1.0, 2.0]));
assert_eq!(x(&r, 3), Some(vec![]));
// Played ahead of the record, or so far behind the ticks are gone.
assert_eq!(x(&r, 4), None);
r.clear();
assert_eq!(x(&r, 2), None);
assert_eq!(x(&r, 3), Some(vec![]));
r.moved(Some([8.0, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
r.moved(Some([9.0, 0.0, 0.0]), None, 0);
r.teleported([5.0, 6.0, 7.0], false);
// A tick that ends before the replay starts again is still one after the teleport.
r.tick_end(0, false);
let restart = r.take_restart().unwrap();
assert_eq!((restart.at, restart.kept_motion), ([5.0, 6.0, 7.0], false));
assert_eq!(r.since(restart.ended).unwrap().count(), 1);
// The tick ended before it stays for a replay that hasn't played it; the move of the one
// under way is gone.
let positions: Vec<_> = r.since(3).unwrap().map(|t| t.position).collect();
assert_eq!(positions, [Some([8.0, 0.0, 0.0]), None]);
assert_eq!(r.take_restart(), None);
}
#[test]
fn changes_keep_the_server_tick_that_sent_them() {
let mut r = Record::default();
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
r.changed(5, Change::Effects);
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
assert_eq!(
all,
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::Effects)]
);
assert_eq!(r.changes_since(1).unwrap().count(), 1);
assert!(r.changes_since(3).is_none());
for i in 0..CHANGES_KEPT as u64 {
r.changed(6 + i, Change::Effects);
}
// The first two are gone.
assert!(r.changes_since(1).is_none());
assert_eq!(r.changes_since(2).unwrap().count(), CHANGES_KEPT);
}
}
-111
View File
@@ -1,111 +0,0 @@
//! The timer check's balance: how far a client's ticks are behind (positive) or ahead (negative) of the
//! server's clock, in milliseconds.
//!
//! The server adds the wall-clock time of each of its ticks, and every ServerboundClientTickEndPacket
//! takes away the length of one tick at the server's tick rate (vanilla's client ticks at that rate,
//! `TickRateManager.millisecondsPerTick`). The balance is capped at `max_lag_ms` *after* the tick's
//! packets are counted: a slow server tick that handles a second of packets at once also added that
//! second, and a quiet connection banks at most `max_lag_ms` for the burst that ends a lag spike.
use crate::TimerConfig;
#[derive(Clone, Debug, Default)]
pub struct Balance {
pub ms: f64,
}
impl Balance {
/// The start of a server tick that came `elapsed_ms` after the previous one.
pub fn elapse(&mut self, elapsed_ms: f64, config: &TimerConfig) {
self.ms = self.ms.min(config.max_lag_ms) + elapsed_ms;
}
/// One client tick ended, at a tick rate of `tick_rate` a second. Some(how far ahead, in ms) when the
/// client is further ahead than the tolerance; the balance starts again from zero then, so a
/// sustained speed-up is flagged again each time it gets that far ahead.
pub fn tick_end(&mut self, tick_rate: f32, config: &TimerConfig) -> Option<f64> {
self.ms -= 1000.0 / f64::from(tick_rate.max(1.0));
if self.ms < -config.tolerance_ms {
let ahead = -self.ms;
self.ms = 0.0;
return Some(ahead);
}
None
}
/// The tick rate changed or the player arrived: start again.
pub fn reset(&mut self) {
self.ms = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
let c = TimerConfig::default();
let mut owed = 0.0;
let mut flags = 0;
for _ in 0..(seconds * 1000.0 / server_ms) as usize {
b.elapse(server_ms, &c);
owed += per_tick;
while owed >= 1.0 {
owed -= 1.0;
flags += usize::from(b.tick_end(20.0, &c).is_some());
}
}
flags
}
#[test]
fn a_vanilla_client_is_never_flagged() {
let mut b = Balance::default();
assert_eq!(run(&mut b, 50.0, 1.0, 600.0), 0);
// A server that runs at 10 ticks a second handles two client ticks in each.
assert_eq!(run(&mut b, 100.0, 2.0, 600.0), 0);
}
#[test]
fn a_slow_server_tick_with_its_backlog_is_not_flagged() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
b.elapse(3000.0, &c);
for _ in 0..60 {
assert_eq!(b.tick_end(20.0, &c), None);
}
}
#[test]
fn a_short_network_stall_is_not_flagged() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
// 800 ms with nothing arriving, then the 16 ticks sent meanwhile.
for _ in 0..16 {
b.elapse(50.0, &c);
}
b.elapse(50.0, &c);
for _ in 0..17 {
assert_eq!(b.tick_end(20.0, &c), None);
}
}
#[test]
fn a_sped_up_client_is_flagged_again_and_again() {
let mut b = Balance::default();
// 10 % faster: 5 ms ahead each tick, 300 ms after 3 s, so close to 20 flags a minute.
let flags = run(&mut b, 50.0, 1.1, 60.0);
assert!((15..=20).contains(&flags), "{flags}");
// Twice as fast: a flag every 7 ticks.
assert!(run(&mut Balance::default(), 50.0, 2.0, 10.0) >= 25);
}
#[test]
fn a_pause_cant_be_saved_for_a_burst() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
for _ in 0..200 {
b.elapse(50.0, &c);
}
let flagged = (0..60).any(|_| b.tick_end(20.0, &c).is_some());
assert!(flagged);
}
}
+10 -10
View File
@@ -16,7 +16,6 @@ use std::{
sync::{Arc, Mutex},
};
mod component_predicate;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -629,7 +628,7 @@ pub enum ConditionProperty {
ExtendedView,
KeybindDown(String),
ViewEntity,
/// A single `component` predicate; external predicate types use the host.
/// A `component` predicate, evaluated by the host.
Component(Value),
}
impl ConditionProperty {
@@ -652,7 +651,13 @@ impl ConditionProperty {
"minecraft:extended_view" => Self::ExtendedView,
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
"minecraft:view_entity" => Self::ViewEntity,
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
"minecraft:component" => {
let predicate = required(m, "predicate")?;
if object(predicate)?.len() != 1 {
return Err(err("a single component predicate is expected"));
}
Self::Component(predicate.clone())
}
_ => return Err(err("unknown condition property")),
})
}
@@ -959,14 +964,10 @@ pub trait ItemEnv {
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
0.0
}
/// Additional component predicate types supplied by the host.
/// `ItemStack.getMaxStackSize` when the prototype has no component.
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
false
}
/// Membership in the level's enchantment registry tags.
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
false
}
fn is_keybind_down(&self, _keybind: &str) -> bool {
false
}
@@ -1334,8 +1335,7 @@ impl Resolve<'_> {
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
ConditionProperty::Component(predicate) => {
component_predicate::matches(predicate, self.components, self.env)
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
self.env.component_matches(self.stack, predicate)
}
}
}
@@ -1,177 +0,0 @@
//! Component conditions used by enchantment-dependent item models.
use super::*;
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
/// (`predicate`) and its wrapped codec's separate `value` field.
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
let id = component_type(required(m, "predicate")?)?;
let mut value = required(m, "value")?.clone();
if matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
let mut entries = Vec::new();
for entry in list(&value)? {
let mut entry = object(entry)?.clone();
if let Some(selector) = get(&entry, "enchantments") {
let selector = match selector {
Value::Array(ids) => Value::Array(
ids.iter()
.map(ident)
.map(|r| r.map(Value::String))
.collect::<Result<_>>()?,
),
Value::String(name) if name.starts_with('#') => {
Value::String(format!("#{}", identifier(&name[1..])?))
}
_ => Value::String(ident(selector)?),
};
entry.insert("enchantments".into(), selector);
}
if let Some(levels) = get(&entry, "levels") {
let levels = if levels.is_object() {
let mut bounds = Map::new();
for key in ["min", "max"] {
if let Some(n) = get(object(levels)?, key) {
bounds.insert(key.into(), Value::from(int(n)?));
}
}
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
if bounds
.get("min")
.and_then(Value::as_i64)
.zip(bounds.get("max").and_then(Value::as_i64))
.is_some_and(|(min, max)| min > max)
{
return Err(err("minimum exceeds maximum"));
}
Value::Object(bounds)
} else {
Value::from(int(levels)?)
};
entry.insert("levels".into(), levels);
}
entries.push(Value::Object(entry));
}
value = Value::Array(entries);
}
Ok(Value::Object(Map::from_iter([(id, value)])))
}
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
/// all entries must match; each entry needs one selected, present enchantment
/// with a matching level. An empty predicate list still needs the component.
pub(super) fn matches(
predicate: &Value,
components: &Components,
env: &dyn ItemEnv,
) -> Option<bool> {
let (id, expected) = predicate.as_object()?.iter().next()?;
if !matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
return None;
}
let Some(have) = components.get(id).and_then(Value::as_object) else {
return Some(false);
};
Some(expected.as_array()?.iter().all(|entry| {
let Some(entry) = entry.as_object() else {
return false;
};
have.iter().any(|(id, level)| {
let level = level.as_i64().unwrap_or(0);
let selector = get(entry, "enchantments");
let selected = match selector {
None => true,
Some(Value::String(s)) => s
.strip_prefix('#')
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
_ => false,
};
selected
&& if selector.is_some() {
matches_enchantment(level, get(entry, "levels"))
} else {
level_matches(level, get(entry, "levels"))
}
})
}))
}
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
/// nonzero level even when its bounds accept zero.
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
level != 0 && level_matches(level, bounds)
}
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
match bounds {
None => true,
Some(Value::Number(n)) => n.as_i64() == Some(level),
Some(Value::Object(bounds)) => {
get(bounds, "min")
.and_then(Value::as_i64)
.is_none_or(|min| level >= min)
&& get(bounds, "max")
.and_then(Value::as_i64)
.is_none_or(|max| level <= max)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct Tags;
impl ItemEnv for Tags {
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
enchantment == "minecraft:density" && tag == "horizon:mace"
}
}
#[test]
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
let components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":0}),
)]);
assert_eq!(
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
Some(true)
);
assert_eq!(
matches(
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
&components,
&NoEnv
),
Some(false)
);
}
#[test]
fn enchantment_selectors_use_the_hosts_registry_tags() {
let condition = json!({"predicate":"enchantments", "value":[
{"enchantments":"#horizon:mace", "levels":{"min":3}}
]});
let predicate = decode(condition.as_object().unwrap()).unwrap();
let mut components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":3}),
)]);
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
components.insert(
"minecraft:enchantments".into(),
json!({"minecraft:density":2}),
);
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
}
}
-216
View File
@@ -335,177 +335,6 @@ pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
}
/// A JOML `Matrix3f`: `m[column][row]`.
type Mat3 = [[f32; 3]; 3];
/// `Matrix3f.mul`: `a * b`.
fn mul3(a: &Mat3, b: &Mat3) -> Mat3 {
std::array::from_fn(|c| {
std::array::from_fn(|r| fma(a[0][r], b[c][0], fma(a[1][r], b[c][1], a[2][r] * b[c][2])))
})
}
/// `Matrix3f.transpose`.
fn transpose3(m: &Mat3) -> Mat3 {
std::array::from_fn(|c| std::array::from_fn(|r| m[r][c]))
}
/// `Matrix3f.rotate(Quaternionfc)`: `m * R(q)`, without fma.
fn rotate3(m: &Mat3, q: [f32; 4]) -> Mat3 {
let [x, y, z, w] = q;
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
let (zw, xy, xz, yw, yz, xw) = (z * w, x * y, x * z, y * w, y * z, x * w);
let (zw, xy, xz, yw, yz, xw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
let rotation = [
[w2 + x2 - z2 - y2, xy + zw, xz - yw],
[xy - zw, y2 - z2 + w2 - x2, yz + xw],
[yw + xz, yz - xw, z2 - y2 - x2 + w2],
];
std::array::from_fn(|c| {
let rc = rotation[c];
std::array::from_fn(|r| m[0][r] * rc[0] + m[1][r] * rc[1] + m[2][r] * rc[2])
})
}
/// `Quaternionf.mul`: `a * b`.
fn quaternion_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
let ([x, y, z, w], [qx, qy, qz, qw]) = (a, b);
[
fma(w, qx, fma(x, qw, fma(y, qz, -z * qy))),
fma(w, qy, fma(-x, qz, fma(y, qw, z * qx))),
fma(w, qz, fma(x, qy, fma(-y, qx, z * qw))),
fma(w, qw, fma(-x, qx, fma(-y, qy, -z * qz))),
]
}
/// `GivensParameters`: the half angle's sine and cosine.
#[derive(Clone, Copy)]
struct Givens(f32, f32);
impl Givens {
/// `GivensParameters.fromUnnormalized`.
fn unnormalized(sin_half: f32, cos_half: f32) -> Self {
let inv = 1.0 / (sin_half * sin_half + cos_half * cos_half).sqrt();
Self(inv * sin_half, inv * cos_half)
}
/// `MatrixUtil.PI_4`: `fromPositiveAngle(π / 4)`.
fn quarter_pi() -> Self {
let half = std::f32::consts::FRAC_PI_4 / 2.0;
let sin = joml_sin(half);
Self(sin, cos_from_sin(sin, half))
}
fn inverse(self) -> Self {
Self(-self.0, self.1)
}
fn cos(self) -> f32 {
self.1 * self.1 - self.0 * self.0
}
fn sin(self) -> f32 {
2.0 * self.0 * self.1
}
/// `aroundX/Y/Z(Quaternionf)` (axis 0, 1, 2).
fn quaternion(self, axis: usize) -> [f32; 4] {
let mut q = [0.0, 0.0, 0.0, self.1];
q[axis] = self.0;
q
}
/// `aroundX/Y/Z(Matrix3f)` (axis 0, 1, 2).
fn matrix(self, axis: usize) -> Mat3 {
let (cos, sin) = (self.cos(), self.sin());
let (a, b) = ((axis + 1) % 3, (axis + 2) % 3);
let mut m = [[0.0; 3]; 3];
m[axis][axis] = 1.0;
m[a][a] = cos;
m[b][b] = cos;
m[a][b] = sin;
m[b][a] = -sin;
m
}
}
/// `MatrixUtil.approxGivensQuat`.
fn approx_givens(a11: f32, a12: f32, a22: f32) -> Givens {
let g = 3.0 + 2.0 * 2.0f32.sqrt();
let (cos_half, sin_half) = (2.0 * (a11 - a22), a12);
if g * sin_half * sin_half < cos_half * cos_half {
Givens::unnormalized(sin_half, cos_half)
} else {
Givens::quarter_pi()
}
}
/// `MatrixUtil.qrGivensQuat`.
fn qr_givens(a1: f32, a2: f32) -> Givens {
let p = f64::from(a1).hypot(f64::from(a2)) as f32;
let mut sin_half = if p > 1.0e-6 { a2 } else { 0.0 };
let mut cos_half = a1.abs() + if p > 1.0e-6 { p } else { 1.0e-6 };
if a1 < 0.0 {
std::mem::swap(&mut sin_half, &mut cos_half);
}
Givens::unnormalized(sin_half, cos_half)
}
/// `MatrixUtil.stepJacobi`: the xy, xz then yz pairs, each by
/// `similarityTransform` (`R^T * m * R`).
fn step_jacobi(m: &mut Mat3, q: &mut [f32; 4]) {
let mut rotate = |m: &mut Mat3, g: Givens, axis: usize| {
*q = quaternion_mul(*q, g.quaternion(axis));
let r = g.matrix(axis);
*m = mul3(&transpose3(&r), &mul3(m, &r));
};
if m[0][1] * m[0][1] + m[1][0] * m[1][0] > 1.0e-6 {
let g = approx_givens(m[0][0], 0.5 * (m[0][1] + m[1][0]), m[1][1]);
rotate(m, g, 2);
}
if m[0][2] * m[0][2] + m[2][0] * m[2][0] > 1.0e-6 {
let g = approx_givens(m[0][0], 0.5 * (m[0][2] + m[2][0]), m[2][2]).inverse();
rotate(m, g, 1);
}
if m[1][2] * m[1][2] + m[2][1] * m[2][1] > 1.0e-6 {
let g = approx_givens(m[1][1], 0.5 * (m[1][2] + m[2][1]), m[2][2]);
rotate(m, g, 0);
}
}
/// `MatrixUtil.svdDecompose(Matrix4fc, ...)`, how `new Transformation(matrix)`
/// splits a matrix: translation, left rotation, scale, right rotation. The
/// right rotation diagonalizes `a^T * a` (`eigenvalueJacobi`, 5 steps), then
/// QR Givens rotations about z, y and x bring `a * R(right)` to the scale.
pub fn svd_decompose(m: &Mat4) -> ([f32; 3], [f32; 4], [f32; 3], [f32; 4]) {
let inv = 1.0 / m.0[3][3];
let a: Mat3 = std::array::from_fn(|c| std::array::from_fn(|r| m.0[c][r] * inv));
let mut b = mul3(&transpose3(&a), &a);
let mut right = [0.0, 0.0, 0.0, 1.0];
for _ in 0..5 {
step_jacobi(&mut b, &mut right);
}
right = normalize_quaternion(right);
let (x_small, y_small) = (f64::from(b[0][0]) < 1.0e-6, f64::from(b[1][1]) < 1.0e-6);
let mut left = [0.0, 0.0, 0.0, 1.0];
let mut r = rotate3(&a, right);
let mut qr = |r: &mut Mat3, g: Givens, axis: usize| {
left = quaternion_mul(left, g.quaternion(axis));
*r = mul3(&transpose3(&g.matrix(axis)), r);
};
let g = if x_small {
qr_givens(r[1][1], -r[1][0])
} else {
qr_givens(r[0][0], r[0][1])
};
qr(&mut r, g, 2);
let g = if x_small {
qr_givens(r[2][2], -r[2][0])
} else {
qr_givens(r[0][0], r[0][2])
};
qr(&mut r, g.inverse(), 1);
let g = if y_small {
qr_givens(r[2][2], -r[2][1])
} else {
qr_givens(r[1][1], r[1][2])
};
qr(&mut r, g, 0);
let scale = [r[0][0], r[1][1], r[2][2]];
let translation = std::array::from_fn(|i| m.0[3][i] * inv);
let [x, y, z, w] = right;
(translation, left, scale, [-x, -y, -z, w])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -526,51 +355,6 @@ mod tests {
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
}
#[test]
fn svd_decomposes_like_matrix_util() {
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
let parts = |m: [f32; 16]| {
let (t, l, s, r) = svd_decompose(&Mat4::from_row_major(m));
[bits(&t), bits(&l), bits(&s), bits(&r)]
};
// Values from MatrixUtil.svdDecompose on the 26.3 jar.
let scaled = [
2.0, 0.0, 0.0, 1.0, 0.0, 3.0, 0.0, -2.0, 0.0, 0.0, 0.5, 0.25, 0.0, 0.0, 0.0, 1.0,
];
assert_eq!(
parts(scaled),
[
vec![0x3f80_0000u32, 0xc000_0000, 0x3e80_0000],
vec![0u32, 0, 0, 0x3f80_0000],
vec![0x4000_0000u32, 0x4040_0000, 0x3f00_0000],
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
]
);
let quarter_turn = [
0.0, -1.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
];
assert_eq!(
parts(quarter_turn),
[
vec![0x3f00_0000u32, 0, 0],
vec![0u32, 0, 0x3f35_04f3, 0x3f35_04f3],
vec![0x3f7f_ffffu32, 0x3f7f_ffff, 0x3f80_0000],
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
]
);
let sheared = [
1.0, 0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.25, 0.0, 2.0, 3.0, 0.0, 0.0, 0.0, 2.0,
];
assert_eq!(
parts(sheared),
[
vec![0u32, 0, 0x3fc0_0000],
vec![0x3c03_7010u32, 0x3d38_d722, 0x3ea8_ef95, 0x3f71_60d9],
vec![0x3f23_2630u32, 0x3ec6_bdde, 0x3f81_5b1f],
vec![0xbcc5_7e11u32, 0xbda8_1e36, 0xbee2_916d, 0x3f64_860a],
]
);
}
#[test]
fn half_turn_has_exact_zeros_like_joml() {
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
+44 -653
View File
@@ -6,12 +6,6 @@
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
//!
//! `--behaviour fight` is the exception: the bots play in survival (`gamemode=survival`, `force-gamemode=true`,
//! and `allow-flight=true` because they hold their spawn height instead of standing on terrain they cannot
//! see). They track the entities the server sends, walk to the nearest living one and attack it at vanilla's
//! bare-hand cooldown (every 5 ticks). With `--pvp true` (the default) the other bots are targets too, which
//! hurts only when the server has `pvp=true`.
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
@@ -21,15 +15,12 @@
//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the
//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly").
//! The report also has wire bytes in and out (`bytes_in`, `bytes_out`, per bot and in total, after
//! compression) and join-time percentiles (`join_time`: p50, p99 and max in milliseconds).
use horizon_protocol::{
PROTOCOL_VERSION,
buf::{Reader, Writer},
connection::{ConnWriter, Connection, Counters},
connection::{ConnWriter, Connection},
crypto::offline_uuid,
entity_data, ids,
ids,
item::Stack,
packets::{
Empty, configuration,
@@ -40,9 +31,9 @@ use horizon_protocol::{
};
use serde_json::json;
use std::{
collections::{BTreeMap, HashSet},
collections::HashSet,
sync::{
Arc, LazyLock, Mutex, OnceLock,
Arc, LazyLock, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
@@ -54,9 +45,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore, fight or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
fight needs gamemode=survival, force-gamemode=true and allow-flight=true.
stand: stays on the ground where the server puts it (/tp), for a survival test.
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
One abusive client, to test a server's limits (run it beside a normal crowd):
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
@@ -70,14 +59,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
in the same tick (a multi-target kill aura, for the anticheat's tests);
the report gives each bot's last health
--attack-after S start attacking S seconds after joining; default 5
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
--no-read stop reading from the server once spawned (a client that lets the
server's replies pile up)
--join-every SECONDS default 1
@@ -85,29 +66,15 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--view-distance N what the bots ask for; default 10
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--pvp B fight bots also attack each other (true or false); default true
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
--ground claim the ground in every move (with --height 0 on a flat world: walking
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
--give SLOT:ID:COUNT,... creative-slot packets sent once joined (inventory menu slot, item
registry id, count), to test what a server takes
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
--raw TICK:ID:HEX,... raw serverbound play packets (id, body in hex) sent TICK ticks after joining,
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Idle,
Stand,
Walk,
Explore,
Fight,
FloodMove,
FloodAcks,
FloodPackets,
@@ -124,10 +91,8 @@ impl Behaviour {
fn name(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Stand => "stand",
Self::Walk => "walk",
Self::Explore => "explore",
Self::Fight => "fight",
Self::FloodMove => "flood-move",
Self::FloodAcks => "flood-acks",
Self::FloodPackets => "flood-packets",
@@ -152,28 +117,10 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
pvp: bool,
/// The player to attack, and how long after joining to start.
attack: Option<String>,
attack_after: f64,
no_swing: bool,
flood: usize,
no_read: bool,
no_loaded: bool,
height: f64,
ground: bool,
/// --raw: (tick after joining, packet id, body).
raw: Vec<(u64, i32, Vec<u8>)>,
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
give: Vec<(i16, i32, i32)>,
/// --click: the block to open, then the slot, button and click type in its window.
click: Option<([i32; 3], i16, i8, i32)>,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
fn options() -> Result<Options, String> {
@@ -187,22 +134,10 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
pvp: true,
attack: None,
attack_after: 5.0,
no_swing: false,
flood: 100,
no_read: false,
no_loaded: false,
height: 24.0,
ground: false,
raw: Vec::new(),
give: Vec::new(),
click: None,
name: "Bot".into(),
report: None,
brand: None,
timer: 1.0,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
@@ -214,18 +149,6 @@ fn options() -> Result<Options, String> {
o.no_read = true;
continue;
}
if arg == "--no-swing" {
o.no_swing = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
}
if arg == "--ground" {
o.ground = true;
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
@@ -243,77 +166,17 @@ fn options() -> Result<Options, String> {
"--explore-speed" => {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
let n: Vec<&str> = part.split(':').collect();
let parsed = match n[..] {
[s, i, c] => s.parse().ok().zip(i.parse().ok()).zip(c.parse().ok()),
_ => None,
};
let ((slot, item), count) = parsed.ok_or_else(|| number("SLOT:ID:COUNT"))?;
o.give.push((slot, item, count));
}
}
"--click" => {
let bad = || number("X,Y,Z:SLOT:BUTTON:MODE");
let n: Vec<&str> = value.split(':').collect();
let [at, slot, button, mode] = n[..] else {
return Err(bad());
};
let at: Vec<i32> = at
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()
.map_err(|_| bad())?;
let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?;
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
}
"--raw" => {
for part in value.split(',') {
let bad = || number("TICK:ID:HEX");
let [tick, id, hex] = part.split(':').collect::<Vec<_>>()[..] else {
return Err(bad());
};
let body = (0..hex.len())
.step_by(2)
.map(|i| {
hex.get(i..i + 2)
.and_then(|b| u8::from_str_radix(b, 16).ok())
})
.collect::<Option<Vec<u8>>>()
.ok_or_else(bad)?;
o.raw.push((
tick.parse().map_err(|_| bad())?,
id.parse().map_err(|_| bad())?,
body,
));
}
}
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
"--timer" => {
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
if !(o.timer >= 0.1 && o.timer <= 20.0) {
return Err(format!("--timer {value}: use 0.1 to 20"));
}
}
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
const BEHAVIOURS: [&str; 16] = [
const BEHAVIOURS: [&str; 14] = [
"idle",
"walk",
"explore",
"fight",
"mixed",
"stand",
"flood-move",
"flood-acks",
"flood-packets",
@@ -327,7 +190,7 @@ fn options() -> Result<Options, String> {
];
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore, fight, mixed or a flood behaviour (--help lists them)",
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
o.behaviour
));
}
@@ -348,57 +211,18 @@ struct Bot {
pulled_back: AtomicU64,
/// Ticks the bot couldn't move because it didn't hold its chunk yet.
waited: AtomicU64,
/// Attack packets sent (fight bots and --attack).
attacks: AtomicU64,
/// The entities the server tells the bot about (fight bots only).
entities: Mutex<Entities>,
/// The chunks the bot holds (ClientChunkCache).
held: Mutex<HashSet<(i32, i32)>>,
/// Where the server last put the bot (a teleport), to start from.
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
/// The connection's byte counters (wire bytes, after compression and encryption), set once it is open.
counters: OnceLock<Arc<Counters>>,
/// When the bot asked to join, and how long after that it got the last
/// chunk new to it: for a bot that stands still, its first full view.
asked: Mutex<Option<Instant>>,
view_after_ms: AtomicU64,
/// The bot stopped reading (--no-read).
deaf: AtomicBool,
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<Vec<(u128, Option<i32>)>>,
/// The last SetHealth.
health: Mutex<Option<f32>>,
}
impl Bot {
fn bytes_in(&self) -> u64 {
self.counters
.get()
.map_or(0, |c| c.bytes_in.load(Ordering::Relaxed).max(0) as u64)
}
fn bytes_out(&self) -> u64 {
self.counters
.get()
.map_or(0, |c| c.bytes_out.load(Ordering::Relaxed).max(0) as u64)
}
}
/// Nearest-rank percentile (`p` in 0..=100) of an ascending-sorted slice; 0 when it is empty.
fn percentile(sorted: &[u64], p: u64) -> u64 {
if sorted.is_empty() {
return 0;
}
let rank = (p * sorted.len() as u64).div_ceil(100).max(1) as usize;
sorted[rank - 1]
}
/// Join-time summary in milliseconds over the bots that got in: count, p50, p99 and max.
fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
times.sort_unstable();
json!({
"count": times.len(),
"p50_ms": percentile(&times, 50),
"p99_ms": percentile(&times, 99),
"max_ms": times.last().copied().unwrap_or(0),
})
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
@@ -416,142 +240,6 @@ impl Random {
}
}
/// Vanilla's bare-hand attack speed is 4 a second: a full-strength swing every 20 / 4 ticks.
const ATTACK_COOLDOWN_TICKS: u32 = 5;
/// How far, in blocks, a fight bot strikes (the survival entity interaction range is 3).
const ATTACK_REACH: f64 = 2.5;
/// A fight bot walks up to this horizontal distance, so it does not stand inside its target.
const CLOSE_IN: f64 = 1.5;
/// Walking speed in blocks a tick.
const FIGHT_STEP: f64 = 0.215;
/// `LivingEntity.DATA_HEALTH_ID`: only living entities carry it, and it is sent once it differs from 1.
const HEALTH_DATA: u8 = 9;
/// What a fight bot knows of one entity.
#[derive(Clone, Debug, PartialEq)]
struct Tracked {
uuid: u128,
pos: [f64; 3],
/// Set once the server sends the health of the entity, which only living entities have.
health: Option<f32>,
}
/// The entities around a bot, from AddEntity, the move packets, SetEntityData and RemoveEntities.
/// Ordered by id, so ties between equally near targets always go to the same entity.
#[derive(Default)]
struct Entities(BTreeMap<i32, Tracked>);
impl Entities {
fn update(&mut self, id: i32, body: &[u8]) {
let Ok(packet) = play::Clientbound::decode(id, body) else {
return;
};
match packet {
play::Clientbound::AddEntity(p) => {
self.0.insert(
p.id,
Tracked {
uuid: p.uuid,
pos: p.position,
health: None,
},
);
}
play::Clientbound::MoveEntityPos(play::MoveEntityPos(m))
| play::Clientbound::MoveEntityPosRot(play::MoveEntityPosRot(m)) => {
if let (Some(e), Some(delta)) = (self.0.get_mut(&m.id), &m.delta) {
e.pos = delta.end(e.pos);
}
}
play::Clientbound::EntityPositionSync(p) => {
if let Some(e) = self.0.get_mut(&p.id) {
e.pos = p.path.end();
}
}
play::Clientbound::SetEntityData(p) => {
if let Some(e) = self.0.get_mut(&p.id) {
for (index, _, value) in &p.values {
if *index == HEALTH_DATA
&& let entity_data::Value::Float(health) = value
{
e.health = Some(*health);
}
}
}
}
play::Clientbound::RemoveEntities(p) => {
for id in p.ids {
self.0.remove(&id);
}
}
_ => {}
}
}
/// The nearest living entity that is not dead; other bots only when `pvp`.
fn nearest(&self, from: [f64; 3], pvp: bool, bots: &HashSet<u128>) -> Option<(i32, [f64; 3])> {
let mut best: Option<(f64, i32, [f64; 3])> = None;
for (id, e) in &self.0 {
if !e.health.is_some_and(|h| h > 0.0) || (!pvp && bots.contains(&e.uuid)) {
continue;
}
let d = distance(from, e.pos);
if best.is_none_or(|(b, ..)| d < b) {
best = Some((d, *id, e.pos));
}
}
best.map(|(_, id, pos)| (id, pos))
}
}
fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
*since_attack = since_attack.saturating_add(1);
let (id, at) = target;
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
let horizontal = dx.hypot(dz);
if horizontal > CLOSE_IN {
let step = FIGHT_STEP.min(horizontal - CLOSE_IN);
pos[0] += dx / horizontal * step;
pos[2] += dz / horizontal * step;
}
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
fn walk_step(
pos: &mut [f64; 3],
target: &mut [f64; 3],
spawn: [f64; 3],
cruise: f64,
random: &mut Random,
) {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
*target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
} else {
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
}
}
/// One bot thread that panics poisons the locks it held; the others and the report go on.
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
@@ -714,19 +402,6 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
.encode(),
)
.map_err(err)?;
// ClientBrandRetriever: vanilla's client says "vanilla", Horizon's "horizon".
if let Some(brand) = &o.brand {
let mut w = Writer::new();
w.string(brand);
c.send(
&configuration::Serverbound::CustomPayload(configuration::CustomPayload {
channel: "minecraft:brand".into(),
data: w.data,
})
.encode(),
)
.map_err(err)?;
}
loop {
let (id, body) = c.recv().map_err(err)?;
let reply = match id {
@@ -754,9 +429,8 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
payload: None,
})
}
configuration::Clientbound::Disconnect(p) => {
let reason = horizon_protocol::text::plain(&p.reason, &Default::default());
return Err(format!("disconnected during configuration: {reason}"));
configuration::Clientbound::Disconnect(_) => {
return Err("disconnected during configuration".into());
}
configuration::Clientbound::FinishConfiguration(_) => {
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
@@ -807,7 +481,6 @@ fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -846,6 +519,9 @@ fn read(
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
if !lock(&bot.held).insert((x, z)) {
bot.resent.fetch_add(1, Ordering::Relaxed);
} else if let Some(asked) = *lock(&bot.asked) {
bot.view_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::Relaxed);
}
if seen.insert((x, z)) {
bot.unique.fetch_add(1, Ordering::Relaxed);
@@ -862,25 +538,6 @@ fn read(
}
Ok(())
}
cb::ADD_ENTITY => {
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
for target in lock(&bot.target).iter_mut() {
if target.0 == p.uuid {
target.1 = Some(p.id);
}
}
}
if track {
lock(&bot.entities).update(id, &body);
}
Ok(())
}
cb::SET_HEALTH => {
if let Ok(play::Clientbound::SetHealth(p)) = play::Clientbound::decode(id, &body) {
*lock(&bot.health) = Some(p.health);
}
Ok(())
}
cb::CHUNK_BATCH_START => {
batch.started = Instant::now();
Ok(())
@@ -925,16 +582,6 @@ fn read(
}
_ => Ok(()),
},
cb::MOVE_ENTITY_POS
| cb::MOVE_ENTITY_POS_ROT
| cb::ENTITY_POSITION_SYNC
| cb::SET_ENTITY_DATA
| cb::REMOVE_ENTITIES
if track =>
{
lock(&bot.entities).update(id, &body);
Ok(())
}
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
@@ -964,12 +611,12 @@ fn run_bot(
o: Arc<Options>,
index: usize,
behaviour: Behaviour,
bots: Arc<HashSet<u128>>,
bot: Arc<Bot>,
until: Arc<Mutex<Option<Instant>>>,
) {
let name = format!("{}{index}", o.name);
let asked = Instant::now();
*lock(&bot.asked) = Some(asked);
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
@@ -978,19 +625,11 @@ fn run_bot(
}
};
let (reader, writer) = connection.split();
let _ = bot.counters.set(reader.counters.clone());
let writer = Arc::new(Mutex::new(writer));
*lock(&bot.target) = o
.attack
.iter()
.flat_map(|names| names.split(','))
.map(|name| (offline_uuid(name), None))
.collect();
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
thread::spawn(move || read(reader, writer, bot));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1009,9 +648,7 @@ fn run_bot(
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
}
send(raw(sb::PLAYER_LOADED, |_| {}));
bot.deaf.store(o.no_read, Ordering::Relaxed);
// What the costly floods need in hand (creative: the server takes the bot's word for it).
let tool = match behaviour {
@@ -1019,15 +656,6 @@ fn run_bot(
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
_ => None,
};
for &(slot, item, count) in &o.give {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item: Some(Stack::new(item, count)),
})
.encode(),
);
}
if tool.is_some() {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
@@ -1041,17 +669,10 @@ fn run_bot(
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
let cruise = if behaviour == Behaviour::Fight {
spawn[1]
} else {
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let cruise = spawn[1] + 24.0;
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
let (joined, mut tick) = (Instant::now(), 0u64);
loop {
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
break;
@@ -1062,9 +683,7 @@ fn run_bot(
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
// The abusive client doesn't wait for its chunk, as a real client must.
if behaviour == Behaviour::Stand {
// Where the server put it, on the ground.
} else if behaviour == Behaviour::FloodMove {
if behaviour == Behaviour::FloodMove {
// The last of these moves goes out below, with the tick's end.
pos[1] = cruise;
for _ in 1..o.flood.max(1) {
@@ -1100,23 +719,23 @@ fn run_bot(
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
match behaviour {
Behaviour::Idle | Behaviour::Stand => {}
Behaviour::Walk => walk_step(&mut pos, &mut target, spawn, cruise, &mut random),
Behaviour::Fight => {
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
if let Some(near) = near {
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
// The client sends the attack, then swings the arm.
let sent = send(
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
) && send(raw(sb::PUNCH, |w| w.varint(0)));
if sent {
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
}
Behaviour::Idle => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
} else {
since_attack = since_attack.saturating_add(1);
walk_step(&mut pos, &mut target, spawn, cruise, &mut random);
// Sprinting: 5.6 blocks a second.
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
}
}
Behaviour::FloodMove
@@ -1158,43 +777,6 @@ fn run_bot(
}
}
}
if let Some((at, slot, button, input)) = o.click {
// Ticks of 50 ms: the block 3 s after joining, the click a second later.
if tick == 60 {
sequence += 1;
send(
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
.encode(),
);
} else if tick == 80 {
// The first window a player opens is 1.
send(
play::Serverbound::ContainerClick(play::ContainerClick {
container: 1,
state: 0,
slot,
button,
input,
changed: Vec::new(),
carried: None,
})
.encode(),
);
}
}
for (at, id, body) in &o.raw {
if *at == tick {
send(raw(*id, |w| w.bytes(body)));
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
@@ -1202,27 +784,12 @@ fn run_bot(
send(body);
}
}
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
for id in targets {
send(play::Serverbound::Attack(play::Attack { id }).encode());
// Minecraft.startAttack swings right after the attack.
if !o.no_swing {
send(play::Serverbound::Punch(Empty).encode());
}
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
}
tick += 1;
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
// Fight bots stand on the ground; the others fly.
flags: u8::from(
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
),
flags: 0,
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
@@ -1230,7 +797,7 @@ fn run_bot(
bot.online.store(false, Ordering::SeqCst);
break;
}
next += Duration::from_secs_f64(0.05 / o.timer);
next += Duration::from_millis(50);
let now = Instant::now();
if next > now {
thread::sleep(next - now);
@@ -1246,10 +813,8 @@ fn run_bot(
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
match mode {
"idle" => Behaviour::Idle,
"stand" => Behaviour::Stand,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
"fight" => Behaviour::Fight,
"flood-move" => Behaviour::FloodMove,
"flood-acks" => Behaviour::FloodAcks,
"flood-packets" => Behaviour::FloodPackets,
@@ -1280,23 +845,14 @@ fn main() {
let start = Instant::now();
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
// The uuids of all bots, so that `--pvp false` leaves them alone.
let bot_uuids: Arc<HashSet<u128>> = Arc::new(
(0..o.bots)
.map(|i| offline_uuid(&format!("{}{i}", o.name)))
.collect(),
);
let launcher = {
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
let bot_uuids = bot_uuids.clone();
thread::spawn(move || {
let mut handles = Vec::new();
for (i, bot) in bots.iter().enumerate() {
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
let (behaviour, uuids) = (behaviour_of(&o.behaviour, i), bot_uuids.clone());
handles.push(thread::spawn(move || {
run_bot(o2, i, behaviour, uuids, bot, until);
}));
let behaviour = behaviour_of(&o.behaviour, i);
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
thread::sleep(Duration::from_secs_f64(o.join_every));
}
handles
@@ -1333,7 +889,6 @@ fn main() {
"resent": total(|b| &b.resent),
"pulled_back": total(|b| &b.pulled_back),
"waited_ticks": total(|b| &b.waited),
"attacks": total(|b| &b.attacks),
})
);
previous_chunks = chunks;
@@ -1353,7 +908,6 @@ fn main() {
"behaviour": o.behaviour,
"seed": o.seed,
"explore_speed": o.explore_speed,
"pvp": o.pvp,
"view_distance": o.view_distance,
"seconds": o.seconds,
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
@@ -1361,21 +915,13 @@ fn main() {
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
"attacks": bots.iter().map(|b| b.attacks.load(Ordering::Relaxed)).sum::<u64>(),
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
"join_time": join_summary(
bots.iter()
.map(|b| b.joined_after_ms.load(Ordering::SeqCst))
.filter(|&ms| ms > 0)
.collect(),
),
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
"name": format!("{}{i}", o.name),
"behaviour": behaviour_of(&o.behaviour, i).name(),
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
"bytes_in": b.bytes_in(),
"bytes_out": b.bytes_out(),
// Only an idle bot's last new chunk ends its first view.
"view_after_ms": (behaviour_of(&o.behaviour, i) == Behaviour::Idle)
.then(|| b.view_after_ms.load(Ordering::Relaxed)),
"chunks": b.chunks.load(Ordering::Relaxed),
"unique_chunks": b.unique.load(Ordering::Relaxed),
"resent": b.resent.load(Ordering::Relaxed),
@@ -1383,8 +929,6 @@ fn main() {
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *lock(&b.error),
"attacks": b.attacks.load(Ordering::Relaxed),
"health": *lock(&b.health),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
@@ -1410,156 +954,3 @@ fn main() {
}
std::process::exit(if errors.is_empty() { 0 } else { 1 });
}
#[cfg(test)]
mod tests {
use super::*;
use play::{
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
};
fn feed(entities: &mut Entities, packet: &Clientbound) {
let bytes = packet.encode();
let mut r = Reader::new(&bytes);
let id = r.varint().unwrap();
entities.update(id, &bytes[bytes.len() - r.remaining()..]);
}
fn add(id: i32, uuid: u128, pos: [f64; 3]) -> Clientbound {
Clientbound::AddEntity(AddEntity {
id,
uuid,
kind: 1,
position: pos,
velocity: [0.0; 3],
pitch: 0,
yaw: 0,
head_yaw: 0,
data: 0,
})
}
fn health(id: i32, health: f32) -> Clientbound {
Clientbound::SetEntityData(SetEntityData {
id,
values: vec![(HEALTH_DATA, 3, entity_data::Value::Float(health))],
})
}
#[test]
fn tracks_living_entities_only() {
let mut e = Entities::default();
feed(&mut e, &add(1, 10, [3.0, 64.0, 0.0]));
feed(&mut e, &add(2, 11, [5.0, 64.0, 0.0]));
feed(&mut e, &health(2, 20.0));
// Entity 1 never sent a health (an item, an arrow), so it is no target.
assert_eq!(
e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()),
Some((2, [5.0, 64.0, 0.0]))
);
feed(&mut e, &health(2, 0.0));
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
feed(&mut e, &health(2, 4.0));
feed(
&mut e,
&Clientbound::RemoveEntities(RemoveEntities { ids: vec![2] }),
);
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
}
#[test]
fn moves_follow_the_deltas() {
let mut e = Entities::default();
feed(&mut e, &add(7, 1, [0.0, 64.0, 0.0]));
feed(&mut e, &health(7, 20.0));
feed(
&mut e,
&Clientbound::MoveEntityPos(MoveEntityPos(MoveEntity {
id: 7,
delta: Some(VecDelta::Linear([4096, 0, -8192])),
rotation: None,
on_ground: true,
})),
);
assert_eq!(e.0[&7].pos, [1.0, 64.0, -2.0]);
}
#[test]
fn pvp_false_spares_the_bots_and_ties_go_to_the_lowest_id() {
let mut e = Entities::default();
feed(&mut e, &add(9, 100, [2.0, 64.0, 0.0]));
feed(&mut e, &add(4, 101, [-2.0, 64.0, 0.0]));
feed(&mut e, &add(5, 102, [0.0, 64.0, 6.0]));
for id in [9, 4, 5] {
feed(&mut e, &health(id, 20.0));
}
let bots = HashSet::from([100, 101]);
let from = [0.0, 64.0, 0.0];
assert_eq!(e.nearest(from, true, &bots).map(|t| t.0), Some(4));
assert_eq!(e.nearest(from, false, &bots).map(|t| t.0), Some(5));
}
#[test]
fn attacks_at_the_vanilla_cooldown() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [1.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let hits: Vec<u32> = (0..40)
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
.collect();
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
assert!(
hits.windows(2)
.all(|w| w[1] - w[0] == ATTACK_COOLDOWN_TICKS)
);
assert_eq!(hits.len(), 8);
}
#[test]
fn walks_into_reach_before_striking() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [10.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let mut first = None;
for tick in 0..100 {
if fight_tick(&mut pos, target, &mut since).is_some() {
first = Some(tick);
break;
}
}
assert!(first.is_some_and(|t| t > 30));
assert!((target.1[0] - pos[0]).abs() <= ATTACK_REACH);
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
}
#[test]
fn percentiles_use_nearest_rank() {
assert_eq!(percentile(&[], 50), 0);
assert_eq!(percentile(&[7], 99), 7);
let v: Vec<u64> = (1..=100).collect();
assert_eq!(percentile(&v, 50), 50);
assert_eq!(percentile(&v, 99), 99);
assert_eq!(percentile(&[10, 20, 30, 40], 50), 20);
}
#[test]
fn join_summary_sorts_and_reports_max() {
let s = join_summary(vec![300, 100, 200]);
assert_eq!(s["count"], 3);
assert_eq!(s["p50_ms"], 200);
assert_eq!(s["p99_ms"], 300);
assert_eq!(s["max_ms"], 300);
assert_eq!(join_summary(vec![])["count"], 0);
}
#[test]
fn bot_bytes_come_from_the_connection_counters() {
let bot = Bot::default();
assert_eq!((bot.bytes_in(), bot.bytes_out()), (0, 0));
let counters = Arc::new(Counters::default());
counters.bytes_in.store(1234, Ordering::Relaxed);
counters.bytes_out.store(56, Ordering::Relaxed);
let _ = bot.counters.set(counters);
assert_eq!((bot.bytes_in(), bot.bytes_out()), (1234, 56));
}
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "horizon-chat-filter"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
[lints]
workspace = true
-63
View File
@@ -1,63 +0,0 @@
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
# words are written here only so the filter can recognise them, because players spammed them on the
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
# aren't slurs out of it (moderators handle those).
#
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
# doubled in the text: "niger", the river and the country, isn't "nigger".
# word matches inside a longer word too
# =word only as a whole word ("spic", not "suspicious")
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
~nigger
~nigga
nigguh
niggah
niqqa
niqqer
nlgger
nlgga
kneegrow
=nig
=nigs
=niglet
=niglets
=nibba
=nibbas
=nibber
=nibbers
jigaboo
porchmonkey
=coon
=coons
~faggot
=fag
=fags
=faggy
=fagot
=fagots
=tranny
=trannies
=kike
=kikes
=chink
=chinks
=gook
=gooks
=spic
=spics
wetback
=beaner
=beaners
raghead
towelhead
zipperhead
=paki
=pakis
!snigger*
!niggard*
-472
View File
@@ -1,472 +0,0 @@
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
//! player's text and carries out what it decides.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
mod normalise;
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
pub const DEFAULT_WORDS: &str = include_str!("../data/slurs.txt");
/// The mutes, beside server.properties.
pub const MUTES_FILE: &str = "chat-filter-mutes.json";
/// `server.toml`'s `[chat_filter]`: on by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub enabled: bool,
/// The hit, counted within `window_minutes`, that mutes the player; 0: never.
pub mute_after: u32,
/// The hit that bans the player for `tempban_hours`; 0: never.
pub tempban_after: u32,
/// How long a hit counts towards the next ones (the time spent muted doesn't count).
pub window_minutes: u32,
pub mute_minutes: u32,
pub tempban_hours: u32,
/// The slur list, relative to the server's folder; empty: the list built into the game.
pub words_file: String,
/// Every refused text with the player and what followed, relative to the server's folder.
pub log: String,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
mute_after: 2,
tempban_after: 3,
window_minutes: 10,
mute_minutes: 10,
tempban_hours: 24,
words_file: String::new(),
log: "logs/chat-filter.log".into(),
}
}
}
impl Config {
/// The `[chat_filter]` section of a whole `server.toml`; the default without it.
pub fn from_server_toml(text: &str) -> Result<Self, String> {
#[derive(Deserialize)]
struct File {
#[serde(default)]
chat_filter: Config,
}
// Other sections are the supervisor's to check: only this one is read here.
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let mut only = toml::Table::new();
if let Some(section) = table.remove("chat_filter") {
only.insert("chat_filter".into(), section);
}
let file: File = only
.try_into()
.map_err(|e: toml::de::Error| format!("[chat_filter]: {e}"))?;
file.chat_filter.check()?;
Ok(file.chat_filter)
}
pub fn check(&self) -> Result<(), String> {
if self.window_minutes == 0 {
return Err("chat_filter.window_minutes must be 1 or more".into());
}
Ok(())
}
}
/// How an entry of the list matches.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
/// Inside a longer word too.
Embedded,
/// Only as a whole word.
Whole,
/// Inside a word, or spread over several words from the start of one.
Spread,
}
#[derive(Debug)]
struct Entry {
runs: Vec<Run>,
kind: Kind,
}
/// The slur list, compiled: each entry's letters as runs, indexed by first letter.
#[derive(Debug)]
pub struct Filter {
entries: Vec<Entry>,
by_first: [Vec<u16>; 27],
/// Words never matched: exact (`false`) or as a prefix (`true`).
allowed: Vec<(Vec<Run>, bool)>,
}
impl Filter {
/// Compiles a list in `data/slurs.txt`'s format.
pub fn new(list: &str) -> Self {
let mut f = Filter {
entries: Vec::new(),
by_first: Default::default(),
allowed: Vec::new(),
};
for line in list.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(word) = line.strip_prefix('!') {
let (word, prefix) = match word.strip_suffix('*') {
Some(w) => (w, true),
None => (word, false),
};
f.allowed.push((normalise::word(word), prefix));
continue;
}
let (kind, word) = match line.as_bytes()[0] {
b'=' => (Kind::Whole, &line[1..]),
b'~' => (Kind::Spread, &line[1..]),
_ => (Kind::Embedded, line),
};
let runs = normalise::word(word);
let Some(first) = runs.first() else {
continue;
};
f.by_first[usize::from(first.letter)].push(f.entries.len() as u16);
f.entries.push(Entry { runs, kind });
}
f
}
/// The built-in list.
pub fn default_list() -> Self {
Self::new(DEFAULT_WORDS)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Whether `text` holds a slur of the list.
pub fn matches(&self, text: &str) -> bool {
let t = Text::new(text);
let runs = &t.runs;
for &(start, end) in t.words.iter().chain(&t.spaced) {
let word = &runs[start..end];
if self.is_allowed(word) {
continue;
}
for at in 0..word.len() {
for &e in &self.by_first[usize::from(word[at].letter)] {
let e = &self.entries[usize::from(e)];
let whole = e.kind == Kind::Whole;
if (!whole || at == 0) && fits(&e.runs, &word[at..], whole) {
return true;
}
}
}
}
// Spread entries over the whole text, from the start of a word, across at least two.
for (w, &at) in t.joined_at.iter().enumerate() {
for &e in &self.by_first[usize::from(t.joined[at].letter)] {
let e = &self.entries[usize::from(e)];
if e.kind == Kind::Spread
&& fits(&e.runs, &t.joined[at..], false)
&& t.spans_words(w, e.runs.len())
{
return true;
}
}
}
false
}
fn is_allowed(&self, word: &[Run]) -> bool {
self.allowed.iter().any(|(a, prefix)| {
if *prefix {
// A prefix: its last run may be part of a longer one.
word.len() >= a.len() && fits(a, word, false)
} else {
fits(a, word, true)
}
})
}
}
/// Whether `entry`'s runs start `runs` (are all of it with `whole`): the same letters, each at least
/// as many times as the entry has it.
fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
if runs.len() < entry.len() || (whole && runs.len() != entry.len()) {
return false;
}
entry
.iter()
.zip(runs)
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
}
/// What a hit leads to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// The text is refused, nothing more.
Refused,
/// Refused, and the player can't chat until then (epoch milliseconds).
Muted { until: i64 },
/// Refused, and the player is banned until then.
Banned { until: i64 },
}
impl Action {
pub fn name(self) -> &'static str {
match self {
Action::Refused => "refused",
Action::Muted { .. } => "muted",
Action::Banned { .. } => "banned",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
}
#[derive(Default, Serialize, Deserialize)]
struct MutesFile {
mutes: Vec<Mute>,
}
/// One player's hits: how many, and when the last one stops counting.
#[derive(Clone, Copy, Debug)]
struct Strikes {
count: u32,
counts_until: i64,
}
/// The filter, the players' hits and the mutes.
pub struct Guard {
pub config: Config,
pub filter: Filter,
strikes: HashMap<u128, Strikes>,
mutes: HashMap<u128, Mute>,
/// Where the mutes are kept; `None` keeps them in memory only (tests).
file: Option<PathBuf>,
}
const MINUTE: i64 = 60_000;
impl Guard {
pub fn new(config: Config, filter: Filter, file: Option<PathBuf>) -> Self {
let mut mutes = HashMap::new();
if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok())
&& let Ok(saved) = serde_json::from_str::<MutesFile>(&text)
{
for m in saved.mutes {
if let Some(uuid) = parse_uuid(&m.uuid) {
mutes.insert(uuid, m);
}
}
}
Self {
config,
filter,
strikes: HashMap::new(),
mutes,
file,
}
}
/// The server's guard: `words_file` or the built-in list, the mutes in `dir`. An unreadable
/// `words_file` falls back to the built-in list, with why.
pub fn load(config: Config, dir: &Path) -> (Self, Option<String>) {
let mut problem = None;
let filter = if config.words_file.is_empty() {
Filter::default_list()
} else {
match std::fs::read_to_string(dir.join(&config.words_file)) {
Ok(text) => Filter::new(&text),
Err(e) => {
problem = Some(format!(
"chat_filter.words_file {}: {e}; using the built-in list",
config.words_file
));
Filter::default_list()
}
}
};
(
Self::new(config, filter, Some(dir.join(MUTES_FILE))),
problem,
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
pub fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
let c = &self.config;
let window = i64::from(c.window_minutes) * MINUTE;
let s = self.strikes.entry(uuid).or_insert(Strikes {
count: 0,
counts_until: 0,
});
if now > s.counts_until {
s.count = 0;
}
s.count += 1;
s.counts_until = now + window;
let count = s.count;
let action = if c.tempban_after > 0 && count >= c.tempban_after {
self.strikes.remove(&uuid);
Action::Banned {
until: now + i64::from(c.tempban_hours) * 60 * MINUTE,
}
} else if c.mute_after > 0 && count >= c.mute_after {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
Action::Muted { until }
} else {
Action::Refused
};
(count, action)
}
/// Lifts a player's mute (and forgets their hits), by name; the name as muted, if they were.
pub fn unmute(&mut self, name: &str) -> Option<String> {
let uuid = *self
.mutes
.iter()
.find(|(_, m)| m.name.eq_ignore_ascii_case(name))?
.0;
let mute = self.mutes.remove(&uuid)?;
self.strikes.remove(&uuid);
self.save();
Some(mute.name)
}
fn save(&mut self) {
let Some(file) = &self.file else {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
let tmp = file.with_extension("json.tmp");
if std::fs::write(&tmp, text).is_ok() {
let _ = std::fs::rename(&tmp, file);
}
}
}
}
/// One line of `logs/chat-filter.log`, without its newline.
pub fn log_line(
now: i64,
name: &str,
uuid: u128,
place: &str,
count: u32,
action: Action,
text: &str,
) -> String {
let text: String = text
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.collect();
format!(
"{} {name} {} {place} hit {count} {}: {text}",
utc(now),
uuid_string(uuid),
action.name()
)
}
/// Appends a line to the log at `path` (its folder made if needed).
pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
use std::io::Write;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(f, "{line}")
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
rest / 3600,
rest / 60 % 60,
rest % 60
)
}
fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
fn parse_uuid(s: &str) -> Option<u128> {
u128::from_str_radix(&s.replace('-', ""), 16).ok()
}
#[cfg(test)]
mod tests;
-255
View File
@@ -1,255 +0,0 @@
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
const OTHER: u8 = 26;
/// One letter and how many times it repeats (`gg` is `g` twice).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Run {
/// 0..=25 for `a`..`z`, 26 for any other letter.
pub letter: u8,
pub count: u8,
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
pub weak: bool,
}
/// A whole message, normalised.
#[derive(Debug, Default)]
pub struct Text {
pub runs: Vec<Run>,
/// Each word's runs, `runs[start..end]`.
pub words: Vec<(usize, usize)>,
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
/// without the punctuation they end with (`fag!`).
pub spaced: Vec<(usize, usize)>,
/// Every word one after the other, a letter that ends one and starts the next merged.
pub joined: Vec<Run>,
/// Where each word starts in `joined`.
pub joined_at: Vec<usize>,
/// The last word each run of `joined` belongs to.
joined_last: Vec<usize>,
}
enum Fold {
Letter(u8, bool),
Separator,
Space,
Skip,
}
impl Text {
pub fn new(text: &str) -> Self {
let mut t = Text::default();
let mut start = 0;
for c in text.chars() {
match fold(c) {
Fold::Letter(letter, weak) => {
let in_word = t.runs.len() > start;
match t.runs.last_mut() {
Some(last) if in_word && last.letter == letter => {
last.count = last.count.saturating_add(1);
last.weak &= weak;
}
_ => t.runs.push(Run {
letter,
count: 1,
weak,
}),
}
}
Fold::Space => {
t.end_word(start);
start = t.runs.len();
}
Fold::Separator | Fold::Skip => {}
}
}
t.end_word(start);
t.add_spaced();
t.join();
t
}
fn end_word(&mut self, start: usize) {
if self.runs.len() > start {
self.words.push((start, self.runs.len()));
}
}
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
/// copied after the real words').
fn add_spaced(&mut self) {
let words = self.words.len();
let mut w = 0;
while w < words {
let mut end = w;
while end < words && self.words[end].1 - self.words[end].0 == 1 {
end += 1;
}
if end - w >= 2 {
let first = self.runs.len();
for k in w..end {
let run = self.runs[self.words[k].0];
let in_word = self.runs.len() > first;
match self.runs.last_mut() {
Some(last) if in_word && last.letter == run.letter => {
last.count = last.count.saturating_add(run.count);
}
_ => self.runs.push(run),
}
}
self.spaced.push((first, self.runs.len()));
w = end;
} else {
w += 1;
}
}
for k in 0..words {
let (start, mut end) = self.words[k];
while end > start + 1 && self.runs[end - 1].weak {
end -= 1;
}
if end < self.words[k].1 {
self.spaced.push((start, end));
}
}
}
fn join(&mut self) {
for (w, &(start, end)) in self.words.iter().enumerate() {
let mut first = true;
for run in &self.runs[start..end] {
match self.joined.last_mut() {
Some(last) if first && last.letter == run.letter => {
last.count = last.count.saturating_add(run.count);
self.joined_at.push(self.joined.len() - 1);
*self.joined_last.last_mut().expect("beside joined") = w;
}
_ => {
if first {
self.joined_at.push(self.joined.len());
}
self.joined.push(*run);
self.joined_last.push(w);
}
}
first = false;
}
}
}
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
pub fn spans_words(&self, w: usize, len: usize) -> bool {
self.joined_last
.get(self.joined_at[w] + len - 1)
.is_some_and(|&last| last > w)
}
}
/// An entry of the list, normalised the same way (one word, whitespace ignored).
pub fn word(text: &str) -> Vec<Run> {
let t = Text::new(&text.replace(char::is_whitespace, ""));
t.words
.first()
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
}
fn letter(c: char) -> Fold {
Fold::Letter(c as u8 - b'a', false)
}
fn fold(c: char) -> Fold {
if c.is_ascii_alphabetic() {
return letter(c.to_ascii_lowercase());
}
if c.is_ascii() {
return match c {
'0' => letter('o'),
'1' => letter('i'),
'3' => letter('e'),
'4' => letter('a'),
'5' => letter('s'),
'6' | '9' => letter('g'),
'7' => letter('t'),
'8' => letter('b'),
'!' | '|' => Fold::Letter(b'i' - b'a', true),
'@' => Fold::Letter(0, true),
'$' => Fold::Letter(b's' - b'a', true),
c if c.is_ascii_whitespace() => Fold::Space,
_ => Fold::Separator,
};
}
let u = c as u32;
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
let mapped = match u {
// Zero-width characters, soft hyphen, combining marks, variation selectors.
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
0x300..=0x36F
| 0x1AB0..=0x1AFF
| 0x1DC0..=0x1DFF
| 0x20D0..=0x20FF
| 0xFE00..=0xFE0F
| 0xFE20..=0xFE2F
| 0xE0100..=0xE01EF => return Fold::Skip,
// Full-width Latin and digits.
0xFF21..=0xFF3A => from(0xFF21, 26),
0xFF41..=0xFF5A => from(0xFF41, 26),
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
// Mathematical alphanumerics: 52 letters a style, then digits.
0x1D400..=0x1D6A3 => from(0x1D400, 26),
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
// Circled, parenthesised, squared and negative letters, regional indicators.
0x24B6..=0x24CF => from(0x24B6, 26),
0x24D0..=0x24E9 => from(0x24D0, 26),
0x249C..=0x24B5 => from(0x249C, 26),
0x1F130..=0x1F149 => from(0x1F130, 26),
0x1F150..=0x1F169 => from(0x1F150, 26),
0x1F170..=0x1F189 => from(0x1F170, 26),
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
_ => {
if c.is_whitespace() {
return Fold::Space;
}
let lower = c.to_lowercase().next().unwrap_or(c);
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
Some((base, _)) => *base,
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
None => return Fold::Separator,
}
}
};
letter(mapped)
}
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
const LOOK_ALIKES: &[(char, &str)] = &[
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
('b', "ƀɓḃḅḇвьъβʙᵇ"),
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
('f', "ƒḟꜰᶠ"),
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
('j', "ĵǰјᴊʲ"),
('k', "ķĸǩḱḳḵкκᴋᵏ"),
('l', "ĺļľŀłḷḹḻḽʟˡ"),
('m', "мᴍᵐ"),
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
('p', "ṕṗрρᴘᵖ"),
('q', "ԛ"),
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
('v', "νᴠᵛ"),
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
('x', "хχˣ"),
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
('z', "źżžƶẑẓẕᴢᶻ"),
];
-253
View File
@@ -1,253 +0,0 @@
// The test texts spell the slurs the filter must refuse: that is what they test.
use super::*;
fn filter() -> Filter {
Filter::default_list()
}
#[test]
fn the_list_compiles() {
let f = filter();
assert!(f.len() > 30, "{} entries", f.len());
assert_eq!(f.allowed.len(), 2);
}
#[test]
fn normalising_catches_the_usual_disguises() {
let f = filter();
for text in [
"nigger",
"NIGGER",
"shut up nigga",
"Niggers",
"n1gg3r",
"ni99a",
"N!GGA",
"nigg@",
"n.i.g.g.e.r",
"n_i_g_g_a",
"n i g g e r",
"n i gg a",
"nig ger",
"nig gaaa",
"niiiiggggggerrrrr",
"ñíggér",
"nigger",
"Niggeг", // Cyrillic ghe for r
"nіgger", // Cyrillic i
"nigg\u{0435}r", // Cyrillic e
"n\u{200B}igger", // a zero-width space
"n\u{0301}igger", // a combining accent
"𝐧𝐢𝐠𝐠𝐞𝐫",
"ⓝⓘⓖⓖⓐ",
"ɴɪɢɢᴀ",
"hi nig",
"f a g",
"fag!",
"you $pic",
"FAGGOTINO-CHAN",
"sandnigger",
"lol kike",
] {
assert!(f.matches(text), "{text:?} should be refused");
}
}
#[test]
fn ordinary_words_pass() {
let f = filter();
for text in [
"hello",
"Niger",
"I live in Niger",
"Nigeria and the Niger river",
"nigerian food",
"proud2benigerian",
"don't snigger",
"sniggering at that",
"a niggardly offer",
"knight",
"qKnights",
"its still night",
"anyone able to boot the latest nightly version?",
"i hate nighbours",
"can someune give mi night vision",
"suspicious stew",
"spicy food",
"a raccoon in a cocoon",
"Scunthorpe",
"bigger trigger digger",
"gg",
"gg ez",
"mini gg all",
"kniga",
"shenanigans",
"dignity and signing",
"Pakistan",
"chinking glasses",
"the trannys? no: transmission",
"1nicerOli",
"ask me evernig",
"冰淇淋",
"привет",
"",
"!!!",
] {
assert!(!f.matches(text), "{text:?} should pass");
}
}
#[test]
fn a_custom_list_has_the_same_syntax() {
let f = Filter::new("# comment\n=bad\n~worse\nmeh\n!mehish*\n");
assert!(f.matches("so bad"));
assert!(!f.matches("badly"));
assert!(f.matches("wor se"));
assert!(f.matches("xxmehxx"));
assert!(!f.matches("mehishness"));
assert!(!f.matches("me h"));
}
fn guard(config: Config) -> Guard {
Guard::new(config, filter(), None)
}
const NOW: i64 = 1_793_469_939_000;
const MIN: i64 = 60_000;
#[test]
fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
(
3,
Action::Banned {
until: until + 8 * MIN + 24 * 60 * MIN
}
)
);
// Then it starts again.
assert_eq!(g.strike(id, "Ann", until + 9 * MIN), (1, Action::Refused));
}
#[test]
fn hits_far_apart_dont_add_up() {
let mut g = guard(Config::default());
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
assert_eq!(g.strike(1, "Bo", NOW + 11 * MIN), (1, Action::Refused));
// Players are counted apart.
assert_eq!(g.strike(2, "Cy", NOW + 12 * MIN), (1, Action::Refused));
}
#[test]
fn thresholds_are_configurable() {
let mut g = guard(Config {
mute_after: 0,
tempban_after: 2,
tempban_hours: 1,
..Config::default()
});
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
assert_eq!(
g.strike(1, "Bo", NOW + MIN),
(
2,
Action::Banned {
until: NOW + MIN + 60 * MIN
}
)
);
let mut off = guard(Config {
mute_after: 0,
tempban_after: 0,
..Config::default()
});
for k in 1..5 {
assert_eq!(off.strike(1, "Bo", NOW + k), (k as u32, Action::Refused));
}
}
#[test]
fn mutes_survive_a_restart_and_unmute_lifts_them() {
let dir = std::env::temp_dir().join(format!("chat-filter-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let now = now_millis();
let (mut g, problem) = Guard::load(Config::default(), &dir);
assert_eq!(problem, None);
g.strike(5, "Dee", now);
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
words_file: "nope.txt".into(),
..Config::default()
};
let (g, problem) = Guard::load(config, Path::new("/nonexistent-horizon"));
assert!(problem.unwrap().contains("nope.txt"));
assert!(g.filter.matches("nigger"));
}
#[test]
fn the_section_reads_from_server_toml() {
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
assert_eq!(c, Config::default());
assert!(c.enabled);
let c = Config::from_server_toml(
"[chat_filter]\nenabled = true\nmute_after = 3\ntempban_after = 5\nwords_file = \"slurs.txt\"\n",
)
.unwrap();
assert_eq!((c.mute_after, c.tempban_after), (3, 5));
assert_eq!(c.words_file, "slurs.txt");
assert!(
!Config::from_server_toml("[chat_filter]\nenabled = false\n")
.unwrap()
.enabled
);
assert!(Config::from_server_toml("[chat_filter]\nmute = 2\n").is_err());
assert!(Config::from_server_toml("[chat_filter]\nwindow_minutes = 0\n").is_err());
}
#[test]
fn log_lines_have_the_time_the_player_and_the_text() {
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(utc(951_782_400_000), "2000-02-29T00:00:00Z");
assert_eq!(
log_line(NOW, "Ann", 1, "chat", 2, Action::Muted { until: 0 }, "a\nb"),
"2026-10-31T18:05:39Z Ann 00000000-0000-0000-0000-000000000001 chat hit 2 muted: a b"
);
}
#[test]
fn a_message_costs_little() {
// A long message through the whole list stays far below a tick (debug builds included).
let f = filter();
let text = "the quick brown fox jumps over the lazy dog ".repeat(6);
let start = std::time::Instant::now();
for _ in 0..1000 {
assert!(!f.matches(&text));
}
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
}
+40 -118
View File
@@ -186,73 +186,6 @@ impl Parser<'_> {
Err("unterminated string".into())
}
}
/// `click_event` / `hover_event`, or the pre-datafixer `clickEvent` /
/// `hoverEvent` names SNBT and older chat JSON still use.
fn style_event_object<'a>(
o: &'a serde_json::Map<String, Value>,
modern: &str,
legacy: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
o.get(modern)
.or_else(|| o.get(legacy))
.and_then(Value::as_object)
}
/// Style flags from JSON bools, or SNBT `1b`/`0b` which parse as numbers.
fn style_flag(v: Option<&Value>) -> Option<bool> {
match v {
Some(Value::Bool(b)) => Some(*b),
Some(Value::Number(n)) => n.as_i64().map(|i| i != 0),
_ => None,
}
}
fn untrusted_http_url(url: &str) -> bool {
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
// https only, scheme case-insensitive.
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
}
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
/// renames (`url` / `command` / `page`).
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
let legacy = || text("value");
match e.get("action").and_then(Value::as_str) {
Some("open_url") => text("url")
.or_else(legacy)
.filter(|u| untrusted_http_url(u))
.map(crate::text::ClickEvent::OpenUrl),
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.or_else(|| e.get("value").and_then(Value::as_i64))
.or_else(|| {
e.get("value")
.and_then(Value::as_str)
.and_then(|s| s.parse().ok())
})
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command")
.or_else(legacy)
.map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command")
.or_else(legacy)
.map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => text("value").map(crate::text::ClickEvent::CopyToClipboard),
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
}
}
/// The style fields of a component object.
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
@@ -263,36 +196,58 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
});
}
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
style.bold = style_flag(o.get("bold"));
style.italic = style_flag(o.get("italic"));
style.underlined = style_flag(o.get("underlined"));
style.strikethrough = style_flag(o.get("strikethrough"));
style.obfuscated = style_flag(o.get("obfuscated"));
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style.font = o.get("font").and_then(crate::text::font_id);
// ClickEvent.CODEC: the actions a chat line acts on here. A component
// from a server never opens a file.
if let Some(e) = style_event_object(o, "click_event", "clickEvent") {
style.click = parse_click_event(e);
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
Some("open_url") => text("url")
.filter(|u| {
u.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
})
.map(crate::text::ClickEvent::OpenUrl),
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
};
}
// HoverEvent.ShowText, ShowItem and ShowEntity.
if let Some(e) = style_event_object(o, "hover_event", "hoverEvent") {
// HoverEvent.ShowText and ShowItem.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
// Modern `value`, or the older `contents` / nested `text`.
let value = e
.get("value")
.or_else(|| e.get("contents"))
.or_else(|| e.get("text"));
if let Some(value) = value {
if let Some(value) = e.get("value") {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
Some("show_entity") if e.get("id").is_some_and(Value::is_string) => {
style.hover_entity = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
}
@@ -548,43 +503,10 @@ mod tests {
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.plain(), "Hi there");
assert_eq!(t.spans[1].style.color, named_color("red"));
// SNBT `1b` is a number in JSON; style flags still take it as true.
assert_eq!(t.spans[1].style.bold, Some(true));
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
}
#[test]
fn legacy_click_event_open_url_uses_value_field() {
use crate::text::ClickEvent;
let modern = serde_json::json!({
"text": "site",
"click_event": {"action": "open_url", "url": "https://example.com"},
"underlined": true
});
let legacy = serde_json::json!({
"text": "site",
"clickEvent": {"action": "open_url", "value": "https://example.com"},
"underlined": 1
});
for component in [modern, legacy] {
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(
text.spans[0].style.click,
Some(ClickEvent::OpenUrl("https://example.com".into()))
);
assert_eq!(text.spans[0].style.underlined, Some(true));
}
let snbt = r#"{text:"site",underlined:1b,clickEvent:{action:open_url,value:"https://example.com"}}"#;
let (v, _) = parse_snbt(snbt).unwrap();
let text = to_text(&v, &mut |_| Text::empty());
assert_eq!(
text.spans[0].style.click,
Some(ClickEvent::OpenUrl("https://example.com".into()))
);
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
+18 -87
View File
@@ -1,68 +1,32 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
use std::{collections::BTreeMap, sync::RwLock};
/// The keys that write a symbol, with what they write on a US layout.
const SYMBOLS: [(&str, &str); 11] = [
("key.keyboard.minus", "-"),
("key.keyboard.equal", "="),
("key.keyboard.left.bracket", "["),
("key.keyboard.right.bracket", "]"),
("key.keyboard.backslash", "\\"),
("key.keyboard.semicolon", ";"),
("key.keyboard.apostrophe", "'"),
("key.keyboard.grave.accent", "`"),
("key.keyboard.comma", ","),
("key.keyboard.period", "."),
("key.keyboard.slash", "/"),
];
/// What the player's keyboard layout writes on each key that writes
/// something, by key name, as the window reported it (`learn_layout`).
static LAYOUT: RwLock<BTreeMap<String, String>> = RwLock::new(BTreeMap::new());
/// The keyboard layout writes `text` on the key `key` (`key.keyboard.q`):
/// its name from now on, as `SDL_GetKeyFromScancode` names it with SDL's
/// default keycode options. Only letter and symbol keys take it; a letter
/// key keeps its Latin letter on a non-Latin layout (`latin_letters`), and
/// the number row keeps its digits (`french_numbers`).
pub fn learn_layout(key: &str, text: &str) {
let Some(rest) = key.strip_prefix("key.keyboard.") else {
return;
};
let mut chars = text.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
return;
};
let letter_key = rest.len() == 1 && rest.as_bytes()[0].is_ascii_lowercase();
let latin = c.is_ascii() || ('\u{C0}'..='\u{24F}').contains(&c);
if c.is_control()
|| c.is_whitespace()
|| !(letter_key || SYMBOLS.iter().any(|(k, _)| *k == key))
|| (letter_key && c.is_alphabetic() && !latin)
{
return;
}
if let Ok(mut layout) = LAYOUT.write() {
layout.insert(key.to_owned(), c.to_uppercase().collect());
}
}
/// InputConstants.Key.getDisplayName: what the layout writes on the key,
/// upper-case, else the US layout's character, else by translation.
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(label) = LAYOUT.read().ok().and_then(|l| l.get(key).cloned()) {
return Text::literal(label);
}
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
if let Some((_, s)) = SYMBOLS.iter().find(|(k, _)| *k == key) {
return Text::literal(*s);
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
@@ -72,36 +36,3 @@ pub fn display_name(key: &str) -> Text {
}
Text::translate(key, &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_show_what_the_layout_writes() {
// The layout table is shared: these keys are this test's alone.
assert_eq!(display_name("key.keyboard.q").plain(), "Q");
// AZERTY: A on the Q key, M on the semicolon key, a dead ^ on the
// left bracket, a comma on the M key.
learn_layout("key.keyboard.q", "a");
learn_layout("key.keyboard.semicolon", "m");
learn_layout("key.keyboard.left.bracket", "^");
learn_layout("key.keyboard.m", ",");
assert_eq!(display_name("key.keyboard.q").plain(), "A");
assert_eq!(display_name("key.keyboard.semicolon").plain(), "M");
assert_eq!(display_name("key.keyboard.left.bracket").plain(), "^");
assert_eq!(display_name("key.keyboard.m").plain(), ",");
// The number row keeps its digits; a Cyrillic letter, nothing
// printable and keys that write nothing are left alone.
learn_layout("key.keyboard.1", "&");
learn_layout("key.keyboard.f", "а");
learn_layout("key.keyboard.g", "\u{8}");
learn_layout("key.keyboard.tab", "\t");
learn_layout("key.keyboard.h", "ab");
assert_eq!(display_name("key.keyboard.1").plain(), "1");
assert_eq!(display_name("key.keyboard.f").plain(), "F");
assert_eq!(display_name("key.keyboard.g").plain(), "G");
assert_eq!(display_name("key.keyboard.h").plain(), "H");
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
}
}
-34
View File
@@ -57,9 +57,6 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
"assets/horizon/lang/zh_cn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/zh_cn.json")
}
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
@@ -69,18 +66,6 @@ impl ResourceSource for Bundled {
"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/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())
@@ -294,16 +279,6 @@ mod tests {
}
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"));
@@ -312,15 +287,6 @@ mod tests {
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
let tr = Language::load(&[&empty], "tr_tr");
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
let es_ve = Language::load(&[&empty], "es_ve");
assert_eq!(
es_ve.get("horizon.options.on_screen_keyboard"),
Some("Teclado en pantalla")
);
}
#[test]
+6 -138
View File
@@ -9,8 +9,7 @@ pub enum Category {
Harmful,
Neutral,
}
/// A `MobEffect`: id (vanilla's without namespace, a mod's `<ns>:<path>`),
/// colour, category, instantaneous.
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
@@ -85,80 +84,11 @@ pub const EFFECTS: &[EffectDef] = &[
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
/// The effects content mods declare, registered after vanilla's.
static MOD_EFFECTS: OnceLock<Vec<EffectDef>> = OnceLock::new();
/// Registers the content mods' effects, `<ns>:<path>` ids in registry order.
/// The list is set once per process: false when it already was, and the
/// effects stay those of the first call until the game restarts.
pub fn register_mod_effects(effects: impl IntoIterator<Item = (String, u32, Category)>) -> bool {
if MOD_EFFECTS.get().is_some() {
return false;
}
let defs: Vec<EffectDef> = effects
.into_iter()
.map(|(id, color, category)| {
debug_assert!(id.contains(':'), "{id} has no namespace");
fx(Box::leak(id.into_boxed_str()), color, category)
})
.collect();
MOD_EFFECTS.set(defs).is_ok()
}
/// The content mods' effects, in registry order.
pub fn mod_effects() -> &'static [EffectDef] {
MOD_EFFECTS.get().map_or(&[], Vec::as_slice)
}
/// Every registered effect: vanilla's, then the content mods'.
pub fn all_effects() -> impl Iterator<Item = &'static EffectDef> {
EFFECTS.iter().chain(mod_effects())
}
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let short = id.trim_start_matches("minecraft:");
match EFFECTS.iter().find(|e| e.id == short) {
Some(e) => Some(e),
None => mod_effect(id),
}
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// A content mod's effect id (`<ns>:<path>`), not vanilla's with or
/// without `minecraft:`.
#[inline]
pub fn is_mod_effect(id: &str) -> bool {
!id.starts_with("minecraft:") && id.bytes().any(|b| b == b':')
}
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
#[cold]
#[inline(never)]
fn mod_effect(id: &str) -> Option<&'static EffectDef> {
mod_effects().iter().find(|e| e.id == id)
}
/// `MobEffect.getDescriptionId` (`Util.makeDescriptionId("effect", key)`):
/// `effect.<namespace>.<path>`, `/` in the path as `.`.
pub fn description_id(id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("effect.{namespace}.{}", path.replace('/', "."))
}
/// The effect's sprite in the GUI atlas (`Hud.getMobEffectSprite`: the key
/// with `mob_effect/` before its path): `mob_effect/<path>` for vanilla's,
/// `<ns>:mob_effect/<path>` for a mod's.
pub fn sprite(id: &str) -> String {
let mut out = String::new();
push_sprite(&mut out, id);
out
}
/// [`sprite`], appended to `out`.
pub fn push_sprite(out: &mut String, id: &str) {
let path = match id.split_once(':') {
None => id,
Some(("minecraft", path)) => path,
Some((namespace, path)) => {
out.push_str(namespace);
out.push(':');
path
}
};
out.push_str("mob_effect/");
out.push_str(path);
}
/// Namespaced effect ids for mob effect storage; a mod's id already is.
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let key = id.strip_prefix("minecraft:").unwrap_or(id);
@@ -300,15 +230,10 @@ pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
effects_color(def.effects.iter().map(|(id, _, amp)| (*id, *amp)))
}
/// `PotionContents.getColorOptional` of some effects, `(id, amplifier)`, all visible: the mix of
/// their colours weighted by amplifier + 1, else the base potion colour.
pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, amp) in effects {
for (e, _, amp) in def.effects {
let Some(e) = effect(e) else { continue };
let w = amp as u32 + 1;
let w = *amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
@@ -323,60 +248,3 @@ pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
_ => BASE_POTION_COLOR,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_ids_and_sprites_keep_the_namespace() {
assert_eq!(description_id("speed"), "effect.minecraft.speed");
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
assert_eq!(sprite("speed"), "mob_effect/speed");
assert_eq!(sprite("minecraft:speed"), "mob_effect/speed");
assert_eq!(sprite("ruby:curse/dark"), "ruby:mob_effect/curse/dark");
}
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
/// amplifier + 1, the channels averaged by integer division; none gives the base colour.
#[test]
fn effects_mix_their_colours_by_amplifier() {
let c = |id: &str| effect(id).unwrap().color;
assert_eq!(effects_color([("poison", 0)].into_iter()), c("poison"));
let (p, s) = (c("poison"), c("speed"));
let mix = |shift: u32| (((p >> shift & 255) + 3 * (s >> shift & 255)) / 4) << shift;
assert_eq!(
effects_color([("poison", 0), ("speed", 2)].into_iter()),
mix(16) | mix(8) | mix(0)
);
assert_eq!(effects_color(std::iter::empty()), BASE_POTION_COLOR);
}
/// The only test that registers: the registry is set once per process.
#[test]
fn mod_effects_follow_vanilla_once_registered() {
assert!(register_mod_effects([(
"ruby:glow".to_owned(),
0xFF0000,
Category::Neutral
)]));
assert!(!register_mod_effects([(
"ruby:late".to_owned(),
1,
Category::Harmful
)]));
let glow = effect("ruby:glow").unwrap();
assert_eq!(
(glow.color, glow.category, glow.instant),
(0xFF0000, Category::Neutral, false)
);
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
assert!(is_mod_effect(glow.id) && !is_mod_effect("speed"));
assert!(!is_mod_effect(namespaced("speed")));
assert_eq!(namespaced(glow.id), "ruby:glow");
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
assert_eq!(ids.len(), EFFECTS.len() + 1);
assert_eq!(ids.last(), Some(&"ruby:glow"));
}
}
@@ -28,16 +28,6 @@ pub struct Registries {
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// A dedicated server's lists, for the completions of `/op`, `/deop`, `/pardon`, `/pardon-ip` and
/// `/whitelist`: the operators' names, the banned names and addresses, the whitelisted names.
pub ops: Vec<String>,
pub banned_players: Vec<String>,
pub banned_ips: Vec<String>,
pub whitelisted: Vec<String>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
/// each once.
pub custom_completions: std::collections::BTreeSet<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
@@ -97,55 +87,7 @@ pub struct RegistryIds {
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
/// ClientboundCustomChatCompletionsPacket.Action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomCompletionAction {
Add,
Remove,
Set,
}
impl CustomCompletionAction {
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
/// so an unknown id is `ADD`.
pub fn from_id(id: i32) -> Self {
match id {
1 => Self::Remove,
2 => Self::Set,
_ => Self::Add,
}
}
}
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
/// removes or replaces the extra chat completions.
pub fn modify_custom_completions(
set: &mut std::collections::BTreeSet<String>,
action: CustomCompletionAction,
entries: &[String],
) {
match action {
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
CustomCompletionAction::Remove => {
for e in entries {
set.remove(e);
}
}
CustomCompletionAction::Set => {
set.clear();
set.extend(entries.iter().cloned());
}
}
}
impl Registries {
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
/// command) completes, the online players' names with the server's custom
/// completions, each once.
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
self.players
.iter()
.chain(&self.custom_completions)
.map(String::as_str)
.collect()
}
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
+173 -2
View File
@@ -3,8 +3,179 @@
//! number formatting.
use crate::text::{self, Style, Text};
use horizon_world::nbt::Tag;
use horizon_world::snbt_text::string_of;
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
/// `Double.toString`.
pub fn java_double(v: f64) -> String {
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
}
/// `Float.toString`.
pub fn java_float(v: f32) -> String {
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
}
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
if v.is_nan() {
return "NaN".into();
}
if v.is_infinite() {
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
}
let a = v.abs();
if (1.0e-3..1.0e7).contains(&a) {
if plain.contains('.') {
plain.to_owned()
} else {
format!("{plain}.0")
}
} else {
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
let mantissa = if mantissa.contains('.') {
mantissa.to_owned()
} else {
format!("{mantissa}.0")
};
format!("{mantissa}E{exponent}")
}
}
/// `SnbtGrammar.escapeControlCharacters`.
fn escape_control(c: char) -> Option<&'static str> {
Some(match c {
'\u{8}' => "b",
'\u{c}' => "f",
'\n' => "n",
'\r' => "r",
'\t' => "t",
_ => return None,
})
}
/// `StringTag.quoteAndEscape`.
pub fn quote_and_escape(input: &str) -> String {
let mut out = String::from(" ");
let mut quote = '\0';
for c in input.chars() {
if c == '\\' {
out.push_str("\\\\");
} else if c != '"' && c != '\'' {
match escape_control(c) {
Some(e) => {
out.push('\\');
out.push_str(e);
}
None => out.push(c),
}
} else {
if quote == '\0' {
quote = if c == '"' { '\'' } else { '"' };
}
if quote == c {
out.push('\\');
}
out.push(c);
}
}
if quote == '\0' {
quote = '"';
}
out.replace_range(0..1, &quote.to_string());
out.push(quote);
out
}
fn unquoted_key(key: &str) -> bool {
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
return false;
}
let mut chars = key.chars();
let first_ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
}
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
t.to_string_lossy()
}
/// `Tag.toString` (StringTagVisitor).
pub fn to_snbt(tag: &Tag) -> String {
let mut out = String::new();
visit(tag, &mut out);
out
}
fn visit(tag: &Tag, out: &mut String) {
match tag {
Tag::End => out.push_str("END"),
Tag::Byte(v) => out.push_str(&format!("{v}b")),
Tag::Short(v) => out.push_str(&format!("{v}s")),
Tag::Int(v) => out.push_str(&v.to_string()),
Tag::Long(v) => out.push_str(&format!("{v}L")),
Tag::Float(v) => {
out.push_str(&java_float(f32::from_bits(*v)));
out.push('f');
}
Tag::Double(v) => {
out.push_str(&java_double(f64::from_bits(*v)));
out.push('d');
}
Tag::String(s) => out.push_str(&quote_and_escape(&string_of(s))),
Tag::ByteArray(a) => {
out.push_str("[B;");
out.push_str(
&a.iter()
.map(|v| format!("{}B", *v as i8))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::IntArray(a) => {
out.push_str("[I;");
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
out.push(']');
}
Tag::LongArray(a) => {
out.push_str("[L;");
out.push_str(
&a.iter()
.map(|v| format!("{v}L"))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::List(l) => {
out.push('[');
for (i, t) in l.iter().enumerate() {
if i > 0 {
out.push(',');
}
visit(t, out);
}
out.push(']');
}
Tag::Compound(c) => {
out.push('{');
let mut entries: Vec<(String, &Tag)> =
c.iter().map(|(k, v)| (string_of(k), v)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
if unquoted_key(k) {
out.push_str(k);
} else {
out.push_str(&quote_and_escape(k));
}
out.push(':');
visit(v, out);
}
out.push('}');
}
}
}
// ---- the coloured form -----------------------------------------------------
+3 -10
View File
@@ -96,8 +96,6 @@ pub struct Style {
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object.
pub hover_entity: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
@@ -129,12 +127,11 @@ impl Style {
if self.click.is_none() {
self.click.clone_from(&base.click);
}
// One HoverEvent: its three kinds are inherited together, so a style
// with its own event never picks up another kind from `base`.
if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() {
if self.hover.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
self.hover_entity.clone_from(&base.hover_entity);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
@@ -594,10 +591,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
} else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() {
let mut event = entity.clone();
event.insert("action".into(), "show_entity".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
@@ -567,34 +567,6 @@ fn length(v: [f32; 2]) -> f32 {
(v[0] * v[0] + v[1] * v[1]).sqrt()
}
/// 8BitDo Ultimate 2 Wireless (`2dc8:6012`). gilrs' bundled SDL DB only has a
/// Windows row for it; on Linux/Bluetooth the right stick is on ABS_Z/ABS_RX
/// (`LeftZ`/`RightStickX`) until an SDL mapping matches.
pub const BITDO_ULTIMATE2_VID_PID: (u16, u16) = (0x2dc8, 0x6012);
/// Stick axes after correcting pads whose right stick gilrs still exposes on
/// `LeftZ`/`RightStickX` (no matching SDL row for this device version).
/// `sdl_mapped` is true when gilrs already applied an SDL gamecontroller row.
pub fn stick_axes(
left: [f32; 2],
right: [f32; 2],
left_z: f32,
vendor: Option<u16>,
product: Option<u16>,
sdl_mapped: bool,
) -> ([f32; 2], [f32; 2]) {
if !sdl_mapped
&& vendor == Some(BITDO_ULTIMATE2_VID_PID.0)
&& product == Some(BITDO_ULTIMATE2_VID_PID.1)
{
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
// is the right trigger and must not drive look.
(left, [left_z, right[0]])
} else {
(left, right)
}
}
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
/// clipped and the first movement past the deadzone is small.
@@ -1206,38 +1178,6 @@ mod tests {
assert_eq!(odd, PadSettings::default());
}
#[test]
fn ultimate2_right_stick_on_z_without_sdl() {
let (left, right) = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
false,
);
assert_eq!(left, [0.2, 0.3]);
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
let kept = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
true,
);
assert_eq!(kept, ([0.2, 0.3], [0.7, -0.9]), "SDL row already remapped");
let other = stick_axes(
[0.0, 0.0],
[1.0, 0.0],
0.5,
Some(0x045e),
Some(0x028e),
false,
);
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
-45
View File
@@ -2,9 +2,6 @@
//! Item-use and sneak multipliers come from the player's current state; this
//! module does not implement sprint eligibility, poses or the input lifecycle.
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
fn length(v: [f32; 2]) -> f32 {
f64::from(v[0] * v[0] + v[1] * v[1]).sqrt() as f32
}
@@ -68,45 +65,3 @@ fn square_movement(v: [f32; 2]) -> [f32; 2] {
},
)
}
/// `InputQuirks.isQuitShortcutDown`: checks whether the platform's window-closing
/// keyboard shortcut is held.
/// - On macOS: Command (`super_key()`) + `Q`.
/// - On Windows / Linux / other: Alt (`alt_key()`) + `F4`.
pub fn is_quit_shortcut_down(modifiers: ModifiersState, keys: &HashSet<KeyCode>) -> bool {
if cfg!(target_os = "macos") {
modifiers.super_key() && keys.contains(&KeyCode::KeyQ)
} else {
modifiers.alt_key() && keys.contains(&KeyCode::F4)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quit_shortcut_follows_platform_modifiers_and_keys() {
let mut keys = HashSet::new();
let alt = ModifiersState::ALT;
let super_key = ModifiersState::SUPER;
assert!(!is_quit_shortcut_down(ModifiersState::empty(), &keys));
keys.insert(KeyCode::F4);
if cfg!(target_os = "macos") {
assert!(!is_quit_shortcut_down(alt, &keys));
keys.remove(&KeyCode::F4);
keys.insert(KeyCode::KeyQ);
assert!(is_quit_shortcut_down(super_key, &keys));
assert!(!is_quit_shortcut_down(alt, &keys));
} else {
assert!(is_quit_shortcut_down(alt, &keys));
assert!(!is_quit_shortcut_down(super_key, &keys));
keys.remove(&KeyCode::F4);
keys.insert(KeyCode::KeyQ);
assert!(!is_quit_shortcut_down(alt, &keys));
assert!(!is_quit_shortcut_down(super_key, &keys));
}
}
}
@@ -81,19 +81,6 @@ impl InteractionInput {
}
}
/// The sentence of `Minecraft.handleKeybinds` that follows a key which opened
/// a screen: `Gui.setScreen` has already run `KeyMapping.releaseAll()`, so
/// `key.use` reads up and the same call releases an active use
/// (`player.isUsingItem() && !keyUse.isDown()` → `MultiPlayerGameMode.releaseUsingItem`).
/// Only the screens opened from the keybinds do this (the inventory, chat and
/// advancements); the pause menu, server containers, mount inventories and
/// dialogs release on the first tick after closing instead, as vanilla does.
pub fn release_on_screen_open(world: &mut impl InteractionWorld) {
if world.using_item() {
world.release_using();
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -284,26 +284,6 @@ pub fn mouse_button(name: &str) -> Option<MouseButton> {
}
pub use horizon_client_core::key_display::display_name;
/// A key event: what the layout writes on its key, without modifiers, names
/// that key from now on (`key_display::learn_layout`), so an AZERTY player's
/// Q key shows A.
pub fn learn_layout(event: &winit::event::KeyEvent) {
use winit::keyboard::{Key, PhysicalKey};
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
let Some(name) = key_name(code) else {
return;
};
let learn = horizon_client_core::key_display::learn_layout;
match event.key_without_modifiers() {
Key::Character(text) => learn(name, &text),
Key::Dead(Some(c)) => learn(name, c.encode_utf8(&mut [0; 4])),
_ => {}
}
}
/// Current bindings by mapping name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyBinds {
+4 -89
View File
@@ -10,10 +10,6 @@ pub struct KeyState {
released_by_screen_when_down: bool,
toggle: Option<bool>,
keyboard: bool,
/// Held down by a controller's toggle (Horizon's pad toggle-sneak, which
/// vanilla has no option for): until it lets go, the mapping toggles as
/// if its vanilla option were on, so a screen releases and restores it.
pad_toggled: bool,
}
impl KeyState {
@@ -24,7 +20,6 @@ impl KeyState {
released_by_screen_when_down: false,
toggle: None,
keyboard: true,
pad_toggled: false,
}
}
/// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key.
@@ -48,28 +43,16 @@ impl KeyState {
true
}
}
/// The mapping toggles: its option is on, or a pad toggled it down.
fn needs_toggle(&self, option: bool) -> bool {
option || self.pad_toggled
}
/// `setDown`; `needs_toggle` is the mapping's option value.
pub fn set_down(&mut self, down: bool, needs_toggle: bool) {
if self.toggle.is_some() && self.needs_toggle(needs_toggle) {
if self.toggle.is_some() && needs_toggle {
if down {
self.is_down = !self.is_down;
// Toggled back up: the pad's toggle is over.
self.pad_toggled &= self.is_down;
}
} else {
self.is_down = down;
}
}
/// A controller's toggle button: flips the mapping like `setDown(true)`
/// with its option on, whatever the option says.
pub fn pad_toggle(&mut self) {
self.is_down = !self.is_down;
self.pad_toggled = self.is_down;
}
/// `shouldSetOnIngameFocus` for a keyboard-bound, bound key.
pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool {
self.keyboard && (self.toggle.is_none() || !needs_toggle)
@@ -78,8 +61,7 @@ impl KeyState {
pub fn release(&mut self, needs_toggle: bool) {
if self.toggle.is_some() {
// ToggleKeyMapping.release does not clear pending clicks.
if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down
{
if needs_toggle && self.is_down || self.released_by_screen_when_down {
self.released_by_screen_when_down = true;
}
self.reset();
@@ -88,8 +70,7 @@ impl KeyState {
self.set_down(false, needs_toggle);
}
}
/// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen
/// that released it restores it.
/// `ToggleKeyMapping.reset`.
pub fn reset(&mut self) {
self.is_down = false;
}
@@ -102,10 +83,7 @@ impl KeyState {
let Some(restore) = self.toggle else {
return false;
};
let result = restore
&& self.needs_toggle(needs_toggle)
&& self.keyboard
&& self.released_by_screen_when_down;
let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down;
self.released_by_screen_when_down = false;
result
}
@@ -160,15 +138,6 @@ impl ToggleKeys {
_ => {}
}
}
/// A controller's toggle button bound to `slot` (the pad's toggle-sneak):
/// a press with no screen open flips the mapping and clicks it, as a
/// key press does with the mapping's toggle option on.
pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) {
if !screen_open {
self.0[slot].pad_toggle();
self.0[slot].click();
}
}
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
/// screen or overlay.
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
@@ -198,9 +167,6 @@ impl ToggleKeys {
pub fn respawn(&mut self) {
for key in &mut self.0 {
key.reset();
// As with the vanilla option, the death screen closing after
// this still restores a toggle it released.
key.pad_toggled &= key.released_by_screen_when_down;
}
}
}
@@ -231,57 +197,6 @@ mod tests {
keys.respawn();
assert!(!keys.is_down(SPRINT));
}
/// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes
/// back after a screen, as the vanilla option's toggle does; a
/// keyboard shift-click in the inventory does not drop it.
#[test]
fn pad_toggle_is_restored_after_a_screen() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
assert!(keys.is_down(SNEAK));
keys.screen_opened(off);
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, true, false);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(keys.is_down(SNEAK), "the pad's toggle is restored");
// Toggled back up, it stays up through the next screen.
keys.pad_toggle(SNEAK, false);
assert!(!keys.is_down(SNEAK));
keys.screen_opened(off);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
// A held key is not restored, as before.
keys.key(SNEAK, KeyAction::Press, false, false);
keys.screen_opened(off);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
}
/// After the pad's toggle is over (toggled up, or reset by a respawn
/// with no screen to restore it), the shift key holds again.
#[test]
fn pad_toggle_ends_with_its_release() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
keys.respawn();
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, false, false);
keys.key(SNEAK, KeyAction::Release, false, false);
assert!(!keys.is_down(SNEAK), "held, not toggled");
// The death screen releases it and closes after the respawn: the
// toggle comes back, as with the vanilla option.
keys.pad_toggle(SNEAK, false);
keys.screen_opened(off);
keys.respawn();
keys.screen_closed(off);
assert!(keys.is_down(SNEAK));
// A press with a screen open does nothing.
keys.pad_toggle(SNEAK, true);
assert!(keys.is_down(SNEAK));
}
#[test]
fn screen_release_restores_toggled_state() {
let mut k = KeyState::toggle(true, true);
-1
View File
@@ -10,6 +10,5 @@ pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod window;
@@ -1,232 +0,0 @@
//! OS modifier reconciliation and modifiers synthesized by agent control.
use crate::{
keybinds::{KeyBinds, key_code},
keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys},
};
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
const MODIFIERS: [(ModifiersState, [KeyCode; 2]); 4] = [
(
ModifiersState::SHIFT,
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
),
(
ModifiersState::CONTROL,
[KeyCode::ControlLeft, KeyCode::ControlRight],
),
(ModifiersState::ALT, [KeyCode::AltLeft, KeyCode::AltRight]),
(
ModifiersState::SUPER,
[KeyCode::SuperLeft, KeyCode::SuperRight],
),
];
/// A synthesized key updates the same modifier word as window events.
/// Releasing one side must preserve the other side's hold.
pub fn set_key(
modifiers: &mut ModifiersState,
physical: &mut HashSet<KeyCode>,
key: KeyCode,
down: bool,
) {
let Some((bit, pair)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&key)) else {
return;
};
if down {
physical.insert(key);
} else {
physical.remove(&key);
}
modifiers.set(*bit, pair.iter().any(|key| physical.contains(key)));
}
/// A modifier held for one synthesized click (the pad's Y, an agent's
/// shift-click): what [`restore`] puts back once the click is done.
#[derive(Clone, Copy, Debug)]
pub struct Held {
key: KeyCode,
was_down: bool,
bit: bool,
}
/// Holds `key` in both the physical keys and the modifier word, which is
/// what screens read (`InputWithModifiers.hasShiftDown`).
pub fn hold(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, key: KeyCode) -> Held {
let bit = MODIFIERS
.iter()
.find(|(_, pair)| pair.contains(&key))
.is_some_and(|(bit, _)| modifiers.contains(*bit));
let held = Held {
key,
was_down: physical.contains(&key),
bit,
};
set_key(modifiers, physical, key, true);
held
}
/// Undoes [`hold`]: a key or bit that was already down stays down.
pub fn restore(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, held: Held) {
if !held.was_down {
physical.remove(&held.key);
}
if let Some((bit, _)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&held.key)) {
modifiers.set(*bit, held.bit);
}
}
/// Recover missing modifier releases without releasing a rebound key or a pad.
/// ToggleKeyMapping itself preserves keyboard and controller toggles on release.
pub fn reconcile(
modifiers: ModifiersState,
physical: &mut HashSet<KeyCode>,
bindings: &KeyBinds,
toggles: &mut ToggleKeys,
options: [bool; 2],
pad_held: [bool; 2],
) {
for (bit, pair) in MODIFIERS {
if modifiers.contains(bit) {
continue;
}
for key in pair {
if !physical.remove(&key) {
continue;
}
for (slot, name) in [(SNEAK, "key.sneak"), (SPRINT, "key.sprint")] {
if key_code(bindings.get(name)) == Some(key) && !pad_held[slot] {
toggles.key(slot, KeyAction::Release, false, options[slot]);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modifier_taps_release_both_bits() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, pair) in MODIFIERS {
for key in pair {
set_key(&mut modifiers, &mut physical, key, true);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, key, false);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
}
}
}
#[test]
fn releasing_one_side_keeps_the_other_modifier_held() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, [left, right]) in MODIFIERS {
set_key(&mut modifiers, &mut physical, left, true);
set_key(&mut modifiers, &mut physical, right, true);
set_key(&mut modifiers, &mut physical, left, false);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, right, false);
assert!(modifiers.is_empty());
}
}
#[test]
fn a_held_click_restores_the_previous_state() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
assert!(modifiers.shift_key());
restore(&mut modifiers, &mut physical, held);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
// Right Shift already held, or the OS word set with no physical key.
for start in [HashSet::from([KeyCode::ShiftRight]), HashSet::new()] {
let mut modifiers = ModifiersState::SHIFT;
let mut physical = start.clone();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
restore(&mut modifiers, &mut physical, held);
assert_eq!(modifiers, ModifiersState::SHIFT);
assert_eq!(physical, start);
}
}
#[test]
fn lost_releases_respect_rebindings_and_pad_holds() {
for slot in [SNEAK, SPRINT] {
for rebound in [false, true] {
for pad in [false, true] {
for toggle in [false, true] {
let mut bindings = KeyBinds::default();
let (name, modifier) = if slot == SNEAK {
("key.sneak", KeyCode::ShiftLeft)
} else {
("key.sprint", KeyCode::ControlLeft)
};
if rebound {
bindings.set(name, "key.keyboard.c");
}
let mut physical = HashSet::from([modifier]);
if rebound {
physical.insert(KeyCode::KeyC);
}
let mut toggles = ToggleKeys::default();
toggles.key(slot, KeyAction::Press, false, toggle);
let mut options = [false; 2];
options[slot] = toggle;
let mut pad_held = [false; 2];
pad_held[slot] = pad;
reconcile(
ModifiersState::ALT,
&mut physical,
&bindings,
&mut toggles,
options,
pad_held,
);
assert_eq!(toggles.is_down(slot), rebound || pad || toggle);
assert!(!physical.contains(&modifier));
assert_eq!(physical.contains(&KeyCode::KeyC), rebound);
}
}
}
}
}
#[test]
fn unrelated_modifier_change_does_not_release_an_action() {
let mut toggles = ToggleKeys::default();
toggles.key(SNEAK, KeyAction::Press, false, false);
reconcile(
ModifiersState::ALT,
&mut HashSet::new(),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
#[test]
fn lost_modifier_release_preserves_a_controller_toggle() {
let mut toggles = ToggleKeys::default();
toggles.pad_toggle(SNEAK, false);
reconcile(
ModifiersState::empty(),
&mut HashSet::from([KeyCode::ShiftLeft]),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
}
-39
View File
@@ -342,28 +342,6 @@ pub fn mouse_position_after_grab_change(
}
}
/// `MacosUtil.setCtrlClickEmulatesRightClick`: with the option on (and only on
/// macOS), a left click made with Control held is a right click, and so is
/// its release, whatever Control does in between.
#[derive(Clone, Copy, Debug, Default)]
pub struct RightClickEmulation {
emulating: bool,
}
impl RightClickEmulation {
/// Whether the left button's `pressed` or released event is a right click.
/// `enabled` is the option on macOS.
pub fn is_right_click(&mut self, pressed: bool, control: bool, enabled: bool) -> bool {
if pressed {
self.emulating = enabled && control;
}
let right = self.emulating;
if !pressed {
self.emulating = false;
}
right
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -548,21 +526,4 @@ mod tests {
None
);
}
#[test]
fn control_click_is_a_right_click_until_released() {
let mut e = RightClickEmulation::default();
// The option off, or no Control: a left click stays one.
assert!(!e.is_right_click(true, true, false));
assert!(!e.is_right_click(false, true, false));
assert!(!e.is_right_click(true, false, true));
assert!(!e.is_right_click(false, true, true));
// Control held at the press: the press and the release are right clicks,
// even when Control was let go before the release.
assert!(e.is_right_click(true, true, true));
assert!(e.is_right_click(false, false, true));
// The next left click is its own.
assert!(!e.is_right_click(true, false, true));
assert!(!e.is_right_click(false, false, true));
}
}
+1 -306
View File
@@ -284,18 +284,12 @@ impl FramerateLimiter {
/// (`Window.updateFullscreenState` -> `Minecraft.fullscreenStateChanged`), and
/// the window is only asked to change when the option itself changes
/// (`Window.updateFullscreenIfChanged`), never re-forced to the option.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FullscreenState {
/// The state the window system last reported (`Window.fullscreen`).
actual: bool,
/// The state last asked of the window system, or adopted from it.
requested: bool,
/// `Window.exclusiveFullscreen`.
exclusive: bool,
/// `Window.preferredFullscreenVideoMode`.
mode: Option<VideoMode>,
/// `Window.dirty`: the preferred mode changed and the screen has not been left yet.
mode_dirty: bool,
}
impl FullscreenState {
@@ -304,70 +298,9 @@ impl FullscreenState {
Self {
actual: false,
requested,
exclusive: false,
mode: None,
mode_dirty: false,
}
}
/// `Window.updateFullscreenIfChanged` with the exclusive option and preferred mode: asks the
/// window to enter, leave or change its fullscreen only when one of them changed.
/// `in_video_screen` holds a changed mode back until the Video Settings screen closes
/// (`changeFullscreenVideoMode`).
pub fn apply(
&mut self,
window: &winit::window::Window,
wanted: bool,
exclusive: bool,
preferred: Option<VideoMode>,
in_video_screen: bool,
) {
let request = self.request(wanted);
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None => {}
}
}
/// `Window.setExclusiveFullscreen`: true when the option changed while fullscreen is
/// requested, so the window has to enter the other kind of fullscreen at once.
pub fn set_exclusive(&mut self, exclusive: bool) -> bool {
let changed = self.exclusive != exclusive;
self.exclusive = exclusive;
changed && self.requested
}
/// `Window.setPreferredFullscreenVideoMode`: a changed mode waits for `take_mode_change`.
pub fn set_preferred_mode(&mut self, mode: Option<VideoMode>) {
if self.mode != mode {
self.mode = mode;
self.mode_dirty = true;
}
}
/// `Window.changeFullscreenVideoMode` (the Video Settings screen closing): true once when
/// a changed mode has to be applied to a fullscreen window.
pub fn take_mode_change(&mut self) -> bool {
let apply = self.requested && self.mode_dirty;
if apply {
self.mode_dirty = false;
}
apply
}
/// `Window.updateFullscreenIfChanged`: the state to ask of the window
/// when the option `wanted` differs from what was last asked or adopted.
pub fn request(&mut self, wanted: bool) -> Option<bool> {
@@ -388,163 +321,6 @@ impl FullscreenState {
}
}
/// `VideoMode`: one display mode of a monitor, as `fullscreenResolution` in options.txt keeps it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VideoMode {
pub width: i32,
pub height: i32,
pub red_bits: i32,
pub green_bits: i32,
pub blue_bits: i32,
pub refresh_rate: f32,
}
impl VideoMode {
/// `VideoMode.read`: the JSON object `write` makes; any other string is None.
pub fn read(text: &str) -> Option<Self> {
let number = |key: &str| -> Option<f64> {
let at = text.find(&format!("\"{key}\""))? + key.len() + 2;
let rest = text[at..].trim_start().strip_prefix(':')?.trim_start();
let end = rest
.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')))
.unwrap_or(rest.len());
rest[..end].parse().ok()
};
if !text.trim_start().starts_with('{') {
return None;
}
Some(Self {
width: number("width")? as i32,
height: number("height")? as i32,
red_bits: number("red_bits")? as i32,
green_bits: number("green_bits")? as i32,
blue_bits: number("blue_bits")? as i32,
refresh_rate: number("refresh_rate")? as f32,
})
}
/// `VideoMode.write`: the Codec's JSON through Gson (a float prints as `60.0`).
pub fn write(&self) -> String {
let rate = if self.refresh_rate == self.refresh_rate.trunc() {
format!("{:.1}", self.refresh_rate)
} else {
self.refresh_rate.to_string()
};
format!(
"{{\"width\":{},\"height\":{},\"red_bits\":{},\"green_bits\":{},\"blue_bits\":{},\"refresh_rate\":{rate}}}",
self.width, self.height, self.red_bits, self.green_bits, self.blue_bits
)
}
/// `VideoMode.refreshRateLabel`: a whole rate without decimals, any other with two.
pub fn refresh_rate_label(&self) -> String {
if self.refresh_rate == self.refresh_rate.round_ties_even() {
(self.refresh_rate as i32).to_string()
} else {
format!("{:.2}", self.refresh_rate)
}
}
/// The colour depth `options.fullscreen.entry` shows.
pub fn bits(&self) -> i32 {
self.red_bits + self.green_bits + self.blue_bits
}
/// A winit mode: its depth is the total, split evenly over the channels.
pub fn from_winit(mode: &winit::monitor::VideoModeHandle) -> Self {
let depth = i32::from(mode.bit_depth());
let size = mode.size();
Self {
width: size.width as i32,
height: size.height as i32,
red_bits: depth / 3,
green_bits: depth / 3,
blue_bits: depth - 2 * (depth / 3),
refresh_rate: mode.refresh_rate_millihertz() as f32 / 1000.0,
}
}
}
/// `Monitor.videoModes`: the modes a monitor offers.
pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<VideoMode> {
monitor
.video_modes()
.map(|m| VideoMode::from_winit(&m))
.collect()
}
/// The video modes of the window's monitor, listed once per monitor. Listing them takes tens of
/// milliseconds on Windows, and settings apply at every step of a dragged slider: listing them
/// each time made the slider stutter.
#[derive(Debug)]
pub struct VideoModeList<M = winit::monitor::MonitorHandle> {
listed: Option<M>,
}
impl<M> Default for VideoModeList<M> {
fn default() -> Self {
Self { listed: None }
}
}
impl<M: PartialEq> VideoModeList<M> {
/// The modes of `monitor` when it is not the monitor listed last, else `None` (the list
/// stands). No monitor lists none.
pub fn refresh(
&mut self,
monitor: Option<M>,
list: impl FnOnce(&M) -> Vec<VideoMode>,
) -> Option<Vec<VideoMode>> {
if monitor.is_some() && monitor == self.listed {
return None;
}
let modes = monitor.as_ref().map(list).unwrap_or_default();
self.listed = monitor;
Some(modes)
}
}
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
/// the monitor's own size with the highest refresh rate and depth.
fn monitor_current_mode(
monitor: &winit::monitor::MonitorHandle,
) -> Option<winit::monitor::VideoModeHandle> {
let size = monitor.size();
monitor
.video_modes()
.filter(|m| m.size() == size)
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
}
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
pub fn fullscreen_request(
window: &winit::window::Window,
exclusive: bool,
preferred: Option<&VideoMode>,
) -> winit::window::Fullscreen {
use winit::window::Fullscreen;
if !exclusive {
return Fullscreen::Borderless(None);
}
let Some(monitor) = window
.current_monitor()
.or_else(|| window.primary_monitor())
else {
return Fullscreen::Borderless(None);
};
let wanted = preferred.and_then(|p| {
monitor
.video_modes()
.find(|m| VideoMode::from_winit(m) == *p)
});
match wanted.or_else(|| monitor_current_mode(&monitor)) {
Some(mode) => Fullscreen::Exclusive(mode),
None => Fullscreen::Borderless(None),
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
@@ -564,63 +340,11 @@ pub fn icon() -> Option<winit::window::Icon> {
mod tests {
use super::*;
#[test]
fn video_mode_round_trips_the_options_json() {
let mode = VideoMode {
width: 1920,
height: 1080,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: 60.0,
};
let text = mode.write();
assert_eq!(
text,
r#"{"width":1920,"height":1080,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#
);
assert_eq!(VideoMode::read(&text), Some(mode));
assert_eq!(VideoMode::read("1920x1080"), None);
assert_eq!(VideoMode::read(r#"{"width":1}"#), None);
}
#[test]
fn refresh_rate_label_is_whole_or_two_decimals() {
let mut mode = VideoMode::read(
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":144.0}"#,
)
.unwrap();
assert_eq!(mode.refresh_rate_label(), "144");
mode.refresh_rate = 59.94;
assert_eq!(mode.refresh_rate_label(), "59.94");
assert_eq!(mode.bits(), 24);
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
}
#[test]
fn exclusive_fullscreen_reapplies_only_while_fullscreen_is_requested() {
let mut f = FullscreenState::new(false);
assert!(!f.set_exclusive(true));
assert!(!f.set_exclusive(true));
assert_eq!(f.request(true), Some(true));
assert!(f.set_exclusive(false));
assert!(!f.set_exclusive(false));
}
#[test]
fn a_changed_video_mode_applies_once_when_fullscreen() {
let mode = VideoMode::read(
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#,
);
let mut f = FullscreenState::new(false);
f.set_preferred_mode(mode);
assert!(!f.take_mode_change());
f.request(true);
f.set_preferred_mode(mode);
assert!(f.take_mode_change());
assert!(!f.take_mode_change());
}
#[test]
fn fullscreen_follows_the_option_only_when_it_changes() {
let mut f = FullscreenState::new(false);
@@ -704,11 +428,6 @@ mod tests {
vsync_present_mode(&[PresentMode::Fifo], false),
Some(PresentMode::Fifo)
);
// Regression guard: GLES capabilities report [Fifo, Immediate] without Mailbox.
assert_eq!(
vsync_present_mode(&[PresentMode::Fifo, PresentMode::Immediate], false),
Some(PresentMode::Immediate)
);
}
#[test]
fn vsync_on_waits_for_the_display_whatever_the_present_mode() {
@@ -729,28 +448,4 @@ mod tests {
Some(Immediate)
);
}
#[test]
fn video_modes_are_listed_again_only_for_another_monitor() {
let mode = |width| VideoMode {
width,
height: 1080,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: 60.0,
};
let mut list = VideoModeList::default();
let mut listings = 0;
let mut modes = |monitor: &i32| {
listings += 1;
vec![mode(*monitor)]
};
assert_eq!(list.refresh(Some(1), &mut modes), Some(vec![mode(1)]));
assert_eq!(list.refresh(Some(1), &mut modes), None);
assert_eq!(list.refresh(Some(2), &mut modes), Some(vec![mode(2)]));
assert_eq!(list.refresh(None, &mut modes), Some(vec![]));
assert_eq!(list.refresh(Some(2), &mut modes), Some(vec![mode(2)]));
assert_eq!(listings, 3);
}
}
+2 -8
View File
@@ -15,8 +15,6 @@ horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
@@ -25,7 +23,6 @@ horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
base64 = "0.22"
bytemuck = { version = "1.24", features = ["derive"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
@@ -34,21 +31,18 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
half = "2"
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha1 = "0.10"
sha2 = "0.10.9"
smallvec = { version = "1", features = ["const_new"] }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
+6 -19
View File
@@ -192,8 +192,8 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
/// layers, without the block or item models or the textures' pixels.
/// [`Assets::reload`] for a server, which draws nothing: the models and
/// the sprites' layers, without the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
@@ -202,24 +202,14 @@ impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
// A server draws no block and no item: its block models (about 24 MB
// baked) and item model library (7 MB) stay empty.
let parsed = if draws {
crate::model_library::ParsedModels::load(root)
} else {
crate::model_library::ParsedModels::default()
};
let models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
@@ -235,11 +225,8 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
crate::native_emissive::load(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
if pixels {
sprites.finish();
} else {
sprites.finish_without_pixels();
+62 -713
View File
@@ -39,12 +39,6 @@ struct Channel {
looping: bool,
at: [f32; 3],
relative: bool,
/// Whose sounds these are: 0 the game's, another splitscreen player's (`Audio::share`).
owner: u8,
/// Its world (`Mixer::world_id`; 0 none).
world: u32,
/// The sound file it started and when, to tell another player's copy of it.
started: Option<(String, std::time::Instant)>,
}
#[derive(Default)]
struct Mixer {
@@ -52,37 +46,7 @@ struct Mixer {
listener: [f64; 3],
listener_yaw: f32,
listener_pitch: f32,
/// Splitscreen: the other players' cameras (position, yaw, pitch) by owner. A world sound is
/// heard from the nearest camera, and panned half as wide while several share the speakers.
listeners: HashMap<u8, ([f64; 3], f32, f32)>,
/// Each camera's world by owner, as ids: the callback compares and copies integers.
worlds: HashMap<u8, u32>,
/// World names by id - 1 (`world_id`).
world_names: Vec<String>,
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
cameras: Vec<([f64; 3], Basis, u32)>,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
device_name: String,
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
#[cfg(feature = "audio")]
device_lost: bool,
}
/// A camera's forward, up and right vectors.
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
impl Mixer {
/// The id of world `name` (1 and up), given once per name.
fn world_id(&mut self, name: &str) -> u32 {
let i = match self.world_names.iter().position(|w| w == name) {
Some(i) => i,
None => {
self.world_names.push(name.to_owned());
self.world_names.len() - 1
}
};
i as u32 + 1
}
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
@@ -105,34 +69,7 @@ pub struct MixerBackend {
decoder: Option<std::sync::mpsc::Sender<Decode>>,
next: ChannelId,
live: bool,
/// Whose channels these are (`Channel::owner`).
owner: u8,
world: u32,
/// How many other players share this output: alone, a sound never looks for its copy.
shares: Arc<std::sync::atomic::AtomicUsize>,
}
/// A splitscreen player's backend leaving: its camera and sounds go with it.
impl Drop for MixerBackend {
fn drop(&mut self) {
if self.owner == 0 {
return;
}
self.shares
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut m) = self.mixer.lock() {
m.listeners.remove(&self.owner);
m.worlds.remove(&self.owner);
let owner = self.owner;
m.channels.retain(|_, c| c.owner != owner);
}
}
}
/// Splitscreen: each shared backend's channel ids start at a block of their own, never handed out
/// again, so a player in a seat used before never reuses an id a departed one's decoder may
/// still attach to. The game's own backend counts from 0, as before.
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Two players' copies of one sound: started within this of each other.
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
@@ -228,22 +165,6 @@ pub struct Audio {
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
#[cfg(feature = "audio")]
default_seen: Option<String>,
/// The ALSA cards (Linux) or Core Audio devices (macOS) last seen: listing
/// the output devices probes every one, so it waits for these to change
/// (`card_fingerprint`).
#[cfg(feature = "audio")]
cards_seen: Option<String>,
/// The other half of `lastSeenDevices`: every output device last seen.
#[cfg(feature = "audio")]
devices_seen: Vec<String>,
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -287,59 +208,7 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
}
Some((mono, rate))
}
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
/// player's game picks its own variant of the same event.
// ponytail: variants named by a trailing number, as vanilla's are; the event name if a pack breaks it.
fn variantless(key: &str) -> &str {
key.trim_end_matches(".ogg")
.trim_end_matches(|c: char| c.is_ascii_digit())
}
impl MixerBackend {
/// Splitscreen: another player's game started this same sound here just now (both clients
/// heard it); this copy stays stopped, the first is heard from the nearest camera.
///
/// A copy is recognised only by its file (without the variant), world, place and time: the
/// games pick variants on their own and play many sounds client-side (steps, digging), so
/// nothing marks two copies of one event. Two different events alike in all four (two
/// players' steps on grass, side by side, within 150 ms) are heard once. That is accepted:
/// at that distance and timing they sound like one; a doubled sound is the louder fault.
fn duplicate(&self, channel: ChannelId, (namespace, path): (&str, &str)) -> bool {
// Alone: nobody else's copy, and no lock taken for it.
if self.shares.load(std::sync::atomic::Ordering::Relaxed) == 0 {
return false;
}
let Ok(mut m) = self.mixer.lock() else {
return false;
};
// Alone (no other player's camera), or a sound by the listener (a click, music): its own.
let Some(c) = m.channels.get(&channel).filter(|c| !c.relative) else {
return false;
};
if m.listeners.is_empty() {
return false;
}
let key = format!("{namespace}:{path}");
let key = variantless(&key);
let at = c.at;
let world = c.world;
let twin = m.channels.values().any(|o| {
o.owner != self.owner
&& o.world == world
&& !o.relative
&& o.started
.as_ref()
.is_some_and(|(k, t)| k == key && t.elapsed() < SAME_SOUND)
&& (0..3).map(|i| (o.at[i] - at[i]).powi(2)).sum::<f32>() < 4.0
});
if let Some(c) = m.channels.get_mut(&channel) {
if twin {
c.state = State::Stopped;
} else {
c.started = Some((key.to_owned(), std::time::Instant::now()));
}
}
twin
}
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
@@ -364,9 +233,6 @@ impl Backend for MixerBackend {
looping: false,
at: [0.0; 3],
relative: false,
owner: self.owner,
world: self.world,
started: None,
},
);
Some(id)
@@ -404,10 +270,6 @@ impl Backend for MixerBackend {
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
// A looping sound follows its entity in one client's tracking only: each keeps its own.
if !looping && self.duplicate(channel, (namespace, &path)) {
return;
}
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
@@ -516,50 +378,28 @@ fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
data.fill(0.0);
let listener = mixer.listener;
// The camera's forward and up vectors (`Camera.forwardVector`/`upVector`)
// and OpenAL's right = forward x up.
let basis = |yaw: f32, pitch: f32| {
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
(forward, up, right)
};
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
let mut cameras = std::mem::take(&mut mixer.cameras);
cameras.clear();
cameras.extend(
std::iter::once((
0,
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
))
.chain(
mixer
.listeners
.iter()
.map(|(&owner, &camera)| (owner, camera)),
)
.map(|(owner, (at, yaw, pitch))| {
(
at,
basis(yaw, pitch),
mixer.worlds.get(&owner).copied().unwrap_or_default(),
)
}),
let (yaw, pitch) = (
f64::from(mixer.listener_yaw.to_radians()),
f64::from(mixer.listener_pitch.to_radians()),
);
let shared = cameras.len() > 1;
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
@@ -570,48 +410,27 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
if c.state != State::Playing {
continue;
}
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
let away = |listener: [f64; 3]| {
let d = if c.relative {
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
} else {
[
at[0] - listener[0],
at[1] - listener[1],
at[2] - listener[2],
f64::from(c.at[0]) - listener[0],
f64::from(c.at[1]) - listener[1],
f64::from(c.at[2]) - listener[2],
]
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
.map(|camera| (camera.0, camera.1));
// Unheard sources still advance: returning to a world must not resume stale sounds.
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
let d = if c.relative { at } else { away(listener) };
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
} else {
0.0
};
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
let [left, right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
let [left, right] = [left, right].map(|pan| gain * pan);
.map(|pan| gain * pan);
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
mixer.cameras = cameras;
for (id, [left, right, gain], step) in voices {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
@@ -685,96 +504,19 @@ fn open_device(
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
F::F32 => build_stream::<f32>(device, &config, mixer),
F::F64 => build_stream::<f64>(device, &config, mixer),
F::I16 => build_stream::<i16>(device, &config, mixer),
F::I32 => build_stream::<i32>(device, &config, mixer),
F::I8 => build_stream::<i8>(device, &config, mixer),
F::U16 => build_stream::<u16>(device, &config, mixer),
F::U32 => build_stream::<u32>(device, &config, mixer),
F::U8 => build_stream::<u8>(device, &config, mixer),
other => Err(format!("unsupported sample format {other}").into()),
}?;
stream.play()?;
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
m.device_lost = false;
}
Ok((stream, config))
}
/// Try one output device for `open`: skip it when it was already tried, log it
/// when it opens, and keep its error when it does not.
#[cfg(feature = "audio")]
fn try_open_device(
device: &cpal::Device,
mixer: Arc<Mutex<Mixer>>,
tried: &mut Vec<String>,
errors: &mut Vec<String>,
) -> Option<cpal::Stream> {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
return None;
}
tried.push(name.clone());
match open_device(device, mixer) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
Some(stream)
}
Err(e) => {
errors.push(format!("{name}: {e}"));
None
}
}
}
/// The name of the system's default output device.
#[cfg(feature = "audio")]
fn default_output_name() -> Option<String> {
cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}
/// A cheap fingerprint of what `output_devices` can list. On Linux, the ALSA
/// cards: the rest are `cpal`'s fixed names. On macOS, every Core Audio
/// device's name, inputs included: telling which of them play opens each one
/// (about 90 ms a second with twenty devices), naming them takes 2 or 3 ms.
/// `None` elsewhere, where the list is always looked at.
#[cfg(feature = "audio")]
fn card_fingerprint() -> Option<String> {
#[cfg(target_os = "linux")]
{
std::fs::read_to_string("/proc/asound/cards").ok()
}
#[cfg(target_os = "macos")]
{
let devices = cpal::default_host().devices().ok()?;
Some(
devices
.filter_map(|d| d.name().ok())
.collect::<Vec<_>>()
.join("\n"),
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
/// Whether the output device list should be looked at again: only the ALSA
/// cards can add or remove an entry, and `None` means there is no fingerprint,
/// so the list has to be checked as before.
#[cfg(any(feature = "audio", test))]
fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
match (seen, cards) {
(Some(seen), Some(cards)) => seen != cards,
_ => true,
}
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
@@ -784,7 +526,6 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut scratch = Vec::new();
let on_error = mixer.clone();
let stream = device.build_output_stream(
&config.config(),
move |data: &mut [T], _| {
@@ -798,14 +539,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
*out = T::from_sample(*s);
}
},
move |e| {
log::warn!("Horizon: audio stream error: {e}");
if matches!(e, cpal::StreamError::DeviceNotAvailable)
&& let Ok(mut m) = on_error.lock()
{
m.device_lost = true;
}
},
|e| log::warn!("Horizon: audio stream error: {e}"),
None,
)?;
Ok(stream)
@@ -879,17 +613,6 @@ impl Audio {
};
let mut engine = SoundEngine::new(registry);
engine.loaded = live;
#[cfg(feature = "audio")]
let cards = if live { card_fingerprint() } else { None };
// Where the cards gate the list (Linux, macOS), it starts empty: opening the
// output does not need it, and listing it probes every device; the first
// change fills it. Elsewhere it starts with every device listed, as before.
#[cfg(feature = "audio")]
let devices = if live && cards.is_none() {
output_devices()
} else {
Vec::new()
};
Self {
_stream: stream,
device_name: target_device.to_owned(),
@@ -901,9 +624,6 @@ impl Audio {
decoder: None,
next: 0,
live,
owner: 0,
world: 0,
shares: Arc::default(),
},
record_heard: false,
listener: [0.0; 3],
@@ -914,15 +634,6 @@ impl Audio {
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
cards_seen: cards,
#[cfg(feature = "audio")]
devices_seen: devices,
#[cfg(feature = "audio")]
device_ticks: 0,
}
}
#[cfg(not(feature = "audio"))]
@@ -987,20 +698,29 @@ impl Audio {
}
let host = cpal::default_host();
let mut errors = Vec::new();
// The default device first: it is what plays, and listing the others
// probes every PCM, which is slow and prints ALSA's errors for the
// plugins this system lacks.
// The default device first, then any other output a host lists:
// a default that cannot open (busy, or a stale ALSA default) should
// not leave the game silent when another device works.
let default = host.default_output_device();
let others = host.output_devices().into_iter().flatten();
let mut tried = Vec::new();
if let Some(device) = host.default_output_device()
&& let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors)
{
return Ok(stream);
}
// A default that cannot open (busy, or a stale ALSA default) should not
// leave the game silent when another device works.
for device in host.output_devices().into_iter().flatten() {
if let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors) {
return Ok(stream);
for device in default.into_iter().chain(others) {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
continue;
}
tried.push(name.clone());
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
Err(e) => errors.push(format!("{name}: {e}")),
}
}
if errors.is_empty() {
@@ -1014,75 +734,15 @@ impl Audio {
backend: &mut self.backend,
}
}
/// World identity for this player's sources and camera. Existing channels keep their world.
pub fn set_world(&mut self, world: &str) {
if let Ok(mut mixer) = self.backend.mixer.lock() {
let id = mixer.world_id(world);
self.backend.world = id;
mixer.worlds.insert(self.backend.owner, id);
}
}
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
self.listener = position;
self.listener_yaw = yaw;
if let Ok(mut m) = self.backend.mixer.lock() {
if self.backend.owner == 0 {
m.listener = position;
m.listener_yaw = yaw;
m.listener_pitch = pitch;
} else {
m.listeners
.insert(self.backend.owner, (position, yaw, pitch));
}
}
}
/// Splitscreen: another player's sounds on this output, heard from its camera too
/// (`owner`: 1 to 255, its own). Dropped, its camera and sounds go.
pub fn share(&self, owner: u8) -> Self {
assert_ne!(owner, 0, "shared audio owners must be nonzero");
self.backend
.shares
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut engine = SoundEngine::new(self.engine.registry.clone());
engine.loaded = self.backend.live;
Self {
_stream: None,
device_name: self.device_name.clone(),
engine,
backend: MixerBackend {
mixer: self.backend.mixer.clone(),
root: self.backend.root.clone(),
cache: self.backend.cache.clone(),
decoder: None,
next: (SHARED_CHANNELS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1)
<< 40,
live: self.backend.live,
owner,
world: self.backend.world,
shares: self.backend.shares.clone(),
},
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
master: self.master,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
// The device belongs to the first player's output, which looks after it.
#[cfg(feature = "audio")]
default_seen: self.default_seen.clone(),
#[cfg(feature = "audio")]
cards_seen: self.cards_seen.clone(),
#[cfg(feature = "audio")]
devices_seen: self.devices_seen.clone(),
#[cfg(feature = "audio")]
device_ticks: 0,
m.listener = position;
m.listener_yaw = yaw;
m.listener_pitch = pitch;
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -1101,81 +761,8 @@ impl Audio {
}
self.engine.listening = self.record_heard;
}
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
/// default changed (with no device chosen in the options), or the chosen
/// device came back; the output is then opened again. The devices are looked
/// at once a second.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
// A splitscreen share plays through the first player's output: it leaves the device,
// and the mixer's lost flag, to that output.
if self.backend.owner != 0 {
return false;
}
let (current, lost) = match self.backend.mixer.lock() {
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
Err(_) => return false,
};
if lost {
log::info!("Horizon: audio device was lost");
self.default_seen = default_output_name();
self.cards_seen = card_fingerprint();
self.devices_seen = output_devices();
return true;
}
self.device_ticks += 1;
if self._stream.is_none() || self.device_ticks < 20 {
return false;
}
self.device_ticks = 0;
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them); only the
// fingerprint's devices can change the list, and it is cheap.
if default == self.default_seen
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
{
return false;
}
self.cards_seen = cards;
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
if default == self.default_seen && devices == self.devices_seen {
return false;
}
self.default_seen = default.clone();
let wanted = self.device_name.clone();
let available = devices.contains(&wanted);
self.devices_seen = devices;
if wanted.is_empty() {
let changed = default.as_deref().is_some_and(|d| d != current);
if changed {
log::info!("Horizon: system default audio device has changed");
}
return changed;
}
let changed = current != wanted && available;
if changed {
log::info!("Horizon: preferred audio device has become available");
}
changed
}
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
#[cfg(feature = "audio")]
{
if self.should_change_device() {
// SoundEngine.reload: stop every sound, open the output again.
self.stop_all();
let device = self.device_name.clone();
if let Err(e) = self.set_device(&device) {
log::warn!("Horizon: could not reopen the audio output: {e}");
}
}
}
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
@@ -1211,37 +798,6 @@ impl Audio {
let seed = self.random.next_long();
self.play_seeded(event, at, volume, pitch, seed);
}
/// `ClientLevel.playLocalSound` with `distanceDelay`: a sound more than 10
/// blocks from the camera starts `sqrt(distance) / 40` seconds later.
pub fn play_distance_delayed(
&mut self,
event: &str,
at: [f64; 3],
volume: f32,
pitch: f32,
camera: [f64; 3],
) {
let distance_sqr: f64 = (0..3).map(|i| (at[i] - camera[i]).powi(2)).sum();
if distance_sqr <= 100.0 {
self.play(event, Some(at), volume, pitch);
return;
}
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
}
*self.heard.entry(event.to_owned()).or_default() += 1;
self.sync_volumes();
let event = crate::sound_events::normalize(event);
let source = self
.source_override
.unwrap_or_else(|| crate::settings::sound_source(&event));
let instance =
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
let id = self.engine.add(instance, false);
self.engine
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
@@ -1261,13 +817,6 @@ impl Audio {
false,
);
}
/// `SoundPreviewHandler.preview`: a category volume slider moved to
/// `volume` on the title screen's options.
pub fn preview(&mut self, source: usize, volume: f32) {
let mut preview = std::mem::take(&mut self.preview);
preview.preview(self, source, volume);
self.preview = preview;
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
@@ -1336,13 +885,6 @@ impl Audio {
self.stop_instance(id);
self.release(id);
}
/// `SoundBufferLibrary.enumerate`: the decoded buffers kept and their bytes.
pub fn cache_stats(&self) -> (usize, usize) {
self.backend
.cache
.lock()
.map_or((0, 0), |c| (c.entries.len(), c.bytes))
}
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
@@ -1461,9 +1003,6 @@ mod tests {
looping,
at,
relative: false,
owner: 0,
world: 0,
started: None,
}
}
@@ -1621,185 +1160,6 @@ mod tests {
assert!(close(gains(0.0, 90.0, [0.0, 0.0, 1.0]), [0.5, 0.5]));
}
#[test]
fn splitscreen_sounds_are_heard_from_the_nearest_camera() {
// P1 far away, a second player beside the source: heard at full gain, centred.
let mut m = Mixer {
listener: [100.0, 0.0, 0.0],
..Mixer::default()
};
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
m.channels
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert!(
data[0] > 0.5 && (data[0] - data[1]).abs() < 1e-4,
"{data:?}"
);
// Alone, P1 would not hear it: 100 blocks past its 16.
m.listeners.clear();
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 2]);
}
#[test]
fn a_shared_listener_registers_even_in_the_hosts_world() {
let mut host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
host.set_world("minecraft:overworld");
let mut guest = host.share(1);
guest.set_world("minecraft:overworld");
guest.set_listener([1.0, 2.0, 3.0], 0.0, 0.0);
let id = guest.backend.create(false).unwrap();
let mixer = host.backend.mixer.lock().unwrap();
let overworld = mixer
.world_names
.iter()
.position(|w| w == "minecraft:overworld");
let overworld = overworld.map(|i| i as u32 + 1);
assert_eq!(mixer.worlds.get(&1).copied(), overworld);
assert_eq!(Some(mixer.channels[&id].world), overworld);
}
#[test]
fn cameras_in_other_dimensions_cannot_hear_a_source() {
let mut m = Mixer {
listener: [100.0, 0.0, 0.0],
..Default::default()
};
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
let nether = m.world_id("nether");
m.worlds.insert(1, nether);
m.channels
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(
data, [0.0; 2],
"nearby Nether camera must not hear Overworld source"
);
m.channels.get_mut(&1).unwrap().world = nether;
mix(&mut m, &mut data, 2, 48_000);
assert!(data[0] > 0.5, "same-world camera must hear source");
let end = m.world_id("end");
m.worlds.insert(1, end);
data.fill(0.0);
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 2], "no listener remains in source's world");
}
#[test]
fn sounds_in_a_world_without_listeners_finish_while_inaudible() {
let mut mixer = Mixer::default();
let mut source = channel(vec![1.0; 4], None, [0.0; 3], false);
let nether = mixer.world_id("nether");
source.world = nether;
mixer.channels.insert(1, source);
let mut data = [0.0; 4];
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 4]);
assert!(mixer.channels[&1].position > 0.0);
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(mixer.channels[&1].state, State::Stopped);
mixer.worlds.insert(0, nether);
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
}
#[test]
fn variants_of_one_sound_are_the_same_sound() {
assert_eq!(
variantless("minecraft:mob/creeper/say1.ogg"),
"minecraft:mob/creeper/say"
);
assert_eq!(
variantless("minecraft:step/grass6.ogg"),
"minecraft:step/grass"
);
assert_eq!(
variantless("minecraft:random/click.ogg"),
"minecraft:random/click"
);
}
#[test]
fn another_players_copy_of_a_sound_is_dropped() {
let mixer = Arc::new(Mutex::new(Mixer::default()));
// One other player shares the output.
let shares = Arc::new(std::sync::atomic::AtomicUsize::new(1));
let backend = |owner| MixerBackend {
mixer: mixer.clone(),
root: PathBuf::new(),
cache: Arc::default(),
decoder: None,
next: (u64::from(owner)) << 40,
live: true,
owner,
world: 0,
shares: shares.clone(),
};
let (mut p1, mut p2) = (backend(0), backend(1));
// P2 is listening (its camera is in the mixer), so two players share the speakers.
mixer
.lock()
.unwrap()
.listeners
.insert(1, ([0.0; 3], 0.0, 0.0));
let started = |b: &mut MixerBackend, at: [f64; 3]| {
let id = b.create(false).unwrap();
b.set_position(id, at);
!b.duplicate(id, ("minecraft", "mob/creeper/say1.ogg"))
};
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
// P2's client got the same sound at the same place: one copy plays.
assert!(!started(&mut p2, [5.2, 64.0, 5.0]));
// Elsewhere, or P1's own second one, it is a sound of its own.
assert!(started(&mut p2, [40.0, 64.0, 5.0]));
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
// Equal coordinates in another dimension describe a different sound.
p2.world = mixer.lock().unwrap().world_id("nether");
assert!(started(&mut p2, [5.0, 64.0, 5.0]));
// P2 leaving takes its sounds.
drop(p2);
assert!(
mixer
.lock()
.unwrap()
.channels
.values()
.all(|c| c.owner == 0)
);
}
#[test]
fn reusing_a_seat_does_not_reuse_its_channels_or_leave_its_camera() {
let host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
let mut guest = host.share(1);
guest.set_world("overworld");
guest.set_listener([1.0, 0.0, 0.0], 0.0, 0.0);
let old = guest.backend.create(false).unwrap();
drop(guest);
{
let mixer = host.backend.mixer.lock().unwrap();
assert!(!mixer.channels.contains_key(&old));
assert!(!mixer.listeners.contains_key(&1));
assert!(!mixer.worlds.contains_key(&1));
}
let mut replacement = host.share(1);
let new = replacement.backend.create(false).unwrap();
assert_ne!(old, new);
assert!(
replacement
.backend
.mixer
.lock()
.unwrap()
.channels
.contains_key(&new)
);
}
#[test]
fn a_mono_output_plays_sources_unpanned() {
let mut m = Mixer::default();
@@ -1809,15 +1169,4 @@ mod tests {
mix(&mut m, &mut data, 1, 48_000);
assert_eq!(data, [0.5; 2]);
}
#[test]
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
// No fingerprint (not Linux, or the cards could not be read): the list
// has to be looked at as before.
assert!(cards_changed(None, None));
assert!(cards_changed(Some("0 [PCH ]"), None));
// The same cards list the same devices; a card added or removed does not.
assert!(!cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]")));
assert!(cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]\n 1 [USB ]")));
}
}
+6 -349
View File
@@ -52,20 +52,20 @@ impl AuthSession {
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
let name = field("name").ok_or("the session has no name")?;
let name = field("name").ok_or("the session file has no name")?;
if name.is_empty() || name.chars().count() > 16 {
return Err("the session's name is not a valid player name".into());
return Err("the session file's name is not a valid player name".into());
}
let uuid = field("uuid")
.and_then(parse_uuid)
.ok_or("the session has no valid uuid")?;
.ok_or("the session file has no valid uuid")?;
let access_token = field("access_token")
.filter(|t| !t.is_empty())
.ok_or("the session has no access token")?;
.ok_or("the session file has no access token")?;
if field("user_type").is_some_and(|t| t != "msa") {
return Err("the session is not a Microsoft account session".into());
return Err("the session file is not a Microsoft account session".into());
}
Ok(Self {
name: name.to_owned(),
@@ -203,46 +203,6 @@ pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<St
.map(str::to_owned)
}
/// `Minecraft`'s constructor futures for the title screen: `UserApiService.fetchProperties`
/// (the `player` service's `getAttributes`, with the bearer token) and
/// `SessionService.fetchProfile(uuid, true)` (`getProfileById`). A failed
/// attributes call falls back to the offline properties, a failed profile call
/// to no actions, as vanilla does. Blocks: call it off the main thread.
pub fn fetch_account_status(session: &AuthSession) -> crate::ban_notice::AccountStatus {
use crate::ban_notice::{AccountStatus, parse_profile_actions, parse_properties};
let Ok(doc) = discovery() else {
return AccountStatus::default();
};
let properties = endpoint(&doc, "player", "getAttributes")
.and_then(|url| {
agent()
.get(&url)
.set("Authorization", &format!("Bearer {}", session.access_token))
.call()
.ok()?
.into_string()
.ok()
})
.and_then(|body| parse_properties(&body))
.unwrap_or_default();
let actions = endpoint(&doc, "session", "getProfileById")
.and_then(|url| {
let url = url.replace("{profileId}", &format!("{:032x}", session.uuid));
agent()
.get(&format!("{url}?unsigned=true"))
.call()
.ok()?
.into_string()
.ok()
})
.map(|body| parse_profile_actions(&body))
.unwrap_or_default();
AccountStatus {
properties,
actions,
}
}
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
serde_json::json!({
@@ -272,211 +232,6 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
}
}
/// The account's chat key pair (ProfileKeyPair): the private key that signs
/// its chat, the public key with the services' signature over it, and when
/// to fetch a new one (refreshedAfter, epoch milliseconds). Like the access
/// token it stays in memory: vanilla's profilekeys file is never written.
pub struct ChatKeyPair {
pub private: horizon_protocol::rsa::RsaPrivateKey,
pub public: horizon_protocol::secure_chat::ProfileKey,
pub refreshed_after: i64,
}
impl std::fmt::Debug for ChatKeyPair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatKeyPair")
.field("expires_at", &self.public.expires_at)
.field("refreshed_after", &self.refreshed_after)
.finish_non_exhaustive()
}
}
impl ChatKeyPair {
/// ProfileKeyPair.dueRefresh.
pub fn due_refresh(&self, now: i64) -> bool {
self.refreshed_after < now
}
}
/// Epoch milliseconds now.
pub fn now_millis() -> i64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// Instant.parse for the services' UTC times
/// (`2022-07-30T23:05:24.548227Z`): epoch milliseconds.
pub fn parse_instant(text: &str) -> Option<i64> {
let text = text.strip_suffix('Z')?;
let (date, time) = text.split_once('T')?;
let mut date = date.splitn(3, '-').map(|p| p.parse::<i64>().ok());
let (y, m, d) = (date.next()??, date.next()??, date.next()??);
let (hms, fraction) = time.split_once('.').unwrap_or((time, ""));
let mut hms = hms.splitn(3, ':').map(|p| p.parse::<i64>().ok());
let (h, min, s) = (hms.next()??, hms.next()??, hms.next()??);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || h > 23 || min > 59 || s > 60 {
return None;
}
if !fraction.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let millis = format!("{fraction:0<3}")[..3].parse::<i64>().ok()?;
// Days from 1970-01-01 (Howard Hinnant's days_from_civil).
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(((days * 24 + h) * 60 + min) * 60_000 + s * 1000 + millis)
}
/// Crypt.stringToPemRsaPrivateKey / stringToRsaPublicKey: the base64
/// between a PEM block's armour lines.
fn pem_der(pem: &str) -> Option<Vec<u8>> {
let body: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.flat_map(|l| l.chars().filter(|c| !c.is_whitespace()))
.collect();
crate::skins::base64_decode(&body)
}
/// KeyPairResponse from the certificates endpoint, as
/// AccountProfileKeyPairManager.fetchProfileKeyPair reads it: the PEM key
/// pair, publicKeySignatureV2 (the signature over the profile id too),
/// expiresAt and refreshedAfter. None for anything malformed, or a private
/// key that isn't the public key's other half.
pub fn parse_certificates(body: &str) -> Option<ChatKeyPair> {
use horizon_protocol::rsa::{RsaPrivateKey, RsaPublicKey};
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let field = |path: &str| v.pointer(path).and_then(|s| s.as_str());
let private = RsaPrivateKey::from_der(&pem_der(field("/keyPair/privateKey")?)?).ok()?;
let key = pem_der(field("/keyPair/publicKey")?)?;
if RsaPublicKey::from_der(&key).ok()? != private.public {
return None;
}
let signature = crate::skins::base64_decode(field("/publicKeySignatureV2")?)?;
Some(ChatKeyPair {
private,
public: horizon_protocol::secure_chat::ProfileKey {
expires_at: parse_instant(field("/expiresAt")?)?,
key,
signature,
},
refreshed_after: parse_instant(field("/refreshedAfter")?)?,
})
}
/// UserApiService.getKeyPair: the certificates endpoint, POSTed with the
/// access token.
pub fn fetch_chat_keys(session: &AuthSession) -> Result<ChatKeyPair, JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "player", "getCertificates").ok_or(JoinError::Unavailable)?;
let body = match agent()
.post(&url)
.set("Authorization", &format!("Bearer {}", session.access_token))
.set("Content-Type", "application/json; charset=utf-8")
.send_string("")
{
Ok(response) => response.into_string().unwrap_or_default(),
Err(ureq::Error::Status(status, response)) => {
return Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
));
}
Err(ureq::Error::Transport(_)) => return Err(JoinError::Unavailable),
};
parse_certificates(&body)
.ok_or_else(|| JoinError::Other("Malformed profile key pair response".to_owned()))
}
/// AccountProfileKeyPairManager.MINIMUM_PROFILE_KEY_REFRESH_INTERVAL.
const KEY_RETRY: Duration = Duration::from_secs(60 * 60);
/// The chat key pair as AccountProfileKeyPairManager keeps it: fetched on a
/// background thread, kept until it is due a refresh, and on a failure kept
/// as it was (the next try waits an hour, or the next join).
#[derive(Default)]
struct ChatKeys {
current: Option<Arc<ChatKeyPair>>,
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
/// due a refresh. Nothing without a signed-in account.
pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
let mut keys = CHAT_KEYS.lock().unwrap();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
}
keys.next_try = Some(instant + KEY_RETRY);
if keys
.current
.as_ref()
.is_some_and(|k| !k.due_refresh(now_millis()))
{
return;
}
keys.fetching = true;
drop(keys);
let spawned = std::thread::Builder::new()
.name("Profile key fetch".into())
.spawn(move || {
let result = fetch_chat_keys(&session);
let mut keys = CHAT_KEYS.lock().unwrap();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
Err(e) => log::error!("Failed to retrieve profile key pair: {e:?}"),
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().fetching = false;
}
}
/// The chat key pair fetched so far, unless it has expired.
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
let now = now_millis();
CHAT_KEYS
.lock()
.unwrap()
.current
.clone()
.filter(|k| !k.public.has_expired(now))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
/// The signed-in account's active skin and cape from `GET /minecraft/profile`.
/// Single-player and integrated servers often send no `textures` property, or
/// one without the equipped cape; the client asks the profile endpoint once.
pub fn own_textures(session: &AuthSession) -> Option<crate::skins::Textures> {
let response = agent()
.get(MINECRAFT_PROFILE)
.set("Authorization", &format!("Bearer {}", session.access_token))
.call()
.ok()?;
if response.status() != 200 {
return None;
}
let body = response.into_string().ok()?;
let profile: serde_json::Value = serde_json::from_str(&body).ok()?;
Some(crate::skins::services_profile_textures(&profile))
}
/// What the session service says about a player joining this server
/// (MinecraftServicesSessionService.hasJoinedServer).
#[derive(Clone, Debug, PartialEq)]
@@ -671,104 +426,6 @@ mod tests {
);
}
#[test]
fn instants_like_java() {
assert_eq!(parse_instant("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_instant("1970-01-01T00:00:01.5Z"), Some(1500));
// Instant.parse("2022-07-30T23:05:24.548227Z").toEpochMilli().
assert_eq!(
parse_instant("2022-07-30T23:05:24.548227Z"),
Some(1_659_222_324_548)
);
assert_eq!(
parse_instant("2024-02-29T12:00:00Z"),
Some(1_709_208_000_000)
);
assert_eq!(parse_instant("1969-12-31T23:59:59Z"), Some(-1000));
for bad in [
"2022-07-30T23:05:24",
"2022-13-01T00:00:00Z",
"2022-07-30 23:05:24Z",
"2022-07-30T23:05:24.5x1Z",
"",
] {
assert_eq!(parse_instant(bad), None, "{bad}");
}
}
/// A throwaway 1024-bit key (`openssl genrsa`, PKCS#8 as the services
/// send it under "RSA PRIVATE KEY") and its public half.
const PRIVATE_B64: &str = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAKSDqaLOF2Ltt4tzWblkFeNYhdss67BhXTbOMUYjpcPdtf4dzNtgZsTf4WEluvc8pItGWChdpq3xzPC9GL4PZ+Oh1o6VR/wSm20/50QT7V+YcSP1VrVKjDpDSg/UoC1Fmdc/vb9AO8Ip7TxuRYd/uyog/ThQ8aGcWtvvtwPqx4XrAgMBAAECgYA+ZGy2gQDflRZSPeoeZHXkphMF/7BXUWW/vk1zs1tPwEsgV/70iEgRjHL2nA+fapFg/Mm8bWAkghrehmjW1BLq5w8sGdGJCHfE/kauu78jJK0/ME38e9bsSEvv5HMMxyx0mlV8DQu2pzCT71ceT8VD/1pewUbpeZypj5pxtsCIMQJBANXgca/mnAQXTuowI8FqMiald6OuAcAWbOaNrCB5HHWuxH7hFatSqGrgp4YLqHv7bzeodU1y/uxwc+/01khd0rMCQQDE6mPfIdZxHhWkMUAKhMEnrLanfCL0dfyAe+lBpqgPwO3UurCfCaFX5RdYB3fCyvgy5foHN1/I8/Eobp+enrvpAkBgkZnjeXWiZz/Lf/b1AtUoG/g8IHpL2BRNuVQpyWVvQX0ZaNoW5BiFQXCClsr5SBr8f4SPdsfhzBidFOiX/jz/AkBDuk6Bd3T3UR6ma54YQBLjX38vWf330QD4/CV9RCH30Sa8DT4kZT6OKah+yYrCf+cgMoNtqfkS9/xRKMNT3krJAkAaFrAT6HF5Y9b5OLASbbFe7MZ4zNCzuEl6plKNUi+JCHESaU/VCCuYOLoWLeSDFYkZUoSmqiRxH8fVTsBz393x";
const PUBLIC_B64: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkg6mizhdi7beLc1m5ZBXjWIXbLOuwYV02zjFGI6XD3bX+HczbYGbE3+FhJbr3PKSLRlgoXaat8czwvRi+D2fjodaOlUf8EpttP+dEE+1fmHEj9Va1Sow6Q0oP1KAtRZnXP72/QDvCKe08bkWHf7sqIP04UPGhnFrb77cD6seF6wIDAQAB";
fn pem(label: &str, b64: &str) -> String {
let lines: Vec<&str> = b64
.as_bytes()
.chunks(76)
.map(|c| std::str::from_utf8(c).unwrap())
.collect();
format!(
"-----BEGIN {label}-----\n{}\n-----END {label}-----\n",
lines.join("\n")
)
}
fn certificates(private: &str, public: &str) -> String {
serde_json::json!({
"keyPair": {
"privateKey": pem("RSA PRIVATE KEY", private),
"publicKey": pem("RSA PUBLIC KEY", public),
},
"publicKeySignature": "djE=",
"publicKeySignatureV2": "c2ln",
"expiresAt": "2022-07-30T23:05:24.548227Z",
"refreshedAfter": "2022-07-30T15:05:24.548227Z",
})
.to_string()
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
assert_eq!(
pair.public.key,
crate::skins::base64_decode(PUBLIC_B64).unwrap()
);
assert_eq!(pair.public.signature, b"sig", "publicKeySignatureV2");
assert_eq!(pair.public.expires_at, 1_659_222_324_548);
assert_eq!(pair.refreshed_after, 1_659_193_524_548);
assert_eq!(pair.private.public_der(), pair.public.key);
assert!(pair.due_refresh(1_659_193_524_549));
assert!(!pair.due_refresh(1_659_193_524_548));
assert!(!format!("{pair:?}").contains("MII"));
// A public key that isn't the private key's, and broken fields.
let other = horizon_protocol::rsa::RsaPrivateKey::generate(1024).unwrap();
let other = base64(&other.public_der());
assert!(parse_certificates(&certificates(PRIVATE_B64, &other)).is_none());
assert!(parse_certificates(&certificates("bm9wZQ==", PUBLIC_B64)).is_none());
assert!(parse_certificates("{}").is_none());
assert!(parse_certificates("").is_none());
}
fn base64(bytes: &[u8]) -> String {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for c in bytes.chunks(3) {
let n = c
.iter()
.enumerate()
.fold(0u32, |n, (i, &b)| n | u32::from(b) << (16 - 8 * i));
for i in 0..4 {
out.push(if i <= c.len() {
char::from(A[(n >> (18 - 6 * i) & 63) as usize])
} else {
'='
});
}
}
out
}
#[test]
fn discovery_endpoints() {
let doc = serde_json::json!({"discovery": {"session": {"endpoints": {"join": {"uri": "https://s/join"}}}}});
@@ -1,373 +0,0 @@
//! The ban notices `Gui.addInitialScreens` queues before the title screen:
//! `Minecraft.multiplayerBan()` (the account's MULTIPLAYER ban from the services
//! attributes call), the forced name change and the banned skin
//! (`ProfileResult.actions()`), and the text `BanNoticeScreens` shows for each.
//! The network calls are in `auth.rs`; this file parses what they return.
use crate::text::Text;
/// `CommonLinks.SUSPENSION_HELP`.
pub const SUSPENSION_HELP: &str = "https://aka.ms/mcjavamoderation";
/// authlib's `BanDetails` (the id is never shown).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BanDetails {
/// Epoch milliseconds the ban ends; `None` is permanent.
pub expires_ms: Option<i64>,
pub reason: Option<String>,
pub reason_message: Option<String>,
}
/// authlib's `UserProperties`, as far as the title screen reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserProperties {
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
pub servers_allowed: bool,
/// `bannedScopes.get("MULTIPLAYER")`.
pub multiplayer_ban: Option<BanDetails>,
}
impl Default for UserProperties {
/// `UserApiService.OFFLINE_PROPERTIES`: what a failed fetch falls back to.
fn default() -> Self {
Self {
servers_allowed: true,
multiplayer_ban: None,
}
}
}
/// authlib's `ProfileResult.actions()`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProfileActions {
pub forced_name_change: bool,
pub using_banned_skin: bool,
}
/// What the title screen learns about the account.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AccountStatus {
pub properties: UserProperties,
pub actions: ProfileActions,
}
impl AccountStatus {
/// `Minecraft.allowsMultiplayer` (without the `--disableMultiplayer` flag).
pub fn allows_multiplayer(&self) -> bool {
self.properties.servers_allowed
&& self.properties.multiplayer_ban.is_none()
&& !self.actions.forced_name_change
}
}
/// One of the screens `Gui.addInitialScreens` adds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Notice {
/// `BanNoticeScreens.create`.
Multiplayer(BanDetails),
/// `BanNoticeScreens.createNameBan` with the profile's name.
Name(String),
/// `BanNoticeScreens.createSkinBan`.
Skin,
}
/// `Gui.addInitialScreens`' ban part, in its order: the multiplayer ban, the
/// name ban, the skin ban.
pub fn initial_notices(status: &AccountStatus, profile_name: &str) -> Vec<Notice> {
let mut notices = Vec::new();
if let Some(ban) = &status.properties.multiplayer_ban {
notices.push(Notice::Multiplayer(ban.clone()));
}
if status.actions.forced_name_change {
notices.push(Notice::Name(profile_name.to_owned()));
}
if status.actions.using_banned_skin {
notices.push(Notice::Skin);
}
notices
}
impl Notice {
/// The screen's title (bold).
pub fn title(&self) -> Text {
let key = match self {
Self::Multiplayer(ban) if ban.expires_ms.is_some() => "gui.banned.title.temporary",
Self::Multiplayer(_) => "gui.banned.title.permanent",
Self::Name(_) => "gui.banned.name.title",
Self::Skin => "gui.banned.skin.title",
};
Text::translate(key, &[]).bold()
}
/// The screen's message; `now_ms` is `Instant.now()`.
pub fn message(&self, now_ms: i64) -> Text {
let link = || Text::literal(SUSPENSION_HELP);
match self {
Self::Multiplayer(ban) => Text::translate(
"gui.banned.description",
&[reason_text(ban), status_text(ban, now_ms), link()],
),
Self::Name(name) => Text::translate(
"gui.banned.name.description",
&[Text::colored(name.clone(), YELLOW), link()],
),
Self::Skin => Text::translate("gui.banned.skin.description", &[link()]),
}
}
}
/// `ChatFormatting.YELLOW`.
const YELLOW: u32 = 0xFFFF55;
/// `BanReason.byId`: the translation key suffix.
fn ban_reason(id: i32) -> Option<&'static str> {
Some(match id {
2 => "false_reporting",
5 => "hate_speech",
16 | 25 => "hate_terrorism_notorious_figure",
17 | 19 | 23 | 31 => "generic_violation",
21 => "harassment_or_bullying",
27 => "defamation_impersonation_false_information",
28 => "drugs",
29 => "fraud",
30 => "spam_or_advertising",
32 => "nudity_or_pornography",
33 | 35 | 36 => "sexually_inappropriate",
34 => "extreme_violence_or_gore",
53 => "imminent_harm_to_person_or_property",
_ => return None,
})
}
/// `BanNoticeScreens.getBanReasonText`.
fn reason_text(ban: &BanDetails) -> Text {
let numeric = ban
.reason
.as_deref()
.filter(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()));
let Some(reason) = numeric else {
return Text::translate("gui.banned.description.unknownreason", &[]);
};
// Integer.parseInt: past i32 the vanilla screen would throw; show the code as is.
let id = reason.parse::<i32>().ok();
let text = if let Some(key) = id.and_then(ban_reason) {
Text::translate(&format!("gui.banned.reason.{key}"), &[]).bold()
} else if let Some(message) = &ban.reason_message {
Text::translate(
"gui.banned.description.reason_id_message",
&[Text::literal(reason), Text::literal(message.clone())],
)
.bold()
} else {
Text::translate("gui.banned.description.reason_id", &[Text::literal(reason)]).bold()
};
Text::translate("gui.banned.description.reason", &[text])
}
/// `BanNoticeScreens.getBanStatusText`.
fn status_text(ban: &BanDetails, now_ms: i64) -> Text {
match ban.expires_ms {
Some(expires) => {
let duration = Text::translate(
"gui.banned.description.temporary.duration",
&[duration_text(expires - now_ms)],
)
.bold();
Text::translate("gui.banned.description.temporary", &[duration])
}
None => Text::translate("gui.banned.description.permanent", &[]).bold(),
}
}
/// `BanNoticeScreens.getBanDurationText`: days past 72 hours, else hours,
/// else minutes (`Duration.toHours` and friends truncate toward zero).
fn duration_text(millis: i64) -> Text {
let count = |n: i64| Text::literal(n.to_string());
let hours = millis / 3_600_000;
if hours > 72 {
Text::translate("gui.days", &[count(millis / 86_400_000)])
} else if hours < 1 {
Text::translate("gui.minutes", &[count(millis / 60_000)])
} else {
Text::translate("gui.hours", &[count(hours)])
}
}
/// `Instant` as authlib's gson adapter reads it: an ISO-8601 string, or epoch milliseconds.
fn parse_instant(value: &serde_json::Value) -> Option<i64> {
match value {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|t| t.timestamp_millis())
.or_else(|| s.parse().ok()),
_ => None,
}
}
/// `UserAttributesResponse` into `UserProperties` (`fetchProperties`). Bad JSON
/// is an error, which vanilla answers with the offline properties.
pub fn parse_properties(body: &str) -> Option<UserProperties> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let servers_allowed = v
.pointer("/privileges/multiplayerServer/enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let multiplayer_ban = v
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
.filter(|b| b.is_object())
.map(|b| {
let text = |k: &str| b.get(k).and_then(|s| s.as_str()).map(str::to_owned);
BanDetails {
expires_ms: b.get("expires").and_then(parse_instant),
reason: text("reason"),
reason_message: text("reasonMessage"),
}
});
Some(UserProperties {
servers_allowed,
multiplayer_ban,
})
}
/// `MinecraftProfilePropertiesResponse.profileActions` into `ProfileActionType`s.
pub fn parse_profile_actions(body: &str) -> ProfileActions {
let mut actions = ProfileActions::default();
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
for action in v
.get("profileActions")
.and_then(|a| a.as_array())
.into_iter()
.flatten()
{
match action.get("type").and_then(|t| t.as_str()) {
Some("FORCED_NAME_CHANGE") => actions.forced_name_change = true,
Some("USING_BANNED_SKIN") => actions.using_banned_skin = true,
_ => {}
}
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR: i64 = 3_600_000;
/// 2026-11-01T00:00:00Z.
const EXPIRES: i64 = 1_793_491_200_000;
#[test]
fn parses_a_multiplayer_ban() {
let body = r#"{"privileges":{"multiplayerServer":{"enabled":false}},
"banStatus":{"bannedScopes":{"MULTIPLAYER":{"banId":"6b0f0a0e-0000-4000-8000-000000000000",
"expires":"2026-11-01T00:00:00Z","reason":"21","reasonMessage":"x"}}}}"#;
let p = parse_properties(body).unwrap();
assert!(!p.servers_allowed);
let ban = p.multiplayer_ban.unwrap();
assert_eq!(ban.expires_ms, Some(EXPIRES));
assert_eq!(ban.reason.as_deref(), Some("21"));
let numeric =
parse_properties(r#"{"banStatus":{"bannedScopes":{"MULTIPLAYER":{"expires":5}}}}"#)
.unwrap();
assert_eq!(numeric.multiplayer_ban.unwrap().expires_ms, Some(5));
assert!(parse_properties("nope").is_none());
let clean = parse_properties(
r#"{"privileges":{"multiplayerServer":{"enabled":true}},"banStatus":{"bannedScopes":{}}}"#,
)
.unwrap();
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
}
#[test]
fn parses_profile_actions() {
let a = parse_profile_actions(
r#"{"id":"x","profileActions":[{"type":"FORCED_NAME_CHANGE"},{"type":"OTHER"}]}"#,
);
assert!(a.forced_name_change && !a.using_banned_skin);
let a = parse_profile_actions(r#"{"profileActions":[{"type":"USING_BANNED_SKIN"}]}"#);
assert!(!a.forced_name_change && a.using_banned_skin);
assert_eq!(parse_profile_actions("{}"), ProfileActions::default());
}
#[test]
fn queues_in_vanilla_order() {
let ban = BanDetails {
expires_ms: None,
reason: None,
reason_message: None,
};
let status = AccountStatus {
properties: UserProperties {
servers_allowed: true,
multiplayer_ban: Some(ban.clone()),
},
actions: ProfileActions {
forced_name_change: true,
using_banned_skin: true,
},
};
assert_eq!(
initial_notices(&status, "Steve"),
vec![
Notice::Multiplayer(ban),
Notice::Name("Steve".into()),
Notice::Skin
]
);
assert!(!status.allows_multiplayer());
assert!(initial_notices(&AccountStatus::default(), "Steve").is_empty());
assert!(AccountStatus::default().allows_multiplayer());
let no_servers = AccountStatus {
properties: UserProperties {
servers_allowed: false,
multiplayer_ban: None,
},
..Default::default()
};
assert!(!no_servers.allows_multiplayer());
}
/// The message's component JSON: the keys and arguments, whatever language is loaded.
fn json(notice: &Notice, now: i64) -> String {
notice.message(now).component().to_string()
}
#[test]
fn ban_text_follows_banned_screens() {
let temp = |reason: Option<&str>, message: Option<&str>| {
Notice::Multiplayer(BanDetails {
expires_ms: Some(100 * HOUR),
reason: reason.map(str::to_owned),
reason_message: message.map(str::to_owned),
})
};
assert_eq!(
temp(None, None).title().translation_key(),
Some("gui.banned.title.temporary")
);
let perm = Notice::Multiplayer(BanDetails {
expires_ms: None,
reason: Some("5".into()),
reason_message: None,
});
assert_eq!(
perm.title().translation_key(),
Some("gui.banned.title.permanent")
);
let text = json(&perm, 0);
assert!(text.contains("gui.banned.reason.hate_speech"));
assert!(text.contains("gui.banned.description.permanent"));
assert!(text.contains(SUSPENSION_HELP));
// Unknown ids show their code, with the message when there is one.
let with_message = json(&temp(Some("999"), Some("why")), 0);
assert!(with_message.contains("gui.banned.description.reason_id_message"));
assert!(with_message.contains("why"));
assert!(json(&temp(Some("999"), None), 0).contains("gui.banned.description.reason_id\""));
assert!(json(&temp(Some("abc"), None), 0).contains("gui.banned.description.unknownreason"));
// 100 hours left is past 72: days (4); less is hours; under an hour is minutes.
let duration = |now| json(&temp(None, None), now);
assert!(duration(0).contains("gui.days") && duration(0).contains("[4]"));
assert!(duration(60 * HOUR).contains("gui.hours") && duration(60 * HOUR).contains("[40]"));
let minutes = duration(100 * HOUR - 90_000);
assert!(minutes.contains("gui.minutes") && minutes.contains("[1]"));
assert!(json(&Notice::Name("Bob".into()), 0).contains("Bob"));
assert!(json(&Notice::Skin, 0).contains(SUSPENSION_HELP));
}
}
+55 -245
View File
@@ -3,12 +3,7 @@
use crate::Result;
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
use std::{collections::HashMap, fs, path::Path};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrassModifier {
@@ -107,9 +102,6 @@ pub struct BiomeInfo {
/// The biome's own fog colour attribute; otherwise the dimension's applies.
pub fog: Option<u32>,
pub water_fog: u32,
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
@@ -138,27 +130,10 @@ impl BiomeInfo {
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
/// file order, and keep their ids whatever a world's data packs do; a pack's
/// new biomes follow them (`with_packs`). Those are the client's own ids:
/// the network registry's are `registry_order`'s.
#[derive(Clone)]
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
info_noise: SimplexNoise,
colormaps: Arc<Colormaps>,
/// The built-in biomes, the first of `list` unless packs replaced them.
builtin: Arc<Vec<BiomeInfo>>,
/// The data packs' biome files (new biomes and replaced built-in ones),
/// for the tables that read more of a biome than `BiomeInfo` keeps.
packs: BTreeMap<String, Arc<Value>>,
}
/// The grass, foliage and dry foliage colormaps.
struct Colormaps {
grass: Vec<u32>,
foliage: Vec<u32>,
dry: Vec<u32>,
}
fn color(v: &Value) -> Option<u32> {
match v {
@@ -191,90 +166,11 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
let index = (y << 8 | x) as usize;
map.get(index).copied().unwrap_or(fallback)
}
impl BiomeInfo {
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
/// leaves out sampled from the colormaps.
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
features: v["features"]
.as_array()
.into_iter()
.flatten()
.map(|step| {
step.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.collect(),
name,
}
}
}
/// `Identifier.compareTo`: the path first, then the namespace.
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
fn split(id: &str) -> (&str, &str) {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
(path, ns)
}
split(a).cmp(&split(b))
}
impl Biomes {
pub fn load(root: &Path) -> Result<Self> {
let maps = Colormaps {
grass: colormap(root, "grass")?,
foliage: colormap(root, "foliage")?,
dry: colormap(root, "dry_foliage")?,
};
let grass = colormap(root, "grass")?;
let foliage = colormap(root, "foliage")?;
let dry = colormap(root, "dry_foliage")?;
let mut list = Vec::new();
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
@@ -290,7 +186,57 @@ impl Biomes {
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
list.push(BiomeInfo::from_json(name, &v, &maps));
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"])
.unwrap_or(0x050533),
name,
});
}
let index = list
.iter()
@@ -298,65 +244,11 @@ impl Biomes {
.map(|(i, b)| (b.name.clone(), i as u16))
.collect();
Ok(Self {
builtin: Arc::new(list.clone()),
list,
index,
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
})
}
/// The built-in biomes with a world's data packs' biome files (id →
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
/// for a built-in biome replaces it, keeping its id, and new biomes join
/// after the built-in ones, in registry order. Starts from the built-in
/// table whatever packs `self` has.
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
let mut out = self.builtin_only();
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
files.sort_by(|a, b| identifier_order(a.0, b.0));
for (name, json) in files {
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
match out.index.get(name) {
Some(&id) => out.list[usize::from(id)] = info,
None if out.list.len() < usize::from(u16::MAX) => {
out.index.insert(name.to_owned(), out.list.len() as u16);
out.list.push(info);
}
None => continue,
}
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
}
out
}
/// The built-in biomes alone: this table without what packs gave it.
pub fn builtin_only(&self) -> Self {
let mut out = self.clone();
if self.packs.is_empty() {
return out;
}
out.list = self.builtin.to_vec();
let builtin = out.list.len();
out.index.retain(|_, id| usize::from(*id) < builtin);
out.packs.clear();
out
}
/// The data pack file a world's biome `name` came from, if a pack added
/// or replaced it.
pub fn pack_file(&self, name: &str) -> Option<&Value> {
self.packs.get(name).map(|v| &**v)
}
/// Whether data packs added or replaced any biome.
pub fn has_packs(&self) -> bool {
!self.packs.is_empty()
}
/// The biome names in the network registry's order: `RegistryDataLoader`
/// sorts a registry's files by `Identifier` (path, then namespace).
pub fn registry_order(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
names.sort_by(|a, b| identifier_order(a, b));
names
}
pub fn id(&self, name: &str) -> u16 {
self.index
.get(name)
@@ -452,88 +344,6 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3])
result.unwrap_or([0.0; 3])
}
#[cfg(test)]
mod pack_tests {
use super::*;
use serde_json::json;
/// A built-in table of `names` (no colormaps: sampled colours fall back).
fn builtin(names: &[&str]) -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<BiomeInfo> = names
.iter()
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
.collect();
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn registry_order_is_path_then_namespace() {
use std::cmp::Ordering::*;
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
assert_eq!(identifier_order("z:a", "a:b"), Less);
assert_eq!(identifier_order("b:same", "a:same"), Greater);
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
}
#[test]
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
let hot = json!({"effects": {"grass_color": 0x123456}});
let files = [
("pack:zeta", &hot),
("minecraft:molten_peaks", &hot),
("minecraft:river", &red),
];
let world = base.with_packs(files);
// The built-in ids stay; the river takes the pack's values.
assert_eq!(world.id("minecraft:plains"), 1);
assert_eq!(world.id("minecraft:river"), 2);
assert_eq!(world.get(2).water, 0xFF0000);
assert_eq!(world.get(2).temperature, 2.0);
// New biomes follow, in registry order.
assert_eq!(world.id("minecraft:molten_peaks"), 3);
assert_eq!(world.id("pack:zeta"), 4);
assert_eq!(world.get(3).grass, 0x123456);
assert!(world.pack_file("minecraft:river").is_some());
assert!(world.pack_file("minecraft:plains").is_none());
// The network registry sorts every name by path, then namespace.
assert_eq!(
world.registry_order(),
[
"minecraft:meadow",
"minecraft:molten_peaks",
"minecraft:plains",
"minecraft:river",
"pack:zeta"
]
);
// Another world starts again from the built-in biomes.
let other = world.with_packs([]);
assert_eq!(other.list.len(), 3);
assert!(!other.contains("minecraft:molten_peaks"));
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::chunk::block_entity::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -70,12 +70,6 @@ pub struct BlockEntities {
replaced: FxHashSet<Pos>,
/// `Ticking` by block state id; empty until the game builds it.
states: Vec<Ticking>,
/// The containers of mod blocks' block entities, by block state id;
/// built with `states`.
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
}
impl std::ops::Deref for BlockEntities {
@@ -158,20 +152,6 @@ impl BlockEntities {
pub fn ticking(&self, state: u32) -> Ticking {
self.states.get(state as usize).copied().unwrap_or_default()
}
pub fn set_mod_containers(&mut self, containers: FxHashMap<u32, Arc<ModContainer>>) {
self.mod_containers = containers;
}
/// The container of a mod block's block entity in block state `state`.
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
self.mod_containers.get(&state)
}
pub fn set_mod_fluids(&mut self, fluids: FxHashMap<u32, Arc<ModFluidTanks>>) {
self.mod_fluids = fluids;
}
/// The fluid tanks of a mod block's block entity in block state `state`.
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -789,8 +789,6 @@ pub struct Scene<'a> {
pub glyphs: u64,
}
/// Empty by default (a dedicated server): no state has a block entity model.
#[derive(Default)]
pub struct BlockEntityRenderer {
models: HashMap<&'static str, ModelPart>,
kinds: Vec<Kind>,
@@ -1116,12 +1114,7 @@ impl BlockEntityRenderer {
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
}
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
/// BellRenderer.submit, LecternRenderer.submit (the book),
/// CopperGolemStatueBlockRenderer.submit, ConduitRenderer.submit (the shell
/// or cage) and EnchantTableRenderer.submit (the book): the model drawn
/// again under the destroy stage.
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
pub fn render_breaking(
&self,
scene: &Scene,
@@ -1130,18 +1123,7 @@ impl BlockEntityRenderer {
stage: [f32; 2],
out: &mut Vec<Vertex>,
) {
if !matches!(
self.kind(state),
Kind::Banner { .. }
| Kind::Chest { .. }
| Kind::Skull { .. }
| Kind::Shulker { .. }
| Kind::Bell
| Kind::Lectern { .. }
| Kind::CopperGolem { .. }
| Kind::Conduit
| Kind::EnchantingTable
) {
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
return;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
@@ -1495,7 +1477,6 @@ impl BlockEntityRenderer {
} else {
None
};
let has_profile = skin.is_some();
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
@@ -1530,13 +1511,7 @@ impl BlockEntityRenderer {
"right_ear" => p[5] = (f64::from(t * PI * 0.2).cos() + 2.5) as f32 * 0.2,
_ => {}
};
let start = out.len();
draw_part(model, "root", m, &anim, paint, out);
// `SkullBlockRenderer.resolveSkullRenderType`: a head with a profile takes
// the player skin's `entityTranslucent` render type.
if has_profile {
crate::gfx::mark_oit(&mut out[start..]);
}
}
Kind::Bell => {
let (Some(model), Some(paint)) = (
@@ -1686,10 +1661,6 @@ impl BlockEntityRenderer {
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees * (PI / 180.0)));
draw_part(model, "root", m, &none, paint, out);
}
// The crumbling overlay covers the shell and the cage only.
if crumbling {
return;
}
let wind = if phase == 1 {
"entity/conduit/wind_vertical"
} else {
@@ -24,9 +24,6 @@ struct Out {
return out;
}
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
return border_color(in);
}
fn border_color(in: Out) -> vec4<f32> {
let color = textureSample(forcefield, forcefield_sampler, in.uv);
if color.a == 0.0 {
discard;
+4 -20
View File
@@ -12,9 +12,8 @@ use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
register_mod_effects,
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
potion, potion_color,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
@@ -37,22 +36,6 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
let potion = stack.potion().and_then(self::potion);
let custom = stack.custom_potion_effects();
potion
.map_or(&[][..], |d| d.effects)
.iter()
.copied()
.chain(
custom
.iter()
.filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))),
)
.collect()
}
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
pub fn tooltip(
potion: Option<&str>,
@@ -81,7 +64,8 @@ pub fn effect_lines<'a>(
.into_iter()
.map(|(e, duration, amp)| {
let e = e.trim_start_matches("minecraft:");
let mut line = translate(&description_id(e)).unwrap_or_else(|| e.to_owned());
let mut line =
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
if amp > 0 {
line.push(' ');
line.push_str(
@@ -434,10 +434,6 @@ pub struct Camera {
pub up: [f32; 3],
pub left: [f32; 3],
pub detached: bool,
/// The entity's eye and view rotation (yaw, pitch), where a detached camera backs off
/// from.
pub eye: [f64; 3],
pub eye_rotation: [f32; 2],
}
impl Default for Camera {
fn default() -> Self {
@@ -454,8 +450,6 @@ impl Default for Camera {
up: [0.0, 1.0, 0.0],
left: [-1.0, 0.0, 0.0],
detached: false,
eye: [0.0; 3],
eye_rotation: [0.0; 2],
}
}
}
@@ -511,8 +505,6 @@ impl Camera {
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
lerp_d(p, entity.previous[2], entity.position[2]),
];
self.eye = self.position;
self.eye_rotation = [self.y_rot, self.x_rot];
self.detached = !camera_type.is_first_person();
if self.detached {
if camera_type.is_mirrored() {
@@ -635,34 +627,6 @@ pub fn perspective(
m[11] = -1.0;
m
}
/// `Frustum.getNearPlaneBounds`: the box around the four corners of the
/// culling frustum's NDC `z = -1` plane, relative to the camera (the caller
/// moves it to the eye). The culling projection is `Camera.update`'s
/// `setPerspective(fov, aspect, 0.05, depthFar, zZeroToOne)`.
pub fn near_plane_bounds(
fov_degrees: f32,
width: f32,
height: f32,
yaw: f32,
pitch: f32,
far: f32,
) -> ([f64; 3], [f64; 3]) {
let projection =
glam::Mat4::from_cols_array(&perspective(fov_degrees, width, height, 0.05, far, true));
// Frustum.calculateFrustum: projection * modelView.
let clip_to_world = (projection * view_matrix(yaw, pitch)).inverse();
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for (x, y) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
let p = clip_to_world * glam::Vec4::new(x, y, -1.0, 1.0);
let p = [p.x / p.w, p.y / p.w, p.z / p.w];
for k in 0..3 {
min[k] = min[k].min(f64::from(p[k]));
max[k] = max[k].max(f64::from(p[k]));
}
}
(min, max)
}
/// `GameRenderer.render3dHud`: the first-person hand and held items are
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
/// death and fluids), never the FOV option or the sprint, speed and bow
@@ -870,19 +834,6 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
#[cfg(test)]
mod tests {
#[test]
fn near_plane_bounds_sit_half_a_near_distance_ahead_of_the_eye() {
// Facing +Z (yaw 0): the zero-to-one culling projection puts NDC
// z = -1 at -far * near / (2 far - near) in front of the camera.
let far = 512.0;
let (min, max) = super::near_plane_bounds(70.0, 1600.0, 900.0, 0.0, 0.0, far);
let depth = f64::from(far * 0.05 / (2.0 * far - 0.05));
assert!((min[2] - depth).abs() < 1e-4 && (max[2] - depth).abs() < 1e-4);
let half_height = depth * 35.0f64.to_radians().tan();
assert!((max[1] - half_height).abs() < 1e-4 && (min[1] + half_height).abs() < 1e-4);
let half_width = half_height * 1600.0 / 900.0;
assert!((max[0] - half_width).abs() < 1e-3 && (min[0] + half_width).abs() < 1e-3);
}
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
/// cape leans back as the player walks forward (south, body yaw 0).
#[test]
+28 -383
View File
@@ -21,14 +21,6 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// `GuiMessageTag.CHAT_MODIFIED_INDICATOR_COLOR`.
const MODIFIED_INDICATOR: u32 = 0x60_6060;
/// A player message's signature (MessageSignature), which names it to a
/// server's deletion.
pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -171,64 +163,15 @@ impl ChatOptions {
struct Message {
added: u64,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
indicator: Option<u32>,
source: ChatSource,
/// `GuiMessage.signature`: a signed player message's, for deletion.
signature: Option<Box<Signature>>,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
struct Line {
added: u64,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
}
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
/// over it, and for a modified message the icon after each line.
#[derive(Clone, Debug, PartialEq)]
pub struct Tag {
pub color: u32,
/// `GuiMessageTag.Icon.CHAT_MODIFIED`.
pub icon: bool,
pub text: Text,
}
impl Tag {
/// `GuiMessageTag.systemSinglePlayer`, in `color`.
pub fn system_single_player(color: u32) -> Self {
Self {
color,
icon: false,
text: Text::translate("chat.tag.system_single_player", &[]),
}
}
/// `GuiMessageTag.chatNotSecure`.
pub fn not_secure() -> Self {
Self {
color: SYSTEM_INDICATOR,
icon: false,
text: Text::translate("chat.tag.not_secure", &[]),
}
}
/// `GuiMessageTag.chatError`: red (`CHAT_ERROR`, 16733525), the tooltip `chat.tag.error`.
pub fn chat_error() -> Self {
Self {
color: 0xFF_5555,
icon: false,
text: Text::translate("chat.tag.error", &[]),
}
}
/// `GuiMessageTag.chatModified`: the tooltip adds the original content in grey.
pub fn modified(original_content: &str) -> Self {
Self {
color: MODIFIED_INDICATOR,
icon: true,
text: Text::empty()
.append(Text::translate("chat.tag.modified", &[]))
.append(Text::literal("\n"))
.append(Text::literal(original_content).with_color(text::GRAY)),
}
}
indicator: Option<u32>,
}
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
@@ -262,9 +205,6 @@ pub struct Chat {
pub received: u64,
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
abilities: ChatAbilities,
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
/// too new to replace, with the tick from which they may be.
deletions: Vec<(Box<Signature>, u64)>,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -285,99 +225,38 @@ impl Chat {
ticks: u64,
focused: bool,
) {
let tag = indicator.map(|color| std::sync::Arc::new(Tag::system_single_player(color)));
self.add_message(&atlas.font, content, tag, source, ticks, focused);
}
/// `add` of a player message with its signature, which a server's
/// deletion names it by (`GuiMessage.signature`).
pub fn add_signed(
&mut self,
atlas: &HudAtlas,
content: Text,
signature: Option<Signature>,
tag: Option<Tag>,
ticks: u64,
focused: bool,
) {
self.add_player_message(&atlas.font, content, signature, tag, ticks, focused);
}
#[cfg(test)]
fn add_signed_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.add_player_message(font, content, signature, None, ticks, focused);
}
/// `ChatComponent.addPlayerMessage`: the tag the message's trust level made.
fn add_player_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
tag: Option<Tag>,
ticks: u64,
focused: bool,
) {
self.push_message(
font,
Message {
added: ticks,
content,
tag: tag.map(std::sync::Arc::new),
source: ChatSource::Player,
signature: signature.map(Box::new),
},
focused,
);
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
}
fn add_message(
&mut self,
font: &crate::font::Font,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
indicator: Option<u32>,
source: ChatSource,
ticks: u64,
focused: bool,
) {
self.push_message(
font,
Message {
added: ticks,
content,
tag,
source,
signature: None,
},
focused,
);
}
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
if !self.abilities.shows(message.source) {
if !self.abilities.shows(source) {
return;
}
// ChatComponent.logChatMessage: every message shown is in the log.
let plain = message.content.plain();
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {plain}"));
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
if self.log {
println!("[CHAT] {plain}");
println!("[CHAT] {}", content.plain());
}
let message = Message {
added: ticks,
content,
indicator,
source,
};
self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
9 + 4 + 2
} else {
0
};
let width = self.max_width() - icon;
let width = self.max_width();
let content = self.displayed(&message.content);
for line in font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
@@ -389,7 +268,7 @@ impl Chat {
Line {
added: message.added,
content: line,
tag: message.tag.clone(),
indicator: message.indicator,
},
);
}
@@ -440,68 +319,8 @@ impl Chat {
pub fn clear(&mut self) {
self.messages.clear();
self.lines.clear();
self.deletions.clear();
self.scroll = 0;
}
/// `ChatComponent.deleteMessage`: the server deleted a message. One
/// shown for 60 ticks becomes the grey "deleted" marker now; a newer
/// one waits until then (`messageDeletionQueue`).
pub fn delete_message(&mut self, atlas: &HudAtlas, signature: &Signature, ticks: u64) {
self.delete_message_with(&atlas.font, signature, ticks);
}
fn delete_message_with(&mut self, font: &crate::font::Font, signature: &Signature, ticks: u64) {
if let Some(delayed) = self.delete_or_delay(font, signature, ticks) {
self.deletions.push((Box::new(*signature), delayed));
}
}
/// `ChatComponent.tick` (`processMessageDeletionQueue`): the delayed
/// deletions whose message has shown long enough.
pub fn tick(&mut self, atlas: &HudAtlas, ticks: u64) {
self.process_deletions(&atlas.font, ticks);
}
fn process_deletions(&mut self, font: &crate::font::Font, ticks: u64) {
let mut deletions = std::mem::take(&mut self.deletions);
deletions.retain(|(signature, after)| {
ticks < *after || self.delete_or_delay(font, signature, ticks).is_some()
});
self.deletions = deletions;
}
/// `ChatComponent.deleteMessageOrDelay`: the newest message with this
/// signature, replaced by `createDeletedMarker` once 60 ticks have
/// passed since it was added; otherwise the tick to retry from.
fn delete_or_delay(
&mut self,
font: &crate::font::Font,
signature: &Signature,
ticks: u64,
) -> Option<u64> {
let message = self
.messages
.iter_mut()
.find(|m| m.signature.as_deref() == Some(signature))?;
let deletable = message.added + DELETION_DELAY;
if ticks < deletable {
return Some(deletable);
}
*message = Message {
added: message.added,
content: Self::deleted_marker(),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
source: ChatSource::Server,
signature: None,
};
// refreshTrimmedMessages.
self.rewrap(font);
None
}
/// `ChatComponent.DELETED_CHAT_MESSAGE`: grey, italic.
fn deleted_marker() -> Text {
Text::translate("chat.deleted_marker", &[])
.with_color(text::GRAY)
.italic()
}
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
/// start the chat's history (`ChatComponent`'s constructor).
pub fn load_history(&mut self, game_folder: &std::path::Path) {
@@ -612,8 +431,8 @@ impl Chat {
shadow: true,
});
}
if let Some(tag) = &line.tag {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
if let Some(indicator) = line.indicator {
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -774,91 +593,12 @@ impl Chat {
}
None
}
/// `DrawingFocusedGraphicsAccess.handleTag` and `handleTagIcon` with the
/// cursor at GUI position `(x, y)` while chat is focused: the tooltip of
/// the tag under it (its bar, or its icon) and the icons to draw, those
/// under the cursor and those of the message the cursor is over.
pub fn focused_tags(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> (Option<Text>, Vec<GuiDraw>) {
let o = &self.options;
let scale = o.scale as f32;
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
let (mouse_x, mouse_y) = (x / f64::from(scale) - 4.0, y / f64::from(scale));
let inside = |left: i32, top: i32, right: i32, bottom: i32| {
mouse_x >= f64::from(left)
&& mouse_x < f64::from(right)
&& mouse_y >= f64::from(top)
&& mouse_y < f64::from(bottom)
};
let visible = self
.lines
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let mut tooltip = None;
let mut icons = Vec::new();
let mut hovered_message = false;
for i in (0..visible).rev() {
let index = i + self.scroll;
let line = &self.lines[index];
let bottom = chat_bottom - i as i32 * entry_height;
let top = bottom - entry_height;
let width = font.width_text(&line.content).ceil() as i32;
hovered_message |= inside(0, top, width, bottom);
// GuiMessage.Line.endOfEntry: the next newer line is another message's.
let end_of_entry = index == 0
|| match (&line.tag, &self.lines[index - 1].tag) {
(Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
let force_icon = end_of_entry && hovered_message;
if end_of_entry {
hovered_message = false;
}
let Some(tag) = &line.tag else { continue };
if inside(-4, top, -2, bottom) {
tooltip = Some(tag.text.clone());
}
if tag.icon {
let (left, text_bottom) = (width + 4, bottom - bottom_to_text + 9);
let icon_top = text_bottom - 9 - 1;
let over = inside(left, icon_top, left + 9, text_bottom);
if over {
tooltip = Some(tag.text.clone());
}
if force_icon || over {
icons.push(GuiDraw::Sprite {
sprite: "icon/chat_modified".into(),
x: left,
y: icon_top,
w: 9,
h: 9,
region: None,
color: hud_layout::WHITE,
blend: hud_layout::Blend::Textured,
});
}
}
}
if icons.is_empty() {
return (tooltip, icons);
}
let mut draws = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
draws.extend(icons);
draws.push(GuiDraw::Pop);
(tooltip, draws)
/// Whether the cursor is on a line's indicator bar while chat is focused
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
/// message tag's text.
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
self.line_at(height, x, y)
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
@@ -878,22 +618,6 @@ impl Chat {
}
}
/// `SuggestionsList.getNarrationMessage` for entry `index` of `count`.
fn suggestion_narration(index: usize, count: usize, text: &str, tooltip: Option<&str>) -> Text {
let mut args = vec![
Text::literal((index + 1).to_string()),
Text::literal(count.to_string()),
Text::literal(text),
];
match tooltip {
Some(tooltip) => {
args.push(Text::literal(tooltip));
Text::translate("narration.suggestion.tooltip", &args)
}
None => Text::translate("narration.suggestion", &args),
}
}
/// `CommandSuggestions.SuggestionsList`.
#[derive(Clone, Debug)]
struct SuggestionList {
@@ -905,17 +629,8 @@ struct SuggestionList {
current: usize,
last_mouse: (i32, i32),
tab_cycles: bool,
/// `lastNarratedEntry`: the entry the narrator last said (-1: none yet).
last_narrated: isize,
}
impl SuggestionList {
/// `getNarrationMessage`: "Selected suggestion 2 out of 5: …", with its
/// tooltip when it has one. It marks the entry said.
fn narration_message(&mut self) -> Text {
self.last_narrated = self.current as isize;
let s = &self.list[self.current];
suggestion_narration(self.current, self.list.len(), &s.text, s.tooltip.as_deref())
}
fn contains(&self, x: i32, y: i32) -> bool {
let [rx, ry, w, h] = self.rect;
x >= rx && x <= rx + w && y >= ry && y <= ry + h
@@ -1038,53 +753,6 @@ fn last_word_index(s: &str) -> usize {
}
if prev_space { s.len() } else { out }
}
/// `ChatScreen.normalizeChatMessage`: `String.trim`, then
/// `StringUtils.normalizeSpace` (one space for every run of whitespace), then
/// `StringUtil.trimChatMessage` (the first 256 UTF-16 code units, the length
/// `ServerboundChatPacket`'s `stringUtf8(256)` decodes). The chat screen sends
/// this, not what was typed: a line of spaces sends nothing, and a message the
/// packet cannot hold is cut on the client, as vanilla's is, instead of by the
/// server.
pub fn normalize_chat_message(message: &str) -> String {
// String.trim: the code points up to U+0020, which is not `str::trim`'s
// Unicode whitespace (U+00A0 is not Java whitespace and stays).
let trimmed = message.trim_matches(|c: char| c <= ' ');
truncate_utf16(&normalize_space(trimmed), 256)
}
/// `StringUtils.normalizeSpace`: one space for every run of
/// `Character.isWhitespace`, and none at either end.
fn normalize_space(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
if crate::ui::java_whitespace(c) {
space = !out.is_empty();
} else {
if space {
out.push(' ');
space = false;
}
out.push(c);
}
}
out
}
/// `StringUtil.truncateStringIfNecessary(s, 256, false)`: Java counts UTF-16
/// code units (`String.length`), so 129 emoji are cut to 128 of them, where
/// `chars().take(256)` would keep all 129 (258 units) for the server to
/// reject. A cut between the halves of a surrogate pair keeps the character
/// below it: vanilla's `substring` leaves half a pair, which a Java `String`
/// may hold and a Rust `String` may not.
fn truncate_utf16(s: &str, max_units: usize) -> String {
let mut units = 0;
for (byte, c) in s.char_indices() {
units += c.len_utf16();
if units > max_units {
return s[..byte].to_owned();
}
}
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
pub fn new(
@@ -1174,14 +842,12 @@ impl ChatScreen {
let value = self.input.value.clone();
let auto = self.auto_suggestions;
let abilities = self.abilities;
let message = std::mem::take(&mut self.input.message);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
} else {
Self::command_block(m, c, &value, width, height)
};
self.auto_suggestions = auto;
self.input.message = message;
}
}
/// `ChatScreen.onEdited`.
@@ -1263,9 +929,7 @@ impl ChatScreen {
} else if !command.chars().all(char::is_whitespace) {
let partial = &command[..cursor_b];
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
// ClientSuggestionProvider.getCustomTabSuggestions: the players
// and the server's custom chat completions.
builder.suggest_matching(c.registries.custom_tab_suggestions());
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
let (range, list) = merge_suggestions(partial, builder.out);
self.awaiting = None;
self.pending = Some((range.0, list));
@@ -1405,12 +1069,11 @@ impl ChatScreen {
};
self.suggestions = None;
if self.allow_suggestions && self.auto_suggestions {
self.show_suggestions(m, false);
self.show_suggestions(m);
}
}
/// `CommandSuggestions.showSuggestions`: `immediate` (Tab asked for
/// them) has the narrator say the first one at once.
fn show_suggestions(&mut self, m: &Measure, immediate: bool) {
/// `CommandSuggestions.showSuggestions`.
fn show_suggestions(&mut self, m: &Measure) {
let Some((start, list)) = self.pending.clone() else {
return;
};
@@ -1453,7 +1116,6 @@ impl ChatScreen {
current: 0,
last_mouse: (0, 0),
tab_cycles: false,
last_narrated: if immediate { -1 } else { 0 },
};
self.select(&mut l, 0);
self.suggestions = Some(l);
@@ -1473,9 +1135,6 @@ impl ChatScreen {
self.input.suggestion = applied
.strip_prefix(self.input.value.as_str())
.map(str::to_owned);
if l.last_narrated != l.current as isize {
horizon_narrator::say_system_now(&l.narration_message().plain());
}
}
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
self.select(l, l.current as isize + dir);
@@ -1586,7 +1245,7 @@ impl ChatScreen {
self.suggestions = Some(l);
handled
} else if key == K::Tab {
self.show_suggestions(m, true);
self.show_suggestions(m);
true
} else {
false
@@ -1819,18 +1478,4 @@ impl ChatScreen {
pub fn suggestions_visible(&self) -> bool {
self.suggestions.is_some()
}
/// `CommandSuggestions.getUsageNarration` while the list shows
/// (`isVisible`): whether Tab cycles it or fills the input in, and
/// whether Escape hides it (`allowHiding`, which only the chat's turns
/// off).
pub fn suggestions_usage(&self) -> Option<&'static str> {
let list = self.suggestions.as_ref()?;
let hidable = self.layout != SuggestLayout::CHAT;
Some(match (list.tab_cycles, hidable) {
(true, true) => "narration.suggestion.usage.cycle.hidable",
(true, false) => "narration.suggestion.usage.cycle.fixed",
(false, true) => "narration.suggestion.usage.fill.hidable",
(false, false) => "narration.suggestion.usage.fill.fixed",
})
}
}
+1 -180
View File
@@ -33,7 +33,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
Line {
added: 0,
content,
tag: None,
indicator: None,
},
);
}
@@ -244,58 +244,6 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
assert_eq!(screen.value(), "/coords");
}
#[test]
fn chat_completes_the_servers_custom_completions_with_the_players() {
// CommandSuggestions.updateCommandInfo completes chat from
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
// and what ClientboundCustomChatCompletionsPacket added.
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
let font = font();
let m = Measure(&font);
let mut registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
assert_eq!(
registries
.custom_tab_suggestions()
.into_iter()
.collect::<Vec<_>>(),
["#trade", "Notch", "Steve", "Zuri"]
);
let completed = |registries: &Registries, line: &str| {
let dispatcher = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
}
screen.value().to_owned()
};
assert_eq!(completed(&registries, "hi no"), "hi Notch");
assert_eq!(completed(&registries, "sell #tr"), "sell #trade");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
assert_eq!(completed(&registries, "hi al"), "hi al");
// SET replaces them all; the players stay.
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
assert_eq!(completed(&registries, "hi no"), "hi no");
assert_eq!(completed(&registries, "hi al"), "hi Alex");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
}
#[test]
fn ask_server_nodes_wait_for_the_servers_completions() {
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
@@ -519,130 +467,3 @@ fn a_guests_line_follows_the_player_message_permission() {
);
}
}
/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60
/// ticks from when it was added, then becomes the grey italic
/// `chat.deleted_marker` line with the system bar; the other lines stay.
#[test]
fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
let font = font();
let mut chat = Chat::default();
let secret = [7; 256];
chat.add_signed_message(
&font,
Text::literal("<Steve> secret"),
Some(secret),
100,
false,
);
chat.add_signed_message(
&font,
Text::literal("<Alex> hi"),
Some([8; 256]),
110,
false,
);
let lines =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
let marker = Chat::deleted_marker().plain();
chat.delete_message_with(&font, &secret, 130);
assert_eq!(
lines(&chat),
["<Alex> hi", "<Steve> secret"],
"too new: delayed"
);
chat.process_deletions(&font, 159);
assert_eq!(lines(&chat), ["<Alex> hi", "<Steve> secret"]);
chat.process_deletions(&font, 160);
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
assert!(chat.deletions.is_empty());
let line = &chat.lines[1];
assert_eq!(line.tag.as_ref().map(|t| t.color), Some(SYSTEM_INDICATOR));
assert_eq!(line.added, 100, "the marker keeps the message's time");
assert!(
line.content
.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
);
assert_eq!(
chat.messages[1].content.translation_key(),
Some("chat.deleted_marker")
);
// A message shown 60 ticks already is replaced at once; an unknown or
// already deleted signature changes nothing.
chat.delete_message_with(&font, &[8; 256], 170);
assert_eq!(lines(&chat), [marker.clone(), marker.clone()]);
chat.delete_message_with(&font, &secret, 200);
chat.delete_message_with(&font, &[9; 256], 200);
assert_eq!(lines(&chat), [marker.clone(), marker]);
assert!(chat.deletions.is_empty());
}
/// `SuggestionsList.select` says an entry once (`lastNarratedEntry`), as
/// "suggestion N out of M", with its tooltip when it has one.
#[test]
fn a_suggestion_is_narrated_by_its_place_once() {
let suggestion = |text: &str, tooltip: Option<&str>| Suggestion {
range: (1, 1),
text: text.into(),
tooltip: tooltip.map(str::to_owned),
int_value: None,
};
let mut l = SuggestionList {
rect: [0; 4],
original: "/".into(),
list: vec![
suggestion("give", None),
suggestion("gamemode", Some("Mode")),
],
offset: 0,
current: 1,
last_mouse: (0, 0),
tab_cycles: false,
last_narrated: 0,
};
let said = l.narration_message().component();
assert_eq!(l.last_narrated, 1);
assert_eq!(said["translate"], "narration.suggestion.tooltip");
assert_eq!(said["with"].as_array().map(Vec::len), Some(4));
l.current = 0;
let said = l.narration_message().component();
assert_eq!(said["translate"], "narration.suggestion");
assert_eq!(said["with"].as_array().map(Vec::len), Some(3));
}
#[test]
fn a_chat_message_is_trimmed_and_its_whitespace_runs_collapsed() {
// ChatScreen.normalizeChatMessage: String.trim, then StringUtils
// .normalizeSpace, so a command gets the arguments the player meant.
assert_eq!(normalize_chat_message(" hello there "), "hello there");
assert_eq!(normalize_chat_message("/msg Alex\thi"), "/msg Alex hi");
assert_eq!(normalize_chat_message("a\n\nb\r\nc"), "a b c");
assert_eq!(normalize_chat_message("\t \n"), "");
assert_eq!(normalize_chat_message(""), "");
// Character.isWhitespace is not str::trim's Unicode whitespace: U+00A0 is
// no Java whitespace, so it is neither trimmed nor collapsed, while the
// ideographic space U+3000 and Java's U+001C separators are.
assert_eq!(normalize_chat_message(" \u{a0} hi"), "\u{a0} hi");
assert_eq!(normalize_chat_message("a\u{a0}\u{a0}b"), "a\u{a0}\u{a0}b");
assert_eq!(normalize_chat_message("a\u{3000}\u{3000}b"), "a b");
assert_eq!(normalize_chat_message("a\u{1c}b"), "a b");
}
#[test]
fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
// StringUtil.trimChatMessage counts String.length(), UTF-16 code units:
// 256 ASCII characters are all kept, 257 are cut to 256.
assert_eq!(normalize_chat_message(&"a".repeat(256)).len(), 256);
assert_eq!(normalize_chat_message(&"a".repeat(300)).len(), 256);
// An emoji is two units, so 128 of them fill the packet and the 129th is
// dropped: chars().take(256) would keep all 129 and the server would
// reject the message.
let emoji = normalize_chat_message(&"\u{1f600}".repeat(129));
assert_eq!(emoji, "\u{1f600}".repeat(128));
assert_eq!(emoji.encode_utf16().count(), 256);
// A cut between the halves of a pair keeps the character below it.
let mixed = format!("{}\u{1f600}", "b".repeat(255));
assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
}
+9 -104
View File
@@ -82,45 +82,13 @@ impl LevelShape {
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
/// the 24 sections they have always had.
pub fn column(self) -> ColumnLayout {
ColumnLayout::of_level(self.min_y, self.height)
}
}
/// The shapes of a world's three levels, from its dimension types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShapes {
pub overworld: LevelShape,
pub nether: LevelShape,
pub end: LevelShape,
}
impl LevelShapes {
pub const VANILLA: Self = Self {
overworld: LevelShape::OVERWORLD,
nether: LevelShape::NETHER,
end: LevelShape::END,
};
/// The shape of the level `id` (`minecraft:overworld`,
/// `minecraft:the_nether`, `minecraft:the_end`); others get the
/// Overworld's.
pub fn get(&self, id: &str) -> LevelShape {
match id {
"minecraft:the_nether" => self.nether,
"minecraft:the_end" => self.end,
_ => self.overworld,
let low = self.min_y.min(MIN_Y);
let high = self.max_y().max(MIN_Y + HEIGHT);
ColumnLayout {
min_y: low,
sections: ((high - low) / 16) as usize,
}
}
pub fn get_mut(&mut self, id: &str) -> &mut LevelShape {
match id {
"minecraft:the_nether" => &mut self.nether,
"minecraft:the_end" => &mut self.end,
_ => &mut self.overworld,
}
}
}
impl Default for LevelShapes {
fn default() -> Self {
Self::VANILLA
}
}
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
@@ -135,16 +103,6 @@ impl ColumnLayout {
min_y: MIN_Y,
sections: SECTIONS,
};
/// The column of a level from `min_y`, `height` high
/// (`LevelShape::column`).
pub fn of_level(min_y: i32, height: i32) -> Self {
let low = min_y.min(MIN_Y);
let high = (min_y + height).max(MIN_Y + HEIGHT);
Self {
min_y: low,
sections: ((high - low) / 16) as usize,
}
}
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
pub const LOWEST_Y: i32 = -2032;
pub const HIGHEST_Y: i32 = 2031;
@@ -197,13 +155,6 @@ impl ColumnLayout {
self.min_y + index as i32 * 16
}
}
/// `true` for each section of `layout`: what callers that keep no
/// per-section record (of fluids, say) pass as "every section may have it".
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
[true; crate::section_mask::SectionMask::CAPACITY];
&ALL[..layout.sections()]
}
impl Default for ColumnLayout {
fn default() -> Self {
Self::VANILLA
@@ -212,7 +163,7 @@ impl Default for ColumnLayout {
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
/// global palette).
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8);
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
/// Bits of the block states' global palette: 16 for vanilla's 35,723 states
/// (what vanilla servers and clients send), more when content mods add states
@@ -243,47 +194,6 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
pub fn set_block_states(states: usize) {
let bits = horizon_core::paletted::ceil_log2(states).max(16);
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
forget_shared();
}
/// One allocation per kind of uniform section (a single block, a single
/// biome and one light value), shared by every column that has it, keyed by
/// (block, biome, light). A tall column is mostly such sections: air with
/// full sky light above the terrain. Edits copy them (`Arc::make_mut`).
type SharedSections = std::collections::HashMap<(u32, u32, u8), Arc<Section>>;
static SHARED: std::sync::Mutex<Option<SharedSections>> = std::sync::Mutex::new(None);
/// Replaces the uniform sections of `sections` with the shared ones.
pub fn share_uniform(sections: &mut [Arc<Section>]) {
let key = |s: &Section| -> Option<(u32, u32, u8)> {
let Light::Uniform(light) = s.light else {
return None;
};
if s.blocks.bits() != 0 || s.biomes.bits() != 0 {
return None;
}
Some((s.blocks.uniform()?, s.biomes.uniform()?, light))
};
let Ok(mut shared) = SHARED.lock() else {
return;
};
let shared = shared.get_or_insert_with(Default::default);
for section in sections {
let Some(k) = key(section) else {
continue;
};
let one = shared.entry(k).or_insert_with(|| section.clone());
if !Arc::ptr_eq(one, section) {
*section = one.clone();
}
}
}
/// Forgets the shared sections, when the palette changes.
fn forget_shared() {
if let Ok(mut shared) = SHARED.lock() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
#[derive(Clone)]
@@ -391,9 +301,6 @@ pub struct Chunk {
pub light_loaded: bool,
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
/// Finished from a proto chunk just now, in any kind of world, not read
/// from a save (`ChunkEvent.Load`'s `isNewChunk`).
pub generated: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
@@ -418,20 +325,18 @@ impl Chunk {
Self::with_layout(x, z, ColumnLayout::VANILLA)
}
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
let mut one = [Arc::new(Section::default())];
share_uniform(&mut one);
let [empty] = one;
Self {
x,
z,
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
sections: (0..layout.sections())
.map(|_| Arc::new(Section::default()))
.collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
light_loaded: false,
fresh: false,
generated: false,
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
@@ -80,78 +80,9 @@ pub enum BlockEntity {
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
/// A block entity of a mod (`native_block_entity`), kept as its saved
/// compound for the mod to read and change.
Mod(Box<ModBlockEntity>),
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
PotentSulfur(PotentSulfur),
/// `StructureBlockEntity` (`structure_block.rs`).
StructureBlock(Box<crate::structure_block::StructureBlock>),
/// `JigsawBlockEntity` (`jigsaw_block.rs`).
JigsawBlock(Box<crate::jigsaw_block::JigsawBlock>),
/// `TestBlockEntity` (`test_block.rs`).
TestBlock(Box<crate::test_block::TestBlock>),
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
}
/// A mod's block entity: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
/// (`Items`) when its block has a container (`ModContainer`), and the data
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
/// which the mod sets and nothing saves.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModBlockEntity {
pub id: String,
pub nbt: Compound,
pub items: Vec<Option<ItemStack>>,
pub data: Vec<i32>,
}
/// The container of a mod block's block entity, as a `WorldlyContainer`
/// says it: its slot count, the slots each face reaches
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
/// and the slots hoppers may put into (`canPlaceItemThroughFace`) and take
/// from (`canTakeItemThroughFace`); and the vanilla menu that shows its
/// slots when the block is used, if any (`MOD_MENUS`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModContainer {
pub slots: usize,
pub faces: [Vec<usize>; 6],
pub inputs: Vec<usize>,
pub outputs: Vec<usize>,
pub menu: Option<String>,
}
/// The fluid tanks of a mod block's block entity, in the container's shape:
/// each tank's capacity in millibuckets, the tanks each face reaches, and
/// the tanks a fill may reach and a drain may take. The tanks' contents are
/// not here: they live in the entity's saved compound, under `fluids`, so
/// they survive restarts and the block's own tick reads them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModFluidTanks {
/// Each tank's capacity in millibuckets, in tank order.
pub tanks: Vec<i32>,
pub faces: [Vec<usize>; 6],
/// The tanks a fill may reach (`canPlaceItemThroughFace`).
pub inputs: Vec<usize>,
/// The tanks a drain may take from (`canTakeItemThroughFace`).
pub outputs: Vec<usize>,
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
/// (`data[2]` of `data[3]`), as `AbstractFurnaceBlockEntity.dataAccess`.
pub const MOD_MENUS: [(&str, usize); 11] = [
("minecraft:furnace", 3),
("minecraft:blast_furnace", 3),
("minecraft:smoker", 3),
("minecraft:hopper", 5),
("minecraft:generic_3x3", 9),
("minecraft:generic_9x1", 9),
("minecraft:generic_9x2", 18),
("minecraft:generic_9x3", 27),
("minecraft:generic_9x4", 36),
("minecraft:generic_9x5", 45),
("minecraft:generic_9x6", 54),
];
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
/// last eruption block event, which the client's plume counts from.
@@ -291,19 +222,11 @@ pub struct Beacon {
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
/// `JukeboxBlockEntity`'s one slot (`ContainerSingleItem`), so a hopper or
/// a dropper reaches it as it reaches any container.
pub items: [Option<ItemStack>; 1],
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
impl Jukebox {
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
pub fn record(&self) -> Option<&ItemStack> {
self.items[0].as_ref()
}
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
+3 -260
View File
@@ -1,13 +1,7 @@
//! Resident chunk membership, with a token for consumers that cache its keys.
use crate::{
chunk::Chunk,
fast_hash::{FxHashMap, FxHashSet},
};
use crate::{chunk::Chunk, fast_hash::FxHashMap};
use std::{
collections::{
VecDeque,
hash_map::{Iter, ValuesMut},
},
collections::hash_map::{Iter, ValuesMut},
ops::Deref,
sync::{
Arc,
@@ -22,46 +16,18 @@ fn next_revision() -> u64 {
NEXT_REVISION.fetch_add(1, Ordering::Relaxed)
}
/// The membership changes one map keeps for the consumers that read them
/// every tick (`TickChunkMembership.synchronize`); older ones are dropped.
const MAX_RECENT_DELTAS: usize = 256;
/// One membership change of one `ChunkMap`, stamped with the revision it
/// brought the map to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChunkMembershipDelta {
Added(Key),
Removed(Key),
Reset,
}
/// Read access behaves like a map. Structural writes are explicit so edits to
/// blocks or light don't invalidate consumers of the loaded chunk key set.
/// Tokens are process-wide: replacing the entire map also invalidates caches.
pub struct ChunkMap {
columns: Columns,
revision: u64,
/// The oldest revision `recent_deltas` still answers for: the revision of
/// the change it last dropped, or the revision that cleared them. A
/// consumer holding an older one rebuilds, it does not replay.
min_valid_revision: u64,
/// This map's recent membership changes, in revision order.
/// Revisions are process-wide: the numbers missing in between belong to
/// the other maps (a server thread's, another dimension's), which say
/// nothing about this one's keys.
recent_deltas: VecDeque<(u64, ChunkMembershipDelta)>,
/// The resident chunks generation has not placed its animals in yet.
fresh: FxHashSet<Key>,
}
impl Default for ChunkMap {
fn default() -> Self {
let revision = next_revision();
Self {
columns: Columns::default(),
revision,
min_valid_revision: revision,
recent_deltas: VecDeque::new(),
fresh: FxHashSet::default(),
revision: next_revision(),
}
}
}
@@ -69,107 +35,26 @@ impl ChunkMap {
pub fn membership_revision(&self) -> u64 {
self.revision
}
fn record_delta(&mut self, delta: ChunkMembershipDelta) {
if self.recent_deltas.len() >= MAX_RECENT_DELTAS
&& let Some((evicted, _)) = self.recent_deltas.pop_front()
{
self.min_valid_revision = evicted;
}
self.recent_deltas.push_back((self.revision, delta));
}
/// This map's membership changes since `last_revision`, one of its own
/// earlier `membership_revision`s, or `None` when they can no longer be
/// answered: the consumer's revision predates what the ring kept, the map
/// was cleared since (a reset rebuilds everything), or the revision is not
/// this map's, which its own revisions give away: every one of them in the
/// answered range is the opening revision or a change the ring still
/// holds, so a revision another map took in between is refused, not
/// replayed.
pub fn deltas_since(
&self,
last_revision: u64,
) -> Option<impl Iterator<Item = (u64, ChunkMembershipDelta)> + '_> {
if last_revision == 0
|| last_revision < self.min_valid_revision
|| last_revision > self.revision
|| self.recent_deltas.is_empty()
{
return None;
}
let pos = self
.recent_deltas
.partition_point(|(rev, _)| *rev <= last_revision);
// A revision this map never took: another map's, drawing on the same
// process-wide counter. Only the opening revision and the ones the
// ring still holds are this map's.
let is_own_revision = last_revision == self.min_valid_revision
|| (pos > 0 && self.recent_deltas[pos - 1].0 == last_revision);
if !is_own_revision {
return None;
}
let slice = self.recent_deltas.range(pos..);
if slice
.clone()
.any(|(_, d)| *d == ChunkMembershipDelta::Reset)
{
return None;
}
Some(slice.copied())
}
pub fn insert(&mut self, key: Key, chunk: Arc<Chunk>) -> Option<Arc<Chunk>> {
if chunk.fresh {
self.fresh.insert(key);
} else {
self.fresh.remove(&key);
}
let previous = self.columns.insert(key, chunk);
if previous.is_none() {
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Added(key));
}
previous
}
pub fn remove(&mut self, key: &Key) -> Option<Arc<Chunk>> {
self.fresh.remove(key);
let previous = self.columns.remove(key);
if previous.is_some() {
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Removed(*key));
}
previous
}
pub fn clear(&mut self) {
if !self.columns.is_empty() {
self.columns.clear();
self.fresh.clear();
self.revision = next_revision();
self.recent_deltas.clear();
self.recent_deltas
.push_back((self.revision, ChunkMembershipDelta::Reset));
self.min_valid_revision = self.revision;
}
}
pub fn has_fresh(&self) -> bool {
!self.fresh.is_empty()
}
/// The fresh keys in the chunk map's own iteration order, so the spawns
/// they drive run in the order a scan of the columns gave.
pub fn fresh_keys_ordered(&self) -> Vec<Key> {
if self.fresh.is_empty() {
Vec::new()
} else if self.fresh.len() == 1 {
self.fresh.iter().copied().collect()
} else {
self.columns
.keys()
.filter(|k| self.fresh.contains(k))
.copied()
.collect()
}
}
pub fn remove_fresh(&mut self, key: &Key) {
self.fresh.remove(key);
}
pub fn get_mut(&mut self, key: &Key) -> Option<&mut Arc<Chunk>> {
self.columns.get_mut(key)
}
@@ -240,146 +125,4 @@ mod tests {
map = ChunkMap::default();
assert_ne!(cleared, map.membership_revision());
}
/// The deltas' kinds, checking the ring answers in revision order.
fn kinds(deltas: Vec<(u64, ChunkMembershipDelta)>) -> Vec<ChunkMembershipDelta> {
let mut last = 0;
for (rev, _) in &deltas {
assert!(*rev > last, "the ring answers in revision order");
last = *rev;
}
deltas.into_iter().map(|(_, d)| d).collect()
}
/// Revision numbers are process-wide: the ones another map takes in
/// between, and a payload-only rewrite that takes none at all, say nothing
/// about this map's keys.
#[test]
fn deltas_since_ignores_revisions_other_maps_take() {
let mut map = ChunkMap::default();
let mut other = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let seen = map.membership_revision();
for key in [(1, 0), (2, 0), (3, 0)] {
other.insert(key, chunk.clone());
}
map.insert((0, 0), Arc::new(Chunk::empty(0, 0))); // Payload only.
map.insert((4, 0), chunk.clone());
map.remove(&(0, 0));
assert_eq!(
kinds(map.deltas_since(seen).unwrap().collect()),
[
ChunkMembershipDelta::Added((4, 0)),
ChunkMembershipDelta::Removed((0, 0)),
]
);
// The consumer's own revision answers, with nothing new since.
assert!(
map.deltas_since(map.membership_revision())
.unwrap()
.next()
.is_none()
);
// A revision another map took in between: refused, not replayed.
assert!(map.deltas_since(other.membership_revision()).is_none());
}
/// A map built ahead of the live one, its revisions interleaved with it,
/// answering for a revision it never took: refused, so the consumer
/// rebuilds rather than replay another map's changes.
#[test]
fn deltas_since_none_for_interleaved_revision_from_another_map() {
let mut map = ChunkMap::default();
let mut other = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let seen = map.membership_revision();
other.insert((1, 0), chunk.clone());
let foreign = other.membership_revision();
map.insert((2, 0), chunk.clone());
assert!(map.deltas_since(foreign).is_none());
assert_eq!(
kinds(map.deltas_since(seen).unwrap().collect()),
[ChunkMembershipDelta::Added((2, 0))]
);
}
/// The ring answers for the changes it kept, and only those.
#[test]
fn deltas_since_none_for_a_revision_the_ring_dropped() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let created = map.membership_revision();
let mut revisions = Vec::new();
let changes = MAX_RECENT_DELTAS as i32 + 1;
for i in 0..changes {
map.insert((i, 0), chunk.clone());
revisions.push(map.membership_revision());
}
let (first, kept) = revisions.split_first().unwrap();
// The oldest change fell out with the consumer's revision: rebuild.
assert!(map.deltas_since(created).is_none());
assert_eq!(map.deltas_since(*first).unwrap().count(), kept.len());
}
/// A reset rebuilds whatever the consumer last saw, and the ring answers
/// for what follows it.
#[test]
fn deltas_since_none_across_a_clear() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let before = map.membership_revision();
map.clear();
assert!(map.deltas_since(before).is_none());
let cleared = map.membership_revision();
map.insert((1, 0), chunk);
assert_eq!(
kinds(map.deltas_since(cleared).unwrap().collect()),
[ChunkMembershipDelta::Added((1, 0))]
);
}
#[test]
fn fresh_chunks_tracking() {
let mut map = ChunkMap::default();
assert!(!map.has_fresh());
let mut chunk = Chunk::empty(1, 2);
chunk.fresh = true;
map.insert((1, 2), Arc::new(chunk));
assert!(map.has_fresh());
assert_eq!(map.fresh_keys_ordered(), vec![(1, 2)]);
map.remove_fresh(&(1, 2));
assert!(!map.has_fresh());
let mut chunk2 = Chunk::empty(3, 4);
chunk2.fresh = true;
map.insert((3, 4), Arc::new(chunk2));
assert!(map.has_fresh());
map.remove(&(3, 4));
assert!(!map.has_fresh());
}
#[test]
fn fresh_keys_ordered_matches_chunk_map_iteration_order() {
let mut map = ChunkMap::default();
let keys = [(-5, 10), (12, -3), (0, 0), (7, 8), (-1, -1), (4, 4)];
for (i, &key) in keys.iter().enumerate() {
let mut chunk = Chunk::empty(key.0, key.1);
chunk.fresh = i % 2 == 0;
map.insert(key, Arc::new(chunk));
}
let ordered = map.fresh_keys_ordered();
let expected: Vec<Key> = map
.keys()
.copied()
.filter(|k| map.fresh.contains(k))
.collect();
assert_eq!(ordered, expected);
assert_eq!(ordered.len(), 3);
}
}
+11 -41
View File
@@ -169,13 +169,11 @@ impl Mesh {
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
let mut quad = |p: [[f32; 3]; 4], shade: u32| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
normal: [0; 4],
pos: v,
uv,
layer: 4,
@@ -188,41 +186,17 @@ impl Mesh {
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
quad(p, 0);
}
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
([[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]], 1),
([[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]], 0),
([[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]], 2),
([[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]], 2),
([[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]], 3),
([[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]], 3),
];
let mask = face_mask(
dx as i32,
@@ -236,12 +210,8 @@ impl Mesh {
],
);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
let (p, shade) = quads[usize::from(direction)];
quad(if inside { [p[3], p[2], p[1], p[0]] } else { p }, shade);
}
}
self.key = Some(key);
+5 -159
View File
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
@@ -343,15 +343,8 @@ impl EntityMovementWorld for SceneCollisions<'_, '_> {
}
impl DynamicSupportWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
/// `ShulkerBoxBlockEntity.isClosed`: whether the lid is still shut. A
/// closed box collides as its plain block (`ShulkerBoxBlock.getShape`
/// asks the block entity, which has grown once the lid starts to open).
fn shulker_is_closed(&mut self, p: [i32; 3]) -> io::Result<Option<bool>> {
Ok(self
.world
.shulkers
.get(&p)
.map(|s| s.status == crate::block_entity_anim::ShulkerStatus::Closed))
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
/// `MovingPistonBlock.getCollisionShape`: the block entity's moving shape
/// (`PistonMovingBlockEntity.getCollisionShape`), outside any push.
@@ -380,17 +373,11 @@ impl PistonCollisionWorld for SceneCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for SceneCollisions<'_, '_> {
/// `ShulkerBoxBlockEntity.getBoundingBox`: the box's collision shape
/// (`getShape`) is the free shape grown by the lid's progress, which is
/// what lets an entity sink into an opening box. None: no such box here.
fn shulker_collision_state(
&mut self,
p: [i32; 3],
_: [i32; 3],
) -> io::Result<Option<ShulkerCollisionState>> {
Ok(self.world.shulkers.get(&p).map(|s| ShulkerCollisionState {
previous_progress: s.progress_old,
progress: s.progress,
}))
Ok(None)
}
}
impl LiquidCollisionWorld for SceneCollisions<'_, '_> {
@@ -510,8 +497,6 @@ impl EntityMovementWorld for LookupCollisions<'_, '_> {
}
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
/// No block-entity data: a lookup world has no shulker box to ask, so the
/// box collides as a shut one (`VoxelShape::block()`).
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
@@ -520,8 +505,6 @@ impl DynamicSupportWorld for LookupCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
/// No block-entity data: the block table's own shape stands in for the lid
/// (`ShulkerBoxBlockEntity.getBoundingBox` needs a block entity).
fn shulker_collision_state(
&mut self,
_: [i32; 3],
@@ -628,140 +611,3 @@ mod border_movement_tests {
);
}
}
#[cfg(test)]
mod shulker_collision_tests {
use super::*;
use crate::block_entity_anim::{Shulker, ShulkerStatus, progress_delta_aabb};
use horizon_core::geometry::{Aabb, Axis, Direction, Vec3, VoxelShape};
/// A shulker box facing up at progress 0.0 (closed): collision is a 1x1x1 block.
#[test]
fn shulker_closed_collision_is_full_block() {
let shulker = Shulker {
status: ShulkerStatus::Closed,
progress: 0.0,
progress_old: 0.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Closed box: height 1.0
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.0).abs() < 1.0e-9);
// Full horizontal extent
assert!((aabb.min(Axis::X) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::X) - 1.0).abs() < 1.0e-9);
}
/// A shulker box facing up at progress 1.0 (fully open): collision is 1.5 tall.
#[test]
fn shulker_open_collision_grows_upward() {
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Fully open: height 1.5 (lid extends 0.5 above the block)
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.5).abs() < 1.0e-9);
}
/// A shulker box halfway open: collision height is 1.25.
#[test]
fn shulker_half_open_collision_height() {
let shulker = Shulker {
status: ShulkerStatus::Opening,
progress: 0.5,
progress_old: 0.5,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Half open: height 1.25
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.25).abs() < 1.0e-9);
}
/// Vanilla clip quirk: an entity whose feet are *inside* the grown lid box
/// moving DOWN is not stopped by the box (collision only triggers when
/// approaching from outside). An entity ABOVE the box moving DOWN is stopped.
#[test]
fn shulker_grown_box_allows_clip_when_inside() {
// Box at progress 1.0: spans y=0..1.5
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
let box_shape = VoxelShape::from_box(aabb).expect("shulker box is a valid shape");
// Entity box whose feet are at y=0.5 (inside the grown box), height 1.8
let entity = Aabb::new(Vec3::new(0.2, 0.5, 0.2), Vec3::new(0.8, 2.3, 0.8));
// Moving DOWN from inside the box: collide() should NOT stop it
// (the entity is already intersecting the box, so the downward movement
// sees no face to hit from above).
let down = -0.1;
let moved_down = box_shape.collide(Axis::Y, entity, down);
assert!(
(moved_down - down).abs() < 1.0e-9,
"entity inside grown box should fall through (clip): moved_down={moved_down}, expected={down}"
);
// Entity box whose feet are at y=1.6 (just ABOVE the grown box)
let entity_above = Aabb::new(Vec3::new(0.2, 1.6, 0.2), Vec3::new(0.8, 3.4, 0.8));
// Moving DOWN from above: collide() SHOULD stop it at y=1.5
let moved_down_above = box_shape.collide(Axis::Y, entity_above, -0.2);
// The box's top is at y=1.5, entity bottom at 1.6, so max down is -0.1
assert!(
(moved_down_above - (-0.1)).abs() < 1.0e-9,
"entity above grown box should be stopped: moved_down_above={moved_down_above}, expected=-0.1"
);
}
/// An entity standing on the lid (y=1.0 when closed, y=1.5 when open)
/// and pushed up by the lid: the lid's swept AABB pushes it along the facing.
/// This is tested at the integration level via `push_shulker_entities`;
/// here we only verify the swept box geometry matches vanilla's
/// `Shulker.getProgressDeltaAabb`.
#[test]
fn shulker_swept_box_matches_vanilla_delta() {
// From closed (0.0) to progress 0.5: the lid sweeps from y=0..1 (closed box)
// to y=0..1.5 (half open), so the swept volume in world coords at position
// [0.5, 0.0, 0.5] (bottom center of block at y=0) is y=1.0..1.5.
let delta = progress_delta_aabb(1.0, Direction::Up as u8, 0.0, 0.5, [0.5, 0.0, 0.5]);
assert!((delta[1] - 1.0).abs() < 1.0e-9); // minY
assert!((delta[4] - 1.5).abs() < 1.0e-9); // maxY
// From progress 0.5 to 1.0: swept y=1.5..2.0 in world coords
let delta2 = progress_delta_aabb(1.0, Direction::Up as u8, 0.5, 1.0, [0.5, 0.0, 0.5]);
assert!((delta2[1] - 1.5).abs() < 1.0e-9);
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
+5 -100
View File
@@ -3,8 +3,7 @@
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -82,39 +81,6 @@ pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_w
display_name(first.or(second), "container.chestDouble")
}
/// `BaseContainerBlockEntity.sendChestLockedNotifications`: the action bar
/// message of a container that refuses to open, `container.isLocked` naming it
/// by its display name.
pub fn locked_message(title: &horizon_world::nbt::Tag) -> horizon_world::nbt::Tag {
use horizon_world::nbt::Tag;
Tag::compound([
("translate", Tag::String("container.isLocked".into())),
("with", Tag::List(vec![title.clone()])),
])
}
/// The menu a container vehicle's `createMenu` builds (`ContainerEntity`):
/// `AbstractChestBoat.createMenu` and `MinecartChest.createMenu` are
/// `ChestMenu.threeRows` (a 9x3 menu), `MinecartHopper.createMenu` is a
/// `HopperMenu` (five slots). `None` for any other entity type.
pub fn vehicle_menu(entity_type: &str) -> Option<(&'static str, usize)> {
match entity_type {
"minecraft:chest_minecart" => Some(("minecraft:generic_9x3", 27)),
"minecraft:hopper_minecart" => Some(("minecraft:hopper", HOPPER_SLOTS)),
n if n.ends_with("_chest_boat") || n.ends_with("_chest_raft") => {
Some(("minecraft:generic_9x3", 27))
}
_ => None,
}
}
/// `MenuProvider.getDisplayName` for a container vehicle: `Entity.getDisplayName`
/// is its custom name, else `getName`, the entity type's name key. Horizon's
/// vehicles keep no custom name, so this is the type's name key.
pub fn vehicle_menu_name(entity_type: &str) -> String {
format!("entity.{}", entity_type.replacen(':', ".", 1))
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
@@ -340,27 +306,6 @@ impl BlockEntity {
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
if name == "minecraft:structure_block" {
// The entity's mode is the state's (`StructureBlock.MODE`); the block's
// state is not known here, so a new one starts in its default, SAVE.
return Some(Self::StructureBlock(Box::new(
crate::structure_block::StructureBlock::new(
crate::structure_block::StructureMode::Save,
),
)));
}
if name == "minecraft:test_block" {
// The mode is the state's, which is not known here.
return Some(Self::TestBlock(Box::new(
crate::test_block::TestBlock::new(crate::test_block::TestBlockMode::Fail),
)));
}
if name == "minecraft:test_instance_block" {
return Some(Self::TestInstanceBlock(Box::default()));
}
if name == "minecraft:jigsaw" {
return Some(Self::JigsawBlock(Box::default()));
}
if name == "minecraft:potent_sulfur" {
return Some(Self::PotentSulfur(PotentSulfur::default()));
}
@@ -374,7 +319,7 @@ impl BlockEntity {
};
Some(Self::Chest(vec![None; size]))
}
pub fn id(&self) -> &str {
pub fn id(&self) -> &'static str {
match self {
Self::Chest(items) => match items.len() {
HOPPER_SLOTS => "minecraft:hopper",
@@ -407,12 +352,7 @@ impl BlockEntity {
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
Self::Mod(m) => &m.id,
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
Self::StructureBlock(_) => "minecraft:structure_block",
Self::JigsawBlock(_) => "minecraft:jigsaw",
Self::TestBlock(_) => "minecraft:test_block",
Self::TestInstanceBlock(_) => "minecraft:test_instance_block",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -421,7 +361,7 @@ impl BlockEntity {
Self::Furnace(f) => f.items.iter().flatten().cloned().collect(),
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
Self::Sign(_) => Vec::new(),
Self::Jukebox(j) => j.items.iter().flatten().cloned().collect(),
Self::Jukebox(j) => j.record.iter().cloned().collect(),
Self::Beacon(_)
| Self::CreakingHeart(_)
| Self::Spawner(_)
@@ -432,13 +372,8 @@ impl BlockEntity {
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
@@ -454,6 +389,7 @@ impl BlockEntity {
Self::Furnace(f) => &f.items,
Self::Brewing(b) => &b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
@@ -461,17 +397,11 @@ impl BlockEntity {
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
Self::Mod(m) => &m.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -488,6 +418,7 @@ impl BlockEntity {
Self::Furnace(f) => &mut f.items,
Self::Brewing(b) => &mut b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
@@ -495,17 +426,11 @@ impl BlockEntity {
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
Self::Mod(m) => &mut m.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -645,26 +570,6 @@ mod tests {
assert_eq!(fifth.count, 1);
}
/// `JukeboxBlockEntity`'s single slot is a `Container` slot, so a hopper
/// (`HopperBlockEntity.getContainerAt`) can reach the disc in it.
#[test]
fn a_jukebox_offers_its_record_as_its_one_container_slot() {
let mut entity = BlockEntity::Jukebox(Jukebox::default());
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none(), "empty until a disc lands");
let disc = ItemStack::new("minecraft:cat", 1);
entity.slots_mut()[0] = Some(disc.clone());
let slots = entity.slots();
assert_eq!(slots[0].as_ref().map(|s| &*s.item), Some("minecraft:cat"));
match &entity {
BlockEntity::Jukebox(j) => {
assert_eq!(j.record().map(|s| &*s.item), Some("minecraft:cat"));
}
_ => panic!("a jukebox"),
}
assert_eq!(entity.contents().len(), 1);
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+15 -75
View File
@@ -296,9 +296,8 @@ fn memory() -> String {
}
}
/// The report's details: Horizon's and the machine's, then what the game set as it ran. A dedicated
/// server renders nothing: it leaves out the render settings it read from options.txt.
pub fn system_details(side: &str) -> Vec<(String, String)> {
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
pub fn system_details() -> Vec<(String, String)> {
let mut out = vec![
("Horizon Version".to_owned(), version()),
(
@@ -309,30 +308,18 @@ pub fn system_details(side: &str) -> Vec<(String, String)> {
("CPU".to_owned(), cpu()),
("Memory".to_owned(), memory()),
];
out.extend(
details()
.into_iter()
.filter(|(k, _)| side != "server" || *k != "Render Settings")
.map(|(k, v)| (k.to_owned(), v)),
);
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
out
}
/// The thread's name as vanilla's reports say it: the main thread is the client's "Render thread"
/// and the dedicated server's "Server thread".
fn thread_name(side: &str) -> String {
fn thread_name() -> String {
let current = std::thread::current();
match current.name() {
Some("main") | None if side == "server" => "Server thread".into(),
Some("main") | None => "Render thread".into(),
Some(name) => name.into(),
}
}
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// Writes the report and tells where it is; the log gets the error too.
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
if REPORTED.swap(true, Ordering::SeqCst) {
@@ -356,7 +343,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
Ok(path) => {
eprintln!("{SAVED_LINE}{}", path.display());
eprintln!("Horizon: crash report saved to {}", path.display());
Some(path)
}
Err(e) => {
@@ -378,9 +365,9 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
thread: thread_name(side),
thread: thread_name(),
side,
details: system_details(side),
details: system_details(),
log: crate::logging::recent_lines(),
},
)
@@ -489,31 +476,15 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
&std::backtrace::Backtrace::force_capture().to_string(),
true,
),
thread: thread_name(side),
thread: thread_name(),
side,
details: system_details(side),
details: system_details(),
log: crate::logging::recent_lines(),
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
/// `Util.onThreadException`: a panic on a worker thread is made a report titled "Exception on
/// worker thread", with the thread's name, as `BlockableEventLoop.relayDelayCrash` hands it to
/// the main thread.
pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Report {
Report {
description: "Exception on worker thread".into(),
..panic_report(info, side)
}
}
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
@@ -525,14 +496,10 @@ pub fn install(home: PathBuf, side: &'static str) {
let main = std::thread::current().name() == Some("main");
if main {
deliver(&home, panic_report(info, side));
} else if is_worker_thread(std::thread::current().name()) {
deliver(&home, worker_panic_report(info, side));
// The crash ends the game, as the relayed report does on vanilla's main thread.
std::process::exit(101);
} else {
log::error!(
"Caught previously unhandled exception in thread {}: {}",
thread_name(side),
thread_name(),
panic_message(info)
);
}
@@ -581,9 +548,9 @@ pub fn report_error(
description: DebugCrash::MESSAGE.into(),
error: DebugCrash::MESSAGE.into(),
stack: debug.stack.clone(),
thread: thread_name(side),
thread: thread_name(),
side,
details: system_details(side),
details: system_details(),
log: crate::logging::recent_lines(),
},
);
@@ -601,9 +568,9 @@ pub fn report_error(
description: "Fatal error".into(),
error: message,
stack: String::new(),
thread: thread_name(side),
thread: thread_name(),
side,
details: system_details(side),
details: system_details(),
log: crate::logging::recent_lines(),
},
)
@@ -701,26 +668,6 @@ mod tests {
));
}
#[test]
fn a_dedicated_servers_report_names_its_server_thread_and_no_render_settings() {
let unnamed = |side: &'static str| {
std::thread::spawn(move || thread_name(side))
.join()
.unwrap()
};
assert_eq!(unnamed("server"), "Server thread");
assert_eq!(unnamed("client"), "Render thread");
set_detail("Render Settings", "render distance 12");
let has = |side| {
system_details(side)
.iter()
.any(|(k, _)| k == "Render Settings")
};
assert!(has("client"));
assert!(!has("server"));
clear_detail("Render Settings");
}
#[test]
fn stacks_start_at_the_panicking_frame() {
let bt = format!(
@@ -754,13 +701,6 @@ mod tests {
assert!(first.contains("crash.rs"), "{}", debug.stack);
}
#[test]
fn worker_threads_are_the_streaming_pool() {
assert!(is_worker_thread(Some("horizon-worker-3")));
assert!(!is_worker_thread(Some("main")));
assert!(!is_worker_thread(None));
}
#[test]
fn a_panic_becomes_a_report() {
// A real panic through a hook, as the game's would be.
+26 -267
View File
@@ -23,7 +23,7 @@ enum Source {
}
impl DataPack {
/// Every file path under `prefix` (relative to the pack root).
pub fn list(&self, prefix: &str) -> Vec<String> {
fn list(&self, prefix: &str) -> Vec<String> {
match &self.source {
Source::Zip(z) => z
.names()
@@ -58,13 +58,12 @@ impl DataPack {
}
out
}
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
Source::Dir(root) => std::fs::read(root.join(path)).ok(),
}
}
/// The `description` of `pack.mcmeta`, as text.
pub fn description(&self) -> String {
let Some(bytes) = self.read("pack.mcmeta") else {
@@ -76,24 +75,6 @@ impl DataPack {
other => other["text"].as_str().unwrap_or_default().to_owned(),
}
}
/// The `features.enabled` flag ids of `pack.mcmeta`
/// (`FeatureFlagsMetadataSection`): the flags the pack asks to turn on.
pub fn features(&self) -> Vec<String> {
self.mcmeta()["features"]["enabled"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| f.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
/// The `pack.mcmeta` as JSON, empty when it is missing or unreadable.
fn mcmeta(&self) -> serde_json::Value {
self.read("pack.mcmeta")
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -146,43 +127,14 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
/// selects them.
pub fn feature_packs(resources: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(resources.join("data").join("minecraft").join("datapacks"))
else {
return Vec::new();
};
let mut packs = Vec::new();
for e in entries.flatten() {
let path = e.path();
if path.join("pack.mcmeta").exists() {
packs.push(DataPack {
id: e.file_name().to_string_lossy().into_owned(),
source: Source::Dir(path),
});
}
}
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The packs of `<world>/datapacks` plus the resources root's feature packs:
/// the world's whole repository, as `PackRepository.reload` sees it.
pub fn discover_with_features(world_root: &Path, resources: &Path) -> Vec<DataPack> {
let mut packs = discover(world_root);
packs.extend(feature_packs(resources));
packs
}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`), the
/// built-in one (`resources` holds its `pack.png`), and the feature packs
/// of `resources` (`PackSource.FEATURE`), available at the bottom and off
/// until picked — the Experiments.
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
/// built-in one (`resources` holds its `pack.png`), which can be unselected
/// and stays at the bottom. Horizon has no experiments, so their feature
/// packs are not offered.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
@@ -220,23 +172,6 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
position: Position::Bottom,
fixed: false,
});
for p in feature_packs(resources) {
let meta = p.mcmeta();
let (description, description_style) =
crate::packs::component_root(&meta["pack"]["description"]);
infos.push(PackInfo {
icon: None,
id: p.id.clone(),
title: Text::translate(&format!("dataPack.{}.name", p.id), &[]),
description,
description_style,
source: Source::Feature,
compat: crate::packs::compatibility_for(&meta, format),
required: false,
position: Position::Bottom,
fixed: false,
});
}
infos
}
/// A new empty folder for Create World's packs
@@ -250,59 +185,6 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
/// does (a folder zipped by the system, which adds a wrapper). Copied into
/// `dir`; false when `path` is no pack.
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
fn copy_dir(from: &Path, at: &Path) {
let _ = std::fs::create_dir_all(at);
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
let path = entry.path();
let to = at.join(entry.file_name());
if path.is_dir() {
copy_dir(&path, &to);
} else {
let _ = std::fs::copy(&path, &to);
}
}
}
let name = || {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
};
if path.is_file() {
return path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
&& {
let _ = std::fs::create_dir_all(dir);
std::fs::copy(path, dir.join(name())).is_ok()
};
}
if !path.is_dir() {
return false;
}
if path.join("pack.mcmeta").is_file() {
copy_dir(path, &dir.join(name()));
return true;
}
let own: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
.collect();
match own.as_slice() {
[one] => {
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
true
}
_ => false,
}
}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
@@ -341,39 +223,6 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
let file = world.join("datapacks.json");
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
}
/// Re-Create World's packs from a saved world: a new folder holding a copy of
/// its `datapacks` (`CreateWorldScreen.createTempDataPackDirFromExistingWorld`; `None` when the
/// world has none), and the selection its `datapacks.json` makes, lowest
/// priority first (`WorldOpenFlows.recreateWorldData` keeps the world's `DataConfiguration`).
/// The built-in pack is selected unless the world turned it off.
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
let packs = discover(world);
// No flags here: the recreated world keeps the original's
// `enabled_features`, and the flag-aware load re-adds the feature packs
// it asks for (`for_recreate`'s selection only seeds the new world's
// `datapacks.json`).
let mut selected = enabled_ids(world, &packs, &[]);
let vanilla_off = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v["disabled"].as_array().cloned())
.is_some_and(|a| a.iter().any(|id| id.as_str() == Some("vanilla")));
if !vanilla_off {
selected.insert(0, "vanilla".into());
}
if packs.is_empty() {
return (None, selected);
}
let dir = temp_folder().ok().and_then(|dir| {
if copy(&world.join("datapacks"), &dir).is_ok() {
Some(dir)
} else {
let _ = std::fs::remove_dir_all(&dir);
None
}
});
(dir, selected)
}
/// A file, or a folder and everything in it.
fn copy(from: &Path, to: &Path) -> Result<(), String> {
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
@@ -575,7 +424,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -583,20 +432,17 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
/// keeps the sub path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
"recipe" | "loot_table" | "advancement" => kind,
"tags" => {
return Some(format!("tags/{ns}:{}", rest.strip_suffix(".json")?));
let (sub, name) = rest.rsplit_once('/')?;
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
}
_ => return None,
};
@@ -622,15 +468,12 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"dialog",
"function",
"item_modifier",
"loot_table",
"predicate",
"recipe",
"tags",
"trade_set",
"villager_trade",
]) {
if !seen.insert(f.clone()) {
continue;
@@ -744,25 +587,10 @@ fn hash_normalize(s: &str) -> String {
}
}
/// Whether every flag `pack` asks for (`Pack.getRequestedFeatures`) is among
/// the world's `enabled_features`.
fn features_enabled(pack: &DataPack, enabled_features: &[String]) -> bool {
pack.features()
.iter()
.all(|f| enabled_features.iter().any(|e| e == f))
}
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
/// order, then every pack it lists neither way, in name order.
///
/// `enabled_features` are the world's feature flags
/// (`WorldDataConfiguration.enabledFeatures`): a pack a pack file selected
/// while asking for missing features comes off
/// (`MinecraftServer.configurePackRepository`), and a feature pack whose
/// features the world enables is forced on however the world's selection
/// feels about it (`MinecraftServer.enableForcedFeaturePacks`).
pub fn enabled_ids(world: &Path, packs: &[DataPack], enabled_features: &[String]) -> Vec<String> {
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
@@ -781,69 +609,15 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack], enabled_features: &[String]
let mut enabled: Vec<String> = list("enabled")
.into_iter()
.filter(|id| packs.iter().any(|p| &p.id == id))
.filter(|id| {
packs
.iter()
.find(|p| &p.id == id)
.is_none_or(|p| features_enabled(p, enabled_features))
})
.collect();
for p in packs {
// A pack nobody mentions comes on (`PackRepository.reload` selects
// every new pack), but a feature pack stays off until the world
// enabled its flag (`PackSource.FEATURE` starts unselected).
if !enabled.contains(&p.id)
&& !disabled.contains(&p.id)
&& p.id.starts_with("file/")
&& features_enabled(p, enabled_features)
{
enabled.push(p.id.clone());
}
}
for p in packs {
let feature_pack = !p.id.starts_with("file/") && p.id != "vanilla";
if feature_pack
&& !p.features().is_empty()
&& features_enabled(p, enabled_features)
&& !enabled.contains(&p.id)
{
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
enabled.push(p.id.clone());
}
}
enabled
}
/// The level stems of `packs` (`data/<namespace>/dimension/<path>.json`, lowest priority first,
/// as `enabled_ids` orders them): the id `namespace:path` and the parsed JSON, a later pack's
/// file for an id replacing an earlier one's. This is `context.datapackDimensions()` of
/// `WorldOpenFlows.loadWorldStem`.
pub fn dimension_stems(packs: &[&DataPack]) -> BTreeMap<String, serde_json::Value> {
let mut out = BTreeMap::new();
for pack in packs {
for file in pack.list_kinds(&["dimension"]) {
let Some(rest) = file.strip_prefix("data/") else {
continue;
};
let Some((namespace, rest)) = rest.split_once('/') else {
continue;
};
let Some(path) = rest
.strip_prefix("dimension/")
.and_then(|p| p.strip_suffix(".json"))
else {
continue;
};
if let Some(json) = pack
.read(&file)
.and_then(|b| serde_json::from_slice(&b).ok())
{
out.insert(format!("{namespace}:{path}"), json);
}
}
}
out
}
// ---- worldgen ----------------------------------------------------------------
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
@@ -862,6 +636,11 @@ pub fn worldgen_overrides(
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
the dimension keeps its vanilla build height",
pack.id
)),
PackFile::NotRead(kind) => {
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
}
@@ -917,7 +696,7 @@ mod tests {
)
.unwrap();
let packs = discover(&dir);
let enabled = enabled_ids(&dir, &packs, &[]);
let enabled = enabled_ids(&dir, &packs);
assert_eq!(enabled, ["file/b", "file/a"]);
let enabled: Vec<&DataPack> = enabled
.iter()
@@ -934,13 +713,10 @@ mod tests {
);
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
assert_eq!(tag_packs, ["file/b", "file/a"]);
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings[0].contains("minecraft:new_peaks"));
assert!(warnings[1].contains("worldgen/world_preset"));
assert_eq!(
o.level_height("minecraft:overworld").map(|h| h.height),
Some(2096)
);
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(warnings[0].contains("height 2096"));
assert!(warnings[1].contains("minecraft:new_peaks"));
assert!(warnings[2].contains("worldgen/world_preset"));
let _ = std::fs::remove_dir_all(&dir);
}
use horizon_world::nbt::parse_snbt;
@@ -1031,10 +807,6 @@ mod tests {
"data/mine/predicate/p.json",
"{\"condition\":\"minecraft:random_chance\",\"chance\":1}",
);
write(
"data/mine/dialog/config/points.json",
"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}",
);
let packs = discover(&dir);
assert_eq!(packs.len(), 1);
assert_eq!(packs[0].id, "file/mypack");
@@ -1047,11 +819,6 @@ mod tests {
["mine:a", "mine:sub/b"]
);
assert!(store.predicates.contains_key("mine:p"));
// Dialogs reach the synchronized registry through the content files.
assert_eq!(
store.files["dialog/mine:config/points"],
[b"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}".to_vec()]
);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1139,17 +906,9 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
write(
"fns",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
);
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
write("fns", "data/x/function/f.mcfunction", "say hi");
write(
"off",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
);
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
@@ -13,37 +13,6 @@ use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
/// Camera-relative position and radius; RGB intensity in a second vec4.
pub type Colored = [[f32; 4]; 2];
/// Nearest validated native visual lights; never enters gameplay lighting.
pub fn select_colored(
sources: impl Iterator<Item = ([f64; 3], u32, f32, f32)>,
camera: DVec3,
) -> Vec<Colored> {
let mut nearest: Vec<_> = sources.collect();
nearest.sort_by(|a, b| {
(DVec3::from(a.0) - camera)
.length_squared()
.total_cmp(&(DVec3::from(b.0) - camera).length_squared())
});
nearest
.into_iter()
.take(MAX)
.map(|(at, rgb, radius, intensity)| {
let at = (DVec3::from(at) - camera).as_vec3();
[
[at.x, at.y, at.z, radius],
[
((rgb >> 16) & 255) as f32 / 255.0,
((rgb >> 8) & 255) as f32 / 255.0,
(rgb & 255) as f32 / 255.0,
intensity,
],
]
})
.collect()
}
/// What an entity on fire gives off, as a lit campfire or fire block does.
pub const BURNING: u8 = 15;
/// Sources farther than this from the camera light nothing it can see well.
@@ -118,17 +87,6 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
mod tests {
use super::*;
#[test]
fn colored_sources_are_camera_relative_and_bounded() {
let lights = select_colored(
(0..20).map(|x| ([x as f64, 64.0, 0.0], 0xff8040, 5.0, 0.5)),
DVec3::new(2.0, 64.0, 0.0),
);
assert_eq!(lights.len(), MAX);
assert_eq!(lights[0][0][0], 0.0);
assert_eq!(lights[0][1], [1.0, 128.0 / 255.0, 64.0 / 255.0, 0.5]);
}
#[test]
fn nearest_sources_win() {
let camera = DVec3::new(100.0, 64.0, 100.0);
+22 -116
View File
@@ -6,8 +6,7 @@ use horizon_client_core::potions;
#[derive(Clone, Debug, PartialEq)]
pub struct EffectInstance {
/// Effect id: vanilla's without namespace (`speed`, `poison`, ...), a
/// content mod's `<ns>:<path>`.
/// Effect id without namespace (`speed`, `poison`, ...).
pub id: &'static str,
/// Remaining ticks; -1 is infinite.
pub duration: i32,
@@ -149,15 +148,6 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
_ => None,
}
}
/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving
/// an infinite (-1) or zero duration alone.
pub fn scaled_duration(duration: i32, scale: f32) -> i32 {
if duration == -1 || duration == 0 {
duration
} else {
((duration as f32 * scale).floor() as i32).max(1)
}
}
/// What an omen's `applyEffectTick` decided.
#[derive(Clone, Debug, PartialEq)]
pub enum OmenTick {
@@ -172,9 +162,6 @@ pub enum OmenTick {
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
pub tick_count: i32,
/// How many times `add` added or updated an effect: each is one
/// `onEffectAdded` or `onEffectUpdated` (`EffectsChangedTrigger`).
pub changes: u32,
}
impl PlayerEffects {
pub fn get(&self, id: &str) -> Option<&EffectInstance> {
@@ -185,17 +172,13 @@ impl PlayerEffects {
}
/// `LivingEntity.addEffect`; returns whether anything changed.
pub fn add(&mut self, effect: EffectInstance) -> bool {
let changed = match self.list.iter_mut().find(|e| e.id == effect.id) {
match self.list.iter_mut().find(|e| e.id == effect.id) {
Some(existing) => existing.update(&effect),
None => {
self.list.push(effect);
true
}
};
if changed {
self.changes = self.changes.wrapping_add(1);
}
changed
}
pub fn clear(&mut self) {
self.list.clear();
@@ -220,7 +203,7 @@ impl PlayerEffects {
.iter()
.map(|e| {
(
potions::namespaced(e.id).to_owned(),
format!("minecraft:{}", e.id),
e.duration,
e.amplifier,
e.ambient,
@@ -235,12 +218,10 @@ impl PlayerEffects {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
/// (on its last tick) and a content mod's effect (every tick, with the
/// tick count `shouldApplyEffectTickThisTick` takes); `absorption_empty`
/// is `AbsorptionMobEffect`'s answer: the effect ends once the
/// absorption is used up. Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -249,7 +230,7 @@ impl PlayerEffects {
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance, i32) -> OmenTick,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
@@ -271,9 +252,8 @@ impl PlayerEffects {
actions.push(a);
}
let answer = match e.id {
"bad_omen" => omen(e, t),
"raid_omen" if t == 1 => omen(e, t),
id if potions::is_mod_effect(id) => omen(e, t),
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
@@ -328,29 +308,19 @@ impl PlayerEffects {
pub fn potion_effects(
potion: Option<&str>,
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let effects = potion
.and_then(potions::potion)
.map_or(&[][..], |d| d.effects);
Self::contents_effects(effects, scale)
}
/// `PotionContents.forEachEffect`'s effects, as `potion_effects`.
pub fn contents_effects(
effects: &[(&'static str, i32, i32)],
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let mut lasting = Vec::new();
let mut now = Vec::new();
for (id, duration, amp) in effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
lasting.push(EffectInstance::new(
id,
scaled_duration(*duration, scale),
*amp,
));
if let Some(def) = potion.and_then(potions::potion) {
for (id, duration, amp) in def.effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
// withScaledDuration: max(floor(d * scale), 1).
let d = ((*duration as f32) * scale).floor() as i32;
lasting.push(EffectInstance::new(id, d.max(1), *amp));
}
}
}
(lasting, now)
@@ -493,25 +463,12 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
mod tests {
use super::*;
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("speed", 100, 0));
assert_eq!(fx.changes, 1);
// A longer duration updates the effect (`onEffectUpdated`).
fx.add(EffectInstance::new("speed", 200, 0));
assert_eq!(fx.changes, 2);
// A shorter one with the same amplifier is hidden by nothing: no change.
fx.add(EffectInstance::new("speed", 50, 0));
assert_eq!(fx.changes, 2);
}
#[test]
fn bad_omen_in_a_village_becomes_raid_omen_over_two_ticks() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("bad_omen", 100, 1));
fx.add(EffectInstance::new("speed", 50, 0));
let mut convert = |e: &EffectInstance, _: i32| match e.id {
let mut convert = |e: &EffectInstance| match e.id {
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
_ => OmenTick::Continue,
};
@@ -529,63 +486,12 @@ mod tests {
assert_eq!(fx.get("speed").unwrap().duration, 49);
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
}
/// The only test here that registers mod effects (once per process).
#[test]
fn a_mod_effect_saves_under_its_namespace() {
potions::register_mod_effects([(
"ruby:glow".to_owned(),
0xFF0000,
potions::Category::Neutral,
)]);
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new(
potions::effect("ruby:glow").unwrap().id,
600,
1,
));
let saved = fx.saved();
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
// Without its mod the saved effect is dropped, as an unknown id is.
assert!(
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
.list
.is_empty()
);
}
/// A content mod's effect answers each tick before it counts down (an
/// infinite one with the tick count), and goes at once on Remove.
#[test]
fn a_mod_effect_answers_before_it_counts_down() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("ruby:glow", 3, 1));
fx.add(EffectInstance::new("ruby:bleed", -1, 0));
let mut seen = Vec::new();
let mut answer = |e: &EffectInstance, t: i32| {
seen.push((e.id, t));
match (e.id, t) {
("ruby:glow", 2) => OmenTick::Remove,
_ => OmenTick::Continue,
}
};
fx.tick_with(&mut answer, false);
fx.tick_with(&mut answer, false);
let ticks = [
("ruby:glow", 3),
("ruby:bleed", 1),
("ruby:glow", 2),
("ruby:bleed", 2),
];
assert_eq!(seen, ticks);
assert!(fx.get("ruby:glow").is_none());
assert_eq!(fx.get("ruby:bleed").unwrap().duration, -1);
}
#[test]
fn raid_omen_acts_on_its_last_tick() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("raid_omen", 2, 0));
let mut seen = Vec::new();
let mut watch = |e: &EffectInstance, _: i32| {
let mut watch = |e: &EffectInstance| {
seen.push(e.duration);
OmenTick::Remove
};
+6 -49
View File
@@ -22,12 +22,7 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_count: vec4<u32>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -94,15 +89,6 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
}
return level * 16.0;
}
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -116,9 +102,6 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
// (cracks): the destroy stage sprite's origin.
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
@@ -148,7 +131,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = (overlay >> 16u) & 0xffu;
out.tier = overlay >> 16u;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
@@ -157,9 +140,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(7) offset: vec3<f32>) -> Out {
var out = entity_vertex(offset + position, uv, overlay & 0xffffu, color, light, normal);
out.tier = (overlay >> 16u) & 0xffu;
return out;
return entity_vertex(offset + position, uv, overlay, color, light, normal);
}
struct CloudInfo {
offset: vec4<f32>,
@@ -243,9 +224,6 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_color(in, front);
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
@@ -293,9 +271,6 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
return shadow_color(in);
}
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
@@ -312,10 +287,7 @@ fn shadow_color(in: Out) -> vec4<f32> {
// way terrain.wgsl marks a light source's, drawn again after the entities with
// only alpha written, so its colour stays as blended.
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
// Beams wrap their v in the sprite as fs_entity does: sample where they draw.
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.tier == 0u || textureSample(atlas, atlas_sampler, uv).a < 0.1 { discard; }
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
}
@@ -334,33 +306,21 @@ fn celestial_color(in: Out) -> vec4<f32> {
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
// geometry that sets none (the End sky, the End flash): it stays brightest.
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
// as breaking::stage_light packs them.
fn energy_swirl_base(in: Out) -> vec4<f32> {
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let bytes = round(in.color * 255.0);
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
if color.a < 0.1 { discard; }
return vec4(color.rgb * (128.0 / 255.0), color.a);
}
fn energy_swirl_fog(in: Out) -> f32 {
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return f * globals.fog_color.a;
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -489,9 +449,6 @@ struct WeatherTime { values: vec4<f32> }
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
return weather_color(in);
}
fn weather_color(in: Out) -> vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
+4 -8
View File
@@ -311,14 +311,10 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
// PowderedSnowFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else {
(0.0, 2.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
fog.environmental_start = 0.0;
fog.environmental_end = 2.0;
fog.sky_end = 2.0;
fog.cloud_end = 2.0;
}
_ if input.blindness.is_some() => {
let (duration, infinite) = input.blindness.unwrap_or((0, true));
File diff suppressed because it is too large. Load diff
+12 -100
View File
@@ -72,8 +72,6 @@ pub(super) struct SpriteAnimator {
size: u32,
/// The layers the last `animate` rewrote, counted over all arrays.
changed: Vec<u32>,
/// Whether each sprite layer has an animation.
is_animated: Vec<bool>,
}
impl SpriteAnimator {
@@ -149,12 +147,7 @@ impl SpriteAnimator {
group,
});
}
let max_layer = animations.iter().map(|a| a.layer).max().unwrap_or(0);
let mut is_animated = vec![false; (max_layer + 1) as usize];
for a in animations {
is_animated[a.layer as usize] = true;
}
let Some((pipeline, layout)) = pipeline else {
let Some(pipeline) = pipeline else {
let groups = keys
.iter()
.zip(&counts)
@@ -175,7 +168,6 @@ impl SpriteAnimator {
},
size,
changed: Vec::new(),
is_animated,
};
};
let frames: Vec<_> = frames
@@ -201,6 +193,7 @@ impl SpriteAnimator {
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
.zip(&counts)
@@ -255,23 +248,9 @@ impl SpriteAnimator {
writer: Writer::Gpu { jobs, pipeline },
size,
changed: Vec::new(),
is_animated,
}
}
/// Whether the sprite layer has an animation.
pub fn is_animated(&self, layer: u16) -> bool {
self.is_animated
.get(layer as usize)
.copied()
.unwrap_or(false)
}
/// Number of words needed to mark the animated layers visible this frame.
pub fn active_layer_words(&self) -> usize {
self.is_animated.len().div_ceil(u64::BITS as usize)
}
/// Whether the jobs can run on the GPU: compute shaders writing a
/// storage texture and reading two storage buffers (not OpenGL before
/// 4.3).
@@ -291,16 +270,14 @@ impl SpriteAnimator {
}
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
/// frame or mix changed (`SpriteContents.AnimationState.tick`), skipping
/// sprites not present in `active` when provided.
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
pub fn animate(
&mut self,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut Uploads,
tick: u64,
active: Option<&[u64]>,
) {
if self.collect(tick, active) == 0 {
if self.collect(tick) == 0 {
return;
}
let (jobs, pipeline) = match &self.writer {
@@ -360,18 +337,10 @@ impl SpriteAnimator {
/// Queue a job for every sprite whose blend at `tick` differs from what
/// its layer shows; the number of jobs.
fn collect(&mut self, tick: u64, active: Option<&[u64]>) -> usize {
fn collect(&mut self, tick: u64) -> usize {
let mut count = 0;
self.changed.clear();
for sprite in &mut self.sprites {
if let Some(active) = active {
let layer = sprite.animation.layer as usize;
let word = layer / u64::BITS as usize;
let bit = 1_u64 << (layer % u64::BITS as usize);
if active.get(word).is_none_or(|active| active & bit == 0) {
continue;
}
}
let blend = sprite.animation.blend(tick);
if sprite.shown == Some(blend) {
continue;
@@ -395,15 +364,11 @@ impl SpriteAnimator {
}
}
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
/// unless the device fails to make the pipeline: an OpenGL driver can list
/// compute shaders among its extensions while wgpu writes the GLSL version
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
/// older drivers). A device lost between the scopes' pushes answers pipeline
/// creation with a silent placeholder and reports its invalidity only when
/// the layout is asked for — inside the scopes that error is caught, outside
/// them it is uncaptured and panics (#3866).
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
/// driver can list compute shaders among its extensions while wgpu writes
/// the GLSL version of its core version, which has none (Mesa's
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -418,10 +383,9 @@ fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindG
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => Some((pipeline, layout)),
None => Some(pipeline),
Some(e) => {
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
None
@@ -859,7 +823,7 @@ mod tests {
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
let mut encoder = device.create_command_encoder(&Default::default());
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
animator.animate(&mut encoder, &mut uploads, tick, None);
animator.animate(&mut encoder, &mut uploads, tick);
writes.push(
animator
.sprites
@@ -896,56 +860,4 @@ mod tests {
// its frame turns over (every 2 ticks, but 12 shows 7's frame).
assert_eq!(writes, [3, 0, 2, 3, 2, 3, 3, 0, 2, 1]);
}
#[test]
fn inactive_sprites_are_skipped_by_collect() {
let Some((device, _)) = device() else {
return;
};
let size = 16;
let clips = [
(1, 16, r#"{"frametime":1}"#),
(3, 16, r#"{"frametime":1}"#),
(5, 16, r#"{"frametime":1}"#),
];
let mut mips = vec![Settings::default(); 6];
let animations: Vec<_> = clips
.iter()
.map(|&(layer, native, metadata)| {
mips[layer as usize] = settings(native, Strategy::Cutout);
let frames = (0..3)
.map(|f| crate::sprites::resample(&frame(native, layer * 7 + f), native, size))
.collect();
crate::sprites::test_frames(layer, frames, metadata)
})
.collect();
// Construct with CPU writer (empty textures is fine for collect testing)
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &[], 2, false);
assert!(animator.is_animated(1));
assert!(!animator.is_animated(2));
assert!(animator.is_animated(3));
assert!(animator.is_animated(5));
// When active layer is [1], only sprite 1 is collected
let active = [1_u64 << 1];
let count = animator.collect(1, Some(&active));
assert_eq!(count, 1);
assert_eq!(animator.changed, vec![1]);
// When active is empty, 0 are collected
let count = animator.collect(2, Some(&[0]));
assert_eq!(count, 0);
assert!(animator.changed.is_empty());
// When active is [3, 5], both 3 and 5 are collected
let active = [1_u64 << 3 | 1_u64 << 5];
let count = animator.collect(3, Some(&active));
assert_eq!(count, 2);
assert_eq!(animator.changed, vec![3, 5]);
// When active is None, all out-of-date sprites update
let count = animator.collect(5, None);
assert_eq!(count, 3);
assert_eq!(animator.changed, vec![1, 3, 5]);
}
}
+10 -194
View File
@@ -76,22 +76,8 @@ struct Page {
/// Capacity in vertices.
capacity: u64,
free: FreeList,
/// The buffer went to system memory (`Arena::create`'s fallback): meshes
/// are never moved into it, and it is the first to empty.
host: bool,
}
impl Page {
/// Vertices in the page's free ranges.
fn free_vertices(&self) -> u64 {
self.free.0.values().sum()
}
}
/// An arena with this many bytes in free ranges, at least half of its pages,
/// has a page worth emptying (`Arena::sparsest`).
const COMPACT_MIN_FREE: u64 = 128 << 20;
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
pub struct Arena {
@@ -100,8 +86,6 @@ pub struct Arena {
page_limit: u64,
initial_capacity: u64,
usage: wgpu::BufferUsages,
/// A page being emptied (`Arena::close`): nothing is placed in it.
closing: Option<u32>,
/// A page had to go to system memory (logged once).
spilled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
@@ -145,7 +129,6 @@ impl Arena {
page_limit,
initial_capacity: capacity,
usage,
closing: None,
spilled: false,
#[cfg(test)]
video_memory_full: false,
@@ -155,7 +138,7 @@ impl Arena {
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -163,7 +146,6 @@ impl Arena {
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
@@ -184,7 +166,7 @@ impl Arena {
(lower the render distance if frames slow down)"
);
}
Some((buffer, true))
Some(buffer)
})
}
/// The rounded length a request of `vertices` occupies.
@@ -225,13 +207,16 @@ impl Arena {
vertices: u64,
) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
if let Some(found) = self.alloc_existing(len) {
return Some(found);
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some(page) = page
&& let Some(start) = page.free.take(len)
{
return Some((i as u32, start));
}
}
let last = self.pages.len().saturating_sub(1);
if let Some(page) = self.pages.last().and_then(Option::as_ref)
&& page.capacity < self.page_limit
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
@@ -250,7 +235,7 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let ((buffer, host), capacity) = match self.create(device, capacity) {
let (buffer, capacity) = match self.create(device, capacity) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None => return None,
@@ -261,7 +246,6 @@ impl Arena {
buffer,
capacity,
free,
host,
};
let index = self
.pages
@@ -274,96 +258,6 @@ impl Arena {
self.pages[index] = Some(page);
Some((index as u32, start))
}
/// A range of `vertices` in a page that exists already and is not being
/// emptied: never grows the arena.
pub fn alloc_existing(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, _)| closing != Some(*i as u32))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// `alloc_existing` for a mesh moved off the closing page: only pages in
/// video memory, so compaction never pushes terrain into system memory.
pub fn alloc_moved(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, p)| closing != Some(*i as u32) && p.as_ref().is_some_and(|p| !p.host))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// The page to empty when much of the arena is free ranges (video memory
/// that fragmentation keeps: a page goes back only once all of it is free):
/// the one with the fewest vertices in use that the other pages' free
/// ranges can take. `None` when the arena is dense enough or already
/// emptying a page.
pub fn sparsest(&self) -> Option<u32> {
self.sparsest_from(COMPACT_MIN_FREE)
}
/// `sparsest` for an arena with at least `min_free` bytes in free ranges.
fn sparsest_from(&self, min_free: u64) -> Option<u32> {
if self.closing.is_some() {
return None;
}
let pages = || {
self.pages
.iter()
.enumerate()
.filter_map(|(i, p)| Some((i, p.as_ref()?)))
};
let free: u64 = pages().map(|(_, p)| p.free_vertices()).sum();
let capacity: u64 = pages().map(|(_, p)| p.capacity).sum();
if free * self.stride < min_free || free * 2 < capacity {
return None;
}
// Moves only go to video-memory pages (`alloc_moved`).
let local_free: u64 = pages()
.filter(|(_, p)| !p.host)
.map(|(_, p)| p.free_vertices())
.sum();
pages()
.filter_map(|(i, p)| {
let used = p.capacity - p.free_vertices();
let own = if p.host { 0 } else { p.free_vertices() };
// The room elsewhere, less a quarter for first fit's gaps.
let elsewhere = (local_free - own) / 4 * 3;
// A page in system memory is emptied first.
(used > 0 && used <= elsewhere).then_some((!p.host, used, i as u32))
})
.min()
.map(|(_, _, i)| i)
}
/// Stops placing vertices in `page` until `reopen`.
pub fn close(&mut self, page: u32) {
self.closing = Some(page);
}
pub fn reopen(&mut self) {
self.closing = None;
}
pub fn closing(&self) -> Option<u32> {
self.closing
}
/// Records the copy of `vertices` from one range to another of other pages.
pub fn copy(
&self,
encoder: &mut wgpu::CommandEncoder,
from: (u32, u64),
to: (u32, u64),
vertices: u64,
) {
debug_assert_ne!(from.0, to.0);
encoder.copy_buffer_to_buffer(
self.buffer(from.0),
from.1 * self.stride,
self.buffer(to.0),
to.1 * self.stride,
vertices * self.stride,
);
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
self.pages[page as usize]
@@ -402,7 +296,7 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some((buffer, host)) = self.create(device, capacity) else {
let Some(buffer) = self.create(device, capacity) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -410,7 +304,6 @@ impl Arena {
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
let previous = old.capacity;
old.buffer = buffer;
old.host = host;
old.capacity = capacity;
old.free.give(previous, capacity - previous);
true
@@ -425,9 +318,6 @@ impl Arena {
*page = None;
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
self.closing = None;
}
// Keep live page indices stable; later allocations reuse interior holes.
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
@@ -435,7 +325,6 @@ impl Arena {
}
pub fn clear(&mut self) {
self.pages.clear();
self.closing = None;
}
}
@@ -518,76 +407,6 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Eight ranges fill two pages, each holding its own number.
let ranges: Vec<(u32, u64)> = (0..8)
.map(|i| {
let range = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
queue.write_buffer(
arena.buffer(range.0),
range.1 * 4,
&(100 + i as u32).to_le_bytes(),
);
range
})
.collect();
assert_eq!(arena.pages.iter().flatten().count(), 2);
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// Fragmented: one range live in the first page, two in the second.
for (i, &(page, start)) in ranges.iter().enumerate() {
if ![0, 6, 7].contains(&i) {
arena.release(page, start, GRAIN);
}
}
// Dense enough arenas, and ones without room elsewhere, are left alone.
assert_eq!(arena.sparsest_from(u64::MAX), None);
assert_eq!(arena.sparsest_from(0), Some(first));
arena.close(first);
assert_eq!(
arena.sparsest_from(0),
None,
"one page is emptied at a time"
);
// New ranges avoid the closing page though it has room.
let (page, extra) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(page, second);
arena.release(page, extra, GRAIN);
// Its live range moves, data included, and the page goes back.
let (page, start) = ranges[0];
let to = arena.alloc_existing(GRAIN).unwrap();
assert_eq!(to.0, second);
let mut encoder = device.create_command_encoder(&Default::default());
arena.copy(&mut encoder, (page, start), to, GRAIN);
queue.submit([encoder.finish()]);
arena.release(page, start, GRAIN);
assert_eq!(read(&device, &queue, arena.buffer(to.0), to.1 * 4), 100);
// Emptied but not trimmed yet: it stays closed, so uploads can't refill it.
assert_eq!(arena.closing(), Some(page));
let refill = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_ne!(refill.0, page);
arena.release(refill.0, refill.1, GRAIN);
let before = arena.bytes();
arena.trim();
assert_eq!(arena.bytes(), before - 4 * GRAIN * 4);
assert_eq!(arena.closing(), None);
// The others kept their ranges.
for i in [6, 7] {
let (page, start) = ranges[i];
assert_eq!(
read(&device, &queue, arena.buffer(page), start * 4),
100 + i as u32
);
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -753,9 +572,6 @@ mod tests {
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
// Compaction never moves terrain into a page in system memory.
assert!(arena.pages[page as usize].as_ref().unwrap().host);
assert_eq!(arena.alloc_moved(GRAIN), None);
}
#[test]
@@ -491,6 +491,10 @@ impl Cull {
}
}
pub fn depth_test(&self) -> DepthTest {
self.depth_test
}
/// In the world pass after phase 1 (`Early`): draw the tested boxes
/// against its depth. Leaves the pass's pipeline and group 0 changed.
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
@@ -9,24 +9,7 @@ use super::Vertex;
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
/// and translucent item quads (32, drawn without depth writes).
///
/// With improved transparency on, quads tagged `OIT_ENTITY` are a tenth
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
const MATERIALS: usize = 10;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
for vertex in quads {
vertex.layer |= OIT_ENTITY;
}
}
const MATERIALS: usize = 9;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
@@ -46,13 +29,9 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
/// drawn like an ordinary entity, pulled toward the camera.
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
fn material(layer: u32, oit: bool) -> usize {
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer & 0xffff {
/// The material of a quad by its overlay (`MATERIALS` order).
fn material(layer: u32) -> usize {
match layer {
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -72,10 +51,6 @@ pub(super) struct EntityBatch {
// frame, keeping their storage.
runs: [Vec<(usize, usize)>; MATERIALS],
pub(super) combined: Vec<Vertex>,
/// Additive quads tagged `OIT_ENTITY` that the last `update` moved into the
/// last material (improved transparency on): the dragon's eyes, which share
/// the additive buffer with its rays.
pub(super) moved_additive: u32,
}
impl EntityBatch {
@@ -85,7 +60,6 @@ impl EntityBatch {
additive: &[Vertex],
hand: &[Vertex],
see_through: &[Vertex],
oit: bool,
) -> [u32; MATERIALS] {
let quads = entities.as_chunks::<4>().0;
for runs in &mut self.runs {
@@ -94,10 +68,10 @@ impl EntityBatch {
let mut counts = [0usize; MATERIALS];
let mut start = 0;
while start < quads.len() {
let kind = material(quads[start][0].layer, oit);
let kind = material(quads[start][0].layer);
let end = quads[start + 1..]
.iter()
.position(|quad| material(quad[0].layer, oit) != kind)
.position(|quad| material(quad[0].layer) != kind)
.map_or(quads.len(), |n| start + 1 + n);
self.runs[kind].push((start, end - start));
counts[kind] += end - start;
@@ -113,32 +87,7 @@ impl EntityBatch {
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
// `RenderTypes.eyes` is blended like the other entities, not added: with
// improved transparency on, tagged additive quads join the last
// material, drawn without the additive overlay and the baked shading.
self.moved_additive = 0;
if oit {
for quad in additive.as_chunks::<4>().0 {
if quad[0].layer & OIT_ENTITY != 0 {
self.combined.extend(quad.iter().map(|v| Vertex {
layer: v.layer & !0xffff,
normal: [0; 4],
..*v
}));
counts[9] += 1;
self.moved_additive += 1;
}
}
}
if self.moved_additive == 0 {
self.combined.extend_from_slice(additive);
} else {
for quad in additive.as_chunks::<4>().0 {
if quad[0].layer & OIT_ENTITY == 0 {
self.combined.extend_from_slice(quad);
}
}
}
self.combined.extend_from_slice(additive);
self.combined.extend_from_slice(hand);
self.combined.extend_from_slice(see_through);
counts.map(|count| count as u32)
@@ -207,87 +156,13 @@ mod tests {
hand: &[Vertex],
) {
let (expected, counts) = baseline(entities, additive, hand);
assert_eq!(batch.update(entities, additive, hand, &[], false), counts);
assert_eq!(batch.update(entities, additive, hand, &[]), counts);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&batch.combined),
bytemuck::cast_slice::<_, u8>(&expected)
);
}
#[test]
fn a_tier_in_the_high_bits_keeps_the_material() {
for layer in [0u32, 11, 12, 15, 32, 33, 35, OIT_ENTITY] {
for oit in [false, true] {
assert_eq!(material(layer | 4 << 16, oit), material(layer, oit));
}
}
}
#[test]
fn oit_entity_quads_are_a_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
(0..4).map(move |_| Vertex {
pos: [x, 0.0, 0.0],
layer,
..geometry(1, false)[0]
})
};
// Plain, tagged, plain, shadow, tagged with a hurt overlay.
let entities: Vec<Vertex> = [
(0, 0.0),
(OIT_ENTITY | 3 << 16, 1.0),
(1, 2.0),
(SHADOW_OVERLAY, 3.0),
(OIT_ENTITY | 1, 4.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
#[test]
fn tagged_additive_quads_join_the_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
(0..4).map(move |_| Vertex {
pos: [x, 0.0, 0.0],
layer,
normal: [1, 2, 3, 4],
..geometry(1, false)[0]
})
};
// The dragon's eyes (tagged, overlay 2) and a ray (plain).
let additive: Vec<Vertex> = [(2 | OIT_ENTITY | 3 << 16, 1.0), (2, 2.0)]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let entities: Vec<Vertex> = quad(0, 0.0).collect();
let mut batch = EntityBatch::default();
// Off: untouched.
let counts = batch.update(&entities, &additive, &[], &[], false);
assert_eq!((counts[9], batch.moved_additive), (0, 0));
assert_eq!(batch.combined.len(), 12);
assert_eq!(batch.combined[4].layer, 2 | OIT_ENTITY | 3 << 16);
// On: the eyes are an entity quad without the additive overlay, the ray stays.
let counts = batch.update(&entities, &additive, &[], &[], true);
assert_eq!((counts[0], counts[9], batch.moved_additive), (1, 1, 1));
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0]);
assert_eq!(batch.combined[4].layer, OIT_ENTITY | 3 << 16);
assert_eq!(batch.combined[4].normal, [0; 4]);
assert_eq!(batch.combined[8].layer, 2);
}
#[test]
fn stable_material_order_tails_and_reuse() {
let mut batch = EntityBatch::default();
@@ -352,7 +227,7 @@ mod tests {
equivalent(&mut batch, &entities, &additive, &hand);
for _ in 0..50 {
black_box(baseline(black_box(&entities), &additive, &hand));
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
black_box(&batch.combined);
}
let mut before = Vec::new();
@@ -365,7 +240,7 @@ mod tests {
before.push(start.elapsed().as_nanos() / 100);
let start = Instant::now();
for _ in 0..100 {
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
black_box(&batch.combined);
}
after.push(start.elapsed().as_nanos() / 100);
+16 -79
View File
@@ -63,8 +63,6 @@ pub struct Call {
pub struct Flat {
pub solid: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
@@ -117,54 +115,18 @@ pub struct Flat {
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
pub const MIN_SECTIONS: usize = 1600;
const MIN_SECTIONS: usize = 1600;
/// Whether flat draws exist: vertex shaders can read storage buffers large
/// enough for an index page. A shader pack's terrain draws flat wherever they
/// do, since only there does it read each quad's face normal and `mc_Entity`;
/// frames without one draw flat only where `for_vanilla` says.
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
/// `HORIZON_FLAT_DRAWS=1` draws every frame flat, `0` none.
pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() != Ok("0") && supported(adapter, device)
}
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|| info.backend == wgpu::Backend::Metal
|| is_broken_intel_vulkan(info)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
/// be drawn with another section's origin and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag)))
match std::env::var("HORIZON_FLAT_DRAWS").as_deref() {
Ok("0") => return false,
Ok("1") => return supported(adapter, device),
_ => {}
}
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -286,19 +248,17 @@ fn plan(
}
impl Flat {
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
/// `pipeline(layout, label, vertex entry, fragment entry, blend)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
) -> wgpu::RenderPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
@@ -352,23 +312,13 @@ impl Flat {
let flatten = compute_pipeline(&compute_layout, "flatten");
let flatten_seen = compute_pipeline(&seen_layout, "flatten_seen");
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
),
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
flatten,
compute_layout,
@@ -407,7 +357,11 @@ impl Flat {
phase1_calls: 0,
solid_calls: 0,
shadow_calls: [0; 2],
min_sections,
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
} else {
MIN_SECTIONS
},
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
}
@@ -744,23 +698,6 @@ impl Flat {
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
}
}
/// Draws `calls` without their index buffer, `instances` times: a shader pack's geometry
/// stage reads the indices itself (`hz_indices`) and stands for one emitted triangle per
/// instance.
pub fn draw_geometry<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
instances: u32,
) {
for call in calls {
self.bind_page(pass, call.page, &vertices);
page(pass, call.page);
pass.draw(call.first..call.first + call.count, 0..instances);
}
}
/// Draws phase 2's calls, as `flatten_phase2` left them.
pub fn draw_phase2<'a>(
&self,
@@ -1,9 +0,0 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow and interpolation; the setting that
//! runs them comes next.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod optical_flow;
@@ -1,806 +0,0 @@
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
//! (`optical_flow.rs`) and the depth.
//!
//! The passes and their order are `ffxFrameInterpolationPrepare` then
//! `ffxFrameInterpolationDispatch`'s: dilate the depth and motion vectors and estimate
//! the previous frame's depth; estimate the made frame's depth; splat the game's and the
//! optical flow's vectors to the made frame's pixels (atomic maximums of packed
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
use glam::Mat4;
/// One real frame's inputs.
pub struct Inputs<'a> {
/// This frame's colour without the hand and HUD: `display` size, a filterable
/// float texture (gamma-encoded RGB, the swapchain's values) that can be copied from.
pub color: &'a wgpu::TextureView,
/// This frame's depth: `Depth32Float` at `render_size`, reversed (1 at the near
/// plane, 0 at the far one), finite, jittered by `jitter`.
pub depth: &'a wgpu::TextureView,
pub render_size: [u32; 2],
/// The projection's sub-pixel jitter this frame, in render pixels (FSR's
/// `jitterOffset`); zero without TAA.
pub jitter: [f32; 2],
/// This frame's unjittered clip space to the previous frame's (camera-relative, as
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
pub fov_y: f32,
/// The previous frame is unrelated (first frame, resize, teleport): show this one.
pub reset: bool,
/// The optical flow from the previous frame's `color` to this one's
/// (`OpticalFlow::vectors`, `scene_change`).
pub flow_vectors: &'a wgpu::TextureView,
pub scene_change: &'a wgpu::Buffer,
}
/// `interpolation.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
reproject: [[f32; 4]; 4],
device_to_view: [f32; 4],
render_size: [i32; 2],
display_size: [i32; 2],
jitter: [f32; 2],
optical_flow_scale: [f32; 2],
optical_flow_size: [i32; 2],
reset: u32,
tan_half_fov: f32,
level_size: [i32; 2],
_pad: [u32; 2],
}
/// The game vector field pyramid's levels (`LoadInpaintedGameFieldMv`) and the
/// inpainting pyramid's (`ComputeInpainting`).
const GAME_LEVELS: u32 = 11;
const INPAINTING_LEVELS: u32 = 10;
struct Pipelines {
prepare: wgpu::ComputePipeline,
setup: wgpu::ComputePipeline,
reconstruct_previous_depth: wgpu::ComputePipeline,
game_motion_vector_field: wgpu::ComputePipeline,
game_field_pyramid_first: wgpu::ComputePipeline,
game_field_pyramid: wgpu::ComputePipeline,
optical_flow_vector_field: wgpu::ComputePipeline,
disocclusion_mask: wgpu::ComputePipeline,
interpolate: wgpu::ComputePipeline,
inpainting_pyramid_first: wgpu::ComputePipeline,
inpainting_pyramid: wgpu::ComputePipeline,
inpainting: wgpu::ComputePipeline,
}
/// A texture with a view of each of its levels (`levels`, for writing one from the one
/// above) and of all of them.
struct Pyramid {
all: wgpu::TextureView,
levels: Vec<wgpu::TextureView>,
sizes: Vec<[u32; 2]>,
}
/// What is sized like the world's render.
struct RenderTargets {
size: [u32; 2],
dilated_depth: wgpu::TextureView,
dilated_mv: wgpu::TextureView,
rec_prev_depth: wgpu::Buffer,
rec_interp_depth: wgpu::Buffer,
game_x: wgpu::Buffer,
game_y: wgpu::Buffer,
of_x: wgpu::Buffer,
of_y: wgpu::Buffer,
disocclusion: wgpu::TextureView,
game_pyramid: Pyramid,
}
pub struct Interpolation {
display: [u32; 2],
pipelines: Pipelines,
sampler: wgpu::Sampler,
/// The passes' constants, and each pyramid level's.
params: wgpu::Buffer,
game_level_params: Vec<wgpu::Buffer>,
inpainting_level_params: Vec<wgpu::Buffer>,
counters: wgpu::Buffer,
interpolated: wgpu::TextureView,
output: wgpu::TextureView,
inpainting_pyramid: Pyramid,
/// The last dispatch's colour (`PREVIOUS_INTERPOLATION_SOURCE`), made on the first.
previous: Option<wgpu::Texture>,
render: Option<RenderTargets>,
}
fn texture(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
levels: u32,
) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
fn storage(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(4),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn uniform(device: &wgpu::Device) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame interpolation constants"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
/// Levels of `base >> (i + 1)` for `i` below `max`, while both sides are 1 or more.
fn pyramid(device: &wgpu::Device, label: &str, base: [u32; 2], max: u32) -> Pyramid {
let sizes: Vec<[u32; 2]> = (0..max)
.map(|i| base.map(|v| v >> (i + 1)))
.take_while(|s| s[0] >= 1 && s[1] >= 1)
.collect();
let levels = (sizes.len() as u32).max(1);
let t = texture(
device,
label,
base.map(|v| (v / 2).max(1)),
wgpu::TextureFormat::Rgba16Float,
levels,
);
Pyramid {
all: t.create_view(&Default::default()),
levels: (0..levels)
.map(|i| {
t.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: i,
mip_level_count: Some(1),
..Default::default()
})
})
.collect(),
sizes,
}
}
impl RenderTargets {
fn new(device: &wgpu::Device, size: [u32; 2]) -> Self {
let view = |t: wgpu::Texture| t.create_view(&Default::default());
let field = |label| storage(device, label, u64::from(size[0] * size[1]) * 4);
Self {
size,
dilated_depth: view(texture(
device,
"Frame interpolation dilated depth",
size,
wgpu::TextureFormat::R32Float,
1,
)),
dilated_mv: view(texture(
device,
"Frame interpolation dilated motion vectors",
size,
wgpu::TextureFormat::Rg32Float,
1,
)),
rec_prev_depth: field("Frame interpolation previous depth"),
rec_interp_depth: field("Frame interpolation interpolated depth"),
game_x: field("Frame interpolation game vectors x"),
game_y: field("Frame interpolation game vectors y"),
of_x: field("Frame interpolation flow vectors x"),
of_y: field("Frame interpolation flow vectors y"),
disocclusion: view(texture(
device,
"Frame interpolation disocclusion",
size,
wgpu::TextureFormat::Rgba8Unorm,
1,
)),
game_pyramid: pyramid(
device,
"Frame interpolation game vector pyramid",
size,
GAME_LEVELS,
),
}
}
}
fn bind(
device: &wgpu::Device,
pipeline: &wgpu::ComputePipeline,
entries: &[(u32, wgpu::BindingResource)],
) -> wgpu::BindGroup {
let entries: Vec<_> = entries
.iter()
.map(|(binding, resource)| wgpu::BindGroupEntry {
binding: *binding,
resource: resource.clone(),
})
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame interpolation"),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
})
}
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
wgpu::BindingResource::TextureView(v)
}
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
b.as_entire_binding()
}
/// `setupDeviceDepthToViewSpaceDepthParams` for inverted, finite depth, in blocks
/// (`viewSpaceToMetersFactor` 1).
fn device_to_view(near: f32, far: f32, fov_y: f32, aspect: f32) -> [f32; 4] {
let (f_min, f_max) = (near.max(far), near.min(far));
let q = f_max / (f_min - f_max);
let cot_half_fov = (0.5 * fov_y).cos() / (0.5 * fov_y).sin();
[-q, q * f_min, aspect / cot_half_fov, 1.0 / cot_half_fov]
}
impl Interpolation {
/// For frames of `display` size; the render size may change between dispatches.
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame interpolation"),
source: wgpu::ShaderSource::Wgsl(include_str!("interpolation.wgsl").into()),
});
let pipeline = |entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let view = |t: wgpu::Texture| t.create_view(&Default::default());
Self {
display,
pipelines: Pipelines {
prepare: pipeline("prepare_pass"),
setup: pipeline("setup_pass"),
reconstruct_previous_depth: pipeline("reconstruct_previous_depth_pass"),
game_motion_vector_field: pipeline("game_motion_vector_field_pass"),
game_field_pyramid_first: pipeline("game_field_pyramid_first_pass"),
game_field_pyramid: pipeline("game_field_pyramid_pass"),
optical_flow_vector_field: pipeline("optical_flow_vector_field_pass"),
disocclusion_mask: pipeline("disocclusion_mask_pass"),
interpolate: pipeline("interpolate_pass"),
inpainting_pyramid_first: pipeline("inpainting_pyramid_first_pass"),
inpainting_pyramid: pipeline("inpainting_pyramid_pass"),
inpainting: pipeline("inpainting_pass"),
},
sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Frame interpolation"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
}),
params: uniform(device),
game_level_params: (0..GAME_LEVELS).map(|_| uniform(device)).collect(),
inpainting_level_params: (0..INPAINTING_LEVELS).map(|_| uniform(device)).collect(),
counters: storage(device, "Frame interpolation counters", 8),
interpolated: view(texture(
device,
"Frame interpolation",
display,
wgpu::TextureFormat::Rgba16Float,
1,
)),
output: view(texture(
device,
"Interpolated frame",
display,
wgpu::TextureFormat::Rgba16Float,
1,
)),
inpainting_pyramid: pyramid(
device,
"Frame interpolation inpainting pyramid",
display,
INPAINTING_LEVELS,
),
previous: None,
render: None,
}
}
/// The frame halfway between the previous dispatch's `color` and this one's, into
/// `output`. Keeps what it needs of this frame for the next dispatch.
pub fn dispatch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
inputs: &Inputs,
) {
let color = inputs.color.texture();
let mut reset = inputs.reset;
if self
.previous
.as_ref()
.is_none_or(|p| p.size() != color.size() || p.format() != color.format())
{
self.previous = Some(device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame interpolation previous frame"),
size: color.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: color.format(),
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
}));
reset = true;
}
if self
.render
.as_ref()
.is_none_or(|r| r.size != inputs.render_size)
{
self.render = Some(RenderTargets::new(device, inputs.render_size));
reset = true;
}
let r = self.render.as_ref().expect("made above");
let previous_texture = self.previous.as_ref().expect("made above");
let previous = previous_texture.create_view(&Default::default());
let display = self.display.map(|v| v as i32);
let render = inputs.render_size.map(|v| v as i32);
let flow_size = self
.display
.map(|v| v.div_ceil(super::optical_flow::BLOCK_SIZE) as i32);
let base = Params {
reproject: inputs.reproject.to_cols_array_2d(),
device_to_view: device_to_view(
inputs.near,
inputs.far,
inputs.fov_y,
render[0] as f32 / render[1].max(1) as f32,
),
render_size: render,
display_size: display,
jitter: inputs.jitter,
optical_flow_scale: self.display.map(|v| 1.0 / v as f32),
optical_flow_size: flow_size,
reset: u32::from(reset),
tan_half_fov: (0.5 * inputs.fov_y).tan() * render[0] as f32 / render[1].max(1) as f32,
level_size: [0; 2],
_pad: [0; 2],
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&base));
let level_params = |buffers: &[wgpu::Buffer], sizes: &[[u32; 2]]| {
for (buffer, size) in buffers.iter().zip(sizes) {
let params = Params {
level_size: size.map(|v| v as i32),
..base
};
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&params));
}
};
level_params(&self.game_level_params, &r.game_pyramid.sizes);
level_params(
&self.inpainting_level_params,
&self.inpainting_pyramid.sizes,
);
for b in [
&r.rec_prev_depth,
&r.rec_interp_depth,
&r.game_x,
&r.game_y,
&r.of_x,
&r.of_y,
] {
encoder.clear_buffer(b, 0, None);
}
if reset {
encoder.clear_buffer(&self.counters, 0, None);
}
let p = &self.pipelines;
let params = || whole(&self.params);
let sampler = || wgpu::BindingResource::Sampler(&self.sampler);
let groups =
|pipeline, entries: &[(u32, wgpu::BindingResource)]| bind(device, pipeline, entries);
let prepare = groups(
&p.prepare,
&[
(0, params()),
(2, view(inputs.depth)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
],
);
let setup = groups(
&p.setup,
&[
(0, params()),
(22, whole(inputs.scene_change)),
(23, whole(&self.counters)),
],
);
let colors = || {
[
(3, view(inputs.color)),
(4, view(&previous)),
(1, sampler()),
]
};
let reconstruct = groups(
&p.reconstruct_previous_depth,
&[
(0, params()),
(7, view(&r.dilated_depth)),
(8, view(&r.dilated_mv)),
(10, whole(&r.rec_interp_depth)),
],
);
let mut entries = vec![
(0, params()),
(7, view(&r.dilated_depth)),
(8, view(&r.dilated_mv)),
(13, whole(&r.game_x)),
(14, whole(&r.game_y)),
];
entries.extend(colors());
let game_field = groups(&p.game_motion_vector_field, &entries);
let game_pyramid: Vec<_> = (0..r.game_pyramid.sizes.len())
.map(|i| {
let mut entries = vec![
(0, whole(&self.game_level_params[i])),
(26, view(&r.game_pyramid.levels[i])),
];
if i == 0 {
entries.extend([(15, whole(&r.game_x)), (16, whole(&r.game_y))]);
groups(&p.game_field_pyramid_first, &entries)
} else {
entries.push((25, view(&r.game_pyramid.levels[i - 1])));
groups(&p.game_field_pyramid, &entries)
}
})
.collect();
let mut entries = vec![
(0, params()),
(21, view(inputs.flow_vectors)),
(17, whole(&r.of_x)),
(18, whole(&r.of_y)),
];
entries.extend(colors());
let flow_field = groups(&p.optical_flow_vector_field, &entries);
let game_mv = || {
[
(15, whole(&r.game_x)),
(16, whole(&r.game_y)),
(27, view(&r.game_pyramid.all)),
]
};
let mut entries = vec![
(0, params()),
(7, view(&r.dilated_depth)),
(11, whole(&r.rec_prev_depth)),
(12, whole(&r.rec_interp_depth)),
(29, view(&r.disocclusion)),
];
entries.extend(game_mv());
let disocclusion = groups(&p.disocclusion_mask, &entries);
let mut entries = vec![
(0, params()),
(19, whole(&r.of_x)),
(20, whole(&r.of_y)),
(24, whole(&self.counters)),
(30, view(&r.disocclusion)),
(31, view(&self.interpolated)),
];
entries.extend(game_mv());
entries.extend(colors());
let interpolate = groups(&p.interpolate, &entries);
let inpainting_pyramid: Vec<_> = (0..self.inpainting_pyramid.sizes.len())
.map(|i| {
let mut entries = vec![
(0, whole(&self.inpainting_level_params[i])),
(26, view(&self.inpainting_pyramid.levels[i])),
];
if i == 0 {
entries.push((32, view(&self.interpolated)));
groups(&p.inpainting_pyramid_first, &entries)
} else {
entries.push((25, view(&self.inpainting_pyramid.levels[i - 1])));
groups(&p.inpainting_pyramid, &entries)
}
})
.collect();
let inpainting = groups(
&p.inpainting,
&[
(0, params()),
(28, view(&self.inpainting_pyramid.all)),
(32, view(&self.interpolated)),
(33, view(&self.output)),
],
);
let groups_for = |size: [u32; 2]| (size[0].div_ceil(8), size[1].div_ceil(8));
let (rx, ry) = groups_for(inputs.render_size);
let (dx, dy) = groups_for(self.display);
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Frame interpolation"),
timestamp_writes: None,
});
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, (x, y)| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(x, y, 1);
};
run(&p.prepare, &prepare, (rx, ry));
run(&p.setup, &setup, (1, 1));
if !reset {
run(&p.reconstruct_previous_depth, &reconstruct, (rx, ry));
run(&p.game_motion_vector_field, &game_field, (rx, ry));
for (i, (group, size)) in game_pyramid.iter().zip(&r.game_pyramid.sizes).enumerate() {
let pipeline = if i == 0 {
&p.game_field_pyramid_first
} else {
&p.game_field_pyramid
};
run(pipeline, group, groups_for(*size));
}
run(
&p.optical_flow_vector_field,
&flow_field,
groups_for(flow_size.map(|v| v as u32)),
);
run(&p.disocclusion_mask, &disocclusion, (rx, ry));
}
run(&p.interpolate, &interpolate, (dx, dy));
for (i, (group, size)) in inpainting_pyramid
.iter()
.zip(&self.inpainting_pyramid.sizes)
.enumerate()
{
let pipeline = if i == 0 {
&p.inpainting_pyramid_first
} else {
&p.inpainting_pyramid
};
run(pipeline, group, groups_for(*size));
}
run(&p.inpainting, &inpainting, (dx, dy));
drop(pass);
encoder.copy_texture_to_texture(
color.as_image_copy(),
previous_texture.as_image_copy(),
color.size(),
);
}
/// The interpolated frame: `display` size, `Rgba16Float`, sampled; its colour is
/// encoded as `Inputs::color`'s is.
pub fn output(&self) -> &wgpu::TextureView {
&self.output
}
}
#[cfg(test)]
mod tests {
use super::super::optical_flow::{OpticalFlow, tests as of};
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("interpolation.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn f16(bits: u16) -> f32 {
let (sign, exp, frac) = (bits >> 15, (bits >> 10) & 0x1f, bits & 0x3ff);
let v = match exp {
0 => f32::from(frac) / 1024.0 * 2f32.powi(-14),
31 => f32::INFINITY,
e => (1.0 + f32::from(frac) / 1024.0) * 2f32.powi(i32::from(e) - 15),
};
if sign == 1 { -v } else { v }
}
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
let size = [512, 288];
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let depth = depth.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.01),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
queue.submit([encoder.finish()]);
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
.iter()
.map(|s| {
let pixels = of::pattern(size, seed, *s);
// COPY_SRC as the frame generation's world texture has.
let t = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
queue.write_texture(
t.as_image_copy(),
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
t.size(),
);
(t.create_view(&Default::default()), pixels)
})
.collect();
for (i, (color, _)) in frames.iter().enumerate() {
let mut encoder = device.create_command_encoder(&Default::default());
flow.dispatch(&device, &queue, &mut encoder, color, i == 0);
interpolation.dispatch(
&device,
&queue,
&mut encoder,
&Inputs {
color,
depth: &depth,
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
near: 0.05,
far: 512.0,
fov_y: 1.2,
reset: i == 0,
flow_vectors: flow.vectors(),
scene_change: flow.scene_change(),
},
);
queue.submit([encoder.finish()]);
}
let out = of::read(&device, &queue, interpolation.output.texture(), 8);
let made: Vec<f32> = out
.chunks(2)
.map(|c| f16(u16::from_le_bytes([c[0], c[1]])))
.collect();
let truth = of::pattern(size, seed, expected);
let n = frames.len();
let error = |pixels: &dyn Fn(usize) -> f32| {
let (mut sum, mut count) = (0.0, 0.0);
for y in 32..size[1] as usize - 32 {
for x in 32..size[0] as usize - 32 {
for c in 0..3 {
let i = (y * size[0] as usize + x) * 4 + c;
sum += (pixels(i) - f32::from(truth[i]) / 255.0).abs();
count += 1.0;
}
}
}
sum / count
};
Some([
error(&|i| made[i]),
error(&|i| f32::from(frames[n - 2].1[i]) / 255.0),
error(&|i| f32::from(frames[n - 1].1[i]) / 255.0),
])
}
/// Something moving on its own (the camera still): the optical flow puts the made
/// frame's blocks halfway where its colours match better than the still camera's
/// (FSR prefers the game's vectors unless they clearly don't: primaries make it
/// clear). The first 10 frames after a reset use the game's vectors only.
#[test]
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
else {
return;
};
// Where the still camera's two colours happen to be the same primary (a third of
// the pixels), FSR keeps the game's vectors.
assert!(
made * 3.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// The camera turning: the game's motion vectors put the made frame halfway.
#[test]
fn moves_the_view_halfway_with_the_camera() {
let size = [512.0, 288.0];
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
}
@@ -1,803 +0,0 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// 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.
// FidelityFX SDK 1.1.4's frame interpolation (ffx_frameinterpolation_*.h), ported to WGSL
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
// be read_write everywhere);
// - NaNs AMD's hardware turns into 0 (max(0, NaN)) are avoided up front.
struct Params {
// This frame's unjittered clip space to the previous frame's.
reproject: mat4x4<f32>,
device_to_view: vec4<f32>,
render_size: vec2<i32>,
display_size: vec2<i32>,
// FSR's jitterOffset: render pixels.
jitter: vec2<f32>,
optical_flow_scale: vec2<f32>,
// GetOpticalFlowSize2.
optical_flow_size: vec2<i32>,
reset: u32,
tan_half_fov: f32,
// A mip level's size, for the pyramid passes.
level_size: vec2<i32>,
_pad: vec2<u32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var linear_clamp: sampler;
@group(0) @binding(2) var input_depth: texture_depth_2d;
@group(0) @binding(3) var current_color: texture_2d<f32>;
@group(0) @binding(4) var previous_color: texture_2d<f32>;
@group(0) @binding(5) var dilated_depth_out: texture_storage_2d<r32float, write>;
@group(0) @binding(6) var dilated_mv_out: texture_storage_2d<rg32float, write>;
@group(0) @binding(7) var dilated_depth: texture_2d<f32>;
@group(0) @binding(8) var dilated_mv: texture_2d<f32>;
@group(0) @binding(9) var<storage, read_write> rec_prev_depth: array<atomic<u32>>;
@group(0) @binding(10) var<storage, read_write> rec_interp_depth: array<atomic<u32>>;
@group(0) @binding(11) var<storage, read> rec_prev_depth_in: array<u32>;
@group(0) @binding(12) var<storage, read> rec_interp_depth_in: array<u32>;
@group(0) @binding(13) var<storage, read_write> game_field_x: array<atomic<u32>>;
@group(0) @binding(14) var<storage, read_write> game_field_y: array<atomic<u32>>;
@group(0) @binding(15) var<storage, read> game_field_x_in: array<u32>;
@group(0) @binding(16) var<storage, read> game_field_y_in: array<u32>;
@group(0) @binding(17) var<storage, read_write> of_field_x: array<atomic<u32>>;
@group(0) @binding(18) var<storage, read_write> of_field_y: array<atomic<u32>>;
@group(0) @binding(19) var<storage, read> of_field_x_in: array<u32>;
@group(0) @binding(20) var<storage, read> of_field_y_in: array<u32>;
@group(0) @binding(21) var optical_flow: texture_2d<i32>;
@group(0) @binding(22) var<storage, read> scene_change: array<u32, 3>;
@group(0) @binding(23) var<storage, read_write> counters: array<u32, 2>;
@group(0) @binding(24) var<storage, read> counters_in: array<u32, 2>;
// A pyramid's previous level, and the level a pyramid pass writes.
@group(0) @binding(25) var pyramid_in: texture_2d<f32>;
@group(0) @binding(26) var pyramid_out: texture_storage_2d<rgba16float, write>;
// The whole game vector field and inpainting pyramids.
@group(0) @binding(27) var game_pyramid: texture_2d<f32>;
@group(0) @binding(28) var inpainting_pyramid: texture_2d<f32>;
@group(0) @binding(29) var disocclusion_out: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(30) var disocclusion: texture_2d<f32>;
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
const COUNTER_FRAME_INDEX_SINCE_LAST_RESET: u32 = 1u;
fn render_index(p: vec2<i32>) -> u32 {
return u32(p.y * params.render_size.x + p.x);
}
fn is_on_screen(p: vec2<i32>, size: vec2<i32>) -> bool {
return all(vec2<u32>(p) < vec2<u32>(size));
}
fn is_uv_inside(uv: vec2<f32>) -> bool {
return uv.x > 0.0 && uv.x < 1.0 && uv.y > 0.0 && uv.y < 1.0;
}
fn min_divided_by_max(v0: f32, v1: f32) -> f32 {
let m = max(v0, v1);
return select(0.0, min(v0, v1) / m, m != 0.0);
}
fn normalized_dot3(v0: vec3<f32>, v1: vec3<f32>) -> f32 {
let m = max(length(v0), length(v1));
return select(1.0, dot(v0 / m, v1 / m), m > 0.0);
}
fn linear_from_srgb(c: vec3<f32>) -> vec3<f32> {
return select(pow(c / 1.055 + 0.055 / 1.055, vec3(2.4)), c / 12.92, c < vec3(0.04045));
}
fn raw_rgb_to_luminance(c: vec3<f32>) -> f32 {
return dot(linear_from_srgb(c), vec3(0.2126, 0.7152, 0.0722));
}
struct Bilinear {
base: vec2<i32>,
weights: vec4<f32>,
};
fn bilinear(uv: vec2<f32>, size: vec2<i32>) -> Bilinear {
let px = uv * vec2<f32>(size) - 0.5;
let f = fract(px);
var b: Bilinear;
b.base = vec2<i32>(floor(px));
b.weights = vec4(
(1.0 - f.x) * (1.0 - f.y),
f.x * (1.0 - f.y),
(1.0 - f.x) * f.y,
f.x * f.y,
);
return b;
}
fn bilinear_offset(i: i32) -> vec2<i32> {
return vec2(i & 1, i >> 1u);
}
fn device_to_view_depth(d: f32) -> f32 {
return params.device_to_view.y / (d - params.device_to_view.x);
}
fn view_space_position(px: vec2<i32>, size: vec2<i32>, d: f32) -> vec3<f32> {
let z = device_to_view_depth(d);
let ndc = vec2<f32>(px) / vec2<f32>(size) * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
return vec3(params.device_to_view.z * ndc.x * z, params.device_to_view.w * ndc.y * z, z);
}
// MOTION VECTOR FIELD (ffx_frameinterpolation_common.h)
const PRIMARY_VECTOR_BIT: u32 = 0x80000000u;
const PRIORITY_LOW_MAX: u32 = 31u;
const PRIORITY_HIGH_MAX: u32 = 1023u;
const PRIORITY_LOW_OFFSET: u32 = 16u;
const PRIORITY_HIGH_OFFSET: u32 = 21u;
struct VectorFieldEntry {
motion_vector: vec2<f32>,
high_priority: f32,
low_priority: f32,
valid: bool,
primary: bool,
secondary: bool,
in_painted: bool,
velocity: f32,
neg_outside: bool,
pos_outside: bool,
};
fn new_entry() -> VectorFieldEntry {
return VectorFieldEntry(vec2(0.0), 0.0, 0.0, false, false, false, false, 0.0, false, false);
}
fn is_primary(packed: u32) -> bool {
return (packed & PRIMARY_VECTOR_BIT) != 0u;
}
// ffxF32ToF16 and its inverse in integer arithmetic: pack2x16float needs a capability
// some GPUs lack.
fn f32_to_f16(v: f32) -> u32 {
let x = bitcast<u32>(v);
let sign = (x >> 16u) & 0x8000u;
let e = i32((x >> 23u) & 0xffu) - 112;
let m = x & 0x7fffffu;
if e >= 31 {
return sign | 0x7c00u;
}
if e <= 0 {
if e < -10 {
return sign;
}
let full = m | 0x800000u;
let shift = u32(14 - e);
return sign | ((full >> shift) + ((full >> (shift - 1u)) & 1u));
}
return sign | (((u32(e) << 10u) | (m >> 13u)) + ((m >> 12u) & 1u));
}
fn f16_to_f32(h: u32) -> f32 {
let sign = (h & 0x8000u) << 16u;
let e = (h >> 10u) & 0x1fu;
let m = h & 0x3ffu;
if e == 0u {
let v = f32(m) * (1.0 / 16777216.0);
return select(v, -v, sign != 0u);
}
if e == 31u {
return bitcast<f32>(sign | 0x7f800000u);
}
return bitcast<f32>(sign | ((e + 112u) << 23u) | (m << 13u));
}
fn pack_entries(primary: bool, high: u32, low: u32, mv: vec2<f32>) -> vec2<u32> {
let priority = select(0u, PRIMARY_VECTOR_BIT, primary)
| ((high & PRIORITY_HIGH_MAX) << PRIORITY_HIGH_OFFSET)
| ((low & PRIORITY_LOW_MAX) << PRIORITY_LOW_OFFSET);
return vec2(priority | f32_to_f16(mv.x), priority | f32_to_f16(mv.y));
}
fn unpack_entries(packed: vec2<u32>) -> VectorFieldEntry {
var e = new_entry();
e.high_priority = f32((packed.x >> PRIORITY_HIGH_OFFSET) & PRIORITY_HIGH_MAX) / f32(PRIORITY_HIGH_MAX);
e.low_priority = f32((packed.x >> PRIORITY_LOW_OFFSET) & PRIORITY_LOW_MAX) / f32(PRIORITY_LOW_MAX);
e.primary = is_primary(packed.x);
e.valid = e.high_priority > 0.0;
e.secondary = e.valid && !e.primary;
if e.secondary {
e.high_priority = 1.0 - e.high_priority;
}
e.motion_vector = vec2(f16_to_f32(packed.x & 0xffffu), f16_to_f32(packed.y & 0xffffu));
return e;
}
fn load_game_field(p: vec2<i32>) -> vec2<u32> {
if !is_on_screen(p, params.render_size) {
return vec2(0u);
}
let i = render_index(p);
return vec2(game_field_x_in[i], game_field_y_in[i]);
}
fn compute_mv_inpainting_level(uv: vec2<f32>, level: i32, size: vec2<i32>) -> vec4<f32> {
let b = bilinear(uv, size);
var sum = 0.0;
var color = vec4(0.0);
for (var i = 0; i < 4; i++) {
let p = b.base + bilinear_offset(i);
if is_on_screen(p, size) {
let s = textureLoad(game_pyramid, p, level);
let weight = b.weights[i] * f32(s.z > 0.0) * s.z;
sum += weight;
color += s * weight;
}
}
return color / select(1.0, sum, sum > 0.0);
}
fn load_inpainted_game_field_mv(uv: vec2<f32>) -> VectorFieldEntry {
let px = vec2<i32>(uv * vec2<f32>(params.render_size));
var e = unpack_entries(load_game_field(px));
if !e.valid {
var size = params.render_size;
var v = vec4(0.0);
let levels = i32(textureNumLevels(game_pyramid));
for (var level = 0; level < min(11, levels) && v.w == 0.0; level++) {
size /= 2;
v = compute_mv_inpainting_level(uv, level, size);
}
e.motion_vector = v.xy;
e.high_priority = v.z;
e.low_priority = v.w;
e.in_painted = true;
}
e.neg_outside = !is_uv_inside(uv - e.motion_vector);
e.pos_outside = !is_uv_inside(uv + e.motion_vector);
e.velocity = length(e.motion_vector);
return e;
}
fn sample_optical_flow_field(uv: vec2<f32>) -> VectorFieldEntry {
let size = params.optical_flow_size;
let b = bilinear(uv, size);
var e = new_entry();
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let p = b.base + bilinear_offset(i);
if is_on_screen(p, size) {
let w = b.weights[i];
let j = render_index(p);
let s = unpack_entries(vec2(of_field_x_in[j], of_field_y_in[j]));
e.motion_vector += s.motion_vector * w;
e.high_priority += s.high_priority * w;
e.low_priority += s.low_priority * w;
weight_sum += w;
}
}
if weight_sum > 0.0 {
e.motion_vector /= weight_sum;
e.high_priority /= weight_sum;
e.low_priority /= weight_sum;
}
e.neg_outside = !is_uv_inside(uv - e.motion_vector);
e.pos_outside = !is_uv_inside(uv + e.motion_vector);
e.velocity = length(e.motion_vector);
return e;
}
fn has_scene_changed() -> bool {
return (scene_change[1] & 0xfu) != 0u;
}
// ffx_frameinterpolation_reconstruct_dilated_velocity_and_previous_depth.h, with the
// motion vectors computed from the depth and the camera.
fn camera_motion_vector(p: vec2<i32>, depth: f32) -> vec2<f32> {
let size = vec2<f32>(params.render_size);
let uv = (vec2<f32>(p) + 0.5) / size - params.jitter / size;
let ndc = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
let previous = params.reproject * vec4(ndc, depth, 1.0);
let previous_uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
return previous_uv - uv;
}
fn load_input_depth(p: vec2<i32>) -> f32 {
if !is_on_screen(p, params.render_size) {
return 0.0;
}
return textureLoad(input_depth, p, 0);
}
@compute @workgroup_size(8, 8)
fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let offsets = array(
vec2(0, 0), vec2(1, 0), vec2(0, 1), vec2(0, -1), vec2(-1, 0),
vec2(-1, 1), vec2(1, 1), vec2(-1, -1), vec2(1, -1),
);
var nearest = load_input_depth(p);
var nearest_coord = p;
for (var i = 1; i < 9; i++) {
let q = p + offsets[i];
if is_on_screen(q, params.render_size) {
let d = load_input_depth(q);
if d > nearest {
nearest_coord = q;
nearest = d;
}
}
}
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
let moving = mv * f32(length(mv * vec2<f32>(params.display_size)) > 0.1);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let b = bilinear(uv + moving, params.render_size);
for (var i = 0; i < 4; i++) {
if b.weights[i] > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.render_size) {
atomicMax(&rec_prev_depth[render_index(q)], bitcast<u32>(nearest));
}
}
}
}
// ffx_frameinterpolation_setup.h: the frames since the last reset (the vector fields are
// cleared by the encoder).
@compute @workgroup_size(1)
fn setup_pass() {
if params.reset != 0u || has_scene_changed() {
counters[COUNTER_FRAME_INDEX_SINCE_LAST_RESET] = 0u;
} else {
counters[COUNTER_FRAME_INDEX_SINCE_LAST_RESET] += 1u;
}
}
// ffx_frameinterpolation_reconstruct_previous_depth.h: the interpolated frame's depth.
@compute @workgroup_size(8, 8)
fn reconstruct_previous_depth_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let mv = textureLoad(dilated_mv, p, 0).xy * 0.5;
let depth = textureLoad(dilated_depth, p, 0).x;
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let b = bilinear(uv + mv, params.render_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if b.weights[i] > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD && is_on_screen(q, params.render_size) {
atomicMax(&rec_interp_depth[render_index(q)], bitcast<u32>(depth));
}
}
}
// ffx_frameinterpolation_game_motion_vector_field.h
fn priority_from_view_depth(view_depth_in: f32) -> u32 {
let d = pow(view_depth_in, 0.33);
let factor = u32((1.0 - d * (1.0 / (1.0 + d))) * f32(PRIORITY_HIGH_MAX));
return max(1u, factor);
}
fn update_game_field(p: vec2<i32>, packed: vec2<u32>) -> u32 {
let i = render_index(p);
let x = atomicMax(&game_field_x[i], packed.x);
let y = atomicMax(&game_field_y[i], packed.y);
return max(x, y);
}
@compute @workgroup_size(8, 8)
fn game_motion_vector_field_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let size = vec2<f32>(params.render_size);
let uv = (vec2<f32>(p) + 0.5) / size;
let depth = textureLoad(dilated_depth, p, 0).x;
let mv = textureLoad(dilated_mv, p, 0).xy;
let half_mv = mv * 0.5;
let interpolated_uv = uv + half_mv;
let high_primary = priority_from_view_depth(device_to_view_depth(depth));
let previous = textureSampleLevel(previous_color, linear_clamp, uv + mv, 0.0).rgb;
let current = textureSampleLevel(current_color, linear_clamp, uv, 0.0).rgb;
let previous_luma = 0.001 + raw_rgb_to_luminance(previous);
let current_luma = 0.001 + raw_rgb_to_luminance(current);
var low = u32(round(min_divided_by_max(previous_luma, current_luma) * f32(PRIORITY_LOW_MAX)))
* u32(is_uv_inside(uv + mv));
let primary = pack_entries(true, high_primary, low, half_mv);
let b = bilinear(interpolated_uv, params.render_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.render_size) {
update_game_field(q, primary);
}
}
if length(half_mv * size) > EPSILON {
var write_secondary = true;
var primary_hits = 0u;
let step_scale = length(1.0 / size);
let step_mv = normalize(mv);
let break_dist = min(length(half_mv), length(vec2(0.5, 0.5)));
for (var s = step_scale; s <= break_dist && write_secondary; s += step_scale) {
let secondary_uv = interpolated_uv - step_mv * s;
let sb = bilinear(secondary_uv, params.render_size);
let high_secondary = max(1u, PRIORITY_HIGH_MAX - high_primary);
let to_centre_raw = vec2(0.5, 0.5) - secondary_uv;
let to_centre = select(vec2(0.0), normalize(to_centre_raw), length(to_centre_raw) > 0.0);
low = u32(max(0.0, dot(to_centre, step_mv)) * f32(PRIORITY_LOW_MAX));
let secondary = pack_entries(false, high_secondary, low, half_mv);
let q = sb.base;
write_secondary = write_secondary && is_on_screen(q, params.render_size);
if write_secondary {
let existing = update_game_field(q, secondary);
primary_hits += u32(is_primary(existing));
write_secondary = write_secondary && primary_hits <= 3u;
}
}
}
}
// ffx_frameinterpolation_compute_game_vector_field_inpainting_pyramid.h: SPD, a level per
// pass. Level 0 reads the field, the others the level above.
fn reduce_game_field(v: array<vec4<f32>, 4>) -> vec4<f32> {
var sum = vec4(0.0);
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let w = f32(v[i].z > 0.0);
sum += v[i] * w;
weight_sum += w;
}
return sum / select(1.0, weight_sum, weight_sum > EPSILON);
}
@compute @workgroup_size(8, 8)
fn game_field_pyramid_first_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
let e = unpack_entries(load_game_field(p * 2 + bilinear_offset(i)));
v[i] = vec4(e.motion_vector, e.high_priority, e.low_priority);
}
textureStore(pyramid_out, p, reduce_game_field(v));
}
@compute @workgroup_size(8, 8)
fn game_field_pyramid_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
v[i] = textureLoad(pyramid_in, p * 2 + bilinear_offset(i), 0);
}
textureStore(pyramid_out, p, reduce_game_field(v));
}
// ffx_frameinterpolation_optical_flow_vector_field.h
fn load_optical_flow(p: vec2<i32>) -> vec2<f32> {
let size = vec2<i32>(textureDimensions(optical_flow));
if !is_on_screen(p, size) {
return vec2(0.0);
}
return vec2<f32>(textureLoad(optical_flow, p, 0).xy) * params.optical_flow_scale;
}
@compute @workgroup_size(8, 8)
fn optical_flow_vector_field_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
let rect = vec2<f32>(params.display_size);
var avg = vec2(0.0);
var sw = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let vs = load_optical_flow(p + vec2(x, y));
let len = length(vs * rect);
let w = max(0.0, 512.0 - len) * f32(len > 1.0);
avg += vs * w;
sw += w;
}
}
// AMD divides by 0 here, and its max(0, NaN) below gives 0 weights: a zero vector.
if sw > 0.0 {
avg /= sw;
}
var v = vec2(0.0);
sw = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let vs = load_optical_flow(p + vec2(x, y));
let len = length(vs * rect);
let len_factor = max(0.0, 512.0 - len) * f32(len > 1.0);
let w = pow(max(0.0, dot(avg, vs)), 1.25) * len_factor;
v += vs * w;
sw += w;
}
}
if sw > EPSILON {
v /= sw;
}
// computeOpticalFlowFieldMvs
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.optical_flow_size);
let half_mv = v * 0.5;
let previous = textureSampleLevel(previous_color, linear_clamp, uv + v, 0.0).rgb;
let current = textureSampleLevel(current_color, linear_clamp, uv, 0.0).rgb;
let previous_luma = 0.001 + raw_rgb_to_luminance(previous);
let current_luma = 0.001 + raw_rgb_to_luminance(current);
let velocity = length(v * rect);
let high = u32(velocity > 1.0)
* u32(saturate(velocity / length(rect * 0.05)) * f32(PRIORITY_HIGH_MAX));
if high > 0u {
let low = u32(round(min_divided_by_max(previous_luma, current_luma) * f32(PRIORITY_LOW_MAX)))
* u32(is_uv_inside(uv + v));
let packed = pack_entries(true, high, low, half_mv);
let b = bilinear(uv + half_mv, params.optical_flow_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.optical_flow_size) {
let j = render_index(q);
atomicMax(&of_field_x[j], packed.x);
atomicMax(&of_field_y[j], packed.y);
}
}
}
}
// ffx_frameinterpolation_disocclusion_mask.h
fn load_estimated_depth(which: u32, p: vec2<i32>) -> f32 {
if which == 0u {
return bitcast<f32>(rec_prev_depth_in[render_index(p)]);
}
return textureLoad(dilated_depth, p, 0).x;
}
fn compute_depth_clip(which: u32, uv: vec2<f32>, current_depth: f32) -> f32 {
let current_view = device_to_view_depth(current_depth);
let b = bilinear(uv, params.render_size);
var depth = 0.0;
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
let w = b.weights[i];
if is_on_screen(q, params.render_size) && w > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD {
let previous_depth = load_estimated_depth(which, q);
let previous_view = device_to_view_depth(previous_depth);
let diff = current_view - previous_view;
if diff > 0.0 {
let plane_depth = min(previous_depth, current_depth);
let centre = view_space_position(params.render_size / 2, params.render_size, plane_depth);
let corner = view_space_position(vec2(0), params.render_size, plane_depth);
let half_viewport_width = length(vec2<f32>(params.render_size));
let threshold = min(current_view, previous_view);
let k_fov = length(corner) / length(centre);
let required = 1.37e-05 * k_fov * half_viewport_width * threshold;
depth += f32(required / diff >= 1.0) * w;
weight_sum += w;
}
}
}
return select(0.0, saturate(1.0 - depth / weight_sum), weight_sum > 0.0);
}
@compute @workgroup_size(8, 8)
fn disocclusion_mask_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let depth = bitcast<f32>(rec_interp_depth_in[render_index(p)]);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let game = load_inpainted_game_field_mv(uv);
var mask = vec2(
1.0 - compute_depth_clip(0u, uv + game.motion_vector, depth),
1.0 - compute_depth_clip(1u, uv - game.motion_vector, depth),
);
mask = vec2<f32>(mask >= vec2(EPSILON));
let source_mv = game.motion_vector * 2.0;
let size = vec2<f32>(params.render_size);
let previous = vec2<i32>((uv + source_mv) * size);
mask.x = saturate(mask.x + f32(!is_on_screen(previous, params.render_size)));
let current = vec2<i32>((uv - source_mv) * size);
mask.y = saturate(mask.y + f32(!is_on_screen(current, params.render_size)));
textureStore(disocclusion_out, p, vec4(mask, 0.0, 0.0));
}
// ffx_frameinterpolation.h
struct SourceColor {
raw: vec3<f32>,
weight_sum: f32,
};
fn sample_bilinear(is_current: bool, uv: vec2<f32>, mv: vec2<f32>) -> SourceColor {
let size = params.display_size;
let b = bilinear(uv + mv, size);
var color = vec3(0.0);
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, size) {
let w = b.weights[i];
if is_current {
color += textureLoad(current_color, q, 0).rgb * w;
} else {
color += textureLoad(previous_color, q, 0).rgb * w;
}
weight_sum += w;
}
}
return SourceColor(select(vec3(0.0), color / weight_sum, weight_sum != 0.0), weight_sum);
}
@compute @workgroup_size(8, 8)
fn interpolate_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.display_size) {
return;
}
let frame_index = counters_in[COUNTER_FRAME_INDEX_SINCE_LAST_RESET];
if frame_index == 0u {
textureStore(interpolated_out, p, vec4(textureLoad(current_color, p, 0).rgb, 0.0));
return;
}
var in_painting = 0.0;
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.display_size);
let game = load_inpainted_game_field_mv(uv);
let flow = sample_optical_flow_field(uv);
var disocclusion_factor = vec2<f32>(
saturate(textureSampleLevel(disocclusion, linear_clamp, uv, 0.0).xy) == vec2(1.0),
);
let previous_game = sample_bilinear(false, uv, game.motion_vector);
let current_game = sample_bilinear(true, uv, -game.motion_vector);
let previous_of = sample_bilinear(false, uv, flow.motion_vector);
let current_of = sample_bilinear(true, uv, -flow.motion_vector);
disocclusion_factor.x *= f32(!game.pos_outside);
disocclusion_factor.y *= f32(!game.neg_outside);
in_painting = saturate(max(in_painting, f32(length(disocclusion_factor) <= EPSILON)));
var t = 0.5;
t += 0.5 * (1.0 - disocclusion_factor.x);
t -= 0.5 * (1.0 - disocclusion_factor.y);
var color = mix(previous_game.raw, current_game.raw, saturate(t));
let disoccluded = saturate(1.0 - min(disocclusion_factor.x, disocclusion_factor.y));
if previous_game.weight_sum == 0.0 {
color = current_game.raw;
} else if current_game.weight_sum == 0.0 {
color = previous_game.raw;
}
if previous_game.weight_sum == 0.0 && current_game.weight_sum == 0.0 {
in_painting = 1.0;
}
var of_t = 1.0;
if previous_of.weight_sum > 0.0 && current_of.weight_sum > 0.0 {
of_t = 0.5;
} else if previous_of.weight_sum > 0.0 {
of_t = 0.0;
}
let of_color = mix(previous_of.raw, current_of.raw, of_t);
let of_sim = normalized_dot3(previous_of.raw, current_of.raw);
var game_sim = normalized_dot3(previous_game.raw, current_game.raw);
game_sim = mix(max(EPSILON, game_sim), 1.0, disoccluded);
var game_bias = saturate(game_sim / max(EPSILON, of_sim));
game_bias = mix(game_bias, 1.0, f32(frame_index < 10u));
color = mix(of_color, color, saturate(game_bias));
textureStore(interpolated_out, p, vec4(color, in_painting));
}
// ffx_frameinterpolation_compute_inpainting_pyramid.h: SPD, a level per pass.
fn reduce_inpainting(v: array<vec4<f32>, 4>) -> vec4<f32> {
let sum = v[0].w + v[1].w + v[2].w + v[3].w;
if sum == 0.0 {
return vec4(0.0);
}
return (v[0] * v[0].w + v[1] * v[1].w + v[2] * v[2].w + v[3] * v[3].w) / sum;
}
@compute @workgroup_size(8, 8)
fn inpainting_pyramid_first_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
let q = p * 2 + bilinear_offset(i);
var c = vec4(0.0);
if is_on_screen(q, params.display_size) {
c = textureLoad(interpolated, q, 0);
c.w = saturate(1.0 - c.w);
}
v[i] = c;
}
textureStore(pyramid_out, p, reduce_inpainting(v));
}
@compute @workgroup_size(8, 8)
fn inpainting_pyramid_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
v[i] = textureLoad(pyramid_in, p * 2 + bilinear_offset(i), 0);
}
textureStore(pyramid_out, p, reduce_inpainting(v));
}
// ffx_frameinterpolation_inpainting.h (no HUD-less source: Horizon draws its HUD over
// the made frame itself).
fn compute_inpainting_level(uv: vec2<f32>, level: i32, size: vec2<i32>) -> vec4<f32> {
let b = bilinear(uv, size);
var color = vec4(0.0);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, size) {
let s = textureLoad(inpainting_pyramid, q, level);
let w = b.weights[i] * f32(s.w > 0.0);
color += vec4(s.rgb * w, w);
}
}
return color;
}
fn compute_inpainting(p: vec2<i32>) -> vec3<f32> {
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.display_size);
var color = vec4(0.0);
var size = params.display_size;
let levels = i32(textureNumLevels(inpainting_pyramid));
for (var level = 0; level < min(10, levels); level++) {
size /= 2;
let mip = compute_inpainting_level(uv, level, size);
if mip.w > 0.0 {
let weight = pow(1.0 - f32(level) / 10.0, 3.0) * mip.w;
color += vec4(mip.rgb / mip.w, 1.0) * weight;
}
}
return select(vec3(0.0), color.rgb / color.w, color.w > 0.0);
}
@compute @workgroup_size(8, 8)
fn inpainting_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.display_size) {
return;
}
var c = textureLoad(interpolated, p, 0);
if c.w > EPSILON {
c = vec4(mix(c.rgb, compute_inpainting(p), c.w), 1.0);
}
textureStore(output, p, c);
}
@@ -1,653 +0,0 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// 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.
//! FSR 3.1's optical flow (AMD FidelityFX SDK 1.1.4, `ffx_opticalflow*`, MIT), ported to
//! WGSL (`optical_flow.wgsl`): block motion between the previous frame's colour and this
//! one's, and scene change detection, for frame interpolation (`interpolation.rs`).
//!
//! As in `ffx_opticalflow.cpp`: luma at the display size and 6 halvings, ping-ponged
//! between frames; a histogram of the luma for scene change detection; then from the
//! coarsest of 7 levels down, a block search seeded by the level above, a 3x3 filter and
//! a scale to the next level. The ported passes differ only where WGSL needs it: the luma
//! levels are `R32Float` holding AMD's 8-bit integers, wave operations are workgroup
//! atomics, and the luma pyramid is one dispatch per level instead of one for all (SPD).
//! The luma textures aren't cleared on a reset (AMD's are): its first 6 frames report a
//! scene change and no motion, and rewrite them.
//!
//! The vectors point from a block of this frame to where it was in the previous one, in
//! display pixels: content moving right by 4 pixels gives (-4, 0).
/// The size in pixels of the square blocks the flow has one vector for.
pub const BLOCK_SIZE: u32 = 8;
/// `OpticalFlowMaxPyramidLevels`: the search's levels.
const LEVELS: usize = 7;
/// `HistogramBins * HistogramsPerDim²`: the scene change histograms.
const HISTOGRAM_WIDTH: u64 = 256 * 9;
/// `optical_flow.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
display: [i32; 2],
level: u32,
level_count: u32,
frame_index: u32,
transfer_function: u32,
min_max_luminance: [f32; 2],
}
struct Pipelines {
prepare_luma: wgpu::ComputePipeline,
luma_pyramid: wgpu::ComputePipeline,
scd_histogram: wgpu::ComputePipeline,
scd_divergence: wgpu::ComputePipeline,
search: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
scale: wgpu::ComputePipeline,
}
/// One frame parity's bindings: which luma pyramid is this frame's.
struct Frame {
/// This frame's luma at the display size, where `prepare_luma` writes.
luma0: wgpu::TextureView,
luma_pyramid: [wgpu::BindGroup; LEVELS - 1],
scd_histogram: wgpu::BindGroup,
search: [wgpu::BindGroup; LEVELS],
scale: [wgpu::BindGroup; LEVELS - 1],
}
pub struct OpticalFlow {
display: [u32; 2],
pipelines: Pipelines,
/// One per level, the level's constants (the passes outside the search use level 0's).
params: Vec<wgpu::Buffer>,
/// The bindings of the frames whose luma lands in pyramid 0, and in pyramid 1.
frames: [Frame; 2],
filter: [wgpu::BindGroup; LEVELS],
scd_divergence: wgpu::BindGroup,
scd_histogram: wgpu::Buffer,
scd_previous_histogram: wgpu::Buffer,
scd_temp: wgpu::Buffer,
scd_output: wgpu::Buffer,
vectors: wgpu::TextureView,
flow_sizes: [[u32; 2]; LEVELS],
/// `resourceFrameIndex` and `frameIndex`.
resource_frame: u32,
frame_index: u32,
first: bool,
}
fn texture(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn buffer(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
/// Bind `entries` (binding number, resource) for `pipeline`'s own layout.
fn bind(
device: &wgpu::Device,
pipeline: &wgpu::ComputePipeline,
entries: &[(u32, wgpu::BindingResource)],
) -> wgpu::BindGroup {
let entries: Vec<_> = entries
.iter()
.map(|(binding, resource)| wgpu::BindGroupEntry {
binding: *binding,
resource: resource.clone(),
})
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Optical flow"),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
})
}
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
wgpu::BindingResource::TextureView(v)
}
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
b.as_entire_binding()
}
impl OpticalFlow {
/// For frames of `display` size.
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Optical flow"),
source: wgpu::ShaderSource::Wgsl(include_str!("optical_flow.wgsl").into()),
});
let pipeline = |entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let pipelines = Pipelines {
prepare_luma: pipeline("prepare_luma"),
luma_pyramid: pipeline("luma_pyramid"),
scd_histogram: pipeline("scd_histogram_pass"),
scd_divergence: pipeline("scd_divergence_pass"),
search: pipeline("search_pass"),
filter: pipeline("filter_flow"),
scale: pipeline("scale_pass"),
};
let params: Vec<_> = (0..LEVELS)
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Optical flow constants"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
})
.collect();
let luma: [Vec<wgpu::TextureView>; 2] = [0, 1].map(|_| {
(0..LEVELS)
.map(|k| {
let size = display.map(|v| (v >> k).max(1));
texture(
device,
"Optical flow luma",
size,
wgpu::TextureFormat::R32Float,
)
})
.collect()
});
let mut flow_sizes = [[0; 2]; LEVELS];
flow_sizes[0] = display.map(|v| v.div_ceil(BLOCK_SIZE));
for l in 1..LEVELS {
flow_sizes[l] = flow_sizes[l - 1].map(|v| v.div_ceil(2));
}
let flow = |label| -> Vec<wgpu::TextureView> {
flow_sizes
.iter()
.map(|&size| texture(device, label, size, wgpu::TextureFormat::Rg32Sint))
.collect()
};
// Per level: the scaled prediction from the level above, the search's vectors and
// the filtered ones (level 0's are the output).
let (predicted, searched, filtered) = (
flow("Optical flow prediction"),
flow("Optical flow search"),
flow("Optical flow"),
);
let scd_histogram = buffer(device, "Scene change histogram", HISTOGRAM_WIDTH * 4);
let scd_previous_histogram = buffer(
device,
"Scene change previous histogram",
HISTOGRAM_WIDTH * 4,
);
let scd_temp = buffer(device, "Scene change temp", 12);
let scd_output = buffer(device, "Scene change detection", 12);
let frame = |current: usize| {
let (cur, prev) = (&luma[current], &luma[1 - current]);
Frame {
luma0: cur[0].clone(),
luma_pyramid: std::array::from_fn(|k| {
bind(
device,
&pipelines.luma_pyramid,
&[(2, view(&cur[k + 1])), (3, view(&cur[k]))],
)
}),
scd_histogram: bind(
device,
&pipelines.scd_histogram,
&[
(0, whole(&params[0])),
(3, view(&cur[0])),
(7, whole(&scd_histogram)),
],
),
search: std::array::from_fn(|l| {
bind(
device,
&pipelines.search,
&[
(0, whole(&params[l])),
(3, view(&cur[l])),
(4, view(&prev[l])),
(5, view(&predicted[l])),
(6, view(&searched[l])),
(10, whole(&scd_output)),
],
)
}),
scale: std::array::from_fn(|i| {
let l = i + 1;
bind(
device,
&pipelines.scale,
&[
(0, whole(&params[l])),
(3, view(&cur[l])),
(4, view(&prev[l])),
(5, view(&filtered[l])),
(6, view(&predicted[l - 1])),
(10, whole(&scd_output)),
],
)
}),
}
};
let frames = [frame(0), frame(1)];
let filter = std::array::from_fn(|l| {
bind(
device,
&pipelines.filter,
&[(5, view(&searched[l])), (6, view(&filtered[l]))],
)
});
let scd_divergence = bind(
device,
&pipelines.scd_divergence,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(10, whole(&scd_output)),
],
);
Self {
display,
pipelines,
params,
frames,
filter,
scd_divergence,
scd_histogram,
scd_previous_histogram,
scd_temp,
scd_output,
vectors: filtered[0].clone(),
flow_sizes,
resource_frame: 0,
frame_index: 0,
first: true,
}
}
/// The flow from the previous call's `color` to this one's. `color`: `display` size,
/// a float texture holding Horizon's frame (gamma-encoded RGB, the swapchain's
/// values: AMD's SDR back buffer). `reset`: the previous frame is unrelated (first
/// frame, resize, teleport).
pub fn dispatch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
reset: bool,
) {
let reset = reset || self.first;
self.first = false;
self.frame_index = if reset { 0 } else { self.frame_index + 1 };
if reset {
for b in [
&self.scd_temp,
&self.scd_output,
&self.scd_histogram,
&self.scd_previous_histogram,
] {
encoder.clear_buffer(b, 0, None);
}
}
for (level, buffer) in self.params.iter().enumerate() {
let params = Params {
display: self.display.map(|v| v as i32),
level: level as u32,
level_count: LEVELS as u32,
frame_index: self.frame_index,
transfer_function: 0,
min_max_luminance: [0.0, 1.0],
};
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&params));
}
let frame = &self.frames[(self.resource_frame & 1) as usize];
let prepare = bind(
device,
&self.pipelines.prepare_luma,
&[
(0, whole(&self.params[0])),
(1, view(color)),
(2, view(&frame.luma0)),
],
);
let p = &self.pipelines;
let [w, h] = self.display;
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Optical flow"),
timestamp_writes: None,
});
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, x, y, z| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(x, y, z);
};
run(
&p.prepare_luma,
&prepare,
(w.div_ceil(2)).div_ceil(16),
(h.div_ceil(2)).div_ceil(16),
1,
);
for (k, group) in frame.luma_pyramid.iter().enumerate() {
let size = self.display.map(|v| (v >> (k + 1)).max(1));
run(
&p.luma_pyramid,
group,
size[0].div_ceil(8),
size[1].div_ceil(8),
1,
);
}
run(
&p.scd_histogram,
&frame.scd_histogram,
((w / 4) / 3).div_ceil(32),
16,
9,
);
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
for level in (0..LEVELS).rev() {
let luma = self.display.map(|v| (v >> level).max(1));
run(
&p.search,
&frame.search[level],
(luma[0].div_ceil(4) * 16).div_ceil(64),
luma[1].div_ceil(16),
1,
);
let size = self.flow_sizes[level];
run(
&p.filter,
&self.filter[level],
size[0].div_ceil(16),
size[1].div_ceil(4),
1,
);
if level > 0 {
let next = self.flow_sizes[level - 1];
run(
&p.scale,
&frame.scale[level - 1],
next[0].div_ceil(4),
next[1].div_ceil(4),
1,
);
}
}
drop(pass);
self.resource_frame = (self.resource_frame + 1) % 16;
}
/// This frame's vectors, one per `BLOCK_SIZE` block: `Rg32Sint`, the display size
/// over `BLOCK_SIZE` rounded up,
/// holding the values FSR's `R16G16_SINT` optical flow vector texture holds.
pub fn vectors(&self) -> &wgpu::TextureView {
&self.vectors
}
/// FSR's scene change detection texture (`R32_UINT`, 3 texels) as a storage
/// buffer of 3 `u32`: element `i` holds texel `i`'s value.
pub fn scene_change(&self) -> &wgpu::Buffer {
&self.scd_output
}
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("optical_flow.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
/// without a GPU.
pub fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Noise in 4x4 pixel cells, moved by `shift` pixels: what was at `p` is at `p + shift`.
/// A `seed` of 100 or more gives cells of one primary each.
pub fn pattern(size: [u32; 2], seed: u32, shift: [i32; 2]) -> Vec<u8> {
let mut pixels = Vec::with_capacity((size[0] * size[1] * 4) as usize);
for y in 0..size[1] as i32 {
for x in 0..size[0] as i32 {
let (cx, cy) = ((x - shift[0]).div_euclid(4), (y - shift[1]).div_euclid(4));
let mut h = (cx as u32).wrapping_mul(0x9e37_79b9)
^ (cy as u32).wrapping_mul(0x85eb_ca6b)
^ seed.wrapping_mul(0xc2b2_ae35);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
let v = (h & 0xff) as u8;
if seed >= 100 {
// Primaries: no two neighbours' colours point the same way.
let mut c = [0, 0, 0, 255];
c[(h >> 8) as usize % 3] = 128 + v / 2;
pixels.extend_from_slice(&c);
} else {
pixels.extend_from_slice(&[v, v.wrapping_add(40), v / 2, 255]);
}
}
}
pixels
}
pub fn upload(
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
pixels: &[u8],
) -> wgpu::TextureView {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
texture.size(),
);
texture.create_view(&Default::default())
}
/// `texture`'s texels (`texel` bytes each), row by row.
pub fn read(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &wgpu::Texture,
texel: u32,
) -> Vec<u8> {
let size = texture.size();
let row = (size.width * texel).next_multiple_of(256);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(row * size.height),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: None,
},
},
size,
);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = buffer.slice(..).get_mapped_range().unwrap();
mapped
.chunks(row as usize)
.flat_map(|r| r[..(size.width * texel) as usize].to_vec())
.collect()
}
fn run(
device: &wgpu::Device,
queue: &wgpu::Queue,
flow: &mut OpticalFlow,
color: &wgpu::TextureView,
reset: bool,
) {
let mut encoder = device.create_command_encoder(&Default::default());
flow.dispatch(device, queue, &mut encoder, color, reset);
queue.submit([encoder.finish()]);
}
fn vectors(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> Vec<[i32; 2]> {
read(device, queue, flow.vectors.texture(), 8)
.chunks(8)
.map(|c| {
[
i32::from_le_bytes(c[0..4].try_into().unwrap()),
i32::from_le_bytes(c[4..8].try_into().unwrap()),
]
})
.collect()
}
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
#[test]
fn finds_a_shift_and_a_scene_change() {
let Some((device, queue)) = device() else {
return;
};
let size = [512, 288];
let still = upload(&device, &queue, size, &pattern(size, 1, [0, 0]));
let moved = upload(&device, &queue, size, &pattern(size, 1, [16, 8]));
// Scene changes are told by the luma's histogram: a much darker frame.
let dark: Vec<u8> = pattern(size, 2, [0, 0]).iter().map(|v| v / 5).collect();
let other = upload(&device, &queue, size, &dark);
let mut flow = OpticalFlow::new(&device, size);
// The first 6 frames after a reset are scene changes.
run(&device, &queue, &mut flow, &still, true);
for _ in 0..6 {
run(&device, &queue, &mut flow, &still, false);
}
run(&device, &queue, &mut flow, &moved, false);
let [w, h] = flow.flow_sizes[0];
let found = vectors(&device, &queue, &flow);
let inside: Vec<_> = (4..h - 4)
.flat_map(|y| (4..w - 4).map(move |x| (x, y)))
.map(|(x, y)| found[(y * w + x) as usize])
.collect();
let right = inside.iter().filter(|v| **v == [-16, -8]).count();
assert!(
right * 10 >= inside.len() * 9,
"{right} of {} blocks: {:?}",
inside.len(),
&inside[..8]
);
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
};
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
run(&device, &queue, &mut flow, &other, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
}
}
@@ -1,537 +0,0 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// 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.
// FidelityFX SDK 1.1.4's optical flow (ffx_opticalflow_*.h, the GLSL path), ported to WGSL
// for Horizon. What differs from AMD's (optical_flow.rs lists why):
// - luma levels are r32float (AMD: r8ui); loads truncate to the same integers;
// - the luma pyramid is one pass per level instead of SPD's single pass;
// - wave sums and minimums are workgroup atomics, and the reductions AMD leaves to a
// wave's lockstep have barriers;
// - the scene change textures are storage buffers;
// - the search reads its prediction from one texture and writes another (rg32sint can't
// be read_write everywhere).
struct Params {
// Display size (cbOF.iInputLumaResolution).
display: vec2<i32>,
level: u32,
level_count: u32,
frame_index: u32,
transfer_function: u32,
min_max_luminance: vec2<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var input_color: texture_2d<f32>;
@group(0) @binding(2) var luma_out: texture_storage_2d<r32float, write>;
// The current frame's luma at this level, and the previous frame's.
@group(0) @binding(3) var luma_in: texture_2d<f32>;
@group(0) @binding(4) var luma_previous: texture_2d<f32>;
// Search: the prediction from the level above; filter: the search; scale: the filtered.
@group(0) @binding(5) var flow_in: texture_2d<i32>;
@group(0) @binding(6) var flow_out: texture_storage_2d<rg32sint, write>;
@group(0) @binding(7) var<storage, read_write> scd_histogram: array<atomic<u32>>;
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let size = vec2<i32>(textureDimensions(t));
if any(p < vec2(0)) || any(p >= size) {
return 0u;
}
return u32(textureLoad(t, p, 0).x);
}
fn load_flow(p: vec2<i32>) -> vec2<i32> {
let size = vec2<i32>(textureDimensions(flow_in));
if any(p < vec2(0)) || any(p >= size) {
return vec2(0);
}
return textureLoad(flow_in, p, 0).xy;
}
fn store_flow(p: vec2<i32>, v: vec2<i32>) {
let size = vec2<i32>(textureDimensions(flow_out));
if all(p >= vec2(0)) && all(p < size) {
textureStore(flow_out, p, vec4(v, 0, 0));
}
}
fn packed_luma(width: i32, x: i32, luma0: u32, luma1: u32, luma2: u32, luma3: u32) -> u32 {
var packed = luma0 | (luma1 << 8u) | (luma2 << 16u) | (luma3 << 24u);
if x < 0 {
let filler = packed & 0xffu;
if x <= -1 {
packed = (packed << 8u) | filler;
}
if x <= -2 {
packed = (packed << 8u) | filler;
}
if x <= -3 {
packed = (packed << 8u) | filler;
}
} else if x > width - 4 {
let filler = packed & 0xff000000u;
if x >= width - 3 {
packed = (packed >> 8u) | filler;
}
if x >= width - 2 {
packed = (packed >> 8u) | filler;
}
if x >= width - 1 {
packed = (packed >> 8u) | filler;
}
}
return packed;
}
fn load_packed(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let width = params.display.x >> params.level;
let height = params.display.y >> params.level;
let q = vec2(clamp(p.x, 0, width - 4), clamp(p.y, 0, height - 1));
return packed_luma(
width,
p.x,
load_luma(t, q),
load_luma(t, q + vec2(1, 0)),
load_luma(t, q + vec2(2, 0)),
load_luma(t, q + vec2(3, 0)),
);
}
fn sad(a: u32, b: u32) -> u32 {
return u32(abs(i32(a & 0xffu) - i32(b & 0xffu))
+ abs(i32((a >> 8u) & 0xffu) - i32((b >> 8u) & 0xffu))
+ abs(i32((a >> 16u) & 0xffu) - i32((b >> 16u) & 0xffu))
+ abs(i32((a >> 24u) & 0xffu) - i32((b >> 24u) & 0xffu)));
}
fn qsad(a0_in: u32, a1_in: u32, b: u32) -> vec4<u32> {
var a0 = a0_in;
var a1 = a1_in;
var s: vec4<u32>;
s.x = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
a1 = a1 >> 8u;
s.y = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
a1 = a1 >> 8u;
s.z = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
s.w = sad(a0, b);
return s;
}
fn is_scene_changed() -> bool {
if params.frame_index <= 5u {
return true;
}
return (atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) & 0xfu) != 0u;
}
// ffx_opticalflow_prepare_luma.h, for an SDR back buffer (transfer function 0: Horizon's
// frames); the HDR transfer functions follow AMD's.
fn luminance_to_perceived(luminance: f32) -> f32 {
var perceived: f32;
if luminance <= 216.0 / 24389.0 {
perceived = luminance * (24389.0 / 27.0);
} else {
perceived = pow(luminance, 1.0 / 3.0) * 116.0 - 16.0;
}
return perceived * 0.01;
}
fn linear_from_pq(x: vec3<f32>) -> vec3<f32> {
let p = pow(x, vec3(0.0126833));
return pow(clamp(p - vec3(0.835938), vec3(0.0), vec3(1.0)) / (vec3(18.8516) - 18.6875 * p), vec3(6.27739));
}
@compute @workgroup_size(16, 16)
fn prepare_luma(@builtin(global_invocation_id) gid: vec3<u32>) {
for (var y = 0; y < 2; y++) {
for (var x = 0; x < 2; x++) {
let pos = vec2<i32>(gid.xy) * 2 + vec2(x, y);
if any(pos >= params.display) {
continue;
}
let c = textureLoad(input_color, pos, 0).rgb;
var y_lum = 0.0;
if params.transfer_function == 0u {
y_lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
} else if params.transfer_function == 1u {
let linear = linear_from_pq(c) * (10000.0 / params.min_max_luminance.y);
y_lum = luminance_to_perceived(dot(linear, vec3(0.2627, 0.678, 0.0593)));
} else if params.transfer_function == 2u {
let mn = params.min_max_luminance.x;
let mx = params.min_max_luminance.y;
let linear = (c - vec3(mn / 80.0)) / vec3((mx - mn) / 80.0);
y_lum = luminance_to_perceived(dot(linear, vec3(0.2126, 0.7152, 0.0722)));
}
textureStore(luma_out, pos, vec4(f32(u32(y_lum * 255.0)), 0.0, 0.0, 0.0));
}
}
}
// ffx_opticalflow_compute_luminance_pyramid.h: SPD's average of 2x2, a level per pass.
@compute @workgroup_size(8, 8)
fn luma_pyramid(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if any(p >= vec2<i32>(textureDimensions(luma_out))) {
return;
}
let s = p * 2;
let v = (textureLoad(luma_in, s, 0).x + textureLoad(luma_in, s + vec2(1, 0), 0).x
+ textureLoad(luma_in, s + vec2(0, 1), 0).x + textureLoad(luma_in, s + vec2(1, 1), 0).x) * 0.25;
textureStore(luma_out, p, vec4(v, 0.0, 0.0, 0.0));
}
// ffx_opticalflow_generate_scd_histogram.h
const LBASE: u32 = 10u;
var<workgroup> scd_buffer: array<atomic<u32>, 2560>;
@compute @workgroup_size(32, 8)
fn scd_histogram_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let histograms_per_dim = 3u;
let div = vec2<u32>(params.display) / histograms_per_dim;
let nx = gid.z % histograms_per_dim;
let ny = gid.z / histograms_per_dim;
let start = div * vec2(nx, ny);
let stop = start + div;
let offset = local_index * LBASE;
for (var i = 0u; i < LBASE; i++) {
atomicStore(&scd_buffer[offset + i], 0u);
}
workgroupBarrier();
var coord = vec2<i32>(i32(start.x + 4u * gid.x), i32(start.y + gid.y));
if u32(coord.x) < stop.x {
for (; u32(coord.y) < stop.y; coord.y += 128) {
let color = vec4(
load_luma(luma_in, coord),
load_luma(luma_in, coord + vec2(1, 0)),
load_luma(luma_in, coord + vec2(2, 0)),
load_luma(luma_in, coord + vec2(3, 0)),
) * LBASE;
let scrambling = local_index % LBASE;
atomicAdd(&scd_buffer[color.x + scrambling], 1u);
atomicAdd(&scd_buffer[color.y + scrambling], 1u);
atomicAdd(&scd_buffer[color.z + scrambling], 1u);
atomicAdd(&scd_buffer[color.w + scrambling], 1u);
}
}
workgroupBarrier();
var value = 0u;
for (var i = 0u; i < LBASE; i++) {
value += atomicLoad(&scd_buffer[offset + i]);
}
let histogram_start = 256u * gid.z;
atomicAdd(&scd_histogram[histogram_start + local_index], value);
}
// ffx_opticalflow_compute_scd_divergence.h
var<workgroup> source_histogram: array<f32, 256>;
var<workgroup> filtered_histogram: array<f32, 256>;
var<workgroup> temp_buffer: array<f32, 256>;
var<workgroup> temp_buffer2: array<vec2<f32>, 256>;
@compute @workgroup_size(256)
fn scd_divergence_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let factor = 1000000.0;
let where_to_stop = 3u * 9u - 1u;
let histogram_count = 9.0;
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
let li = i32(local_index);
source_histogram[li] = f32(atomicLoad(&scd_histogram[gid.x]));
workgroupBarrier();
let read = li - 5;
var val = 0.0;
val += kernel[0] * source_histogram[clamp(read + 0, 0, 255)];
val += kernel[1] * source_histogram[clamp(read + 1, 0, 255)];
val += kernel[2] * source_histogram[clamp(read + 2, 0, 255)];
val += kernel[3] * source_histogram[clamp(read + 3, 0, 255)];
val += kernel[4] * source_histogram[clamp(read + 4, 0, 255)];
val += kernel[5] * source_histogram[clamp(read + 5, 0, 255)];
val += kernel[4] * source_histogram[clamp(read + 6, 0, 255)];
val += kernel[3] * source_histogram[clamp(read + 7, 0, 255)];
val += kernel[2] * source_histogram[clamp(read + 8, 0, 255)];
val += kernel[1] * source_histogram[clamp(read + 9, 0, 255)];
val += kernel[0] * source_histogram[clamp(read + 10, 0, 255)];
val += 1.0;
if gid.y == 0u {
if li == 0 {
filtered_histogram[255] = 1.0;
} else {
filtered_histogram[li - 1] = val;
}
} else if gid.y == 1u {
filtered_histogram[li] = val;
} else if gid.y == 2u {
if li == 255 {
filtered_histogram[0] = 1.0;
} else {
filtered_histogram[li + 1] = val;
}
}
workgroupBarrier();
temp_buffer[li] = filtered_histogram[li];
workgroupBarrier();
for (var stride = 128; stride > 0; stride >>= 1u) {
if li < stride {
temp_buffer[li] += temp_buffer[li + stride];
}
workgroupBarrier();
}
filtered_histogram[li] /= temp_buffer[0];
let current = filtered_histogram[li];
let previous = scd_previous_histogram[gid.x];
temp_buffer2[li] = vec2(current * log(current / previous), previous * log(previous / current));
workgroupBarrier();
for (var stride = 128; stride > 0; stride >>= 1u) {
if li < stride {
temp_buffer2[li] += temp_buffer2[li + stride];
}
workgroupBarrier();
}
if li == 0 {
let sum = temp_buffer2[0];
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
let res_uint = u32((res_float / histogram_count) * factor);
atomicAdd(&scd_temp[gid.y], res_uint);
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
if finished == where_to_stop {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / factor;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
if gid.y == 1u {
scd_previous_histogram[gid.x] = current;
atomicStore(&scd_histogram[gid.x], 0u);
}
}
// ffx_opticalflow_compute_optical_flow_v5.h: block matching of 8x8 blocks over +-8 pixels,
// 2x2 blocks per workgroup of 64.
var<workgroup> pixels: array<array<u32, 2>, 8>;
// SearchBufferSizeY (24) rows of SearchBufferSizeX (6) packed lumas.
var<workgroup> search_buffer: array<u32, 144>;
var<workgroup> sad_map: array<array<array<u32, 5>, 16>, 4>;
var<workgroup> wave_min: atomic<u32>;
var<workgroup> wave_sum: atomic<u32>;
var<workgroup> scene_changed: u32;
fn abs_2(v: vec2<i32>) -> vec2<u32> {
let mask = v >> vec2(31u);
return vec2<u32>((v + mask) ^ mask);
}
// FFX_OPTICALFLOW_FIX_TOP_LEFT_BIAS: the nearest of equal SADs wins.
fn encode_search_coord(coord: vec2<i32>) -> u32 {
let a = abs_2(coord - 8);
return (a.y << 12u) | (a.x << 8u) | (u32(coord.y) << 4u) | u32(coord.x);
}
fn decode_search_coord(bits: u32) -> vec2<i32> {
return vec2(i32(bits & 0xfu) - 8, i32((bits >> 4u) & 0xfu) - 8);
}
fn calculate_qsads2(search_id: vec2<i32>) -> vec4<u32> {
var s = vec4(0u);
for (var dy = 0; dy < 8; dy++) {
let row = (search_id.y + dy) * 6;
let a0 = search_buffer[row + search_id.x];
let a1 = search_buffer[row + search_id.x + 1];
let a2 = search_buffer[row + search_id.x + 2];
s += qsad(a0, a1, pixels[dy][0]);
s += qsad(a1, a2, pixels[dy][1]);
}
return s;
}
@compute @workgroup_size(64)
fn search_pass(
@builtin(workgroup_id) group_id: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let li = i32(local_index);
let search_id = vec2(li & 3, (li >> 2u) & 15);
let lane_block = ((li >> 1u) & 1) | (((li >> 5u) & 1) << 1u);
let group = vec2<i32>(group_id.xy);
let px = (group << vec2(4u)) + search_id * vec2(4, 1);
if local_index == 0u {
scene_changed = u32(is_scene_changed());
}
if workgroupUniformLoad(&scene_changed) != 0u {
if (search_id.y & 7) == 0 && (search_id.x & 1) == 0 {
store_flow(px >> vec2(3u), vec2(0));
}
return;
}
let use_prediction = params.level != params.level_count - 1u;
let packed = load_packed(luma_in, px);
let sad_4blocks = sad(packed, load_packed(luma_previous, px));
let of_group = group << vec2(1u);
let pixel_group = group << vec2(4u);
for (var by = 0; by < 2; by++) {
for (var bx = 0; bx < 2; bx++) {
let block = vec2(bx, by);
var current = vec2(0);
if use_prediction {
current = load_flow(of_group + block);
}
if lane_block == by * 2 + bx {
pixels[search_id.y & 7][search_id.x & 1] = packed;
}
// LoadSearchBuffer
let base = pixel_group + block * 8 + current - vec2(8, 8);
for (var id = li; id < 144; id += 64) {
search_buffer[id] = load_packed(luma_previous, vec2(base.x + (id % 6) * 4, base.y + id / 6));
}
if local_index == 0u {
atomicStore(&wave_min, 0xffffffffu);
atomicStore(&wave_sum, 0u);
}
workgroupBarrier();
let q = calculate_qsads2(search_id);
// PrepareSadMap
let sx = search_id.x;
let sy = search_id.y;
sad_map[0][sy][sx] = (q.x << 16u) | encode_search_coord(vec2(sx * 4 + 0, sy));
sad_map[1][sy][sx] = (q.y << 16u) | encode_search_coord(vec2(sx * 4 + 1, sy));
sad_map[2][sy][sx] = (q.z << 16u) | encode_search_coord(vec2(sx * 4 + 2, sy));
sad_map[3][sy][sx] = (q.w << 16u) | encode_search_coord(vec2(sx * 4 + 3, sy));
workgroupBarrier();
// SadMapMinReduction256 and BlockSad64 (FFX_LOCAL_SEARCH_FALLBACK)
let min01 = min(sad_map[0][sy][sx], sad_map[1][sy][sx]);
let min23 = min(sad_map[2][sy][sx], sad_map[3][sy][sx]);
atomicMin(&wave_min, min(min01, min23));
atomicAdd(&wave_sum, select(0u, sad_4blocks, lane_block == bx + by * 2));
workgroupBarrier();
let min_sad = atomicLoad(&wave_min);
let block_sad_sum = atomicLoad(&wave_sum);
var v = current + decode_search_coord(min_sad);
if params.level == 0u && block_sad_sum <= (min_sad >> 16u) {
v = vec2(0);
}
if local_index == 0u {
store_flow(of_group + block, v);
}
// The next block reuses the shared buffers and counters.
workgroupBarrier();
}
}
}
// ffx_opticalflow_filter_optical_flow_v5.h: the vector of the 3x3 nearest to all the others.
@compute @workgroup_size(16, 4)
fn filter_flow(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
var mv: array<vec2<i32>, 9>;
var idx = 0;
for (var xx = -1; xx < 2; xx++) {
for (var yy = -1; yy < 2; yy++) {
mv[idx] = load_flow(p + vec2(xx, yy));
idx++;
}
}
var ret = 0xffffffffu;
for (var i = 0; i < 9; i++) {
var tmp = 0u;
for (var j = 0; j < 9; j++) {
let delta = mv[i] - mv[j];
tmp = u32(delta.x * delta.x) + (u32(delta.y * delta.y) + tmp);
}
ret = min((tmp << 4u) | u32(i), ret);
}
store_flow(p, mv[ret & 0xfu]);
}
// ffx_opticalflow_scale_optical_flow_advanced_v5.h: the next level's vectors, each the
// best of its 4 nearest at this level.
var<workgroup> nearest_vectors: array<array<array<vec2<i32>, 4>, 4>, 4>;
var<workgroup> local_region: array<array<array<u32, 4>, 4>, 4>;
var<workgroup> sads: array<array<array<u32, 4>, 4>, 4>;
@compute @workgroup_size(4, 4, 4)
fn scale_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_id) lid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
if local_index == 0u {
scene_changed = u32(is_scene_changed());
}
if workgroupUniformLoad(&scene_changed) != 0u {
store_flow(vec2<i32>(gid.xy), vec2(0));
return;
}
let x_offset = i32(lid.z % 2u) - 1 + i32(gid.x % 2u);
let y_offset = i32(lid.z / 2u) - 1 + i32(gid.y % 2u);
let src = vec2(i32(gid.x / 2u) + x_offset, i32(gid.y / 2u) + y_offset);
let nearest = load_flow(src);
nearest_vectors[lid.z][lid.y][lid.x] = nearest * 2;
let max_y = 4u;
for (var n = lid.z; n < max_y; n += 4u) {
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n));
local_region[n][lid.y][lid.x] = load_packed(luma_in, luma_pos);
}
workgroupBarrier();
var s = 0u;
for (var n = 0u; n < max_y; n++) {
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n)) + nearest;
s += sad(local_region[n][lid.y][lid.x], load_packed(luma_previous, luma_pos));
}
sads[lid.z][lid.y][lid.x] = s;
workgroupBarrier();
if lid.z == 0u {
var best_sad = 0xffffffffu;
var best = 0u;
for (var n = 0u; n < 4u; n++) {
if sads[n][lid.y][lid.x] < best_sad {
best_sad = sads[n][lid.y][lid.x];
best = n;
}
}
store_flow(vec2<i32>(gid.xy), nearest_vectors[best][lid.y][lid.x]);
}
}
+4 -107
View File
@@ -91,35 +91,9 @@ pub const TIERS: [Tier; 4] = [
full: 0.38,
},
];
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
/// (`mesher::WHOLE`). The tier windows still decide how much.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Masks {
pub parts: f32,
pub whole: f32,
}
pub const MASKS: Masks = Masks {
parts: 0.32,
whole: 0.0,
};
/// A step of HDR calibration (`crate::hdr_calibration`): its test picture is drawn in nits
/// above the controls instead of the frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Calibration {
/// 1 black level, 2 peak brightness, 3 paper white.
pub step: u32,
/// The slider's value, in nits.
pub nits: f32,
/// Where the controls start, as a share of the height.
pub band: f32,
}
pub struct Hdr {
layout: wgpu::BindGroupLayout,
/// Horizon's logo as a 1-bit mask, for calibration's test pictures.
logo: wgpu::TextureView,
pipeline: wgpu::RenderPipeline,
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
@@ -146,19 +120,10 @@ pub struct Hdr {
pub tiers: [Tier; 4],
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
impl Hdr {
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
) -> Self {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
@@ -178,7 +143,6 @@ impl Hdr {
entries: &[
texture(0),
texture(1),
texture(3),
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
@@ -268,13 +232,12 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 96,
size: 80,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
logo: logo_mask(device, queue),
layout,
pipeline: pipeline_for(output.format()),
clear_alpha,
@@ -291,8 +254,6 @@ impl Hdr {
black: 0.0,
tiers: TIERS,
ui: 1.0,
masks: MASKS,
calibration: None,
}
}
@@ -337,10 +298,6 @@ impl Hdr {
binding: 2,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
self.targets = Some(Targets {
@@ -353,16 +310,6 @@ impl Hdr {
self.targets.as_ref().unwrap().frame.clone()
}
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
/// patch through as bright as the display goes and the logo under it shows.
pub fn declared_peak(&self) -> f32 {
match self.calibration {
Some(c) if c.step == 2 => 10_000.0,
_ => self.peak,
}
}
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
@@ -483,10 +430,6 @@ impl Hdr {
binding: 2,
resource: self.capture_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
queue.write_buffer(
@@ -499,10 +442,9 @@ impl Hdr {
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
let paper = self.paper_white.max(1.0);
let c = self.calibration;
let mut params = [0.0; 24];
let mut params = [0.0; 20];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -517,9 +459,6 @@ impl Hdr {
params[12 + i] = t.start;
params[16 + i] = t.full;
}
params[20] = c.map_or(0.0, |c| c.step as f32);
params[21] = c.map_or(0.0, |c| c.nits);
params[22] = c.map_or(0.0, |c| c.band);
params
}
@@ -550,47 +489,6 @@ impl Hdr {
}
}
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
/// edges, are the logo; everything else is not.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
.iter()
.map(|p| {
if p[3] > 128 && p[..3].iter().all(|&c| c > 200) {
255
} else {
0
}
})
.collect();
device
.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Horizon HDR calibration logo"),
size: wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&mask,
)
.create_view(&Default::default())
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
@@ -602,7 +500,6 @@ mod tests {
for source in [
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
include_str!("../hdr_stats.wgsl"),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
@@ -1,158 +0,0 @@
//! HDR emission thresholds (`hdr_stats.wgsl`): for every block sprite, the brightness its
//! pixels must reach to shine in HDR output, measured on the GPU from the sprite's own
//! darkest and brightest opaque pixel when HDR output turns on. Until then (and with HDR
//! off) the texture holds zeros: every pixel of a light source may shine, and hdr.wgsl's
//! tier windows alone decide, as before.
use super::hdr::Masks;
pub struct EmissionStats {
/// One texel per sprite: x the layer in its array, y the array.
pub view: wgpu::TextureView,
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
params: wgpu::Buffer,
}
impl EmissionStats {
pub fn new(device: &wgpu::Device, split: u32, arrays: u32) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon HDR emission thresholds"),
size: wgpu::Extent3d {
width: split,
height: arrays,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR emission thresholds"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr_stats.wgsl").into()),
});
let mut entries: Vec<_> = (0..super::SPRITE_ARRAYS)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
})
.collect();
entries.push(wgpu::BindGroupLayoutEntry {
binding: super::SPRITE_ARRAYS,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR emission thresholds"),
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR emission thresholds"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR emission thresholds"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_stats"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_stats"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR emission threshold parameters"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view,
layout,
pipeline,
params,
}
}
/// Measures every sprite of `sprites` (terrain.wgsl's arrays) at `masks`.
pub fn measure(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
sprites: &[wgpu::TextureView],
masks: Masks,
) {
let mut params = [0u8; 16];
params[..4].copy_from_slice(&masks.parts.to_le_bytes());
params[4..8].copy_from_slice(&masks.whole.to_le_bytes());
queue.write_buffer(&self.params, 0, &params);
let mut entries: Vec<_> = sprites
.iter()
.enumerate()
.map(|(binding, view)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: wgpu::BindingResource::TextureView(view),
})
.collect();
entries.push(wgpu::BindGroupEntry {
binding: super::SPRITE_ARRAYS,
resource: self.params.as_entire_binding(),
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR emission thresholds"),
layout: &self.layout,
entries: &entries,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR emission thresholds"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
queue.submit([encoder.finish()]);
}
}
@@ -83,8 +83,7 @@ fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
normal: [0; 4],
pos: c,
uv,
// HDR output: the bolt shines like the sun (entity.wgsl fs_hdr_mark).
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
layer: 2,
color,
light: 240 | 240 << 8,
@@ -57,8 +57,7 @@ fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex
normal: [0; 4],
pos: (o + glam::Vec3::from_array(c)).to_array(),
uv,
// The HDR tier lightning.rs adds; the geometry is vanilla's.
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
layer: 2,
color,
light: 240 | 240 << 8,
+41 -313
View File
@@ -3,7 +3,7 @@
use super::{Frustum, Renderer, SectionGpu};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
lod::{MAX_LEVEL, TileKey},
lod::TileKey,
mesher::SectionMesh,
};
use glam::{DVec3, Vec3};
@@ -45,47 +45,6 @@ impl Pending {
pub(super) struct Tile {
pub mesh: SectionGpu,
bounds: Bounds,
/// When it began to fade in; none while the rest of its job uploads.
shown: Option<Instant>,
/// When its replacements began to fade in; it goes once they have.
dying: Option<Instant>,
/// Dying with nothing to replace it (out of range): it fades out.
alone: bool,
/// Its mesh before near chunks took some of its columns, drawn in its
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<(SectionGpu, Option<Instant>)>,
}
/// How long a tile takes to fade in over the ones it replaces.
pub(super) const FADE: Duration = Duration::from_millis(600);
/// A tile or section fading in: 0 drawn whole, else 1 + `level` * 129 +
/// `shown`, `shown` 1 to 63 sixty-fourths of `FADE` (`fade_out_code`: 65 to
/// 128, fading out). Chunk by chunk (columns of its own grid, wider for
/// coarse tiles: 2^`level` blocks), each starting in turn over the first half
/// and fading over the second, as a fine ordered pattern fixed to the terrain
/// (terrain.wgsl's `faded`). It is drawn a hair toward the camera, the newest
/// most, over the solid tiles it replaces, which stay until it is whole
/// (`upload_lods`): no pixel fought over, no hole. None: nothing to draw yet.
pub(super) fn fade_code(shown: Option<Instant>, level: u8, now: Instant) -> Option<u32> {
let faded = now.saturating_duration_since(shown?).as_secs_f32() / FADE.as_secs_f32();
let shown = (faded * 64.0) as u32 + 1;
Some(if shown >= 64 {
0
} else {
1 + u32::from(level) * 129 + shown
})
}
/// A section past the render distance that far terrain is coming in under:
/// whole, a fading-out code at its start (terrain.wgsl's `fade_depth` draws
/// leaving sections in front of far terrain).
pub(super) const HELD: u32 = 1 + 64;
/// `fade_code` for one leaving since `since`, chunk by chunk as it came, over
/// what stays behind it; once `FADE` has passed it draws nothing.
pub(super) fn fade_out_code(since: Instant, level: u8, now: Instant) -> u32 {
let faded = now.saturating_duration_since(since).as_secs_f32() / FADE.as_secs_f32();
1 + u32::from(level) * 129 + 64 + ((faded * 64.0) as u32 + 1).min(64)
}
#[derive(Clone, Copy)]
@@ -96,17 +55,30 @@ struct Bounds {
impl Bounds {
fn mesh(mesh: &SectionMesh) -> Option<Self> {
let [min, max] = mesh.bounds?;
Some(Self { min, max })
let mut positions = mesh
.solid
.iter()
.chain(&mesh.translucent)
.map(|v| Vec3::from(v.pos));
let first = positions.next()?;
let mut bounds = Self {
min: first,
max: first,
};
for pos in positions {
bounds.min = bounds.min.min(pos);
bounds.max = bounds.max.max(pos);
}
Some(bounds)
}
/// In view: the plan alone decides which tiles are in range (a circle),
/// so a tile leaving it fades out (`upload_lods`). Cut here at the range,
/// tiles popped out as the player moved, before the plan dropped them.
fn visible(self, origin: DVec3, frustum: &Frustum) -> Option<u64> {
fn visible(self, origin: DVec3, distance: f32, frustum: &Frustum) -> Option<u64> {
let min = origin + self.min.as_dvec3();
let max = origin + self.max.as_dvec3();
if !frustum.intersects(min.as_vec3(), max.as_vec3()) {
let nearest = DVec3::ZERO.clamp(min, max);
if nearest.x * nearest.x + nearest.z * nearest.z > f64::from(distance).powi(2)
|| !frustum.intersects(min.as_vec3(), max.as_vec3())
{
return None;
}
Some(((min + max) * 0.5).length_squared().to_bits())
@@ -114,19 +86,12 @@ impl Bounds {
}
/// A tile's instance: like a section's, with the UV step of merged faces.
/// A fading tile's step is negated past its `fade_code`, which the shaders
/// read back as the whole part (they take the step as `fract(abs(w))`).
pub(super) fn instance(origin: [f32; 3], fade: u32) -> [f32; 4] {
let step = 1.0 / crate::mesher::TILED_UV_STEPS;
pub(super) fn instance(origin: [f32; 3]) -> [f32; 4] {
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
step
} else {
-(fade as f32 + step)
},
1.0 / crate::mesher::TILED_UV_STEPS,
]
}
@@ -134,123 +99,45 @@ pub(super) struct Visible {
pub key: TileKey,
pub distance: u64,
pub origin: [f32; 3],
pub fade: u32,
/// Replaced, standing until its replacements are whole: its water goes
/// after theirs (`Renderer::render`'s water tiers), so depth leaves it only
/// where they have none yet.
pub old: bool,
}
impl Tile {
/// The mesh drawn this frame: the previous one while it stands in.
pub fn drawn(&self) -> &SectionGpu {
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
}
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
match &mut self.previous {
Some((mesh, _)) => mesh,
None => &mut self.mesh,
}
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
}
pub fn visible(
&self,
key: TileKey,
camera: DVec3,
distance: f32,
frustum: &Frustum,
now: Instant,
) -> Option<Visible> {
let fade = match self.dying {
Some(since) if self.alone => fade_out_code(since, key.level, now),
_ => fade_code(self.shown, key.level, now)?,
};
let origin = DVec3::from(key.origin().map(f64::from)) - camera;
Some(Visible {
key,
distance: self.bounds.visible(origin, frustum)?,
distance: self.bounds.visible(origin, distance, frustum)?,
origin: origin.as_vec3().to_array(),
fade,
old: self.dying.is_some() || self.previous.is_some(),
})
}
}
impl Renderer {
/// A tile meshed again (finer detail) takes its place at once, but for
/// one meshed around near chunks or finer tiles that are new (`keep`):
/// its old mesh stands in while they fade in. A new one waits for the rest of its job
/// (`upload_lods`), then fades in.
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh, keep: bool) {
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
self.release_mesh(old.mesh);
(old.shown, Some(previous))
}
None if keep => (old.shown, Some((old.mesh, None))),
None => {
self.release_mesh(old.mesh);
(old.shown, None)
}
},
Some(old) => {
self.release_tile(old);
(None, None)
}
None => (None, None),
};
match Bounds::mesh(mesh).zip(self.upload_mesh(mesh)) {
Some((bounds, mesh)) => {
self.lods.insert(
key,
Tile {
mesh,
bounds,
shown,
dying: None,
alone: false,
previous,
},
);
}
None => {
if let Some((previous, _)) = previous {
self.release_mesh(previous);
}
}
}
}
fn release_tile(&mut self, tile: Tile) {
self.release_mesh(tile.mesh);
if let Some((previous, _)) = tile.previous {
self.release_mesh(previous);
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
self.remove_lod(key);
if let Some(bounds) = Bounds::mesh(mesh)
&& let Some(mesh) = self.upload_mesh(mesh)
{
self.lods.insert(key, Tile { mesh, bounds });
}
}
pub fn remove_lod(&mut self, key: TileKey) {
if let Some(tile) = self.lods.remove(&key) {
self.release_tile(tile);
self.release_mesh(tile.mesh);
}
}
pub fn clear_lods(&mut self) {
self.lod_pending = None;
// No far terrain left to fade out over.
for (key, since) in std::mem::take(&mut self.lod_leaving) {
if self
.sections
.find(key)
.is_some_and(|s| s.leaving == Some(since))
{
self.release_section(key);
}
}
for (_, tile) in std::mem::take(&mut self.lods) {
self.release_tile(tile);
self.release_mesh(tile.mesh);
}
self.set_lod_cutout(Default::default());
}
@@ -273,134 +160,14 @@ impl Renderer {
}
/// Uploads queued tile meshes for about `budget` (at least one), then,
/// once all are in, fades them in over the tiles they replace.
/// once all are in, retires the tiles they replace.
pub fn upload_lods(&mut self, budget: Duration) {
let start = Instant::now();
self.release_left(start);
let faded: Vec<_> = self
.lods
.iter()
.filter(|(_, tile)| tile.dying.is_some_and(|d| start - d >= FADE))
.map(|(&key, _)| key)
.collect();
for key in faded {
self.remove_lod(key);
}
let stood_in: Vec<_> = self
.lods
.values_mut()
.filter_map(|tile| match tile.previous {
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
_ => None,
})
.collect();
for (previous, _) in stood_in {
self.release_mesh(previous);
}
// The columns near chunks take in this job, and the tiles holding the
// job's new finer ones: a tile meshed again around them keeps its
// old mesh until they are in, so no hole shows through them.
let mut around_new = FxHashSet::default();
let new = self
.lod_pending
.iter()
.flat_map(|pending| pending.meshes.keys())
.filter(|key| !self.lods.contains_key(key))
.chain(
self.lods
.iter()
.filter(|(_, tile)| tile.shown.is_none())
.map(|(key, _)| key),
);
for &key in new {
let mut key = key;
while key.level < MAX_LEVEL && around_new.insert(key.parent()) {
key = key.parent();
}
}
let taken: Vec<_> = self
.lod_pending
.iter()
.flat_map(|pending| pending.cutout.iter().flatten())
.filter(|column| !self.lod_cutout.contains(column))
.copied()
.collect();
while let Some(pending) = &mut self.lod_pending {
let Some(&key) = pending.meshes.keys().next() else {
let pending = self.lod_pending.take().expect("pending tiles");
// A tile fades in over the ones it replaces. One over nothing
// shows whole: its hidden chunks would open holes to the sky
// or the sea bed (dark patches, as far terrain first loads).
let replaced: FxHashSet<TileKey> = pending
.removed
.iter()
.copied()
.filter(|key| self.lods.contains_key(key))
.collect();
let mut above_replaced = FxHashSet::default();
for &key in &replaced {
let mut key = key;
while key.level < MAX_LEVEL && above_replaced.insert(key.parent()) {
key = key.parent();
}
}
let over_replaced = |key: TileKey| {
let mut ancestor = key;
replaced.contains(&key)
|| above_replaced.contains(&key)
|| (key.level..MAX_LEVEL).any(|_| {
ancestor = ancestor.parent();
replaced.contains(&ancestor)
})
};
// A removed tile no new tile overlaps, and no near chunk takes
// a column of, leaves the plan (out of range): it fades out.
// Over near chunks fading in it stays whole: two patterns
// fading at once left holes between them.
// Two tiles overlap when one is the other or its ancestor:
// `kept` holds the new tiles and the near chunks' columns
// (level 0), `above` their ancestors.
let mut kept: FxHashSet<TileKey> = self
.lods
.iter()
.filter(|(_, tile)| tile.shown.is_none())
.map(|(&key, _)| key)
.collect();
let cut = pending.cutout.as_ref().unwrap_or(&self.lod_cutout);
kept.extend(cut.iter().map(|&(x, z)| TileKey { x, z, level: 0 }));
let mut above = FxHashSet::default();
for &key in &kept {
let mut key = key;
while key.level < MAX_LEVEL && above.insert(key.parent()) {
key = key.parent();
}
}
let alone = |key: TileKey| {
let mut ancestor = key;
!kept.contains(&key)
&& !above.contains(&key)
&& !(key.level..MAX_LEVEL).any(|_| {
ancestor = ancestor.parent();
kept.contains(&ancestor)
})
};
let whole = start.checked_sub(FADE).unwrap_or(start);
for (&key, tile) in &mut self.lods {
if tile.shown.is_none() {
tile.shown = Some(if over_replaced(key) { start } else { whole });
}
if let Some((_, since)) = &mut tile.previous {
since.get_or_insert(start);
}
}
for key in pending.removed {
let alone = alone(key);
if let Some(tile) = self.lods.get_mut(&key)
&& tile.dying.is_none()
{
tile.dying = Some(start);
tile.alone = alone;
}
self.remove_lod(key);
}
if let Some(cutout) = pending.cutout {
self.set_lod_cutout(cutout);
@@ -408,14 +175,7 @@ impl Renderer {
return;
};
let mesh = pending.meshes.remove(&key).expect("pending tile");
let [x, _, z] = key.origin();
let size = key.size();
let keep = mesh.keep
|| around_new.contains(&key)
|| taken.iter().any(|&(cx, cz)| {
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
});
self.upload_lod(key, &mesh, keep);
self.upload_lod(key, &mesh);
if start.elapsed() >= budget {
return;
}
@@ -434,7 +194,7 @@ impl Renderer {
.collect();
for key in released {
self.lod_retiring.remove(&key);
self.leave_section(key);
self.release_section(key);
}
}
}
@@ -444,36 +204,6 @@ mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn tiles_fade_in_on_their_own_grid() {
let start = Instant::now();
let at = |ms| start + Duration::from_millis(ms);
// terrain.wgsl's `faded` reads the level and sixty-fourths back.
let decode = |code: u32| ((code - 1) / 129, (code - 1) % 129);
assert_eq!(fade_code(None, 0, at(0)), None, "waits for its job");
assert_eq!(fade_code(Some(start), 3, at(0)).map(decode), Some((3, 1)));
let mut last = 0;
for ms in [0, 100, 300, 500, 590] {
let (level, shown) = decode(fade_code(Some(start), 10, at(ms)).unwrap());
assert_eq!(level, 10);
assert!(shown > last && shown < 64, "{shown} at {ms} ms");
last = shown;
}
assert_eq!(fade_code(Some(start), 10, at(600)), Some(0), "whole");
// Fading out: 65 to 128, then nothing drawn (128) until released.
let mut last = 64;
for ms in [0, 100, 300, 500, 590] {
let (level, out) = decode(fade_out_code(start, 3, at(ms)));
assert_eq!(level, 3);
assert!(out > last && out < 128, "{out} at {ms} ms");
last = out;
}
assert_eq!(decode(fade_out_code(start, 3, at(700))), (3, 128));
// A section's largest code keeps its UV step exact (terrain_uv).
let w = 129.0f32 + 1.0 / 32768.0;
assert_eq!(w.fract(), 1.0 / 32768.0);
}
#[test]
fn joined_jobs_apply_as_if_in_turn() {
let key = |x| TileKey { x, z: 0, level: 0 };
@@ -503,7 +233,6 @@ mod tests {
let mesh = SectionMesh {
solid: vec![vertex([0.0, -64.0, 0.0]), vertex([1024.0, 100.0, 1024.0])],
translucent: vec![vertex([512.0, 120.0, 512.0])],
bounds: Some([Vec3::new(0.0, -64.0, 0.0), Vec3::new(1024.0, 120.0, 1024.0)]),
..Default::default()
};
let bounds = Bounds::mesh(&mesh).unwrap();
@@ -515,17 +244,16 @@ mod tests {
* crate::camera::view_matrix(0.0, 0.0),
),
};
// The tile origin is far behind, but its far edge reaches the camera.
// Negative world origins therefore remain visible.
// The tile origin is farther than the range, but its far edge reaches
// the camera. Negative world origins therefore remain visible.
assert!(
bounds
.visible(DVec3::new(-1000.0, -80.0, -512.0), &view)
.visible(DVec3::new(-1000.0, -80.0, -512.0), 256.0, &view)
.is_some()
);
// Wholly behind the camera: out of view.
assert!(
bounds
.visible(DVec3::new(-512.0, -80.0, -2000.0), &view)
.visible(DVec3::new(-2000.0, -80.0, -512.0), 256.0, &view)
.is_none()
);
assert!(Bounds::mesh(&SectionMesh::default()).is_none());
@@ -28,9 +28,6 @@ struct HorizonMaterialOut {
@location(5) position: vec3<f32>,
@location(6) spherical: f32,
@location(7) cylindrical: f32,
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
@@ -48,19 +45,6 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.position = pos;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
// The colour's alpha byte (mesher.rs's `fluid_face` and `emitting`): a fluid quad's
// high nibble is 8 + its face (0..5), every other quad keeps 0xF there; the low nibble
// is 15 minus the face's HDR tier. Every corner of a quad carries the same byte, so the
// interpolated value rounds back exactly.
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
out.face = select(0u, high & 7u, fluid);
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
let tier = 15u - (flags & 15u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@vertex fn horizon_material_vs(
@@ -81,13 +65,8 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
translucent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
@@ -112,9 +91,6 @@ pub struct TerrainShape<'a> {
pub sprites: [f64; 2],
/// The world target's colour format.
pub format: wgpu::TextureFormat,
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -134,34 +110,17 @@ fn vanilla_terrain() -> String {
)
}
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
layers.join(", "),
group[0].1
);
}
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`.
fn shader_source(source: &str) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
vanilla_terrain()
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
fn module(stage: &CheckedStage) -> Result<naga::Module, String> {
let text = shader_source(&stage.source);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -175,7 +134,7 @@ impl NativeMaterial {
stage: &CheckedStage,
constants: &[(String, f64)],
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags)?;
let module = module(stage)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
@@ -338,22 +297,15 @@ mod tests {
fn apply_fog(c: vec4<f32>) -> vec4<f32> { return c * gain; }\n\
fn material(input: MaterialInput) -> Surface {\n \
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
let t = vec2<i32>(input.screen * frame.resolution);\n \
let behind = textureLoad(opaque_color, t, 0) * textureLoad(opaque_depth, t, 0);\n \
let mirrored = textureSampleLevel(reflection, reflection_sampler, input.screen, 0.0);\n \
let kind = f32(input.face + input.fluid) * input.emission * mirrored.a;\n \
let frame_of = vec4(cross(input.normal, input.tangent.xyz) * input.tangent.w, kind);\n \
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
return Surface(apply_fog(sprite(lit)));\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let module = module(&stage).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source))));
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -29,20 +29,7 @@ impl NativePack {
constants: &[(String, f64)],
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let mut frame = NativeFrame::new(device);
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.is_some_and(|s| s.reads("reflection"))
&& option.is_none_or(|id| {
constants
.iter()
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let frame = NativeFrame::new(device);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -24,87 +24,20 @@ pub struct NativePost {
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
history_blank: wgpu::Texture,
/// One bind group per pass, for these world views and either the valid history or black.
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// One bind group per pass, for these world views.
groups: Option<(WorldViews, Vec<wgpu::BindGroup>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame.
pub struct NativeFrame {
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
/// Group 3 of the stages that read only the frame (the sky).
pub layout: wgpu::BindGroupLayout,
pub group: wgpu::BindGroup,
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
/// binding 6), copied each frame.
pub opaque_depth: wgpu::Texture,
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
blank: wgpu::TextureView,
reflection_sampler: wgpu::Sampler,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
/// Last frame's view-projection, camera and time (`write`).
last: Option<([[f32; 4]; 4], glam::DVec3, std::time::Instant)>,
/// The eased eye light (block, sky) and being in water and lava (`write`); NaN until the
/// first frame sets them.
eye: [f32; 4],
/// A post stage must use black history after a long gap until it has rendered a frame.
history_reset: bool,
start: std::time::Instant,
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
}
/// A depth texture of `size` the world's depth is copied into.
fn depth_copy(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
copy_target(
device,
"Native shader pack opaque depth",
size,
wgpu::TextureFormat::Depth32Float,
)
}
/// A colour texture of `size` and `format` the world's colour is copied into.
fn color_copy(device: &wgpu::Device, size: [u32; 2], format: wgpu::TextureFormat) -> wgpu::Texture {
copy_target(device, "Native shader pack opaque colour", size, format)
}
fn copy_target(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
}
/// The uniform `frame` at binding 0, read by `visibility`.
@@ -121,24 +54,6 @@ fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
}
}
/// The size of the water mirror for a world of `size`: half of it.
pub fn reflection_size(size: [u32; 2]) -> [u32; 2] {
size.map(|s| s.div_ceil(2).max(1))
}
/// Seconds between frames past which the last one is forgotten (`NativeFrame::write`).
const MAX_FRAME_GAP: f32 = 0.5;
/// `value` moved toward `target` over `dt` seconds by a half-life in seconds; a NaN `value`
/// (the first frame) starts at `target`.
fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
if value.is_nan() {
target
} else {
target + (value - target) * 0.5f32.powf(dt / half_life)
}
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -149,202 +64,32 @@ impl NativeFrame {
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
entries: &[frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT)],
});
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
device,
"Native shader pack reflection blank",
[1, 1],
wgpu::TextureFormat::Rgba8Unorm,
)
.create_view(&Default::default());
let reflection_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack reflection sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&opaque_depth,
&opaque_color,
);
Self {
buffer,
layout,
group,
opaque_depth,
opaque_color,
reflection: blank.clone(),
blank,
reflection_sampler,
reflects: false,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
start: std::time::Instant::now(),
frames: 0,
wetness: (0.0, None),
}
}
fn group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
&opaque_depth.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
})
}
/// Keeps the opaque copies the size of the world's depth `depth` and, where the world's
/// colour `color` can be copied (the scaled target, not the window's own), its size and
/// format; the group is made again when they change.
pub fn fit_opaque(
&mut self,
device: &wgpu::Device,
depth: &wgpu::Texture,
color: Option<&wgpu::Texture>,
) {
let size = [depth.width(), depth.height()];
let (color_size, format) = match color {
Some(c) => ([c.width(), c.height()], c.format()),
None => ([1, 1], self.opaque_color.format()),
};
let color_fits = [self.opaque_color.width(), self.opaque_color.height()] == color_size
&& self.opaque_color.format() == format;
let depth_fits = [self.opaque_depth.width(), self.opaque_depth.height()] == size;
if depth_fits && color_fits {
return;
}
if !color_fits {
self.opaque_color = color_copy(device, color_size, format);
}
if !depth_fits {
self.opaque_depth = depth_copy(device, size);
}
self.regroup(device);
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
let view = view.unwrap_or(&self.blank);
if *view != self.reflection {
self.reflection = view.clone();
self.regroup(device);
}
}
fn regroup(&mut self, device: &wgpu::Device) {
self.group = Self::group(
device,
&self.layout,
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.opaque_depth,
&self.opaque_color,
);
}
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
let now = std::time::Instant::now();
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
}
let view_proj = (Mat4::from_cols_array_2d(&inputs.projection)
* Mat4::from_cols_array_2d(&inputs.view))
.to_cols_array_2d();
inputs.previous_view_projection = view_proj;
// `write` skips title frames, so a gap means a pause or another world: the frame
// before says nothing about this one, and the eased values start over.
match self.last {
Some((previous, camera, time)) if (now - time).as_secs_f32() < MAX_FRAME_GAP => {
inputs.previous_view_projection = previous;
inputs.camera_motion = (frame.camera - camera).as_vec3().to_array();
inputs.delta_time = (now - time).as_secs_f32();
}
_ => {
self.eye = [f32::NAN; 4];
self.history_reset = true;
}
}
self.last = Some((view_proj, frame.camera, now));
// The eye's light and medium eased by half-lives in seconds, from where they are at
// first.
let targets = [
(inputs.eye_light[0], 2.0),
(inputs.eye_light[1], 2.0),
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
];
let dt = inputs.delta_time;
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
*value = eased(*value, target, half_life, dt);
}
let [block, sky, water, lava] = self.eye;
inputs.eye_light_smooth = [block, sky];
inputs.in_water = water;
inputs.in_lava = lava;
let inputs = frame_inputs(frame, size, seconds, self.frames);
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
/// Takes the post stage's pending history reset. It stays pending if post rendering is
/// skipped for a frame, so the first frame it does render still sees black history.
pub(super) fn take_history_reset(&mut self) -> bool {
let reset = self.history_reset;
self.history_reset = false;
reset
}
}
/// The option values of `constants` that `module` declares as overrides (wgpu refuses others).
@@ -406,7 +151,6 @@ impl NativePost {
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(8, float),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -474,10 +218,7 @@ impl NativePost {
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
history: None,
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
format,
})
}
@@ -492,7 +233,6 @@ impl NativePost {
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
@@ -508,10 +248,8 @@ impl NativePost {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
// COPY_SRC: the last pass's input is kept as next frame's `history`.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
@@ -523,28 +261,15 @@ impl NativePost {
target("Native shader pack B"),
],
));
self.history = Some(history_target(device, "Native shader pack history", size));
self.history_valid = false;
self.groups = None;
}
let blank_history = history_reset || !self.history_valid;
if self
.groups
.as_ref()
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
})
.is_some_and(|((color, depth), _)| color == world.0 && depth == world.1)
{
return;
}
let history = if blank_history {
&self.history_blank
} else {
self.history
.as_ref()
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
@@ -580,15 +305,11 @@ impl NativePost {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
],
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
self.groups = Some(((world.0.clone(), world.1.clone()), groups));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -600,14 +321,13 @@ impl NativePost {
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size, history_reset);
self.prepare(device, world, size);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, _, groups)) = &self.groups else {
let Some((_, groups)) = &self.groups else {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
@@ -634,47 +354,9 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
self.history_valid = true;
}
}
}
/// A target of `size` the post stage's `history` is copied into.
fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
}
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
let half_life = if rain > wetness { 30.0 } else { 10.0 };
rain + (wetness - rain) * 0.5f32.powf(dt / half_life)
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
@@ -734,42 +416,14 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
},
frame_index: index,
hide_gui: u32::from(pack.hide_gui),
precipitation: pack.biome_precipitation.clamp(0, 2) as u32,
temperature: pack.temperature,
downfall: pack.rainfall,
wetness: pack.rain,
// Filled by `NativeFrame::write`, which remembers the frames before.
previous_view_projection: m(Mat4::IDENTITY),
camera_motion: [0.0; 3],
delta_time: 0.0,
eye_light_smooth: [level(pack.eye_light[0]), level(pack.eye_light[1])],
in_water: f32::from(u8::from(pack.eye_in as u32 == 1)),
in_lava: f32::from(u8::from(pack.eye_in as u32 == 2)),
water_plane: frame.water_plane.unwrap_or(0.0),
has_reflection: u32::from(frame.water_plane.is_some()),
reflection_resolution: reflection_size(size).map(|s| s as f32),
}
}
#[cfg(test)]
mod tests {
#[test]
fn eased_values_start_at_the_target_and_halve_the_distance() {
use super::eased;
assert_eq!(eased(f32::NAN, 0.25, 2.0, 0.0), 0.25);
assert_eq!(eased(1.0, 0.0, 2.0, 2.0), 0.5);
assert_eq!(eased(1.0, 0.0, 2.0, 0.0), 1.0);
}
use super::*;
use horizon_shader_api::{MANIFEST, Pack, check};
#[test]
fn wetness_rises_slower_than_it_dries() {
assert_eq!(wetness(0.0, 1.0, 30.0), 0.5);
assert_eq!(wetness(1.0, 0.0, 10.0), 0.5);
assert_eq!(wetness(0.25, 0.25, 5.0), 0.25);
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -867,15 +521,7 @@ mod tests {
..Default::default()
});
let view = screen.create_view(&Default::default());
post.run(
&device,
&mut encoder,
(&color, &depth),
&view,
[4, 4],
true,
None,
);
post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
@@ -906,111 +552,4 @@ mod tests {
"{pixel:?}"
);
}
/// `history` is black on the first frame, then what the pass before the last wrote the
/// frame before.
#[test]
fn the_last_pass_reads_last_frames_input_as_history() {
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1
id = \"t\"
name = \"T\"
version = \"1\"
api = \"0.1\"
[[post]]
file = \"a.wgsl\"
[[post]]
file = \"b.wgsl\"
";
let a = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 0.5, 1.0); }
";
let b = "fn post(input: PostInput) -> vec4<f32> { return textureLoad(history, vec2<i32>(input.pixel), 0); }
";
let files = [(MANIFEST, manifest), ("a.wgsl", a), ("b.wgsl", b)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = screen.create_view(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut frame_pixel = |history_reset| {
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
post.run(
&device,
&mut encoder,
(&color, &depth),
&view,
[4, 4],
history_reset,
None,
);
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
buffer.unmap();
pixel
};
assert_eq!(
frame_pixel(true)[..3],
[0, 0, 0],
"black on the first frame"
);
let second = frame_pixel(false);
assert!(
second[..3].iter().all(|&v| v.abs_diff(128) <= 1),
"{second:?}"
);
assert_eq!(frame_pixel(true)[..3], [0, 0, 0], "black after a frame gap");
let fourth = frame_pixel(false);
assert!(
fourth[..3].iter().all(|&v| v.abs_diff(128) <= 1),
"{fourth:?}"
);
}
}
@@ -1,74 +0,0 @@
//! Render hook points for native mods (docs/native-mods.md): empty functions the renderer calls at
//! fixed places of a frame, which a mod's mixins hook like those of
//! `horizon-client/src/game/native_hooks.rs`, and listed with them in `native-hooks.json`. API
//! 58.
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
pub struct WorldDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
pub encoder: &'a mut wgpu::CommandEncoder,
/// The frame's target and a view of it in `color_format`.
pub color: &'a wgpu::Texture,
pub color_view: &'a wgpu::TextureView,
pub color_format: wgpu::TextureFormat,
/// The size of `color`.
pub size: [u32; 2],
/// The world's depth (Depth32Float, sampleable), and its size: smaller than `size` when the
/// world draws at a lower resolution. It holds the frame's depth only when a mod asked for
/// it ([`RenderNeeds::world_depth`]) or something else kept it.
pub depth: &'a wgpu::TextureView,
pub depth_size: [u32; 2],
/// Camera-relative world to clip space, without TAA's jitter, and with it (what the world
/// was drawn with).
pub view_proj: Mat4,
pub jittered: Mat4,
/// The camera's position in the world.
pub camera: DVec3,
/// The camera-relative entity geometry of the frame (`Frame::entities`), quads of 4
/// vertices, and its runs by entity when a mod asked for them
/// ([`RenderNeeds::entity_spans`]) or a shader pack draws them.
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
}
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
/// needs and leaves the rest, so several can ask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RenderNeeds {
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
/// their type's name, block entity runs their block state.
pub entity_spans: bool,
/// Keep the world pass's depth for [`native_world_drawn`] when nothing else does.
pub world_depth: bool,
}
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
#[inline(never)]
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
let _ = world;
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
let _ = needs;
}
/// What the mods need this frame ([`native_render_needs`]).
pub fn render_needs() -> RenderNeeds {
let mut needs = RenderNeeds::default();
native_render_needs(&mut needs);
needs
}
@@ -128,9 +128,6 @@ struct Graph {
/// (`Node.sourceDirections`, as the faces rather than the steps); none for
/// the camera's.
entered: Vec<u8>,
/// Whether any section in the searched top layer was reached. Sections
/// above a clamped box use this conservative sky-band answer.
sky_reached: bool,
/// Per section corner, during a full search: `SEES`, or how far the line
/// from it towards the camera is known to stay in reached sections (the
/// first sample that was not). Sections are only ever added during a
@@ -159,22 +156,8 @@ impl Graph {
)
}
fn contains(&self, key: SectionKey) -> bool {
if let Some(i) = self.index(key) {
self.directions[i] & REACHED != 0
} else if (0..=self.sections).contains(&key.1)
&& key.1 >= self.y0 + self.height
&& (key.0 - self.from.0).abs() <= self.radius
&& (key.2 - self.from.2).abs() <= self.radius
{
// Everything above the highest non-air section is open sky. A
// full search can reach that layer from a neighbouring column,
// so checking only this column's top cell can hide an upper
// section that is visible through the open layer. Once any top
// cell is reached, conservatively keep the whole sky band visible.
self.sky_reached
} else {
false
}
self.index(key)
.is_some_and(|i| self.directions[i] & REACHED != 0)
}
/// The full search from the camera (vanilla's `scheduleFullUpdate`).
fn search(
@@ -191,37 +174,13 @@ impl Graph {
self.min_y = columns.min_y;
self.sections = columns.sections;
self.y0 = -1;
if from.1 >= columns.sections {
self.height = columns.sections + 2;
} else {
let mut max_non_air_y = 0;
for dz in -radius..=radius {
for dx in -radius..=radius {
let Some(column) = columns.map.get(&(from.0 + dx, from.2 + dz)) else {
continue;
};
for (y, &v) in column.iter().enumerate().rev() {
let y = y as i32;
if y <= max_non_air_y {
break;
}
if v != ALL_VISIBLE {
max_non_air_y = y;
break;
}
}
}
}
let top_y = max_non_air_y.max(from.1);
self.height = (top_y + 3).max(3).min(columns.sections + 2);
}
self.height = columns.sections + 2;
let side = (2 * radius + 1) as usize;
let size = side * side * self.height as usize;
self.directions.clear();
self.directions.resize(size, 0);
self.entered.clear();
self.entered.resize(size, 0);
self.sky_reached = false;
self.rays.clear();
self.rays
.resize((side + 1) * (side + 1) * (self.height + 1) as usize, 0);
@@ -235,8 +194,7 @@ impl Graph {
let base = self
.index((from.0 + dx, 0, from.2 + dz))
.expect("column inside the searched box");
let len = (column.len() as i32).min(self.height - 1) as usize;
vis[base..base + len].copy_from_slice(&column[..len]);
vis[base..base + column.len()].copy_from_slice(column);
}
}
queue.clear();
@@ -321,9 +279,6 @@ impl Graph {
while let Some(index) = queue.pop_front() {
let i = index as usize;
let key = self.key(i);
if key.1 == self.y0 + self.height - 1 {
self.sky_reached = true;
}
let directions = self.directions[i];
let entered = self.entered[i];
let exits = if entered == 0 {
@@ -1127,64 +1082,6 @@ mod tests {
);
}
/// A clamped search may conservatively show sections above its searched
/// box, but it must never hide one that the full-height search reaches.
#[test]
fn vertical_clamping_only_adds_overdraw() {
let mut compared = 0;
let mut overdraw = 0;
let mut clamped = 0;
for seed in [0x9e37_79b9, 7, 0xdead_beef, 0x2545_f491] {
for (spread, count, radius) in [(6, 900, 6), (12, 2600, 12)] {
let mut o = Occlusion::new(0, 24);
random_world(&mut o, seed, count, spread);
let camera = at((0, 2, 0)) + DVec3::new(3.3, -5.7, 1.9);
o.settle(camera, radius);
let graph = o.graph.as_ref().expect("searched");
clamped += usize::from(graph.height < o.columns.sections + 2);
let full = reference(&o.columns, camera, radius, true);
let from = o.columns.section_of(camera);
let side = 2 * radius + 1;
let height = o.columns.sections + 2;
let full_contains = |key: SectionKey| {
let (dx, dz, dy) = (key.0 - from.0, key.2 - from.2, key.1 + 1);
if dx.abs() > radius || dz.abs() > radius || !(0..height).contains(&dy) {
return false;
}
full[(((dz + radius) * side + dx + radius) * height + dy) as usize]
};
for x in from.0 - radius..=from.0 + radius {
for z in from.2 - radius..=from.2 + radius {
for y in -1..=o.columns.sections {
let key = (x, y, z);
let expected = full_contains(key);
let actual = o.contains(key);
compared += 1;
assert!(
!expected || actual,
"clamped search hid {key:?} for seed {seed:#x}, spread {spread}, radius {radius}; from={from:?}, y0={}, height={}, expected={expected}, actual={actual}",
graph.y0,
graph.height
);
overdraw += usize::from(actual && !expected);
if actual && !expected {
assert!(
y >= graph.y0 + graph.height,
"overdraw must be above the clamped box: {key:?}, y0={}, height={}",
graph.y0,
graph.height
);
}
}
}
}
}
}
assert!(clamped > 0, "the comparison exercised a clamped search");
assert!(compared > 50_000, "compared {compared} sections");
assert!(overdraw > 0, "the conservative top-cell rule was exercised");
}
/// A line checked again from the sample it failed at, after more
/// sections were reached, ends where checking it from its corner does.
#[test]
@@ -1329,26 +1226,4 @@ mod tests {
assert_eq!(graph.entered, local.entered);
}
}
#[test]
fn vertical_clamping_bounds_height_and_reaches_open_sky() {
let mut o = Occlusion::new(0, 24);
o.set((0, 5, 0), 0); // Wall at section 5
o.settle(at((0, 2, 0)), 4);
let graph = o.graph.as_ref().expect("searched");
assert_eq!(graph.height, 5 + 3);
assert!(o.contains((0, 2, 0)), "camera section is reached");
assert!(o.contains((1, 20, 1)), "open air column is reached");
// An enclosed cave cannot reach the sky
let mut cave = Occlusion::new(0, 24);
for (dx, dy, dz) in STEPS {
cave.set((dx, 1 + dy, dz), 0);
}
cave.settle(at((0, 1, 0)), 4);
assert!(
!cave.contains((0, 10, 0)),
"enclosed cave does not see the sky"
);
}
}
-968
View File
@@ -1,968 +0,0 @@
//! Improved transparency: vanilla 26.3's order-independent transparency
//! (`LevelRenderer.executeOit`). The translucent layer is drawn three times
//! against the opaque world's depth, without writing it, into targets the
//! next stage reads (`oit.wgsl` says what each holds), then a full-screen pass
//! composites the sum. With the option off none of this exists and the world
//! draws as before.
use super::oit_features::Features;
use glam::Mat4;
/// The depth bounds' clear: `LevelRenderer.DEPTH_BOUNDS_CLEAR_COLOR`.
const BOUNDS_CLEAR: wgpu::Color = wgpu::Color {
r: -(f32::MAX as f64),
g: 0.0,
b: 0.0,
a: 0.0,
};
const BOUNDS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
const COEFFICIENT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The shader's bind group: `terrain.wgsl` holds groups 0 and 1.
const GROUP: u32 = 2;
/// `OitStage`.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Stage {
DepthBounds,
Transmittance,
Accumulate,
}
/// What the device must support: max blending on 32-bit float targets.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::FLOAT32_BLENDABLE
}
pub fn supported(device: &wgpu::Device) -> bool {
device
.features()
.contains(wgpu::Features::FLOAT32_BLENDABLE)
}
/// What a stage's pipelines write: `OIT_*_SNIPPET`'s colour targets.
pub(super) fn stage_targets(stage: Stage) -> Vec<Option<wgpu::ColorTargetState>> {
let blend = |operation| wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation,
};
let target = |format, operation| {
Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: blend(operation),
alpha: blend(operation),
}),
write_mask: wgpu::ColorWrites::ALL,
})
};
let add = wgpu::BlendOperation::Add;
match stage {
Stage::DepthBounds => vec![target(BOUNDS_FORMAT, wgpu::BlendOperation::Max)],
Stage::Transmittance => vec![
target(COEFFICIENT_FORMAT, add),
target(COEFFICIENT_FORMAT, add),
],
Stage::Accumulate => vec![target(COEFFICIENT_FORMAT, add)],
}
}
/// `ProjMat[2][2]` and `ProjMat[3][2]`: a device depth `d` is `y / (d + x)`
/// blocks along the view axis (`oit_linear_depth`).
fn projection_terms(projection: &Mat4) -> [f32; 2] {
[projection.z_axis.z, projection.w_axis.z]
}
struct Targets {
size: [u32; 2],
bounds: wgpu::Texture,
culled: wgpu::Texture,
views: Views,
/// The uniform alone, with the culled bounds, with those and the
/// coefficients, for the cull with the unculled bounds, and the composite.
alone: wgpu::BindGroup,
sampled: wgpu::BindGroup,
accumulate: wgpu::BindGroup,
cull: wgpu::BindGroup,
composite: wgpu::BindGroup,
/// The depth the terrain's stages test against while a water mask is
/// drawn (a copy of the world's, plus the masks), and the clouds' own.
terrain_depth: Option<Depth>,
cloud_depth: Option<Depth>,
}
struct Depth {
texture: wgpu::Texture,
view: wgpu::TextureView,
}
impl Depth {
fn new(device: &wgpu::Device, label: &str, size: [u32; 2]) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent(size),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
Self {
view: texture.create_view(&Default::default()),
texture,
}
}
}
fn extent(size: [u32; 2]) -> wgpu::Extent3d {
wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
}
}
/// What the translucent layer's other drawers add to a frame: each draws with
/// a pipeline per stage where it has one.
#[derive(Default)]
pub struct Extras<'a> {
/// `executeOitWaterMask`: the boats' water patches, depth only.
pub water_mask: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>)>,
/// `CloudRenderer.renderOit`: draws the clouds with the pipeline given.
pub clouds: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline)>,
/// "Features, World Border, Weather": draws what the entity, weather and
/// border shaders put after the opaque world, with `Oit::features`'s
/// pipelines of the stage.
pub features: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, Stage)>,
}
struct Views {
bounds: wgpu::TextureView,
culled: wgpu::TextureView,
coefficients: [wgpu::TextureView; 2],
accumulated: wgpu::TextureView,
}
pub struct Oit {
uniform: wgpu::Buffer,
/// Layouts of the groups at `GROUP`: the uniform; with the bounds; with the
/// coefficients; and with the accumulation too.
layouts: [wgpu::BindGroupLayout; 4],
/// One per `Stage`.
terrain: [wgpu::RenderPipeline; 3],
/// The terrain's flat section draws (`Flat`), where the device has them.
flat: Option<[wgpu::RenderPipeline; 3]>,
features: Features,
clouds: [wgpu::RenderPipeline; 3],
cull: wgpu::RenderPipeline,
composite: wgpu::RenderPipeline,
targets: Option<Targets>,
}
impl Oit {
/// `terrain`: the terrain bind group layout that `vs_terrain` reads,
/// `flat` the flat draws' second group, `features` the entity, weather
/// and world border layouts,
/// `clouds` the cloud pipeline's two, `sprites` the terrain's override
/// constants, `format` the world target's.
pub fn new(
device: &wgpu::Device,
terrain: &wgpu::BindGroupLayout,
flat: Option<&wgpu::BindGroupLayout>,
features: [&wgpu::BindGroupLayout; 3],
clouds: [&wgpu::BindGroupLayout; 2],
sprites: [f64; 2],
format: wgpu::TextureFormat,
) -> Self {
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let buffers = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance,
}),
];
let buffers = &buffers[..];
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"))
.into(),
),
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Improved transparency"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let entry = |binding: u32, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty,
count: None,
};
let texture = |binding| {
entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
};
let params = entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
);
let layout = |label, entries: &[wgpu::BindGroupLayoutEntry]| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries,
})
};
let layouts = [
layout("Improved transparency", &[params]),
layout("Improved transparency bounds", &[params, texture(1)]),
layout(
"Improved transparency coefficients",
&[params, texture(1), texture(2), texture(3)],
),
layout(
"Improved transparency composite",
&[params, texture(1), texture(2), texture(3), texture(4)],
),
];
let pipeline_layout = |first: Option<&wgpu::BindGroupLayout>, group: usize| {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency"),
bind_group_layouts: &[first, None, Some(&layouts[group])],
..Default::default()
})
};
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
let add = wgpu::BlendOperation::Add;
let depth = |write| {
Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
})
};
let pipeline = |label,
layout: &wgpu::PipelineLayout,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
targets: &[Option<wgpu::ColorTargetState>],
depth_stencil| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(vertex),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: vertex.starts_with("vs_terrain").then_some(wgpu::Face::Back),
..Default::default()
},
depth_stencil,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets,
}),
multiview_mask: None,
cache: None,
})
};
let terrain_pipeline = |label, fragment, stage| {
pipeline(
label,
&pipeline_layout(Some(terrain), stage as usize),
"vs_terrain",
fragment,
buffers,
&stage_targets(stage),
depth(false),
)
};
let terrain_pipelines = [
terrain_pipeline("OIT depth bounds", "fs_oit_bounds", Stage::DepthBounds),
terrain_pipeline(
"OIT transmittance",
"fs_oit_transmittance",
Stage::Transmittance,
),
terrain_pipeline("OIT accumulate", "fs_oit_accumulate", Stage::Accumulate),
];
// The same fragments over `vs_terrain_flat`: one vertex buffer, and
// the draw table at group 1.
let flat_pipelines = flat.map(|draw| {
let flat_buffers = &buffers[..1];
[
(
"OIT depth bounds (flat)",
"fs_oit_bounds",
Stage::DepthBounds,
),
(
"OIT transmittance (flat)",
"fs_oit_transmittance",
Stage::Transmittance,
),
(
"OIT accumulate (flat)",
"fs_oit_accumulate",
Stage::Accumulate,
),
]
.map(|(label, fragment, stage)| {
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency flat terrain"),
bind_group_layouts: &[
Some(terrain),
Some(draw),
Some(&layouts[stage as usize]),
],
..Default::default()
});
pipeline(
label,
&layout,
"vs_terrain_flat",
fragment,
flat_buffers,
&stage_targets(stage),
depth(false),
)
})
});
let screen = |label, fragment, group, targets: &[_], depth_stencil| {
pipeline(
label,
&pipeline_layout(None, group),
"vs_sky",
fragment,
&[],
targets,
depth_stencil,
)
};
let replace = |format| {
Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})
};
let cull = screen(
"OIT cull depth bounds",
"fs_oit_cull",
1,
&[replace(BOUNDS_FORMAT)],
depth(true),
);
// `RenderPipelines.OIT_COMPOSITE`: premultiplied alpha over the world,
// the nearest see-through depth written.
let premultiplied = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: add,
};
let composite = screen(
"OIT composite",
"fs_oit_composite",
3,
&[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: premultiplied,
alpha: premultiplied,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
);
// The clouds: entity.wgsl's vertex shader and vertex format, with the
// stages' fragments.
let cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency clouds"),
source: wgpu::ShaderSource::Wgsl(
(super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_clouds.wgsl"))
.into(),
),
});
let cloud_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let cloud_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &cloud_attributes,
})];
let cloud_pipeline = |label, fragment, stage: Stage| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency clouds"),
bind_group_layouts: &[
Some(clouds[0]),
Some(clouds[1]),
Some(&layouts[stage as usize]),
],
..Default::default()
}),
),
vertex: wgpu::VertexState {
module: &cloud_shader,
entry_point: Some("vs_cloud"),
compilation_options: Default::default(),
buffers: &cloud_buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
// `OIT_CLOUDS`: only the bounds stage writes the cloud depth,
// so the later stages keep the nearest face of each cloud.
depth_stencil: depth(stage == Stage::DepthBounds),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &cloud_shader,
entry_point: Some(fragment),
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
cache: None,
})
};
let cloud_pipelines = [
cloud_pipeline(
"OIT cloud depth bounds",
"fs_oit_cloud_bounds",
Stage::DepthBounds,
),
cloud_pipeline(
"OIT cloud transmittance",
"fs_oit_cloud_transmittance",
Stage::Transmittance,
),
cloud_pipeline(
"OIT cloud accumulate",
"fs_oit_cloud_accumulate",
Stage::Accumulate,
),
];
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
Self {
uniform,
layouts,
terrain: terrain_pipelines,
flat: flat_pipelines,
features,
clouds: cloud_pipelines,
cull,
composite,
targets: None,
}
}
/// The pipeline the terrain's translucent layer draws with in `stage`.
pub fn terrain(&self, stage: Stage) -> &wgpu::RenderPipeline {
&self.terrain[stage as usize]
}
/// The pipeline the terrain's translucent layer draws with in `stage`
/// as flat section draws, where they run.
pub fn flat(&self, stage: Stage) -> Option<&wgpu::RenderPipeline> {
self.flat.as_ref().map(|f| &f[stage as usize])
}
pub fn features(&self) -> &Features {
&self.features
}
/// Sizes the targets to the world's and writes this frame's projection.
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
projection: &Mat4,
wants: [bool; 2],
) {
queue.write_buffer(
&self.uniform,
0,
bytemuck::cast_slice(&[projection_terms(projection), [0.0; 2]].concat()),
);
let [mask, clouds] = wants;
if let Some(t) = self.targets.as_mut().filter(|t| t.size == size) {
if mask && t.terrain_depth.is_none() {
t.terrain_depth = Some(Depth::new(device, "OIT terrain depth", size));
}
if clouds && t.cloud_depth.is_none() {
t.cloud_depth = Some(Depth::new(device, "OIT cloud depth", size));
}
return;
}
let make = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent(size),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let attachment =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let bounds = make(
"OIT depth bounds",
BOUNDS_FORMAT,
attachment | wgpu::TextureUsages::COPY_SRC,
);
let culled = make(
"OIT culled depth bounds",
BOUNDS_FORMAT,
attachment | wgpu::TextureUsages::COPY_DST,
);
let coefficients =
[0, 1].map(|_| make("OIT transmittance", COEFFICIENT_FORMAT, attachment));
let accumulated = make("OIT accumulate", COEFFICIENT_FORMAT, attachment);
let views = Views {
bounds: bounds.create_view(&Default::default()),
culled: culled.create_view(&Default::default()),
coefficients: coefficients.map(|t| t.create_view(&Default::default())),
accumulated: accumulated.create_view(&Default::default()),
};
let group = |layout: &wgpu::BindGroupLayout, views: &[&wgpu::TextureView]| {
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform.as_entire_binding(),
}];
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
binding: i as u32 + 1,
resource: wgpu::BindingResource::TextureView(v),
}));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Improved transparency"),
layout,
entries: &entries,
})
};
let [c0, c1] = &views.coefficients;
self.targets = Some(Targets {
size,
alone: group(&self.layouts[0], &[]),
sampled: group(&self.layouts[1], &[&views.culled]),
accumulate: group(&self.layouts[2], &[&views.culled, c0, c1]),
cull: group(&self.layouts[1], &[&views.bounds]),
composite: group(
&self.layouts[3],
&[&views.bounds, c0, c1, &views.accumulated],
),
terrain_depth: mask.then(|| Depth::new(device, "OIT terrain depth", size)),
cloud_depth: clouds.then(|| Depth::new(device, "OIT cloud depth", size)),
bounds,
culled,
views,
});
}
/// `LevelRenderer.executeOit`: `draw` records the terrain's translucent
/// layer into each stage's pass with that stage's pipeline, `extras` the
/// water masks and clouds, then the composite goes over `color`. `depth`
/// is the opaque world's: the stages only read it; the cull and the
/// composite write it.
pub fn run(
&self,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
let load = wgpu::LoadOp::Load;
let read_only = |view| {
Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: None,
stencil_ops: None,
})
};
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
let culled = &t.views.culled;
for (stage, label, group, first, then) in [
(
Stage::DepthBounds,
"OIT depth bounds",
&t.alone,
vec![attachment(
&t.views.bounds,
wgpu::LoadOp::Clear(BOUNDS_CLEAR),
)],
// The clouds' bounds join the culled ones.
vec![attachment(culled, load)],
),
(
Stage::Transmittance,
"OIT transmittance",
&t.sampled,
vec![attachment(c0, clear), attachment(c1, clear)],
vec![attachment(c0, load), attachment(c1, load)],
),
(
Stage::Accumulate,
"OIT accumulate",
&t.accumulate,
vec![attachment(&t.views.accumulated, clear)],
vec![attachment(&t.views.accumulated, load)],
),
] {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, stage);
}
if let Some(features) = extras.features {
// Over the world's own depth, even where a water mask patched
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
Stage::Transmittance => c0,
Stage::Accumulate => &t.views.accumulated,
},
load,
)]
.into_iter()
.chain((stage == Stage::Transmittance).then(|| attachment(c1, load)))
.collect::<Vec<_>>(),
depth_stencil_attachment: read_only(depth),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
encoder,
t,
stage,
depth,
(label, group, &then),
(clouds, cloud_depth),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.composite);
pass.set_bind_group(GROUP, &t.composite, &[]);
pass.draw(0..3, 0..1);
}
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
stage: Stage,
depth: &wgpu::TextureView,
(label, group, targets): (
&str,
&wgpu::BindGroup,
&[Option<wgpu::RenderPassColorAttachment<'_>>],
),
(draw, cloud_depth): (
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
depth.texture().as_image_copy(),
cloud_depth.texture.as_image_copy(),
extent(t.size),
);
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
depth_ops: (stage == Stage::DepthBounds).then_some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, &self.clouds[stage as usize]);
}
/// `LevelRenderer.executeOitWaterMask`: the terrain's depth is the world's
/// again, with the boats' water patches on top.
fn execute_water_mask(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
) {
let Some(terrain) = &t.terrain_depth else {
return;
};
encoder.copy_texture_to_texture(
depth.texture().as_image_copy(),
terrain.texture.as_image_copy(),
extent(t.size),
);
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &terrain.view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
mask(&mut pass);
}
/// `LevelRenderer.executeDepthBoundsCull`: the bounds are copied, then where
/// the nearest fully opaque see-through fragment is in front of the world,
/// the far bound stops there and its depth replaces the world's.
fn execute_depth_bounds_cull(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
t.culled.as_image_copy(),
wgpu::Extent3d {
width: t.size[0].max(1),
height: t.size[1].max(1),
depth_or_array_layers: 1,
},
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.cull);
pass.set_bind_group(GROUP, &t.cull, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn validate(source: &str, entries: &[&str]) {
let module = naga::front::wgsl::parse_str(source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
for entry in entries {
assert!(
module.entry_points.iter().any(|e| e.name == *entry),
"{entry}"
);
}
}
#[test]
fn the_terrain_shader_validates() {
validate(
&(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl")),
&[
"fs_oit_bounds",
"fs_oit_transmittance",
"fs_oit_accumulate",
"fs_oit_cull",
"fs_oit_composite",
],
);
}
#[test]
fn the_cloud_shader_validates() {
validate(
&(super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_clouds.wgsl")),
&[
"fs_oit_cloud_bounds",
"fs_oit_cloud_transmittance",
"fs_oit_cloud_accumulate",
],
);
}
#[test]
fn the_feature_shaders_validate() {
let stages = |name: &str| {
["bounds", "transmittance", "accumulate"].map(|s| format!("fs_oit_{name}_{s}"))
};
let entity = ["entity", "shadow", "weather", "swirl"]
.map(stages)
.concat();
validate(
&(super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_features.wgsl")),
&entity.iter().map(String::as_str).collect::<Vec<_>>(),
);
let border = stages("border");
validate(
&(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl")),
&border.iter().map(String::as_str).collect::<Vec<_>>(),
);
}
#[test]
fn a_device_depth_gives_the_distance_along_the_view_axis() {
// The world's reversed-Z projection: near plane at depth 1.
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let [x, y] = projection_terms(&projection);
for distance in [0.1_f32, 1.0, 7.5, 64.0, 500.0] {
let clip = projection * glam::Vec4::new(0.0, 0.0, -distance, 1.0);
let device = clip.z / clip.w;
let linear = y / (device + x);
assert!(
(linear - distance).abs() < distance * 2e-3,
"{distance}: {linear}"
);
}
}
}
Loaded 100 of 1362 files, more files were not shown because too many files have changed in this diff. Show more