mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0cf561ba | ||
|
|
b06a15edee | ||
|
|
a56afe1de4 |
No files matched your search
@@ -7,4 +7,3 @@ kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
|
||||
@@ -9,9 +9,7 @@ permissions:
|
||||
actions: write
|
||||
jobs:
|
||||
cancel:
|
||||
# One of 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:
|
||||
|
||||
+70
-41
@@ -1,7 +1,7 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
|
||||
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
@@ -24,32 +24,19 @@ env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
|
||||
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
|
||||
#
|
||||
# Where: 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
|
||||
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
|
||||
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
|
||||
# verify runs always() and fails unless all three passed.
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@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?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
@@ -65,20 +52,30 @@ jobs:
|
||||
fi
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
env:
|
||||
HOSTED: ${{ runner.environment == 'github-hosted' }}
|
||||
run: |
|
||||
if [ "$HOSTED" = true ]; then
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
fi
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
shared-key: verify-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -87,7 +84,32 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# these 4-core runners it was no faster (#4978).
|
||||
@@ -99,12 +121,24 @@ jobs:
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
verify:
|
||||
if: always()
|
||||
needs: [python, clippy, tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Did python, clippy and tests pass?
|
||||
env:
|
||||
RESULTS: ${{ join(needs.*.result, ' ') }}
|
||||
run: |
|
||||
echo "python, clippy, tests: $RESULTS"
|
||||
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
launcher:
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI == '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 +152,12 @@ jobs:
|
||||
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust and the headers GPUI links against
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
env:
|
||||
HOSTED: ${{ runner.environment == 'github-hosted' }}
|
||||
run: |
|
||||
if [ "$HOSTED" = true ]; then
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
fi
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher
|
||||
@@ -144,8 +174,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
|
||||
@@ -8,9 +8,6 @@ name: launcher
|
||||
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
|
||||
# commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
|
||||
# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
|
||||
# itself instead (launcher/src/backend.rs).
|
||||
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
@@ -22,7 +19,7 @@ on:
|
||||
push:
|
||||
branches: [main, launcher, 'launcher-*']
|
||||
paths:
|
||||
# Everything the launcher is built and packaged from, as tools/release_train.py's LAUNCHER_PATHS.
|
||||
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'crates/horizon-friends/**'
|
||||
@@ -32,8 +29,6 @@ on:
|
||||
- 'crates/horizon-release-sign/**'
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- 'crates/horizon-theme/**'
|
||||
- 'packaging/linux/**'
|
||||
- 'packaging/icons/**'
|
||||
- '.github/workflows/launcher.yml'
|
||||
schedule:
|
||||
- cron: '7-59/10 * * * *'
|
||||
@@ -166,7 +161,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev patchelf
|
||||
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
|
||||
- name: Metal shaders compiled now, or at run time without the Metal toolchain
|
||||
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
|
||||
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
|
||||
@@ -259,13 +254,6 @@ jobs:
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
if [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
# The .tar.gz below is what the launcher updates itself from; the AppImage is what a
|
||||
# player downloads and double-clicks, and it replaces itself instead
|
||||
# (packaging/linux/appimage.sh, launcher/src/backend.rs).
|
||||
../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
|
||||
fi
|
||||
mkdir -p dist
|
||||
cp "$BIN/${{ matrix.binary }}" dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
name: mojang republish
|
||||
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
|
||||
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
|
||||
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
|
||||
# gh workflow run "mojang republish" -f dry_run=true
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Only print what it would do with issues
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: mojang-republish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check the manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
|
||||
@@ -1,55 +0,0 @@
|
||||
name: native hook stamp
|
||||
# Opens a PR that numbers pending fragments after they merge. Only pushes to
|
||||
# main run main's own tool; fragments are read as data, not PR code.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tools/native-hook-fragments/*.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: native-hook-stamp
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
stamp:
|
||||
if: github.repository == 'horizon-rust/horizon'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- name: Open a stamp pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
branch=automation/native-hook-stamp
|
||||
if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
|
||||
echo "A stamp pull request is already open."
|
||||
exit 0
|
||||
fi
|
||||
python3 tools/stamp_native_hooks.py
|
||||
if git diff --quiet; then
|
||||
echo "No fragments to stamp."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git checkout -b "$branch"
|
||||
git add -u
|
||||
git commit -m 'Stamp native hook API fragments'
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
|
||||
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
git push --force-with-lease origin "$branch"
|
||||
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
|
||||
@@ -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,61 +0,0 @@
|
||||
name: pack load bench
|
||||
# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs
|
||||
# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull
|
||||
# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't
|
||||
# start a workflow its main lacks), against horizon-rust/horizon's main.
|
||||
on:
|
||||
push:
|
||||
branches: ['pack-bench/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base:
|
||||
description: Ref to compare against
|
||||
default: main
|
||||
scale:
|
||||
description: How many times the default pack sizes
|
||||
default: '1'
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
CARGO_TERM_COLOR: never
|
||||
|
||||
jobs:
|
||||
bench:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Rust and the ALSA and udev headers
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: pack-bench
|
||||
- name: Bench the branch
|
||||
env:
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md
|
||||
- name: Bench the base, with the branch's bench
|
||||
env:
|
||||
BASE: ${{ inputs.base || 'main' }}
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
|
||||
git worktree add ../base FETCH_HEAD
|
||||
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
|
||||
# A bench row that times a function the base lacks keeps it from building there: then
|
||||
# the branch's own rows compare the two ways.
|
||||
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
|
||||
--example pack_load_bench -- --scale "$SCALE") > base.md \
|
||||
|| echo "The bench does not build on the base." > base.md
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md
|
||||
echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
|
||||
@@ -40,8 +39,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
|
||||
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
@@ -58,7 +56,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -71,23 +68,13 @@ unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
|
||||
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
|
||||
|
||||
## Write tests with signal
|
||||
|
||||
- Test observable behavior, not words in docs, comments or source. Keep checks for machine-consumed contracts.
|
||||
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
|
||||
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
|
||||
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
|
||||
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
|
||||
- Make slow tests faster without removing cases or weakening their coverage.
|
||||
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -104,11 +91,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
|
||||
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
|
||||
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
@@ -133,11 +117,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
|
||||
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
|
||||
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
|
||||
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
|
||||
state stays separate from the library tests. This compiles gameplay twice for executable tests.
|
||||
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
@@ -159,7 +140,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
@@ -168,7 +149,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
|
||||
+2
-6
@@ -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.
|
||||
@@ -113,10 +113,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 +136,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
-603
File diff suppressed because it is too large.
Load diff
+1
-7
@@ -1,14 +1,12 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = [
|
||||
"crates/horizon-core",
|
||||
"crates/horizon-dualsense",
|
||||
"crates/horizon-narrator",
|
||||
"crates/horizon-threads",
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-lib",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
@@ -16,7 +14,6 @@ members = [
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-anticheat",
|
||||
"crates/horizon-chat-filter",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
@@ -31,7 +28,6 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"xtask",
|
||||
@@ -63,8 +59,6 @@ large_enum_variant = "allow"
|
||||
# Drop this and the folder once a wgpu release has these fixes.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
|
||||
naga = { path = "third_party/naga" }
|
||||
|
||||
# Line tables keep file:line in backtraces; full debug info made every
|
||||
# example and test binary hundreds of megabytes and slow to link.
|
||||
|
||||
@@ -111,7 +111,7 @@ What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
|
||||
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/HcTeMZxe5E). Before a pull request, run the self-check and paste its output:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
|
||||
+4
-36
@@ -8,12 +8,6 @@ developers, with upstream fixes applied
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## naga
|
||||
|
||||
`third_party/naga` is [naga](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs developers, with
|
||||
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
|
||||
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## Pixelify Sans
|
||||
|
||||
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
|
||||
@@ -29,14 +23,13 @@ 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
|
||||
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
|
||||
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
|
||||
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
|
||||
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
|
||||
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
|
||||
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
|
||||
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
|
||||
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
|
||||
trademarks; Horizon is not affiliated with AMD.
|
||||
|
||||
## Mod loader logos
|
||||
@@ -66,28 +59,3 @@ with Microsoft accounts
|
||||
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
|
||||
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
|
||||
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
|
||||
|
||||
## CIT Resewn
|
||||
|
||||
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
|
||||
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
|
||||
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
|
||||
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2021 SHsuperCM
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,5 +6,5 @@ authors = []
|
||||
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks, items, mob effects and entity types to your worlds".
|
||||
# registries: "May add blocks, items and mob effects to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -8,16 +8,12 @@
|
||||
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
|
||||
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
|
||||
//! player is sent back where the server last had them); from `alert` on, online operators and the
|
||||
//! console are told; from `kick` on, the player is disconnected. A check's kicks are counted per player
|
||||
//! across sessions ([`Strikes`]), and the kick that reaches its `ban_after` bans the player instead
|
||||
//! (`banned-players.json`, reason "Horizon anticheat: <check>"). Any threshold at 0 turns that action
|
||||
//! off.
|
||||
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
|
||||
//! that action off.
|
||||
//!
|
||||
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
|
||||
//! do, never from another anticheat's code.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
@@ -32,14 +28,12 @@ pub struct Actions {
|
||||
pub kick: f32,
|
||||
/// Violation level lost each second.
|
||||
pub decay: f32,
|
||||
/// The kick, counting this check's earlier ones for the player, that bans instead; 0: never.
|
||||
pub ban_after: u32,
|
||||
}
|
||||
|
||||
macro_rules! checks {
|
||||
($(
|
||||
$(#[$doc:meta])*
|
||||
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr, $ban_after:expr] {
|
||||
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
|
||||
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
|
||||
}
|
||||
)*) => {
|
||||
@@ -65,7 +59,6 @@ macro_rules! checks {
|
||||
pub alert: f32,
|
||||
pub kick: f32,
|
||||
pub decay: f32,
|
||||
pub ban_after: u32,
|
||||
$( $(#[$xdoc])* pub $extra: $ty, )*
|
||||
}
|
||||
|
||||
@@ -77,7 +70,6 @@ macro_rules! checks {
|
||||
alert: $alert,
|
||||
kick: $kick,
|
||||
decay: $decay,
|
||||
ban_after: $ban_after,
|
||||
$($extra: $default,)*
|
||||
}
|
||||
}
|
||||
@@ -95,9 +87,6 @@ macro_rules! checks {
|
||||
pub alert_level: u8,
|
||||
/// Every flag, one line each, relative to the server's folder; empty: no file.
|
||||
pub log: String,
|
||||
/// The players' kicks by check ([`Strikes`]), relative to the server's folder; empty: kept
|
||||
/// only while the server runs.
|
||||
pub strikes: String,
|
||||
$(pub $field: $Config,)*
|
||||
}
|
||||
|
||||
@@ -108,7 +97,6 @@ macro_rules! checks {
|
||||
alerts: true,
|
||||
alert_level: 2,
|
||||
log: "logs/anticheat.log".into(),
|
||||
strikes: "anticheat-strikes.json".into(),
|
||||
$($field: $Config::default(),)*
|
||||
}
|
||||
}
|
||||
@@ -124,13 +112,7 @@ macro_rules! checks {
|
||||
match check {
|
||||
$(Check::$Check => {
|
||||
let c = &self.$field;
|
||||
Actions {
|
||||
setback: c.setback,
|
||||
alert: c.alert,
|
||||
kick: c.kick,
|
||||
decay: c.decay,
|
||||
ban_after: c.ban_after,
|
||||
}
|
||||
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
|
||||
})*
|
||||
}
|
||||
}
|
||||
@@ -143,7 +125,7 @@ checks! {
|
||||
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
|
||||
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
|
||||
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02, 0] {
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
|
||||
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
|
||||
tolerance_ms: f64 = 300.0,
|
||||
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
|
||||
@@ -153,12 +135,9 @@ checks! {
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
|
||||
/// fall damage follows (the player is never sent back: `setback = 0` makes the check log only), and
|
||||
/// a player that keeps claiming is kicked, then banned at the second kick (`ban_after = 2`). A claim standing still isn't
|
||||
/// judged (no fall to skip). On the official server's first two hours (67 players), every player who
|
||||
/// wasn't flying or walking on water peaked at level 7 (4 claims standing still in one second); the
|
||||
/// ones who were, at 113 to 3749.
|
||||
ground: Ground => GroundConfig [10.0, 15.0, 30.0, 2.0, 2] {}
|
||||
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
|
||||
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
|
||||
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
@@ -170,7 +149,7 @@ checks! {
|
||||
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
|
||||
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
|
||||
/// `kick = 60`.
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0, 0] {
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
|
||||
/// How far, in blocks, a move may land from where the replay puts it.
|
||||
tolerance: f64 = 0.03,
|
||||
}
|
||||
@@ -178,9 +157,8 @@ checks! {
|
||||
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
|
||||
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
|
||||
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default: the first
|
||||
/// kick warns, the second bans (`ban_after = 2`).
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5, 2] {}
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
@@ -229,33 +207,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times each check kicked each player, kept across sessions (`strikes`, a JSON object of
|
||||
/// players' UUIDs to check names to counts): the kick that reaches a check's `ban_after` is a ban.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Strikes(pub BTreeMap<String, BTreeMap<String, u32>>);
|
||||
|
||||
impl Strikes {
|
||||
/// Player `uuid` was kicked by `check`: its kicks by it so far, this one included, and whether this
|
||||
/// one bans. A ban starts the count again, so a player an operator pardons is kicked before the
|
||||
/// next ban.
|
||||
pub fn kick(&mut self, uuid: &str, check: Check, ban_after: u32) -> (u32, bool) {
|
||||
let count = self
|
||||
.0
|
||||
.entry(uuid.to_owned())
|
||||
.or_default()
|
||||
.entry(check.name().to_owned())
|
||||
.or_default();
|
||||
*count = count.saturating_add(1);
|
||||
let kicks = *count;
|
||||
let ban = ban_after > 0 && kicks >= ban_after;
|
||||
if ban {
|
||||
*count = 0;
|
||||
}
|
||||
(kicks, ban)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flag leads to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Verdict {
|
||||
@@ -281,13 +232,6 @@ pub struct Player {
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
@@ -333,19 +277,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_by_default_but_only_combat_and_ground_act() {
|
||||
fn on_by_default_but_only_combat_acts() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
for &check in Check::ALL {
|
||||
assert!(c.runs(check), "{}", check.name());
|
||||
let a = c.actions(check);
|
||||
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
|
||||
assert_eq!(
|
||||
acts,
|
||||
matches!(check, Check::Combat | Check::Ground),
|
||||
"{}",
|
||||
check.name()
|
||||
);
|
||||
assert_eq!(acts, check == Check::Combat, "{}", check.name());
|
||||
}
|
||||
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
|
||||
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
|
||||
@@ -397,25 +336,6 @@ mod tests {
|
||||
assert!(v.kick && v.alert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kick_that_reaches_ban_after_bans() {
|
||||
let c = Config::default();
|
||||
assert_eq!(c.actions(Check::Combat).ban_after, 2);
|
||||
assert_eq!(c.actions(Check::Prediction).ban_after, 0);
|
||||
let mut s = Strikes::default();
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
|
||||
// Another player, another check: their own counts.
|
||||
assert_eq!(s.kick("b", Check::Combat, 2), (1, false));
|
||||
assert_eq!(s.kick("a", Check::Timer, 2), (1, false));
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (2, true));
|
||||
// Pardoned, the player is kicked once more before the next ban.
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
|
||||
// 0 never bans; 1 bans at the first kick.
|
||||
assert_eq!(s.kick("c", Check::Timer, 0), (1, false));
|
||||
assert_eq!(s.kick("c", Check::Timer, 0), (2, false));
|
||||
assert_eq!(s.kick("d", Check::Ground, 1), (1, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_turns_an_action_off() {
|
||||
let mut c = on();
|
||||
|
||||
@@ -33,21 +33,9 @@ pub enum Change {
|
||||
Velocity([f64; 3]),
|
||||
/// ClientboundExplodePacket's player knockback, added to the velocity.
|
||||
Knockback([f64; 3]),
|
||||
/// ClientboundUpdateAttributesPacket for the player itself: the server's attributes as they
|
||||
/// are when the replay reaches it, but for the movement speed's sprint modifier, which the
|
||||
/// client's sprinting decides: `sprint` is whether the packet had it when it carried the
|
||||
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
|
||||
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
|
||||
Attributes { sprint: Option<bool> },
|
||||
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
|
||||
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
|
||||
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
|
||||
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
|
||||
Sprinting(bool),
|
||||
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
|
||||
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
|
||||
/// are the server's: they reach the client as attributes.)
|
||||
Effects,
|
||||
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
|
||||
/// attributes and effects, as they are when the replay reaches it.
|
||||
State,
|
||||
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
|
||||
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
|
||||
/// tick played before the client had them may be off.
|
||||
@@ -398,16 +386,16 @@ mod tests {
|
||||
fn changes_keep_the_server_tick_that_sent_them() {
|
||||
let mut r = Record::default();
|
||||
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
|
||||
r.changed(5, Change::Effects);
|
||||
r.changed(5, Change::State);
|
||||
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
|
||||
assert_eq!(
|
||||
all,
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::Effects)]
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
|
||||
);
|
||||
assert_eq!(r.changes_since(1).unwrap().count(), 1);
|
||||
assert!(r.changes_since(3).is_none());
|
||||
for i in 0..CHANGES_KEPT as u64 {
|
||||
r.changed(6 + i, Change::Effects);
|
||||
r.changed(6 + i, Change::State);
|
||||
}
|
||||
// The first two are gone.
|
||||
assert!(r.changes_since(1).is_none());
|
||||
|
||||
@@ -202,8 +202,10 @@ fn os_version() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
// x86 names the CPU per core; ARM boards name the machine once.
|
||||
key_value(&info, "model name", ':')
|
||||
.or_else(|| key_value(&info, "Model", ':'))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -225,12 +227,12 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
output("cmd", &["/C", "ver"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -279,16 +281,4 @@ mod tests {
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reports_the_cpu_brand() {
|
||||
let cpu = Hardware::detect().cpu.expect("a CPU name");
|
||||
assert!(
|
||||
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
|
||||
"family and model, not a name: {cpu:?}"
|
||||
);
|
||||
assert_eq!(cpu, cpu.trim());
|
||||
}
|
||||
}
|
||||
+30
-375
@@ -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`.
|
||||
@@ -29,7 +23,7 @@ use horizon_protocol::{
|
||||
buf::{Reader, Writer},
|
||||
connection::{ConnWriter, Connection, Counters},
|
||||
crypto::offline_uuid,
|
||||
entity_data, ids,
|
||||
ids,
|
||||
item::Stack,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
@@ -40,7 +34,7 @@ use horizon_protocol::{
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, LazyLock, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
@@ -54,8 +48,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore, fight or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
fight needs gamemode=survival, force-gamemode=true and allow-flight=true.
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
stand: stays on the ground where the server puts it (/tp), for a survival test.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
@@ -73,11 +66,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health. A UUID instead of a NAME: the
|
||||
entity with that UUID (a boat summoned with it, say)
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
|
||||
default no limit
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
@@ -88,7 +78,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--view-distance N what the bots ask for; default 10
|
||||
--seed N default 12345
|
||||
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
|
||||
--pvp B fight bots also attack each other (true or false); default true
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--brand B the client brand sent in configuration (minecraft:brand); default none
|
||||
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
|
||||
@@ -110,7 +99,6 @@ enum Behaviour {
|
||||
Stand,
|
||||
Walk,
|
||||
Explore,
|
||||
Fight,
|
||||
FloodMove,
|
||||
FloodAcks,
|
||||
FloodPackets,
|
||||
@@ -130,7 +118,6 @@ impl Behaviour {
|
||||
Self::Stand => "stand",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::Fight => "fight",
|
||||
Self::FloodMove => "flood-move",
|
||||
Self::FloodAcks => "flood-acks",
|
||||
Self::FloodPackets => "flood-packets",
|
||||
@@ -155,12 +142,9 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
pvp: bool,
|
||||
/// The players or entity UUIDs to attack, how long after joining to start,
|
||||
/// and how many rounds.
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
@@ -192,10 +176,8 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
pvp: true,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
@@ -249,11 +231,9 @@ fn options() -> Result<Options, String> {
|
||||
"--explore-speed" => {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
@@ -314,11 +294,10 @@ fn options() -> Result<Options, String> {
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
const BEHAVIOURS: [&str; 16] = [
|
||||
const BEHAVIOURS: [&str; 15] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"fight",
|
||||
"mixed",
|
||||
"stand",
|
||||
"flood-move",
|
||||
@@ -334,7 +313,7 @@ fn options() -> Result<Options, String> {
|
||||
];
|
||||
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore, fight, mixed or a flood behaviour (--help lists them)",
|
||||
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
@@ -355,10 +334,6 @@ struct Bot {
|
||||
pulled_back: AtomicU64,
|
||||
/// Ticks the bot couldn't move because it didn't hold its chunk yet.
|
||||
waited: AtomicU64,
|
||||
/// Attack packets sent (fight bots and --attack).
|
||||
attacks: AtomicU64,
|
||||
/// The entities the server tells the bot about (fight bots only).
|
||||
entities: Mutex<Entities>,
|
||||
/// The chunks the bot holds (ClientChunkCache).
|
||||
held: Mutex<HashSet<(i32, i32)>>,
|
||||
/// Where the server last put the bot (a teleport), to start from.
|
||||
@@ -371,6 +346,7 @@ struct Bot {
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<Vec<(u128, Option<i32>)>>,
|
||||
attacks: AtomicU64,
|
||||
/// The last SetHealth.
|
||||
health: Mutex<Option<f32>>,
|
||||
}
|
||||
@@ -408,16 +384,6 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
|
||||
/// the offline UUID of the player with that name (a name has no `-`).
|
||||
fn target_uuid(name: &str) -> u128 {
|
||||
let hex: String = name.chars().filter(|&c| c != '-').collect();
|
||||
match u128::from_str_radix(&hex, 16) {
|
||||
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
|
||||
_ => offline_uuid(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -433,142 +399,6 @@ impl Random {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vanilla's bare-hand attack speed is 4 a second: a full-strength swing every 20 / 4 ticks.
|
||||
const ATTACK_COOLDOWN_TICKS: u32 = 5;
|
||||
/// How far, in blocks, a fight bot strikes (the survival entity interaction range is 3).
|
||||
const ATTACK_REACH: f64 = 2.5;
|
||||
/// A fight bot walks up to this horizontal distance, so it does not stand inside its target.
|
||||
const CLOSE_IN: f64 = 1.5;
|
||||
/// Walking speed in blocks a tick.
|
||||
const FIGHT_STEP: f64 = 0.215;
|
||||
/// `LivingEntity.DATA_HEALTH_ID`: only living entities carry it, and it is sent once it differs from 1.
|
||||
const HEALTH_DATA: u8 = 9;
|
||||
|
||||
/// What a fight bot knows of one entity.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Tracked {
|
||||
uuid: u128,
|
||||
pos: [f64; 3],
|
||||
/// Set once the server sends the health of the entity, which only living entities have.
|
||||
health: Option<f32>,
|
||||
}
|
||||
|
||||
/// The entities around a bot, from AddEntity, the move packets, SetEntityData and RemoveEntities.
|
||||
/// Ordered by id, so ties between equally near targets always go to the same entity.
|
||||
#[derive(Default)]
|
||||
struct Entities(BTreeMap<i32, Tracked>);
|
||||
|
||||
impl Entities {
|
||||
fn update(&mut self, id: i32, body: &[u8]) {
|
||||
let Ok(packet) = play::Clientbound::decode(id, body) else {
|
||||
return;
|
||||
};
|
||||
match packet {
|
||||
play::Clientbound::AddEntity(p) => {
|
||||
self.0.insert(
|
||||
p.id,
|
||||
Tracked {
|
||||
uuid: p.uuid,
|
||||
pos: p.position,
|
||||
health: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
play::Clientbound::MoveEntityPos(play::MoveEntityPos(m))
|
||||
| play::Clientbound::MoveEntityPosRot(play::MoveEntityPosRot(m)) => {
|
||||
if let (Some(e), Some(delta)) = (self.0.get_mut(&m.id), &m.delta) {
|
||||
e.pos = delta.end(e.pos);
|
||||
}
|
||||
}
|
||||
play::Clientbound::EntityPositionSync(p) => {
|
||||
if let Some(e) = self.0.get_mut(&p.id) {
|
||||
e.pos = p.path.end();
|
||||
}
|
||||
}
|
||||
play::Clientbound::SetEntityData(p) => {
|
||||
if let Some(e) = self.0.get_mut(&p.id) {
|
||||
for (index, _, value) in &p.values {
|
||||
if *index == HEALTH_DATA
|
||||
&& let entity_data::Value::Float(health) = value
|
||||
{
|
||||
e.health = Some(*health);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
play::Clientbound::RemoveEntities(p) => {
|
||||
for id in p.ids {
|
||||
self.0.remove(&id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest living entity that is not dead; other bots only when `pvp`.
|
||||
fn nearest(&self, from: [f64; 3], pvp: bool, bots: &HashSet<u128>) -> Option<(i32, [f64; 3])> {
|
||||
let mut best: Option<(f64, i32, [f64; 3])> = None;
|
||||
for (id, e) in &self.0 {
|
||||
if !e.health.is_some_and(|h| h > 0.0) || (!pvp && bots.contains(&e.uuid)) {
|
||||
continue;
|
||||
}
|
||||
let d = distance(from, e.pos);
|
||||
if best.is_none_or(|(b, ..)| d < b) {
|
||||
best = Some((d, *id, e.pos));
|
||||
}
|
||||
}
|
||||
best.map(|(_, id, pos)| (id, pos))
|
||||
}
|
||||
}
|
||||
|
||||
fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
|
||||
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
|
||||
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
|
||||
*since_attack = since_attack.saturating_add(1);
|
||||
let (id, at) = target;
|
||||
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
|
||||
let horizontal = dx.hypot(dz);
|
||||
if horizontal > CLOSE_IN {
|
||||
let step = FIGHT_STEP.min(horizontal - CLOSE_IN);
|
||||
pos[0] += dx / horizontal * step;
|
||||
pos[2] += dz / horizontal * step;
|
||||
}
|
||||
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
|
||||
*since_attack = 0;
|
||||
return Some(id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
|
||||
fn walk_step(
|
||||
pos: &mut [f64; 3],
|
||||
target: &mut [f64; 3],
|
||||
spawn: [f64; 3],
|
||||
cruise: f64,
|
||||
random: &mut Random,
|
||||
) {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
if distance < 1.0 {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 8.0 + random.unit() * 40.0;
|
||||
*target = [
|
||||
spawn[0] + angle.cos() * reach,
|
||||
cruise,
|
||||
spawn[2] + angle.sin() * reach,
|
||||
];
|
||||
} else {
|
||||
let step = 0.28_f64.min(distance);
|
||||
pos[0] += dx / distance * step;
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
|
||||
/// One bot thread that panics poisons the locks it held; the others and the report go on.
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
@@ -824,7 +654,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;
|
||||
@@ -887,9 +716,6 @@ fn read(
|
||||
}
|
||||
}
|
||||
}
|
||||
if track {
|
||||
lock(&bot.entities).update(id, &body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::SET_HEALTH => {
|
||||
@@ -942,16 +768,6 @@ fn read(
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
cb::MOVE_ENTITY_POS
|
||||
| cb::MOVE_ENTITY_POS_ROT
|
||||
| cb::ENTITY_POSITION_SYNC
|
||||
| cb::SET_ENTITY_DATA
|
||||
| cb::REMOVE_ENTITIES
|
||||
if track =>
|
||||
{
|
||||
lock(&bot.entities).update(id, &body);
|
||||
Ok(())
|
||||
}
|
||||
cb::DISCONNECT | cb::START_CONFIGURATION => {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
|
||||
@@ -981,7 +797,6 @@ fn run_bot(
|
||||
o: Arc<Options>,
|
||||
index: usize,
|
||||
behaviour: Behaviour,
|
||||
bots: Arc<HashSet<u128>>,
|
||||
bot: Arc<Bot>,
|
||||
until: Arc<Mutex<Option<Instant>>>,
|
||||
) {
|
||||
@@ -1001,13 +816,12 @@ fn run_bot(
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (target_uuid(name), None))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
let track = behaviour == Behaviour::Fight;
|
||||
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.
|
||||
@@ -1056,16 +870,9 @@ fn run_bot(
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut rounds = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
|
||||
let cruise = if behaviour == Behaviour::Fight {
|
||||
spawn[1]
|
||||
} else {
|
||||
spawn[1] + o.height
|
||||
};
|
||||
let mut since_attack = ATTACK_COOLDOWN_TICKS;
|
||||
let cruise = spawn[1] + o.height;
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
@@ -1119,22 +926,22 @@ fn run_bot(
|
||||
} else {
|
||||
match behaviour {
|
||||
Behaviour::Idle | Behaviour::Stand => {}
|
||||
Behaviour::Walk => walk_step(&mut pos, &mut target, spawn, cruise, &mut random),
|
||||
Behaviour::Fight => {
|
||||
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
|
||||
if let Some(near) = near {
|
||||
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
|
||||
// The client sends the attack, then swings the arm.
|
||||
let sent = send(
|
||||
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
|
||||
) && send(raw(sb::PUNCH, |w| w.varint(0)));
|
||||
if sent {
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Behaviour::Walk => {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
if distance < 1.0 {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 8.0 + random.unit() * 40.0;
|
||||
target = [
|
||||
spawn[0] + angle.cos() * reach,
|
||||
cruise,
|
||||
spawn[2] + angle.sin() * reach,
|
||||
];
|
||||
} else {
|
||||
since_attack = since_attack.saturating_add(1);
|
||||
walk_step(&mut pos, &mut target, spawn, cruise, &mut random);
|
||||
// Sprinting: 5.6 blocks a second.
|
||||
let step = 0.28_f64.min(distance);
|
||||
pos[0] += dx / distance * step;
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::FloodMove
|
||||
@@ -1220,13 +1027,8 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
&& rounds < o.attack_rounds
|
||||
{
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
// A round counts once a target is known.
|
||||
rounds += u32::from(!targets.is_empty());
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
@@ -1242,10 +1044,7 @@ fn run_bot(
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
// Fight bots stand on the ground; the others fly.
|
||||
flags: u8::from(
|
||||
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
|
||||
),
|
||||
flags: u8::from(behaviour == Behaviour::Stand || o.ground),
|
||||
})
|
||||
.encode(),
|
||||
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
|
||||
@@ -1272,7 +1071,6 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
"stand" => Behaviour::Stand,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"fight" => Behaviour::Fight,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
"flood-acks" => Behaviour::FloodAcks,
|
||||
"flood-packets" => Behaviour::FloodPackets,
|
||||
@@ -1303,23 +1101,14 @@ fn main() {
|
||||
let start = Instant::now();
|
||||
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
|
||||
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
|
||||
// The uuids of all bots, so that `--pvp false` leaves them alone.
|
||||
let bot_uuids: Arc<HashSet<u128>> = Arc::new(
|
||||
(0..o.bots)
|
||||
.map(|i| offline_uuid(&format!("{}{i}", o.name)))
|
||||
.collect(),
|
||||
);
|
||||
let launcher = {
|
||||
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
|
||||
let bot_uuids = bot_uuids.clone();
|
||||
thread::spawn(move || {
|
||||
let mut handles = Vec::new();
|
||||
for (i, bot) in bots.iter().enumerate() {
|
||||
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
|
||||
let (behaviour, uuids) = (behaviour_of(&o.behaviour, i), bot_uuids.clone());
|
||||
handles.push(thread::spawn(move || {
|
||||
run_bot(o2, i, behaviour, uuids, bot, until);
|
||||
}));
|
||||
let behaviour = behaviour_of(&o.behaviour, i);
|
||||
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
|
||||
thread::sleep(Duration::from_secs_f64(o.join_every));
|
||||
}
|
||||
handles
|
||||
@@ -1356,7 +1145,6 @@ fn main() {
|
||||
"resent": total(|b| &b.resent),
|
||||
"pulled_back": total(|b| &b.pulled_back),
|
||||
"waited_ticks": total(|b| &b.waited),
|
||||
"attacks": total(|b| &b.attacks),
|
||||
})
|
||||
);
|
||||
previous_chunks = chunks;
|
||||
@@ -1376,7 +1164,6 @@ fn main() {
|
||||
"behaviour": o.behaviour,
|
||||
"seed": o.seed,
|
||||
"explore_speed": o.explore_speed,
|
||||
"pvp": o.pvp,
|
||||
"view_distance": o.view_distance,
|
||||
"seconds": o.seconds,
|
||||
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
|
||||
@@ -1384,7 +1171,6 @@ fn main() {
|
||||
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"attacks": bots.iter().map(|b| b.attacks.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
|
||||
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
|
||||
"join_time": join_summary(
|
||||
@@ -1437,137 +1223,6 @@ fn main() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use play::{
|
||||
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
|
||||
};
|
||||
|
||||
fn feed(entities: &mut Entities, packet: &Clientbound) {
|
||||
let bytes = packet.encode();
|
||||
let mut r = Reader::new(&bytes);
|
||||
let id = r.varint().unwrap();
|
||||
entities.update(id, &bytes[bytes.len() - r.remaining()..]);
|
||||
}
|
||||
|
||||
fn add(id: i32, uuid: u128, pos: [f64; 3]) -> Clientbound {
|
||||
Clientbound::AddEntity(AddEntity {
|
||||
id,
|
||||
uuid,
|
||||
kind: 1,
|
||||
position: pos,
|
||||
velocity: [0.0; 3],
|
||||
pitch: 0,
|
||||
yaw: 0,
|
||||
head_yaw: 0,
|
||||
data: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn health(id: i32, health: f32) -> Clientbound {
|
||||
Clientbound::SetEntityData(SetEntityData {
|
||||
id,
|
||||
values: vec![(HEALTH_DATA, 3, entity_data::Value::Float(health))],
|
||||
})
|
||||
}
|
||||
|
||||
/// `--attack`: a UUID names that entity, anything else a player.
|
||||
#[test]
|
||||
fn attack_targets_a_uuid_or_a_player() {
|
||||
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
|
||||
assert_eq!(
|
||||
target_uuid("0000000a-0000-0001-0000-000000000002"),
|
||||
0x0000000a_0000_0001_0000_000000000002
|
||||
);
|
||||
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
|
||||
// A name of 32 hex digits is a name: a UUID is written with dashes.
|
||||
let name = "0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(target_uuid(name), offline_uuid(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_living_entities_only() {
|
||||
let mut e = Entities::default();
|
||||
feed(&mut e, &add(1, 10, [3.0, 64.0, 0.0]));
|
||||
feed(&mut e, &add(2, 11, [5.0, 64.0, 0.0]));
|
||||
feed(&mut e, &health(2, 20.0));
|
||||
// Entity 1 never sent a health (an item, an arrow), so it is no target.
|
||||
assert_eq!(
|
||||
e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()),
|
||||
Some((2, [5.0, 64.0, 0.0]))
|
||||
);
|
||||
feed(&mut e, &health(2, 0.0));
|
||||
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
|
||||
feed(&mut e, &health(2, 4.0));
|
||||
feed(
|
||||
&mut e,
|
||||
&Clientbound::RemoveEntities(RemoveEntities { ids: vec![2] }),
|
||||
);
|
||||
assert_eq!(e.nearest([0.0, 64.0, 0.0], true, &HashSet::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_follow_the_deltas() {
|
||||
let mut e = Entities::default();
|
||||
feed(&mut e, &add(7, 1, [0.0, 64.0, 0.0]));
|
||||
feed(&mut e, &health(7, 20.0));
|
||||
feed(
|
||||
&mut e,
|
||||
&Clientbound::MoveEntityPos(MoveEntityPos(MoveEntity {
|
||||
id: 7,
|
||||
delta: Some(VecDelta::Linear([4096, 0, -8192])),
|
||||
rotation: None,
|
||||
on_ground: true,
|
||||
})),
|
||||
);
|
||||
assert_eq!(e.0[&7].pos, [1.0, 64.0, -2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pvp_false_spares_the_bots_and_ties_go_to_the_lowest_id() {
|
||||
let mut e = Entities::default();
|
||||
feed(&mut e, &add(9, 100, [2.0, 64.0, 0.0]));
|
||||
feed(&mut e, &add(4, 101, [-2.0, 64.0, 0.0]));
|
||||
feed(&mut e, &add(5, 102, [0.0, 64.0, 6.0]));
|
||||
for id in [9, 4, 5] {
|
||||
feed(&mut e, &health(id, 20.0));
|
||||
}
|
||||
let bots = HashSet::from([100, 101]);
|
||||
let from = [0.0, 64.0, 0.0];
|
||||
assert_eq!(e.nearest(from, true, &bots).map(|t| t.0), Some(4));
|
||||
assert_eq!(e.nearest(from, false, &bots).map(|t| t.0), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attacks_at_the_vanilla_cooldown() {
|
||||
let mut pos = [0.0, 64.0, 0.0];
|
||||
let target = (3, [1.0, 64.0, 0.0]);
|
||||
let mut since = ATTACK_COOLDOWN_TICKS;
|
||||
let hits: Vec<u32> = (0..40)
|
||||
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
|
||||
.collect();
|
||||
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
|
||||
assert!(
|
||||
hits.windows(2)
|
||||
.all(|w| w[1] - w[0] == ATTACK_COOLDOWN_TICKS)
|
||||
);
|
||||
assert_eq!(hits.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_into_reach_before_striking() {
|
||||
let mut pos = [0.0, 64.0, 0.0];
|
||||
let target = (3, [10.0, 64.0, 0.0]);
|
||||
let mut since = ATTACK_COOLDOWN_TICKS;
|
||||
let mut first = None;
|
||||
for tick in 0..100 {
|
||||
if fight_tick(&mut pos, target, &mut since).is_some() {
|
||||
first = Some(tick);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(first.is_some_and(|t| t > 30));
|
||||
assert!((target.1[0] - pos[0]).abs() <= ATTACK_REACH);
|
||||
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentiles_use_nearest_rank() {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.88"
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,63 +0,0 @@
|
||||
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
|
||||
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
|
||||
# words are written here only so the filter can recognise them, because players spammed them on the
|
||||
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
|
||||
# aren't slurs out of it (moderators handle those).
|
||||
#
|
||||
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
|
||||
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
|
||||
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
|
||||
# doubled in the text: "niger", the river and the country, isn't "nigger".
|
||||
# word matches inside a longer word too
|
||||
# =word only as a whole word ("spic", not "suspicious")
|
||||
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
|
||||
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
|
||||
|
||||
~nigger
|
||||
~nigga
|
||||
nigguh
|
||||
niggah
|
||||
niqqa
|
||||
niqqer
|
||||
nlgger
|
||||
nlgga
|
||||
kneegrow
|
||||
=nig
|
||||
=nigs
|
||||
=niglet
|
||||
=niglets
|
||||
=nibba
|
||||
=nibbas
|
||||
=nibber
|
||||
=nibbers
|
||||
jigaboo
|
||||
porchmonkey
|
||||
=coon
|
||||
=coons
|
||||
~faggot
|
||||
=fag
|
||||
=fags
|
||||
=faggy
|
||||
=fagot
|
||||
=fagots
|
||||
=tranny
|
||||
=trannies
|
||||
=kike
|
||||
=kikes
|
||||
=chink
|
||||
=chinks
|
||||
=gook
|
||||
=gooks
|
||||
=spic
|
||||
=spics
|
||||
wetback
|
||||
=beaner
|
||||
=beaners
|
||||
raghead
|
||||
towelhead
|
||||
zipperhead
|
||||
=paki
|
||||
=pakis
|
||||
|
||||
!snigger*
|
||||
!niggard*
|
||||
@@ -1,472 +0,0 @@
|
||||
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
|
||||
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
|
||||
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
|
||||
//! player's text and carries out what it decides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
pub const DEFAULT_WORDS: &str = include_str!("../data/slurs.txt");
|
||||
|
||||
/// The mutes, beside server.properties.
|
||||
pub const MUTES_FILE: &str = "chat-filter-mutes.json";
|
||||
|
||||
/// `server.toml`'s `[chat_filter]`: on by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// The hit, counted within `window_minutes`, that mutes the player; 0: never.
|
||||
pub mute_after: u32,
|
||||
/// The hit that bans the player for `tempban_hours`; 0: never.
|
||||
pub tempban_after: u32,
|
||||
/// How long a hit counts towards the next ones (the time spent muted doesn't count).
|
||||
pub window_minutes: u32,
|
||||
pub mute_minutes: u32,
|
||||
pub tempban_hours: u32,
|
||||
/// The slur list, relative to the server's folder; empty: the list built into the game.
|
||||
pub words_file: String,
|
||||
/// Every refused text with the player and what followed, relative to the server's folder.
|
||||
pub log: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
mute_after: 2,
|
||||
tempban_after: 3,
|
||||
window_minutes: 10,
|
||||
mute_minutes: 10,
|
||||
tempban_hours: 24,
|
||||
words_file: String::new(),
|
||||
log: "logs/chat-filter.log".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// The `[chat_filter]` section of a whole `server.toml`; the default without it.
|
||||
pub fn from_server_toml(text: &str) -> Result<Self, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
chat_filter: Config,
|
||||
}
|
||||
// Other sections are the supervisor's to check: only this one is read here.
|
||||
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut only = toml::Table::new();
|
||||
if let Some(section) = table.remove("chat_filter") {
|
||||
only.insert("chat_filter".into(), section);
|
||||
}
|
||||
let file: File = only
|
||||
.try_into()
|
||||
.map_err(|e: toml::de::Error| format!("[chat_filter]: {e}"))?;
|
||||
file.chat_filter.check()?;
|
||||
Ok(file.chat_filter)
|
||||
}
|
||||
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.window_minutes == 0 {
|
||||
return Err("chat_filter.window_minutes must be 1 or more".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// How an entry of the list matches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
/// Inside a longer word too.
|
||||
Embedded,
|
||||
/// Only as a whole word.
|
||||
Whole,
|
||||
/// Inside a word, or spread over several words from the start of one.
|
||||
Spread,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entry {
|
||||
runs: Vec<Run>,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
/// The slur list, compiled: each entry's letters as runs, indexed by first letter.
|
||||
#[derive(Debug)]
|
||||
pub struct Filter {
|
||||
entries: Vec<Entry>,
|
||||
by_first: [Vec<u16>; 27],
|
||||
/// Words never matched: exact (`false`) or as a prefix (`true`).
|
||||
allowed: Vec<(Vec<Run>, bool)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Compiles a list in `data/slurs.txt`'s format.
|
||||
pub fn new(list: &str) -> Self {
|
||||
let mut f = Filter {
|
||||
entries: Vec::new(),
|
||||
by_first: Default::default(),
|
||||
allowed: Vec::new(),
|
||||
};
|
||||
for line in list.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(word) = line.strip_prefix('!') {
|
||||
let (word, prefix) = match word.strip_suffix('*') {
|
||||
Some(w) => (w, true),
|
||||
None => (word, false),
|
||||
};
|
||||
f.allowed.push((normalise::word(word), prefix));
|
||||
continue;
|
||||
}
|
||||
let (kind, word) = match line.as_bytes()[0] {
|
||||
b'=' => (Kind::Whole, &line[1..]),
|
||||
b'~' => (Kind::Spread, &line[1..]),
|
||||
_ => (Kind::Embedded, line),
|
||||
};
|
||||
let runs = normalise::word(word);
|
||||
let Some(first) = runs.first() else {
|
||||
continue;
|
||||
};
|
||||
f.by_first[usize::from(first.letter)].push(f.entries.len() as u16);
|
||||
f.entries.push(Entry { runs, kind });
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The built-in list.
|
||||
pub fn default_list() -> Self {
|
||||
Self::new(DEFAULT_WORDS)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `text` holds a slur of the list.
|
||||
pub fn matches(&self, text: &str) -> bool {
|
||||
let t = Text::new(text);
|
||||
let runs = &t.runs;
|
||||
for &(start, end) in t.words.iter().chain(&t.spaced) {
|
||||
let word = &runs[start..end];
|
||||
if self.is_allowed(word) {
|
||||
continue;
|
||||
}
|
||||
for at in 0..word.len() {
|
||||
for &e in &self.by_first[usize::from(word[at].letter)] {
|
||||
let e = &self.entries[usize::from(e)];
|
||||
let whole = e.kind == Kind::Whole;
|
||||
if (!whole || at == 0) && fits(&e.runs, &word[at..], whole) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spread entries over the whole text, from the start of a word, across at least two.
|
||||
for (w, &at) in t.joined_at.iter().enumerate() {
|
||||
for &e in &self.by_first[usize::from(t.joined[at].letter)] {
|
||||
let e = &self.entries[usize::from(e)];
|
||||
if e.kind == Kind::Spread
|
||||
&& fits(&e.runs, &t.joined[at..], false)
|
||||
&& t.spans_words(w, e.runs.len())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_allowed(&self, word: &[Run]) -> bool {
|
||||
self.allowed.iter().any(|(a, prefix)| {
|
||||
if *prefix {
|
||||
// A prefix: its last run may be part of a longer one.
|
||||
word.len() >= a.len() && fits(a, word, false)
|
||||
} else {
|
||||
fits(a, word, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `entry`'s runs start `runs` (are all of it with `whole`): the same letters, each at least
|
||||
/// as many times as the entry has it.
|
||||
fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
|
||||
if runs.len() < entry.len() || (whole && runs.len() != entry.len()) {
|
||||
return false;
|
||||
}
|
||||
entry
|
||||
.iter()
|
||||
.zip(runs)
|
||||
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
|
||||
}
|
||||
|
||||
/// What a hit leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The text is refused, nothing more.
|
||||
Refused,
|
||||
/// Refused, and the player can't chat until then (epoch milliseconds).
|
||||
Muted { until: i64 },
|
||||
/// Refused, and the player is banned until then.
|
||||
Banned { until: i64 },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Refused => "refused",
|
||||
Action::Muted { .. } => "muted",
|
||||
Action::Banned { .. } => "banned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds.
|
||||
pub until: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct MutesFile {
|
||||
mutes: Vec<Mute>,
|
||||
}
|
||||
|
||||
/// One player's hits: how many, and when the last one stops counting.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Strikes {
|
||||
count: u32,
|
||||
counts_until: i64,
|
||||
}
|
||||
|
||||
/// The filter, the players' hits and the mutes.
|
||||
pub struct Guard {
|
||||
pub config: Config,
|
||||
pub filter: Filter,
|
||||
strikes: HashMap<u128, Strikes>,
|
||||
mutes: HashMap<u128, Mute>,
|
||||
/// Where the mutes are kept; `None` keeps them in memory only (tests).
|
||||
file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
const MINUTE: i64 = 60_000;
|
||||
|
||||
impl Guard {
|
||||
pub fn new(config: Config, filter: Filter, file: Option<PathBuf>) -> Self {
|
||||
let mut mutes = HashMap::new();
|
||||
if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok())
|
||||
&& let Ok(saved) = serde_json::from_str::<MutesFile>(&text)
|
||||
{
|
||||
for m in saved.mutes {
|
||||
if let Some(uuid) = parse_uuid(&m.uuid) {
|
||||
mutes.insert(uuid, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
config,
|
||||
filter,
|
||||
strikes: HashMap::new(),
|
||||
mutes,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's guard: `words_file` or the built-in list, the mutes in `dir`. An unreadable
|
||||
/// `words_file` falls back to the built-in list, with why.
|
||||
pub fn load(config: Config, dir: &Path) -> (Self, Option<String>) {
|
||||
let mut problem = None;
|
||||
let filter = if config.words_file.is_empty() {
|
||||
Filter::default_list()
|
||||
} else {
|
||||
match std::fs::read_to_string(dir.join(&config.words_file)) {
|
||||
Ok(text) => Filter::new(&text),
|
||||
Err(e) => {
|
||||
problem = Some(format!(
|
||||
"chat_filter.words_file {}: {e}; using the built-in list",
|
||||
config.words_file
|
||||
));
|
||||
Filter::default_list()
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
Self::new(config, filter, Some(dir.join(MUTES_FILE))),
|
||||
problem,
|
||||
)
|
||||
}
|
||||
|
||||
/// When the player's mute ends, if they are muted at `now`.
|
||||
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
self.mutes
|
||||
.get(&uuid)
|
||||
.map(|m| m.until)
|
||||
.filter(|&until| until > now)
|
||||
}
|
||||
|
||||
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
|
||||
pub fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
|
||||
let c = &self.config;
|
||||
let window = i64::from(c.window_minutes) * MINUTE;
|
||||
let s = self.strikes.entry(uuid).or_insert(Strikes {
|
||||
count: 0,
|
||||
counts_until: 0,
|
||||
});
|
||||
if now > s.counts_until {
|
||||
s.count = 0;
|
||||
}
|
||||
s.count += 1;
|
||||
s.counts_until = now + window;
|
||||
let count = s.count;
|
||||
let action = if c.tempban_after > 0 && count >= c.tempban_after {
|
||||
self.strikes.remove(&uuid);
|
||||
Action::Banned {
|
||||
until: now + i64::from(c.tempban_hours) * 60 * MINUTE,
|
||||
}
|
||||
} else if c.mute_after > 0 && count >= c.mute_after {
|
||||
let until = now + i64::from(c.mute_minutes) * MINUTE;
|
||||
// The window starts again when the mute ends.
|
||||
s.counts_until = until + window;
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
Action::Muted { until }
|
||||
} else {
|
||||
Action::Refused
|
||||
};
|
||||
(count, action)
|
||||
}
|
||||
|
||||
/// Lifts a player's mute (and forgets their hits), by name; the name as muted, if they were.
|
||||
pub fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
let uuid = *self
|
||||
.mutes
|
||||
.iter()
|
||||
.find(|(_, m)| m.name.eq_ignore_ascii_case(name))?
|
||||
.0;
|
||||
let mute = self.mutes.remove(&uuid)?;
|
||||
self.strikes.remove(&uuid);
|
||||
self.save();
|
||||
Some(mute.name)
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
let Some(file) = &self.file else {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.until > now);
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, text).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of `logs/chat-filter.log`, without its newline.
|
||||
pub fn log_line(
|
||||
now: i64,
|
||||
name: &str,
|
||||
uuid: u128,
|
||||
place: &str,
|
||||
count: u32,
|
||||
action: Action,
|
||||
text: &str,
|
||||
) -> String {
|
||||
let text: String = text
|
||||
.chars()
|
||||
.map(|c| if c == '\n' { ' ' } else { c })
|
||||
.collect();
|
||||
format!(
|
||||
"{} {name} {} {place} hit {count} {}: {text}",
|
||||
utc(now),
|
||||
uuid_string(uuid),
|
||||
action.name()
|
||||
)
|
||||
}
|
||||
|
||||
/// Appends a line to the log at `path` (its folder made if needed).
|
||||
pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
|
||||
rest / 3600,
|
||||
rest / 60 % 60,
|
||||
rest % 60
|
||||
)
|
||||
}
|
||||
|
||||
fn uuid_string(u: u128) -> String {
|
||||
let h = format!("{u:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..]
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_uuid(s: &str) -> Option<u128> {
|
||||
u128::from_str_radix(&s.replace('-', ""), 16).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,255 +0,0 @@
|
||||
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
|
||||
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
|
||||
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
|
||||
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
|
||||
|
||||
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
|
||||
const OTHER: u8 = 26;
|
||||
|
||||
/// One letter and how many times it repeats (`gg` is `g` twice).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Run {
|
||||
/// 0..=25 for `a`..`z`, 26 for any other letter.
|
||||
pub letter: u8,
|
||||
pub count: u8,
|
||||
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
|
||||
pub weak: bool,
|
||||
}
|
||||
|
||||
/// A whole message, normalised.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Text {
|
||||
pub runs: Vec<Run>,
|
||||
/// Each word's runs, `runs[start..end]`.
|
||||
pub words: Vec<(usize, usize)>,
|
||||
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
|
||||
/// without the punctuation they end with (`fag!`).
|
||||
pub spaced: Vec<(usize, usize)>,
|
||||
/// Every word one after the other, a letter that ends one and starts the next merged.
|
||||
pub joined: Vec<Run>,
|
||||
/// Where each word starts in `joined`.
|
||||
pub joined_at: Vec<usize>,
|
||||
/// The last word each run of `joined` belongs to.
|
||||
joined_last: Vec<usize>,
|
||||
}
|
||||
|
||||
enum Fold {
|
||||
Letter(u8, bool),
|
||||
Separator,
|
||||
Space,
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn new(text: &str) -> Self {
|
||||
let mut t = Text::default();
|
||||
let mut start = 0;
|
||||
for c in text.chars() {
|
||||
match fold(c) {
|
||||
Fold::Letter(letter, weak) => {
|
||||
let in_word = t.runs.len() > start;
|
||||
match t.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == letter => {
|
||||
last.count = last.count.saturating_add(1);
|
||||
last.weak &= weak;
|
||||
}
|
||||
_ => t.runs.push(Run {
|
||||
letter,
|
||||
count: 1,
|
||||
weak,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Fold::Space => {
|
||||
t.end_word(start);
|
||||
start = t.runs.len();
|
||||
}
|
||||
Fold::Separator | Fold::Skip => {}
|
||||
}
|
||||
}
|
||||
t.end_word(start);
|
||||
t.add_spaced();
|
||||
t.join();
|
||||
t
|
||||
}
|
||||
|
||||
fn end_word(&mut self, start: usize) {
|
||||
if self.runs.len() > start {
|
||||
self.words.push((start, self.runs.len()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
|
||||
/// copied after the real words').
|
||||
fn add_spaced(&mut self) {
|
||||
let words = self.words.len();
|
||||
let mut w = 0;
|
||||
while w < words {
|
||||
let mut end = w;
|
||||
while end < words && self.words[end].1 - self.words[end].0 == 1 {
|
||||
end += 1;
|
||||
}
|
||||
if end - w >= 2 {
|
||||
let first = self.runs.len();
|
||||
for k in w..end {
|
||||
let run = self.runs[self.words[k].0];
|
||||
let in_word = self.runs.len() > first;
|
||||
match self.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
}
|
||||
_ => self.runs.push(run),
|
||||
}
|
||||
}
|
||||
self.spaced.push((first, self.runs.len()));
|
||||
w = end;
|
||||
} else {
|
||||
w += 1;
|
||||
}
|
||||
}
|
||||
for k in 0..words {
|
||||
let (start, mut end) = self.words[k];
|
||||
while end > start + 1 && self.runs[end - 1].weak {
|
||||
end -= 1;
|
||||
}
|
||||
if end < self.words[k].1 {
|
||||
self.spaced.push((start, end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&mut self) {
|
||||
for (w, &(start, end)) in self.words.iter().enumerate() {
|
||||
let mut first = true;
|
||||
for run in &self.runs[start..end] {
|
||||
match self.joined.last_mut() {
|
||||
Some(last) if first && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
self.joined_at.push(self.joined.len() - 1);
|
||||
*self.joined_last.last_mut().expect("beside joined") = w;
|
||||
}
|
||||
_ => {
|
||||
if first {
|
||||
self.joined_at.push(self.joined.len());
|
||||
}
|
||||
self.joined.push(*run);
|
||||
self.joined_last.push(w);
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
|
||||
pub fn spans_words(&self, w: usize, len: usize) -> bool {
|
||||
self.joined_last
|
||||
.get(self.joined_at[w] + len - 1)
|
||||
.is_some_and(|&last| last > w)
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry of the list, normalised the same way (one word, whitespace ignored).
|
||||
pub fn word(text: &str) -> Vec<Run> {
|
||||
let t = Text::new(&text.replace(char::is_whitespace, ""));
|
||||
t.words
|
||||
.first()
|
||||
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
|
||||
}
|
||||
|
||||
fn letter(c: char) -> Fold {
|
||||
Fold::Letter(c as u8 - b'a', false)
|
||||
}
|
||||
|
||||
fn fold(c: char) -> Fold {
|
||||
if c.is_ascii_alphabetic() {
|
||||
return letter(c.to_ascii_lowercase());
|
||||
}
|
||||
if c.is_ascii() {
|
||||
return match c {
|
||||
'0' => letter('o'),
|
||||
'1' => letter('i'),
|
||||
'3' => letter('e'),
|
||||
'4' => letter('a'),
|
||||
'5' => letter('s'),
|
||||
'6' | '9' => letter('g'),
|
||||
'7' => letter('t'),
|
||||
'8' => letter('b'),
|
||||
'!' | '|' => Fold::Letter(b'i' - b'a', true),
|
||||
'@' => Fold::Letter(0, true),
|
||||
'$' => Fold::Letter(b's' - b'a', true),
|
||||
c if c.is_ascii_whitespace() => Fold::Space,
|
||||
_ => Fold::Separator,
|
||||
};
|
||||
}
|
||||
let u = c as u32;
|
||||
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
|
||||
let mapped = match u {
|
||||
// Zero-width characters, soft hyphen, combining marks, variation selectors.
|
||||
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
|
||||
0x300..=0x36F
|
||||
| 0x1AB0..=0x1AFF
|
||||
| 0x1DC0..=0x1DFF
|
||||
| 0x20D0..=0x20FF
|
||||
| 0xFE00..=0xFE0F
|
||||
| 0xFE20..=0xFE2F
|
||||
| 0xE0100..=0xE01EF => return Fold::Skip,
|
||||
// Full-width Latin and digits.
|
||||
0xFF21..=0xFF3A => from(0xFF21, 26),
|
||||
0xFF41..=0xFF5A => from(0xFF41, 26),
|
||||
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
|
||||
// Mathematical alphanumerics: 52 letters a style, then digits.
|
||||
0x1D400..=0x1D6A3 => from(0x1D400, 26),
|
||||
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
|
||||
// Circled, parenthesised, squared and negative letters, regional indicators.
|
||||
0x24B6..=0x24CF => from(0x24B6, 26),
|
||||
0x24D0..=0x24E9 => from(0x24D0, 26),
|
||||
0x249C..=0x24B5 => from(0x249C, 26),
|
||||
0x1F130..=0x1F149 => from(0x1F130, 26),
|
||||
0x1F150..=0x1F169 => from(0x1F150, 26),
|
||||
0x1F170..=0x1F189 => from(0x1F170, 26),
|
||||
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
|
||||
_ => {
|
||||
if c.is_whitespace() {
|
||||
return Fold::Space;
|
||||
}
|
||||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||||
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
|
||||
Some((base, _)) => *base,
|
||||
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
|
||||
None => return Fold::Separator,
|
||||
}
|
||||
}
|
||||
};
|
||||
letter(mapped)
|
||||
}
|
||||
|
||||
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
|
||||
const LOOK_ALIKES: &[(char, &str)] = &[
|
||||
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
|
||||
('b', "ƀɓḃḅḇвьъβʙᵇ"),
|
||||
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
|
||||
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
|
||||
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
|
||||
('f', "ƒḟꜰᶠ"),
|
||||
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
|
||||
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
|
||||
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
|
||||
('j', "ĵǰјᴊʲ"),
|
||||
('k', "ķĸǩḱḳḵкκᴋᵏ"),
|
||||
('l', "ĺļľŀłḷḹḻḽʟˡ"),
|
||||
('m', "мᴍᵐ"),
|
||||
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
|
||||
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
|
||||
('p', "ṕṗрρᴘᵖ"),
|
||||
('q', "ԛ"),
|
||||
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
|
||||
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
|
||||
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
|
||||
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
|
||||
('v', "νᴠᵛ"),
|
||||
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
|
||||
('x', "хχˣ"),
|
||||
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
|
||||
('z', "źżžƶẑẓẕᴢᶻ"),
|
||||
];
|
||||
@@ -1,253 +0,0 @@
|
||||
// The test texts spell the slurs the filter must refuse: that is what they test.
|
||||
use super::*;
|
||||
|
||||
fn filter() -> Filter {
|
||||
Filter::default_list()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_compiles() {
|
||||
let f = filter();
|
||||
assert!(f.len() > 30, "{} entries", f.len());
|
||||
assert_eq!(f.allowed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalising_catches_the_usual_disguises() {
|
||||
let f = filter();
|
||||
for text in [
|
||||
"nigger",
|
||||
"NIGGER",
|
||||
"shut up nigga",
|
||||
"Niggers",
|
||||
"n1gg3r",
|
||||
"ni99a",
|
||||
"N!GGA",
|
||||
"nigg@",
|
||||
"n.i.g.g.e.r",
|
||||
"n_i_g_g_a",
|
||||
"n i g g e r",
|
||||
"n i gg a",
|
||||
"nig ger",
|
||||
"nig gaaa",
|
||||
"niiiiggggggerrrrr",
|
||||
"ñíggér",
|
||||
"nigger",
|
||||
"Niggeг", // Cyrillic ghe for r
|
||||
"nіgger", // Cyrillic i
|
||||
"nigg\u{0435}r", // Cyrillic e
|
||||
"n\u{200B}igger", // a zero-width space
|
||||
"n\u{0301}igger", // a combining accent
|
||||
"𝐧𝐢𝐠𝐠𝐞𝐫",
|
||||
"ⓝⓘⓖⓖⓐ",
|
||||
"ɴɪɢɢᴀ",
|
||||
"hi nig",
|
||||
"f a g",
|
||||
"fag!",
|
||||
"you $pic",
|
||||
"FAGGOTINO-CHAN",
|
||||
"sandnigger",
|
||||
"lol kike",
|
||||
] {
|
||||
assert!(f.matches(text), "{text:?} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_words_pass() {
|
||||
let f = filter();
|
||||
for text in [
|
||||
"hello",
|
||||
"Niger",
|
||||
"I live in Niger",
|
||||
"Nigeria and the Niger river",
|
||||
"nigerian food",
|
||||
"proud2benigerian",
|
||||
"don't snigger",
|
||||
"sniggering at that",
|
||||
"a niggardly offer",
|
||||
"knight",
|
||||
"qKnights",
|
||||
"its still night",
|
||||
"anyone able to boot the latest nightly version?",
|
||||
"i hate nighbours",
|
||||
"can someune give mi night vision",
|
||||
"suspicious stew",
|
||||
"spicy food",
|
||||
"a raccoon in a cocoon",
|
||||
"Scunthorpe",
|
||||
"bigger trigger digger",
|
||||
"gg",
|
||||
"gg ez",
|
||||
"mini gg all",
|
||||
"kniga",
|
||||
"shenanigans",
|
||||
"dignity and signing",
|
||||
"Pakistan",
|
||||
"chinking glasses",
|
||||
"the trannys? no: transmission",
|
||||
"1nicerOli",
|
||||
"ask me evernig",
|
||||
"冰淇淋",
|
||||
"привет",
|
||||
"",
|
||||
"!!!",
|
||||
] {
|
||||
assert!(!f.matches(text), "{text:?} should pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_list_has_the_same_syntax() {
|
||||
let f = Filter::new("# comment\n=bad\n~worse\nmeh\n!mehish*\n");
|
||||
assert!(f.matches("so bad"));
|
||||
assert!(!f.matches("badly"));
|
||||
assert!(f.matches("wor se"));
|
||||
assert!(f.matches("xxmehxx"));
|
||||
assert!(!f.matches("mehishness"));
|
||||
assert!(!f.matches("me h"));
|
||||
}
|
||||
|
||||
fn guard(config: Config) -> Guard {
|
||||
Guard::new(config, filter(), None)
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_793_469_939_000;
|
||||
const MIN: i64 = 60_000;
|
||||
|
||||
#[test]
|
||||
fn the_second_hit_mutes_and_the_third_bans() {
|
||||
let mut g = guard(Config::default());
|
||||
let id = 7;
|
||||
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.muted_until(id, NOW), None);
|
||||
let until = NOW + 3 * MIN + 10 * MIN;
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", NOW + 3 * MIN),
|
||||
(2, Action::Muted { until })
|
||||
);
|
||||
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
|
||||
assert_eq!(g.muted_until(id, until), None);
|
||||
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", until + 8 * MIN),
|
||||
(
|
||||
3,
|
||||
Action::Banned {
|
||||
until: until + 8 * MIN + 24 * 60 * MIN
|
||||
}
|
||||
)
|
||||
);
|
||||
// Then it starts again.
|
||||
assert_eq!(g.strike(id, "Ann", until + 9 * MIN), (1, Action::Refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hits_far_apart_dont_add_up() {
|
||||
let mut g = guard(Config::default());
|
||||
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.strike(1, "Bo", NOW + 11 * MIN), (1, Action::Refused));
|
||||
// Players are counted apart.
|
||||
assert_eq!(g.strike(2, "Cy", NOW + 12 * MIN), (1, Action::Refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thresholds_are_configurable() {
|
||||
let mut g = guard(Config {
|
||||
mute_after: 0,
|
||||
tempban_after: 2,
|
||||
tempban_hours: 1,
|
||||
..Config::default()
|
||||
});
|
||||
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
|
||||
assert_eq!(
|
||||
g.strike(1, "Bo", NOW + MIN),
|
||||
(
|
||||
2,
|
||||
Action::Banned {
|
||||
until: NOW + MIN + 60 * MIN
|
||||
}
|
||||
)
|
||||
);
|
||||
let mut off = guard(Config {
|
||||
mute_after: 0,
|
||||
tempban_after: 0,
|
||||
..Config::default()
|
||||
});
|
||||
for k in 1..5 {
|
||||
assert_eq!(off.strike(1, "Bo", NOW + k), (k as u32, Action::Refused));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutes_survive_a_restart_and_unmute_lifts_them() {
|
||||
let dir = std::env::temp_dir().join(format!("chat-filter-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let now = now_millis();
|
||||
let (mut g, problem) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(problem, None);
|
||||
g.strike(5, "Dee", now);
|
||||
let (_, action) = g.strike(5, "Dee", now);
|
||||
assert!(matches!(action, Action::Muted { .. }));
|
||||
let (mut again, _) = Guard::load(Config::default(), &dir);
|
||||
assert!(again.muted_until(5, now).is_some());
|
||||
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
|
||||
assert_eq!(again.unmute("dee"), None);
|
||||
assert_eq!(again.muted_until(5, now), None);
|
||||
let (reloaded, _) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(reloaded.muted_until(5, now), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_words_file_falls_back_to_the_built_in_list() {
|
||||
let config = Config {
|
||||
words_file: "nope.txt".into(),
|
||||
..Config::default()
|
||||
};
|
||||
let (g, problem) = Guard::load(config, Path::new("/nonexistent-horizon"));
|
||||
assert!(problem.unwrap().contains("nope.txt"));
|
||||
assert!(g.filter.matches("nigger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_section_reads_from_server_toml() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(c.enabled);
|
||||
let c = Config::from_server_toml(
|
||||
"[chat_filter]\nenabled = true\nmute_after = 3\ntempban_after = 5\nwords_file = \"slurs.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((c.mute_after, c.tempban_after), (3, 5));
|
||||
assert_eq!(c.words_file, "slurs.txt");
|
||||
assert!(
|
||||
!Config::from_server_toml("[chat_filter]\nenabled = false\n")
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
assert!(Config::from_server_toml("[chat_filter]\nmute = 2\n").is_err());
|
||||
assert!(Config::from_server_toml("[chat_filter]\nwindow_minutes = 0\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_lines_have_the_time_the_player_and_the_text() {
|
||||
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(utc(951_782_400_000), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(
|
||||
log_line(NOW, "Ann", 1, "chat", 2, Action::Muted { until: 0 }, "a\nb"),
|
||||
"2026-10-31T18:05:39Z Ann 00000000-0000-0000-0000-000000000001 chat hit 2 muted: a b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_costs_little() {
|
||||
// A long message through the whole list stays far below a tick (debug builds included).
|
||||
let f = filter();
|
||||
let text = "the quick brown fox jumps over the lazy dog ".repeat(6);
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
assert!(!f.matches(&text));
|
||||
}
|
||||
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
|
||||
}
|
||||
@@ -69,22 +69,6 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
@@ -325,24 +309,8 @@ mod tests {
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
let tr = Language::load(&[&empty], "tr_tr");
|
||||
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
// German and French, and their regional variants on the same file
|
||||
// (Swiss German without "ß").
|
||||
let de = Language::load(&[&empty], "de_de");
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
let fr = Language::load(&[&empty], "fr_fr");
|
||||
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
|
||||
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
|
||||
let variant = Language::load(&[&empty], code);
|
||||
assert_eq!(variant.map(), base.map(), "{code}");
|
||||
}
|
||||
let de_ch = Language::load(&[&empty], "de_ch");
|
||||
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
|
||||
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
|
||||
assert_eq!(de_ch.len(), de.len());
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
@@ -350,148 +318,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
body.lines()
|
||||
.filter(|line| line.starts_with(" \""))
|
||||
.map(|line| {
|
||||
serde_json::Deserializer::from_str(line.trim_start())
|
||||
.into_iter::<String>()
|
||||
.next()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_else(|| panic!("no key on line {line}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
|
||||
/// percent sign that starts none of them ("100% is") is plain text.
|
||||
fn placeholders(text: &str) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find('%') {
|
||||
rest = &rest[at + 1..];
|
||||
let spec = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
|
||||
.unwrap_or(rest.len());
|
||||
let after = rest[spec..].chars().next();
|
||||
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
|
||||
let end = spec + 1;
|
||||
found.push(format!("%{}", &rest[..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
};
|
||||
seen += 1;
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
let map: HashMap<String, String> = serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
|
||||
let keys = file_keys(&body);
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
map.len(),
|
||||
"{name} repeats a key or has a multi-line entry"
|
||||
);
|
||||
for (key, value) in &map {
|
||||
// Stale: the code no longer asks for it, or it was renamed in en_us only.
|
||||
let english = en
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
|
||||
// A translation keeps the fields the code fills in.
|
||||
assert_eq!(
|
||||
placeholders(value),
|
||||
placeholders(english),
|
||||
"{code}: {key} changes the placeholders of the English text"
|
||||
);
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_horizon_key_the_code_names_is_in_en_us() {
|
||||
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
"horizon-client",
|
||||
"horizon-client-render",
|
||||
"horizon-client-core",
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
let text = std::fs::read_to_string(&file).unwrap();
|
||||
for (at, _) in text.match_indices("\"horizon.") {
|
||||
let rest = &text[at + 1..];
|
||||
let Some(end) = rest.find('"') else { continue };
|
||||
let key = &rest[..end];
|
||||
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
|
||||
// between the dots. A key built at run time has a brace; a file name
|
||||
// has one dot; the server addresses that look like keys are listed.
|
||||
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
let key_like = key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
|
||||
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
assert!(
|
||||
en.contains_key(key),
|
||||
"{} names {key}, which is not in en_us.json",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(found > 50, "found only {found} key names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
|
||||
@@ -118,12 +118,6 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
None => mod_effect(id),
|
||||
}
|
||||
}
|
||||
/// A content mod's effect id (`<ns>:<path>`), not vanilla's with or
|
||||
/// without `minecraft:`.
|
||||
#[inline]
|
||||
pub fn is_mod_effect(id: &str) -> bool {
|
||||
!id.starts_with("minecraft:") && id.bytes().any(|b| b == b':')
|
||||
}
|
||||
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
@@ -136,28 +130,6 @@ pub fn description_id(id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
format!("effect.{namespace}.{}", path.replace('/', "."))
|
||||
}
|
||||
/// The effect's sprite in the GUI atlas (`Hud.getMobEffectSprite`: the key
|
||||
/// with `mob_effect/` before its path): `mob_effect/<path>` for vanilla's,
|
||||
/// `<ns>:mob_effect/<path>` for a mod's.
|
||||
pub fn sprite(id: &str) -> String {
|
||||
let mut out = String::new();
|
||||
push_sprite(&mut out, id);
|
||||
out
|
||||
}
|
||||
/// [`sprite`], appended to `out`.
|
||||
pub fn push_sprite(out: &mut String, id: &str) {
|
||||
let path = match id.split_once(':') {
|
||||
None => id,
|
||||
Some(("minecraft", path)) => path,
|
||||
Some((namespace, path)) => {
|
||||
out.push_str(namespace);
|
||||
out.push(':');
|
||||
path
|
||||
}
|
||||
};
|
||||
out.push_str("mob_effect/");
|
||||
out.push_str(path);
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage; a mod's id already is.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
@@ -328,13 +300,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn description_ids_and_sprites_keep_the_namespace() {
|
||||
fn description_ids_keep_the_namespace() {
|
||||
assert_eq!(description_id("speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
|
||||
assert_eq!(sprite("speed"), "mob_effect/speed");
|
||||
assert_eq!(sprite("minecraft:speed"), "mob_effect/speed");
|
||||
assert_eq!(sprite("ruby:curse/dark"), "ruby:mob_effect/curse/dark");
|
||||
}
|
||||
|
||||
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
|
||||
@@ -371,8 +340,6 @@ mod tests {
|
||||
(0xFF0000, Category::Neutral, false)
|
||||
);
|
||||
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
|
||||
assert!(is_mod_effect(glow.id) && !is_mod_effect("speed"));
|
||||
assert!(!is_mod_effect(namespaced("speed")));
|
||||
assert_eq!(namespaced(glow.id), "ruby:glow");
|
||||
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
|
||||
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
|
||||
|
||||
@@ -28,12 +28,6 @@ pub struct Registries {
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// A dedicated server's lists, for the completions of `/op`, `/deop`, `/pardon`, `/pardon-ip` and
|
||||
/// `/whitelist`: the operators' names, the banned names and addresses, the whitelisted names.
|
||||
pub ops: Vec<String>,
|
||||
pub banned_players: Vec<String>,
|
||||
pub banned_ips: Vec<String>,
|
||||
pub whitelisted: Vec<String>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
|
||||
/// each once.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -483,21 +483,6 @@ impl KeyBinds {
|
||||
.find(|mapping| self.get(mapping) == name)
|
||||
.and_then(|mapping| key_code(Self::default_of(mapping)))
|
||||
}
|
||||
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
|
||||
/// hotbar slots, as the controls bind them now. Answers the key the menu
|
||||
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
|
||||
/// swaps instead of reaching the screen.
|
||||
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
|
||||
let name = key_name(key)?;
|
||||
if self.get("key.swapOffhand") == name {
|
||||
return Some(KeyCode::KeyF);
|
||||
}
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.filter(|m| m.0.starts_with("key.hotbar."))
|
||||
.find(|m| self.get(m.0) == name)
|
||||
.and_then(|m| key_code(Self::default_of(m.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -534,29 +519,6 @@ mod tests {
|
||||
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_hotkeys_follow_the_bound_keys() {
|
||||
let mut keys = KeyBinds::default();
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyF),
|
||||
Some(KeyCode::KeyF)
|
||||
);
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::Digit3),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
|
||||
// The report's key: bound to the off hand, the menu takes it first.
|
||||
keys.set("key.swapOffhand", "key.keyboard.tab");
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
|
||||
keys.set("key.hotbar.3", "key.keyboard.t");
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyT),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_translates_to_defaults() {
|
||||
let mut k = KeyBinds::default();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -473,37 +473,6 @@ pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<Video
|
||||
.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(
|
||||
@@ -549,7 +518,6 @@ pub fn fullscreen_request(
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size()?];
|
||||
@@ -730,28 +698,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);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-client-lib"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Reusable client systems, control tool and parity probes"
|
||||
|
||||
[lib]
|
||||
path = "../horizon-client/src/client_lib.rs"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
|
||||
sha1 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
|
||||
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
|
||||
# `above_menu_bar`); all already in the tree through winit and wgpu.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
|
||||
[[bin]]
|
||||
name = "horizon-ctl"
|
||||
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
|
||||
|
||||
[[example]]
|
||||
name = "chunk_load_bench"
|
||||
path = "../horizon-client/examples/chunk_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gameplay_decode_profile"
|
||||
path = "../horizon-client/examples/gameplay_decode_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "json_load_bench"
|
||||
path = "../horizon-client/examples/json_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "mod_port_probe"
|
||||
path = "../horizon-client/examples/mod_port_probe.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_library_bench"
|
||||
path = "../horizon-client/examples/model_library_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_states_bench"
|
||||
path = "../horizon-client/examples/model_states_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "old_world_fixture"
|
||||
path = "../horizon-client/examples/old_world_fixture.rs"
|
||||
|
||||
[[example]]
|
||||
name = "portal_scan_bench"
|
||||
path = "../horizon-client/examples/portal_scan_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "random_tick_eligibility_bench"
|
||||
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "resource_memory"
|
||||
path = "../horizon-client/examples/resource_memory.rs"
|
||||
|
||||
[[example]]
|
||||
name = "save_batch_bench"
|
||||
path = "../horizon-client/examples/save_batch_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "shared_json_startup_profile"
|
||||
path = "../horizon-client/examples/shared_json_startup_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "sprite_cache_parity"
|
||||
path = "../horizon-client/examples/sprite_cache_parity.rs"
|
||||
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "../horizon-client/examples/probe/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_api"
|
||||
path = "../horizon-client/tests/custom_component_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_unregistered"
|
||||
path = "../horizon-client/tests/custom_component_unregistered.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_wire"
|
||||
path = "../horizon-client/tests/custom_component_wire.rs"
|
||||
|
||||
[[test]]
|
||||
name = "forced_chunks"
|
||||
path = "../horizon-client/tests/forced_chunks.rs"
|
||||
|
||||
[[test]]
|
||||
name = "generation_saves"
|
||||
path = "../horizon-client/tests/generation_saves.rs"
|
||||
|
||||
[[test]]
|
||||
name = "lan_bot"
|
||||
path = "../horizon-client/tests/lan_bot.rs"
|
||||
|
||||
[[test]]
|
||||
name = "streaming_meshes"
|
||||
path = "../horizon-client/tests/streaming_meshes.rs"
|
||||
|
||||
[[test]]
|
||||
name = "tall_column"
|
||||
path = "../horizon-client/tests/tall_column.rs"
|
||||
|
||||
[[test]]
|
||||
name = "wide_states"
|
||||
path = "../horizon-client/tests/wide_states.rs"
|
||||
@@ -15,11 +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" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
@@ -38,20 +33,18 @@ glam = "0.30"
|
||||
log = "0.4"
|
||||
miniz_oxide = "0.8"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
|
||||
half = "2"
|
||||
png = "0.18"
|
||||
pollster = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10.9"
|
||||
smallvec = { version = "1", features = ["const_new"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
//! How long a big resource pack takes to load and reload, on generated files only (no game
|
||||
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
|
||||
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
|
||||
//! is printed as a Markdown table.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
|
||||
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
|
||||
use horizon_client_render::{
|
||||
hud::HudAtlas,
|
||||
model_library::ParsedModels,
|
||||
packs::{self, Pack},
|
||||
sprites::Sprites,
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// A small deterministic generator: the same files on every run and every branch.
|
||||
struct Noise(u64);
|
||||
impl Noise {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
|
||||
/// about as well as real textures do.
|
||||
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut out, side, side * frames);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize);
|
||||
let mut colour = [0u8; 4];
|
||||
for i in 0..side * side * frames {
|
||||
if i % 3 == 0 {
|
||||
let n = noise.next();
|
||||
colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255];
|
||||
if alpha && n >> 40 & 3 == 0 {
|
||||
colour[3] = 0;
|
||||
}
|
||||
}
|
||||
pixels.extend_from_slice(&colour);
|
||||
}
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec<u8> {
|
||||
let n = noise.next() % 4;
|
||||
format!(
|
||||
r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##,
|
||||
h = 4 + n * 4
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, bytes: &[u8]) {
|
||||
let path = root.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535
|
||||
/// entries.
|
||||
#[derive(Default)]
|
||||
struct ZipOut {
|
||||
out: Vec<u8>,
|
||||
central: Vec<u8>,
|
||||
count: u16,
|
||||
}
|
||||
impl ZipOut {
|
||||
fn put(&mut self, name: &str, body: &[u8]) {
|
||||
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
|
||||
let offset = u32::try_from(self.out.len()).unwrap();
|
||||
let sizes = [data.len() as u32, body.len() as u32];
|
||||
self.out.extend(0x0403_4b50u32.to_le_bytes());
|
||||
self.out.extend([20, 0, 0, 0, 8, 0]);
|
||||
self.out.extend([0; 8]);
|
||||
sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes()));
|
||||
self.out.extend((name.len() as u16).to_le_bytes());
|
||||
self.out.extend([0; 2]);
|
||||
self.out.extend(name.as_bytes());
|
||||
self.out.extend(&data);
|
||||
self.central.extend(0x0201_4b50u32.to_le_bytes());
|
||||
self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]);
|
||||
self.central.extend([0; 8]);
|
||||
sizes
|
||||
.iter()
|
||||
.for_each(|s| self.central.extend(s.to_le_bytes()));
|
||||
self.central.extend((name.len() as u16).to_le_bytes());
|
||||
self.central.extend([0; 12]);
|
||||
self.central.extend(offset.to_le_bytes());
|
||||
self.central.extend(name.as_bytes());
|
||||
self.count = self.count.checked_add(1).expect("too many zip entries");
|
||||
}
|
||||
fn finish(mut self, path: &Path) {
|
||||
let start = self.out.len() as u32;
|
||||
self.out.extend(&self.central);
|
||||
self.out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
self.out.extend([0; 4]);
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend((self.central.len() as u32).to_le_bytes());
|
||||
self.out.extend(start.to_le_bytes());
|
||||
self.out.extend([0; 2]);
|
||||
fs::write(path, self.out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#;
|
||||
|
||||
/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON
|
||||
/// files (lang, sounds, fonts, atlases) every reload merges.
|
||||
fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec<String> {
|
||||
let mut blocks = Vec::new();
|
||||
for i in 0..1100 * scale {
|
||||
let name = format!("block/b{i}");
|
||||
let frames = if i % 50 == 0 { 8 } else { 1 };
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/textures/{name}.png"),
|
||||
&png(noise, 16, frames, i % 7 == 0),
|
||||
);
|
||||
if frames > 1 {
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/textures/{name}.png.mcmeta"),
|
||||
br#"{"animation":{"frametime":2}}"#,
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/models/block/b{i}.json"),
|
||||
&model(noise, "minecraft", &name),
|
||||
);
|
||||
blocks.push(format!("minecraft:{name}"));
|
||||
}
|
||||
for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] {
|
||||
for i in 0..count * scale {
|
||||
let sub = if folder == "gui" { "sprites/" } else { "" };
|
||||
write(
|
||||
root,
|
||||
&format!(
|
||||
"assets/minecraft/textures/{folder}/{sub}{}/t{i}.png",
|
||||
i % 20
|
||||
),
|
||||
&png(noise, side, 1, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..1500 * scale {
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/models/item/i{i}.json"),
|
||||
&model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)),
|
||||
);
|
||||
}
|
||||
let lang: String = (0..4000)
|
||||
.map(|i| format!("\"key.{i}\":\"Value {i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/lang/en_us.json",
|
||||
format!("{{{lang}}}").as_bytes(),
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/sounds.json",
|
||||
br#"{"a":{"sounds":["a"]}}"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/atlases/blocks.json",
|
||||
br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#,
|
||||
);
|
||||
write(root, "pack.mcmeta", META);
|
||||
blocks
|
||||
}
|
||||
|
||||
/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each
|
||||
/// with its own items' textures and models, its sounds and its lang files.
|
||||
fn server_zip(path: &Path, scale: usize, noise: &mut Noise) {
|
||||
let mut zip = ZipOut::default();
|
||||
zip.put("pack.mcmeta", META);
|
||||
for n in 0..190 {
|
||||
let ns = format!("plugin{n}");
|
||||
for i in 0..50 * scale {
|
||||
let texture = format!("item/{}/t{i}", i % 5);
|
||||
zip.put(
|
||||
&format!("assets/{ns}/textures/{texture}.png"),
|
||||
&png(noise, 32, 1, true),
|
||||
);
|
||||
zip.put(
|
||||
&format!("assets/{ns}/models/item/{}/m{i}.json", i % 5),
|
||||
&model(noise, &ns, &texture),
|
||||
);
|
||||
if i % 10 == 0 {
|
||||
zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]);
|
||||
}
|
||||
}
|
||||
zip.put(
|
||||
&format!("assets/{ns}/lang/en_us.json"),
|
||||
br#"{"item.custom":"Custom"}"#,
|
||||
);
|
||||
}
|
||||
let lang: String = (0..2000)
|
||||
.map(|i| format!("\"server.{i}\":\"Server {i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
zip.put(
|
||||
"assets/minecraft/lang/en_us.json",
|
||||
format!("{{{lang}}}").as_bytes(),
|
||||
);
|
||||
zip.finish(path);
|
||||
}
|
||||
|
||||
/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style
|
||||
/// tiles beside them: what the big OptiFine packs look like on disk.
|
||||
fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) {
|
||||
let mut zip = ZipOut::default();
|
||||
zip.put("pack.mcmeta", META);
|
||||
for i in 0..1100 * scale {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/textures/block/b{i}.png"),
|
||||
&png(noise, 64, 1, i % 7 == 0),
|
||||
);
|
||||
if i % 4 == 0 {
|
||||
for tile in 0..12 {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"),
|
||||
&png(noise, 64, 1, false),
|
||||
);
|
||||
}
|
||||
zip.put(
|
||||
&format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"),
|
||||
format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..750 * scale {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20),
|
||||
&png(noise, 64, 1, true),
|
||||
);
|
||||
}
|
||||
zip.finish(path);
|
||||
}
|
||||
|
||||
fn median(mut ms: Vec<f64>) -> f64 {
|
||||
ms.sort_by(f64::total_cmp);
|
||||
ms[ms.len() / 2]
|
||||
}
|
||||
|
||||
/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run.
|
||||
fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 {
|
||||
median(
|
||||
(0..runs)
|
||||
.map(|_| {
|
||||
before();
|
||||
let started = Instant::now();
|
||||
f();
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn zip_pack(id: &str, path: PathBuf) -> Pack {
|
||||
// Read the way `packs::folder` reads a `resourcepacks/` zip.
|
||||
let dir = path.parent().unwrap().join(format!("{id}-dir"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let at = dir.join(path.file_name().unwrap());
|
||||
fs::rename(&path, &at).unwrap();
|
||||
packs::folder(&dir, packs::RESOURCE_FORMAT)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("{id} is not a pack"))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let scale = args
|
||||
.iter()
|
||||
.position(|a| a == "--scale")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map_or(1, |s| s.parse().unwrap());
|
||||
let runs = 3;
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
fs::create_dir_all(&scratch).unwrap();
|
||||
let mut noise = Noise(0x9e37_79b9_7f4a_7c15);
|
||||
let vanilla_root = scratch.join("vanilla");
|
||||
let blocks = vanilla(&vanilla_root, scale, &mut noise);
|
||||
server_zip(&scratch.join("server.zip"), scale, &mut noise);
|
||||
hd_zip(&scratch.join("hd.zip"), scale, &mut noise);
|
||||
let server = zip_pack("server", scratch.join("server.zip"));
|
||||
let hd = zip_pack("hd", scratch.join("hd.zip"));
|
||||
let cache = scratch.join("cache");
|
||||
let both = [hd.clone(), server.clone()];
|
||||
|
||||
let mut rows: Vec<(String, f64)> = Vec::new();
|
||||
let mut row = |name: &str, ms: f64| {
|
||||
eprintln!("{name}: {ms:.1} ms");
|
||||
rows.push((name.to_owned(), ms));
|
||||
};
|
||||
let fresh = || {
|
||||
let _ = fs::remove_dir_all(&cache);
|
||||
};
|
||||
row(
|
||||
"overlay: build, both packs (cold)",
|
||||
time(runs, fresh, || {
|
||||
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
}),
|
||||
);
|
||||
let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
row(
|
||||
"overlay: reuse, both packs (cache hit)",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
},
|
||||
),
|
||||
);
|
||||
// The pack screen: one pack turned off, then on again.
|
||||
row(
|
||||
"overlay: toggle the server pack off (new selection)",
|
||||
time(
|
||||
runs,
|
||||
|| {
|
||||
let only =
|
||||
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
|
||||
fs::remove_dir_all(&only).unwrap();
|
||||
},
|
||||
|| {
|
||||
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"models: parse every model",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
std::hint::black_box(ParsedModels::load(&root));
|
||||
},
|
||||
),
|
||||
);
|
||||
let sprite_cache = root.join("horizon-sprites.cache");
|
||||
// OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does.
|
||||
let tiles: Vec<String> = (0..1100 * scale)
|
||||
.step_by(4)
|
||||
.flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png")))
|
||||
.collect();
|
||||
let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| {
|
||||
time(
|
||||
runs,
|
||||
|| {
|
||||
if cold {
|
||||
let _ = fs::remove_file(&sprite_cache);
|
||||
}
|
||||
},
|
||||
|| {
|
||||
let mut sprites = Sprites::with_disk_cache(&root);
|
||||
if prefetch {
|
||||
sprites.prefetch(&root, names.iter().map(String::as_str));
|
||||
}
|
||||
for name in names {
|
||||
if files {
|
||||
sprites.insert_file(&root, name).unwrap();
|
||||
} else {
|
||||
sprites.insert(&root, name).unwrap();
|
||||
}
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
std::hint::black_box(&sprites.pixels);
|
||||
},
|
||||
)
|
||||
};
|
||||
row(
|
||||
"block sprites: one by one (no cache)",
|
||||
sprites(true, false, &blocks, false),
|
||||
);
|
||||
row(
|
||||
"block sprites: prefetched (no cache)",
|
||||
sprites(true, true, &blocks, false),
|
||||
);
|
||||
row(
|
||||
"block sprites: from the disk cache",
|
||||
sprites(false, false, &blocks, false),
|
||||
);
|
||||
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
|
||||
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
|
||||
row(
|
||||
"GUI atlas: decode and stitch, both packs",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) {
|
||||
eprintln!("GUI atlas failed: {e}");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"GUI atlas: vanilla alone",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
let _ = HudAtlas::build(&vanilla_root);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |");
|
||||
println!("|---|---:|");
|
||||
for (name, ms) in &rows {
|
||||
println!("| {name} | {ms:.0} |");
|
||||
}
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
@@ -96,13 +96,6 @@ pub struct RenderAssets {
|
||||
pub translucent_layers: Vec<bool>,
|
||||
/// Each layer's `MipmapGenerator` settings.
|
||||
pub sprite_mips: Vec<crate::mipmap::Settings>,
|
||||
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
|
||||
/// empty when they ship none.
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -242,34 +235,6 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &models.catalog;
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
root,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
sprites.finish();
|
||||
@@ -291,10 +256,6 @@ impl RenderAssets {
|
||||
sprite_layers: sprites.layers,
|
||||
translucent_layers: sprites.translucent,
|
||||
sprite_mips: sprites.mips,
|
||||
pbr_normals: sprites.normals,
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,13 +1074,14 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
// The device belongs to the first player's output, which looks after it.
|
||||
// A shared output has no stream of its own: the owner's engine
|
||||
// tracks the devices.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: self.default_seen.clone(),
|
||||
default_seen: None,
|
||||
#[cfg(feature = "audio")]
|
||||
cards_seen: self.cards_seen.clone(),
|
||||
cards_seen: None,
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: self.devices_seen.clone(),
|
||||
devices_seen: Vec::new(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
}
|
||||
@@ -1107,11 +1108,6 @@ impl Audio {
|
||||
/// 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,
|
||||
@@ -1179,15 +1175,6 @@ impl Audio {
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
/// The `uiSoundReserve` extra: positional sounds leave
|
||||
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
|
||||
pub fn set_listener_reserve(&mut self, on: bool) {
|
||||
self.engine.listener_reserve = if on {
|
||||
crate::sound_engine::HEARD_RESERVE
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
|
||||
pub fn pause(&mut self) {
|
||||
self.engine.pause_all_except(
|
||||
@@ -1235,17 +1222,6 @@ impl Audio {
|
||||
self.play(event, Some(at), volume, pitch);
|
||||
return;
|
||||
}
|
||||
self.play_after(
|
||||
event,
|
||||
at,
|
||||
volume,
|
||||
pitch,
|
||||
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
|
||||
);
|
||||
}
|
||||
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
|
||||
/// second) from now: `SoundEngine.playDelayed`.
|
||||
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
|
||||
let seed = self.random.next_long();
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
@@ -1259,7 +1235,8 @@ impl Audio {
|
||||
let instance =
|
||||
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine.play_delayed(id, ticks);
|
||||
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.
|
||||
@@ -1355,13 +1332,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 {
|
||||
|
||||
@@ -272,192 +272,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`.
|
||||
@@ -671,104 +485,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"}}}}});
|
||||
|
||||
@@ -172,7 +172,6 @@ fn color(v: &Value) -> Option<u32> {
|
||||
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
|
||||
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
|
||||
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
|
||||
@@ -388,52 +387,6 @@ impl Biomes {
|
||||
}
|
||||
}
|
||||
|
||||
/// A biome's colours as a mod may change them (`native_biome_colors`), each
|
||||
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
|
||||
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
|
||||
/// water.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct BiomeColors {
|
||||
pub grass: u32,
|
||||
pub foliage: u32,
|
||||
pub dry_foliage: u32,
|
||||
pub water: u32,
|
||||
}
|
||||
|
||||
impl Biomes {
|
||||
/// This table with each biome's colours passed through `f`, given the
|
||||
/// biome's name (`minecraft:plains`) and its colours; None when `f`
|
||||
/// changed none. Bits past 0xFFFFFF are dropped.
|
||||
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
|
||||
let mut out = self.clone();
|
||||
let mut changed = false;
|
||||
for b in &mut out.list {
|
||||
let before = BiomeColors {
|
||||
grass: b.grass,
|
||||
foliage: b.foliage,
|
||||
dry_foliage: b.dry_foliage,
|
||||
water: b.water,
|
||||
};
|
||||
let mut colors = before;
|
||||
f(&b.name, &mut colors);
|
||||
let after = BiomeColors {
|
||||
grass: colors.grass & 0xFF_FFFF,
|
||||
foliage: colors.foliage & 0xFF_FFFF,
|
||||
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
|
||||
water: colors.water & 0xFF_FFFF,
|
||||
};
|
||||
if after != before {
|
||||
changed = true;
|
||||
b.grass = after.grass;
|
||||
b.foliage = after.foliage;
|
||||
b.dry_foliage = after.dry_foliage;
|
||||
b.water = after.water;
|
||||
}
|
||||
}
|
||||
changed.then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 24-bit colour as sRGB components in 0..1.
|
||||
pub fn rgb(c: u32) -> [f32; 3] {
|
||||
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
|
||||
@@ -581,81 +534,6 @@ mod pack_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn biomes() -> Biomes {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let effects = |grass: u32, modifier: &str| {
|
||||
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
|
||||
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
|
||||
"grass_color_modifier": modifier}})
|
||||
};
|
||||
let list = vec![
|
||||
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
|
||||
BiomeInfo::from_json(
|
||||
"minecraft:dark_forest".into(),
|
||||
&effects(0x91BD59, "dark_forest"),
|
||||
&maps,
|
||||
),
|
||||
];
|
||||
Biomes {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_reaches_the_tints_the_mesher_reads() {
|
||||
let autumn = biomes()
|
||||
.with_colors(|name, c| {
|
||||
c.foliage = 0xD2691E;
|
||||
if name == "minecraft:plains" {
|
||||
c.grass = 0xBFA055;
|
||||
}
|
||||
})
|
||||
.expect("changed");
|
||||
let plains = autumn.id("minecraft:plains");
|
||||
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
|
||||
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
|
||||
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
|
||||
assert_eq!(autumn.get(plains).water, 0x3F76E4);
|
||||
// The dark forest's modifier still darkens its (unchanged) grass:
|
||||
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
|
||||
let dark = autumn.id("minecraft:dark_forest");
|
||||
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
|
||||
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_change_gives_none_and_alpha_bits_are_dropped() {
|
||||
assert!(biomes().with_colors(|_, _| {}).is_none());
|
||||
assert!(
|
||||
biomes()
|
||||
.with_colors(|_, c| c.water |= 0xFF00_0000)
|
||||
.is_none()
|
||||
);
|
||||
let blue = biomes()
|
||||
.with_colors(|_, c| c.water = 0xFF00_00FF)
|
||||
.expect("changed");
|
||||
assert_eq!(blue.get(0).water, 0x0000FF);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blend_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
//! Bulk block edits: runs of blocks up columns written straight into the
|
||||
//! resident sections, a section unpacked and packed once however many of its
|
||||
//! blocks change, instead of a `World::set` per block. Nothing round them is
|
||||
//! told (as `fill ... strict`); the light of each chunk changed is worked out
|
||||
//! again whole, off the game thread, rather than block by block, and each
|
||||
//! section changed is remeshed once.
|
||||
use crate::chunk::index;
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::world::World;
|
||||
|
||||
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
|
||||
/// `state` where filter `only` (if any) passes the block there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Run {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub y0: i32,
|
||||
pub y1: i32,
|
||||
pub state: u32,
|
||||
pub only: Option<u16>,
|
||||
}
|
||||
|
||||
/// What an edit changed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Outcome {
|
||||
/// Blocks changed.
|
||||
pub changed: u64,
|
||||
/// Chunks with a change.
|
||||
pub chunks: usize,
|
||||
/// Changes of the states `tracked` asked for (block entities, watched
|
||||
/// blocks): position, previous and new state.
|
||||
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
|
||||
}
|
||||
|
||||
/// Per state answers, worked out once each.
|
||||
struct Memo(Vec<u8>);
|
||||
impl Memo {
|
||||
#[inline]
|
||||
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
|
||||
let i = state as usize;
|
||||
if i >= self.0.len() {
|
||||
self.0.resize(i + 1, 0);
|
||||
}
|
||||
if self.0[i] == 0 {
|
||||
self.0[i] = 1 + u8::from(ask(state));
|
||||
}
|
||||
self.0[i] == 2
|
||||
}
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// Make `runs` in order, each seeing the changes of those before it.
|
||||
/// `matches(filter, state)` says whether a filter passes a state, and
|
||||
/// `tracked(state)` whether its changes are reported; each is asked once
|
||||
/// per state. Runs in unloaded chunks and the parts of runs outside each
|
||||
/// chunk's own column (`Chunk::layout`) are left out.
|
||||
///
|
||||
/// Unlike `World::set`, this records nothing per block: no
|
||||
/// `block_entities.state_changed` (a caller adds or removes block
|
||||
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
|
||||
/// for those states through `tracked`), no `block_log` entry (the chunk
|
||||
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
|
||||
/// paths are not recomputed for these blocks) and no `light_edits` (the
|
||||
/// chunk and its neighbours are lit again whole, through `dirty_light`).
|
||||
pub fn edit_runs(
|
||||
&mut self,
|
||||
runs: &[Run],
|
||||
mut matches: impl FnMut(u16, u32) -> bool,
|
||||
mut tracked: impl FnMut(u32) -> bool,
|
||||
) -> Outcome {
|
||||
let mut out = Outcome::default();
|
||||
// the runs of each chunk, in order, and the chunks in the order they were first named
|
||||
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
|
||||
let mut order = Vec::new();
|
||||
for (i, r) in runs.iter().enumerate() {
|
||||
if r.y1 < r.y0 {
|
||||
continue;
|
||||
}
|
||||
let key = (r.x >> 4, r.z >> 4);
|
||||
by_chunk
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
order.push(key);
|
||||
Vec::new()
|
||||
})
|
||||
.push(i as u32);
|
||||
}
|
||||
let mut filter_memo: Vec<Memo> = Vec::new();
|
||||
let mut tracked_memo = Memo(Vec::new());
|
||||
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
|
||||
let mut changed: Vec<bool> = Vec::new();
|
||||
for key in order {
|
||||
let Some(chunk) = self.chunks.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
|
||||
let max_y = chunk.max_y();
|
||||
let list = &by_chunk[&key];
|
||||
changed.clear();
|
||||
changed.resize(sections, false);
|
||||
values.clear();
|
||||
values.resize_with(sections, || None);
|
||||
let mut any = 0u64;
|
||||
for &i in list {
|
||||
let r = runs[i as usize];
|
||||
let (lx, lz) = (r.x & 15, r.z & 15);
|
||||
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
|
||||
let mut y = y0;
|
||||
while y <= y1 {
|
||||
let s = ((y - min_y) >> 4) as usize;
|
||||
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
|
||||
let vals = values[s].get_or_insert_with(|| {
|
||||
let mut v = Box::new([0u32; 4096]);
|
||||
chunk.sections[s].unpack(&mut v);
|
||||
v
|
||||
});
|
||||
for yy in y..=top {
|
||||
let at = index(lx, yy, lz);
|
||||
let old = vals[at];
|
||||
if old == r.state {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = r.only {
|
||||
let f = f as usize;
|
||||
if filter_memo.len() <= f {
|
||||
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
|
||||
}
|
||||
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
vals[at] = r.state;
|
||||
changed[s] = true;
|
||||
any += 1;
|
||||
if tracked_memo.get(old, &mut tracked)
|
||||
|| tracked_memo.get(r.state, &mut tracked)
|
||||
{
|
||||
out.tracked.push(((r.x, yy, r.z), old, r.state));
|
||||
}
|
||||
}
|
||||
y = top + 1;
|
||||
}
|
||||
}
|
||||
if any == 0 {
|
||||
continue;
|
||||
}
|
||||
out.changed += any;
|
||||
out.chunks += 1;
|
||||
let chunk =
|
||||
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
|
||||
for s in 0..sections {
|
||||
if changed[s]
|
||||
&& let Some(v) = &values[s]
|
||||
{
|
||||
chunk.replace_blocks(s, v);
|
||||
}
|
||||
}
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.modified.insert(key);
|
||||
if self.block_log.is_some() {
|
||||
self.bulk_log.insert(key);
|
||||
}
|
||||
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
|
||||
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
|
||||
for ds in -1..=1 {
|
||||
let sy = s as i32 + ds;
|
||||
if !(0..sections as i32).contains(&sy) {
|
||||
continue;
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::Chunk;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn world() -> World {
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
// stone up to y 63
|
||||
for z in -16..32 {
|
||||
for x in -16..32 {
|
||||
for y in -64..64 {
|
||||
w.set(x, y, z, BlockStateId(1)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
w.dirty_light.clear();
|
||||
w.dirty_sections.clear();
|
||||
w.block_log = Some(Vec::new());
|
||||
w
|
||||
}
|
||||
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
|
||||
Run {
|
||||
x,
|
||||
z,
|
||||
y0,
|
||||
y1,
|
||||
state,
|
||||
only,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_change_their_blocks_in_order() {
|
||||
let mut w = world();
|
||||
let runs = [
|
||||
run(3, 4, 10, 40, 0, None),
|
||||
run(3, 4, 10, 12, 5, None),
|
||||
// only where it is air: over the stone, and the three blocks of 5 stay
|
||||
run(3, 4, 0, 80, 7, Some(0)),
|
||||
run(-1, 20, -70, 400, 9, None),
|
||||
];
|
||||
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
|
||||
assert_eq!(w.block(3, 9, 4).0, 1);
|
||||
assert_eq!(w.block(3, 11, 4).0, 5);
|
||||
assert_eq!(w.block(3, 13, 4).0, 7);
|
||||
assert_eq!(w.block(3, 70, 4).0, 7);
|
||||
assert_eq!(w.block(3, 81, 4).0, 0);
|
||||
assert_eq!(w.block(3, 0, 4).0, 1);
|
||||
assert_eq!(w.block(-1, -64, 20).0, 9);
|
||||
assert_eq!(w.block(-1, 319, 20).0, 9);
|
||||
assert_eq!(w.block(0, 30, 20).0, 1);
|
||||
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
|
||||
assert_eq!(out.changed, 31 + 3 + 45 + 384);
|
||||
assert_eq!(out.chunks, 2);
|
||||
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
|
||||
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
|
||||
assert!(w.dirty_sections.contains(&(1, 4, 1)));
|
||||
// the per-block log is left to the blocks set one at a time
|
||||
assert!(w.block_log.as_ref().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(
|
||||
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|
||||
|_, _| true,
|
||||
|_| false,
|
||||
);
|
||||
assert_eq!(out.changed, 0);
|
||||
assert_eq!(out.chunks, 0);
|
||||
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_states_are_reported() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
|
||||
assert_eq!(out.tracked.len(), 6);
|
||||
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
|
||||
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heightmaps_follow_the_edit() {
|
||||
let mut w = world();
|
||||
let air = |s: u32| s == 0;
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
|
||||
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
|
||||
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_follow_each_chunks_own_layout() {
|
||||
use crate::chunk::ColumnLayout;
|
||||
// a data pack's taller dimension: -128..=511
|
||||
let tall = ColumnLayout::new(-128, 640).unwrap();
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
w.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
|
||||
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
|
||||
assert_eq!(out.changed, 640);
|
||||
let chunk = &w.chunks[&(0, 0)];
|
||||
assert_eq!(chunk.block(2, -128, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, -65, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, 320, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, 511, 3).0, 4);
|
||||
assert!(w.dirty_sections.contains(&(0, 39, 0)));
|
||||
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
|
||||
}
|
||||
}
|
||||
@@ -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, ModEnergyBuffer, ModFluidTanks};
|
||||
use crate::chunk::block_entity::{BlockEntity, ModContainer};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -73,32 +73,6 @@ pub struct BlockEntities {
|
||||
/// The containers of mod blocks' block entities, by block state id;
|
||||
/// built with `states`.
|
||||
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
|
||||
/// The fluid tanks of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
|
||||
/// The energy buffers of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
|
||||
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
|
||||
/// for the renderer: the mesher lists block entities by their template,
|
||||
/// which leaves out a mod block whose template has none. One taken out
|
||||
/// (`take`) stays listed until `put` or `remove`.
|
||||
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
|
||||
}
|
||||
|
||||
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
|
||||
(x >> 4, z >> 4)
|
||||
}
|
||||
|
||||
/// Takes `pos` out of its chunk's mod block entity set.
|
||||
fn forget_mod(mods: &mut FxHashMap<(i32, i32), FxHashSet<Pos>>, pos: Pos) {
|
||||
let chunk = chunk_of(pos);
|
||||
if let Some(set) = mods.get_mut(&chunk) {
|
||||
set.remove(&pos);
|
||||
if set.is_empty() {
|
||||
mods.remove(&chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for BlockEntities {
|
||||
@@ -188,20 +162,6 @@ impl BlockEntities {
|
||||
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
|
||||
self.mod_containers.get(&state)
|
||||
}
|
||||
pub fn set_mod_fluids(&mut self, fluids: FxHashMap<u32, Arc<ModFluidTanks>>) {
|
||||
self.mod_fluids = fluids;
|
||||
}
|
||||
/// The fluid tanks of a mod block's block entity in block state `state`.
|
||||
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
|
||||
self.mod_fluids.get(&state)
|
||||
}
|
||||
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
|
||||
self.mod_energy = energy;
|
||||
}
|
||||
/// The energy buffer of a mod block's block entity in block state `state`.
|
||||
pub fn mod_energy(&self, state: u32) -> Option<&Arc<ModEnergyBuffer>> {
|
||||
self.mod_energy.get(&state)
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u32 {
|
||||
let n = self.next_slot;
|
||||
@@ -220,23 +180,6 @@ impl BlockEntities {
|
||||
self.pending.remove(pos);
|
||||
}
|
||||
|
||||
/// Whether any mod block entity is in the map: until one is, a block change
|
||||
/// never looks one up (`LevelAccess::has_mod_block_entity`).
|
||||
pub fn has_mods(&self) -> bool {
|
||||
!self.mods.is_empty()
|
||||
}
|
||||
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
|
||||
/// far ones are skipped whole; the caller checks each distance).
|
||||
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
|
||||
let far = move |c: i32, at: f64| {
|
||||
let (lo, hi) = (f64::from(c * 16), f64::from(c * 16 + 16));
|
||||
at < lo - radius || at > hi + radius
|
||||
};
|
||||
self.mods
|
||||
.iter()
|
||||
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
|
||||
.flat_map(|(_, set)| set)
|
||||
}
|
||||
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
|
||||
self.map.get_mut(pos)
|
||||
}
|
||||
@@ -249,16 +192,10 @@ impl BlockEntities {
|
||||
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
|
||||
/// block entity may get a slot; one replacing another keeps its slot.
|
||||
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
|
||||
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
self.add(pos);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, pos);
|
||||
}
|
||||
old
|
||||
}
|
||||
/// The block entity at `pos`, made by `make` when there is none.
|
||||
@@ -267,24 +204,15 @@ impl BlockEntities {
|
||||
pos: Pos,
|
||||
make: impl FnOnce() -> BlockEntity,
|
||||
) -> &mut BlockEntity {
|
||||
let absent = !self.map.contains_key(&pos);
|
||||
if absent {
|
||||
if !self.map.contains_key(&pos) {
|
||||
self.add(pos);
|
||||
}
|
||||
let entity = self.map.entry(pos).or_insert_with(make);
|
||||
if absent && matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
entity
|
||||
self.map.entry(pos).or_insert_with(make)
|
||||
}
|
||||
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
|
||||
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
self.cancel(pos);
|
||||
let removed = self.map.remove(pos);
|
||||
if matches!(removed, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, *pos);
|
||||
}
|
||||
removed
|
||||
self.map.remove(pos)
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
/// for `put` to bring it back (a tick working on it).
|
||||
@@ -298,9 +226,6 @@ impl BlockEntities {
|
||||
/// Brings back what `take` took, in its slot.
|
||||
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
|
||||
self.out.remove(&pos);
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
@@ -312,20 +237,17 @@ impl BlockEntities {
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
|
||||
let mods = &mut self.mods;
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
if !kept {
|
||||
*freed |= live.remove(p).is_some();
|
||||
pending.remove(p);
|
||||
forget_mod(mods, *p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
@@ -342,16 +264,9 @@ impl BlockEntities {
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
let new_is_mod = matches!(e, BlockEntity::Mod(_));
|
||||
let old = self.map.insert(p, e);
|
||||
if old.is_some() {
|
||||
if self.map.insert(p, e).is_some() {
|
||||
self.replaced.insert(p);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(p)).or_default().insert(p);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, p);
|
||||
}
|
||||
}
|
||||
for p in order {
|
||||
self.add(p);
|
||||
@@ -444,38 +359,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
|
||||
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
#[test]
|
||||
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
|
||||
let mut b = level();
|
||||
let modded = || BlockEntity::Mod(Box::default());
|
||||
b.insert((1, 64, 1), modded());
|
||||
b.insert((200, 64, 1), modded());
|
||||
b.insert((2, 64, 2), hopper());
|
||||
b.get_or_insert_with((3, 64, 3), modded);
|
||||
b.load_chunk(vec![((4, 64, 4), modded()), ((5, 64, 5), hopper())]);
|
||||
// Only mods, and only chunks within reach.
|
||||
assert_eq!(
|
||||
near(&b, 0.0, 0.0, 64.0),
|
||||
[(1, 64, 1), (3, 64, 3), (4, 64, 4)]
|
||||
);
|
||||
assert_eq!(near(&b, 200.0, 0.0, 8.0), [(200, 64, 1)]);
|
||||
// Taken out for a tick, still listed; removed or replaced, gone.
|
||||
let taken = b.take(&(1, 64, 1)).unwrap();
|
||||
assert_eq!(near(&b, 0.0, 0.0, 64.0).len(), 3);
|
||||
b.put((1, 64, 1), taken);
|
||||
b.remove(&(3, 64, 3));
|
||||
b.insert((4, 64, 4), hopper());
|
||||
assert_eq!(near(&b, 0.0, 0.0, 64.0), [(1, 64, 1)]);
|
||||
b.retain(|p, _| p.0 != 1);
|
||||
assert!(near(&b, 0.0, 0.0, 64.0).is_empty());
|
||||
b.clear();
|
||||
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn slots_follow_vanilla_lifecycle() {
|
||||
let mut b = level();
|
||||
|
||||
@@ -885,26 +885,25 @@ impl BlockEntityRenderer {
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
let items = match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for (item, components) in &items {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
for (item, components) in items.as_object().into_iter().flatten() {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(renderer)
|
||||
@@ -1119,10 +1118,9 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
/// `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.
|
||||
/// BellRenderer.submit, LecternRenderer.submit (the book) and
|
||||
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
|
||||
/// destroy stage.
|
||||
pub fn render_breaking(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
@@ -1140,8 +1138,6 @@ impl BlockEntityRenderer {
|
||||
| Kind::Bell
|
||||
| Kind::Lectern { .. }
|
||||
| Kind::CopperGolem { .. }
|
||||
| Kind::Conduit
|
||||
| Kind::EnchantingTable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1687,10 +1683,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 {
|
||||
|
||||
@@ -13,8 +13,7 @@ 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,
|
||||
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
@@ -160,18 +159,6 @@ pub struct Brewing {
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
// NeoForge's load conditions: a file whose don't hold is silently
|
||||
// absent. Vanilla's brewing recipes carry none; a pack's can.
|
||||
let mods: Vec<String> = {
|
||||
let mut mods = horizon_items::content_namespaces::mod_ids(root);
|
||||
if !mods.iter().any(|m| m == "minecraft") {
|
||||
mods.push("minecraft".to_owned());
|
||||
}
|
||||
mods
|
||||
};
|
||||
let conditions_hold = |v: &Value| {
|
||||
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
|
||||
};
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
@@ -203,11 +190,6 @@ impl Brewing {
|
||||
None => sources.push((id.to_owned(), v)),
|
||||
}
|
||||
}
|
||||
// The gate answers the merged file, as the crafting loader's does:
|
||||
// a false-conditioned pack file leaves the lower one gone too.
|
||||
// The data's recipe filters, over the final set.
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
|
||||
sources.retain_mut(|(id, v)| conditions_hold(v) && filters.apply(id, v));
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,19 +495,6 @@ impl Camera {
|
||||
entity: &CameraEntity,
|
||||
partial: f32,
|
||||
camera_type: CameraType,
|
||||
clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) {
|
||||
self.align_with_entity_distance(entity, partial, camera_type, |distance| distance, clip);
|
||||
}
|
||||
/// Adjust the detached distance after entity/mount scaling, before collision clipping.
|
||||
/// Only finite overrides in 0..=1024 blocks are accepted (within the game's ray budget).
|
||||
/// First-person views do not call `adjust_distance`.
|
||||
pub fn align_with_entity_distance(
|
||||
&mut self,
|
||||
entity: &CameraEntity,
|
||||
partial: f32,
|
||||
camera_type: CameraType,
|
||||
adjust_distance: impl FnOnce(f32) -> f32,
|
||||
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) {
|
||||
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
|
||||
@@ -524,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() {
|
||||
@@ -541,14 +520,10 @@ impl Camera {
|
||||
mount_scale = s;
|
||||
mount_distance = d as f32;
|
||||
}
|
||||
let vanilla = java_max(scale * distance, mount_scale * mount_distance);
|
||||
let adjusted = adjust_distance(vanilla);
|
||||
let distance = if adjusted.is_finite() && (0.0..=1024.0).contains(&adjusted) {
|
||||
adjusted
|
||||
} else {
|
||||
vanilla
|
||||
};
|
||||
let zoom = self.max_zoom(distance, &mut clip);
|
||||
let zoom = self.max_zoom(
|
||||
java_max(scale * distance, mount_scale * mount_distance),
|
||||
&mut clip,
|
||||
);
|
||||
self.move_by(-zoom, 0.0, 0.0);
|
||||
} else if let Some(LivingCamera {
|
||||
sleeping: Some(bed),
|
||||
@@ -986,100 +961,6 @@ mod tests {
|
||||
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
|
||||
}
|
||||
#[test]
|
||||
fn detached_distance_override_keeps_collision_clipping() {
|
||||
let entity = CameraEntity {
|
||||
position: [0.0; 3],
|
||||
previous: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot_o: 0.0,
|
||||
y_rot_o: 0.0,
|
||||
eye_height: 1.62,
|
||||
living: Some(LivingCamera {
|
||||
scale: 2.0,
|
||||
camera_distance: 3.0,
|
||||
sleeping: None,
|
||||
dead: None,
|
||||
}),
|
||||
mount: Some((3.0, 4.0)),
|
||||
};
|
||||
for kind in [CameraType::ThirdPersonBack, CameraType::ThirdPersonFront] {
|
||||
let mut camera = Camera::default();
|
||||
let mut calls = 0;
|
||||
let mut rays = 0;
|
||||
let z = if kind.is_mirrored() { 1.0 } else { -1.0 };
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
kind,
|
||||
|distance| {
|
||||
assert_eq!(distance, 12.0);
|
||||
8.0
|
||||
},
|
||||
|_, _| None,
|
||||
);
|
||||
assert!((camera.position[2] - z * 8.0).abs() < 1e-5);
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
kind,
|
||||
|distance| {
|
||||
calls += 1;
|
||||
assert_eq!(distance, 12.0);
|
||||
8.0
|
||||
},
|
||||
|_, _| {
|
||||
rays += 1;
|
||||
Some([0.0, 0.0, z])
|
||||
},
|
||||
);
|
||||
assert_eq!((calls, rays), (1, 8));
|
||||
assert!((camera.position[2] - z).abs() < 0.2);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn detached_distance_rejects_invalid_values_and_accepts_zero() {
|
||||
let entity = CameraEntity {
|
||||
position: [0.0; 3],
|
||||
previous: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot_o: 0.0,
|
||||
y_rot_o: 0.0,
|
||||
eye_height: 1.62,
|
||||
living: None,
|
||||
mount: None,
|
||||
};
|
||||
for distance in [f32::NAN, f32::INFINITY, -1.0, 1025.0] {
|
||||
let mut camera = Camera::default();
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::ThirdPersonBack,
|
||||
|_| distance,
|
||||
|_, _| None,
|
||||
);
|
||||
assert!((camera.position[2] + 4.0).abs() < 1e-6);
|
||||
}
|
||||
let mut camera = Camera::default();
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::ThirdPersonBack,
|
||||
|_| 0.0,
|
||||
|_, _| None,
|
||||
);
|
||||
assert_eq!(camera.position, camera.eye);
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::FirstPerson,
|
||||
|_| panic!("first person has no detached distance"),
|
||||
|_, _| None,
|
||||
);
|
||||
assert_eq!(camera.position, camera.eye);
|
||||
}
|
||||
#[test]
|
||||
fn fov_modifier_combines_speed_flight_and_bow() {
|
||||
let mut p = FovPlayer {
|
||||
flying: false,
|
||||
|
||||
@@ -29,10 +29,6 @@ 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;
|
||||
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
|
||||
/// and how far left of its place it starts (GUI pixels).
|
||||
const INTRO_TICKS: f32 = 5.0;
|
||||
const INTRO_WIDTH: f32 = 12.0;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -214,14 +210,6 @@ impl Tag {
|
||||
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 {
|
||||
@@ -237,10 +225,14 @@ impl Tag {
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
/// fading out quadratically over 20.
|
||||
fn time_alpha(ticks: f32, added: u64) -> f32 {
|
||||
let mut t = 1.0 - (ticks - added as f32) / 200.0;
|
||||
t = (t * 10.0).clamp(0.0, 1.0);
|
||||
t * t
|
||||
fn time_alpha(ticks: u64, added: u64) -> f32 {
|
||||
let delta = ticks as i64 - added as i64;
|
||||
let mut t = delta as f64 / 200.0;
|
||||
t = 1.0 - t;
|
||||
t *= 10.0;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
t *= t;
|
||||
t as f32
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Chat {
|
||||
@@ -256,11 +248,6 @@ pub struct Chat {
|
||||
scroll: usize,
|
||||
new_since_scroll: bool,
|
||||
pub options: ChatOptions,
|
||||
/// The smooth chat extra: a new line slides in from the left while it
|
||||
/// fades in, instead of appearing whole.
|
||||
pub smooth: bool,
|
||||
/// The smooth chat extra: no indicator bar at the left of a line.
|
||||
pub hide_indicator: bool,
|
||||
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
@@ -560,7 +547,7 @@ impl Chat {
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
|
||||
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
|
||||
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
|
||||
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
|
||||
let total = self.lines.len();
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
@@ -574,47 +561,28 @@ impl Chat {
|
||||
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;
|
||||
// Vanilla's 200-tick fade reads the whole GUI ticks
|
||||
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
|
||||
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
|
||||
let alpha_of = |line: &Line| {
|
||||
if focused {
|
||||
1.0
|
||||
} else {
|
||||
time_alpha(fade_ticks, line.added)
|
||||
time_alpha(ticks, line.added)
|
||||
}
|
||||
};
|
||||
// The smooth chat extra: how far into its intro a line is, 0 at the
|
||||
// tick it was added and 1 once it has arrived, as (alpha, offset x).
|
||||
let intro_of = |line: &Line| {
|
||||
if focused || !self.smooth {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
|
||||
let eased = 1.0 - (1.0 - t) * (1.0 - t);
|
||||
(eased, (eased - 1.0) * INTRO_WIDTH)
|
||||
};
|
||||
let visible = total
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(focused));
|
||||
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
|
||||
let lines: Vec<(usize, &Line, f32)> = (0..visible)
|
||||
.rev()
|
||||
.map(|i| (i, &self.lines[i + self.scroll]))
|
||||
.map(|(i, line)| {
|
||||
let (eased, slide) = intro_of(line);
|
||||
(i, line, alpha_of(line) * eased, slide)
|
||||
})
|
||||
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
|
||||
.map(|(i, line)| (i, line, alpha_of(line)))
|
||||
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
|
||||
.collect();
|
||||
let mut out = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
for &(i, _, alpha, slide) in &lines {
|
||||
for &(i, _, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
@@ -622,15 +590,9 @@ impl Chat {
|
||||
bottom,
|
||||
hud_layout::as8(alpha * background_opacity) << 24,
|
||||
));
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
for &(i, line, alpha, slide) in &lines {
|
||||
for &(i, line, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
let opacity = alpha * text_opacity;
|
||||
let color = hud_layout::white(opacity);
|
||||
if color >> 24 != 0 {
|
||||
@@ -642,7 +604,7 @@ impl Chat {
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
|
||||
if let Some(tag) = &line.tag {
|
||||
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
@@ -652,9 +614,6 @@ impl Chat {
|
||||
color,
|
||||
));
|
||||
}
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
if focused {
|
||||
let count = lines.len() as i32;
|
||||
@@ -691,7 +650,7 @@ impl Chat {
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
|
||||
/// open the restricted prompt above the lines.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
@@ -911,22 +870,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 {
|
||||
@@ -938,17 +881,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
|
||||
@@ -1071,53 +1005,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(
|
||||
@@ -1207,14 +1094,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`.
|
||||
@@ -1438,12 +1323,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;
|
||||
};
|
||||
@@ -1486,7 +1370,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);
|
||||
@@ -1506,9 +1389,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);
|
||||
@@ -1619,7 +1499,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
|
||||
@@ -1852,18 +1732,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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -39,61 +39,6 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
|
||||
let mut chat = Chat {
|
||||
smooth: true,
|
||||
..Chat::default()
|
||||
};
|
||||
chat.lines.push(Line {
|
||||
added: 0,
|
||||
content: Text::literal("hi"),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
))),
|
||||
});
|
||||
let text_alpha = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Text { color, .. } => Some(color >> 24),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The slide is the negative translate the line carries while it moves.
|
||||
let slide = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
|
||||
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The tick it arrives: fully faded out, so nothing is drawn.
|
||||
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
|
||||
// Halfway through the intro: left of its place and still faint.
|
||||
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
|
||||
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
|
||||
let faded = text_alpha(&halfway).expect("the line shows halfway through");
|
||||
// Settled: in place and as opaque as vanilla's line.
|
||||
let settled = chat.layout(361, INTRO_TICKS, false);
|
||||
assert_eq!(slide(&settled), None);
|
||||
assert!(text_alpha(&settled).expect("settled") > faded);
|
||||
// The extra off draws the line whole on the tick it arrives, as vanilla.
|
||||
chat.smooth = false;
|
||||
let plain = chat.layout(361, 0.0, false);
|
||||
assert_eq!(slide(&plain), None);
|
||||
assert_eq!(text_alpha(&plain), text_alpha(&settled));
|
||||
// The indicator bar is the -4 to -2 fill at the left of the line.
|
||||
let bar = |draws: &[GuiDraw]| {
|
||||
draws
|
||||
.iter()
|
||||
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
|
||||
};
|
||||
assert!(bar(&plain));
|
||||
chat.hide_indicator = true;
|
||||
assert!(!bar(&chat.layout(361, 0.0, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
|
||||
let font = font();
|
||||
@@ -150,7 +95,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
|
||||
for scroll in [0, 3, 12] {
|
||||
chat.scroll = scroll;
|
||||
let mut links = 0;
|
||||
for draw in chat.layout(361, 0.0, true) {
|
||||
for draw in chat.layout(361, 0, true) {
|
||||
if let GuiDraw::Text { text, x, y, .. } = draw {
|
||||
let mut at = x as f64;
|
||||
for (c, style) in text.chars() {
|
||||
@@ -633,71 +578,3 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
|
||||
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));
|
||||
}
|
||||
@@ -212,7 +212,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
|
||||
@@ -391,9 +391,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).
|
||||
@@ -431,7 +428,6 @@ impl Chunk {
|
||||
lit: false,
|
||||
light_loaded: false,
|
||||
fresh: false,
|
||||
generated: false,
|
||||
block_entities: Vec::new(),
|
||||
block_names: Vec::new(),
|
||||
structure_refs: Vec::new(),
|
||||
@@ -508,15 +504,6 @@ impl Chunk {
|
||||
}
|
||||
previous
|
||||
}
|
||||
/// Section `s`'s block states replaced whole (a bulk edit): its storage
|
||||
/// compacted, and the heightmaps built again on their next read.
|
||||
pub fn replace_blocks(&mut self, s: usize, values: &[u32; 4096]) {
|
||||
let section = Arc::make_mut(&mut self.sections[s]);
|
||||
section.blocks = Section::from_blocks(values);
|
||||
section.compact();
|
||||
self.surface = OnceLock::new();
|
||||
self.motion_blocking = OnceLock::new();
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
|
||||
@@ -120,38 +120,6 @@ pub struct ModContainer {
|
||||
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 energy buffer of a mod block's block entity (NeoForge's
|
||||
/// `SimpleEnergyHandler`): its capacity, the most one insert and one extract
|
||||
/// moves, and the faces a fill may come through and a drain may leave by.
|
||||
/// The amount is not here: it lives in the entity's saved compound, under
|
||||
/// `energy`, so it survives restarts and the block's own tick reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ModEnergyBuffer {
|
||||
pub capacity: i32,
|
||||
/// The most one insert moves; 0 takes none.
|
||||
pub max_insert: i32,
|
||||
/// The most one extract moves; 0 gives none.
|
||||
pub max_extract: i32,
|
||||
/// The faces, by `Direction` number, a fill may come through.
|
||||
pub insert_faces: [bool; 6],
|
||||
/// The faces a drain may leave by.
|
||||
pub extract_faces: [bool; 6],
|
||||
}
|
||||
/// 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
|
||||
@@ -250,9 +218,6 @@ pub struct SkullSkin {
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<NameOrId>,
|
||||
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
|
||||
/// texture drawn instead of any skin.
|
||||
pub texture: Option<String>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
|
||||
@@ -311,19 +276,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,662 +0,0 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
|
||||
//! `horizon_optifine::cit`'s.
|
||||
|
||||
pub use horizon_optifine::cit::{Hand, Kind, Rule};
|
||||
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
|
||||
let bytes = fs::read(root.join(&path)).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
|
||||
root.join(p).is_file()
|
||||
});
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
rules
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CitRules {
|
||||
pub rules: Vec<Rule>,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
Self::new(load_rules(root))
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
pub fn new(rules: Vec<Rule>) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
Self {
|
||||
rules,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
|
||||
pub fn select(
|
||||
&self,
|
||||
kind: Kind,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
|
||||
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, Hand::Any),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Option<Hand>,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
let text = format!("texture=x\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
CitRules::new(rules)
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
//! Cloud visibility and flat/fancy cell geometry (`CloudRenderer`).
|
||||
/// `EnvironmentAttributes.CLOUD_HEIGHT`'s default, and the overworld's `visual/cloud_height`.
|
||||
pub const CLOUD_HEIGHT: f64 = 192.33;
|
||||
|
||||
/// Whether the cloud color has any packed alpha (vanilla gates the clouds on it).
|
||||
pub fn has_color(alpha: f32) -> bool {
|
||||
(alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
|
||||
}
|
||||
|
||||
/// `LevelRenderer`: skip OFF clouds and colors with no packed alpha.
|
||||
pub fn visible(mode: &str, alpha: f32) -> bool {
|
||||
mode != "false" && has_color(alpha)
|
||||
mode != "false" && (alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
|
||||
}
|
||||
|
||||
/// `buildFlatCell`: bottom plane, in both windings for the culled cloud pipeline.
|
||||
@@ -118,7 +110,7 @@ impl Input<'_> {
|
||||
z -= (z / th).floor() * th;
|
||||
let cell_x = (x / 12.0).floor() as i64;
|
||||
let cell_z = (z / 12.0).floor() as i64;
|
||||
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
|
||||
let bottom = (192.33 - self.camera.y) as f32;
|
||||
let key = MeshKey {
|
||||
cell_x,
|
||||
cell_z,
|
||||
@@ -177,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,
|
||||
@@ -196,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,
|
||||
@@ -244,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);
|
||||
|
||||
@@ -199,9 +199,8 @@ where
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker or
|
||||
/// a living entity in leather boots collides as the living entity it is,
|
||||
/// empty-handed.
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
/// collides as the living entity it is, empty-handed.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
@@ -237,10 +236,7 @@ impl SceneCollisions<'_, '_> {
|
||||
}
|
||||
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
|
||||
let y = bounds.min_position().y;
|
||||
if !self
|
||||
.subject
|
||||
.is_some_and(|s| s.lava_walker || s.leather_boots)
|
||||
{
|
||||
if !self.subject.is_some_and(|s| s.lava_walker) {
|
||||
return CollisionContext::position(y);
|
||||
}
|
||||
CollisionContext::for_entity(
|
||||
@@ -347,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.
|
||||
@@ -384,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<'_, '_> {
|
||||
@@ -514,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)
|
||||
}
|
||||
@@ -524,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],
|
||||
@@ -632,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);
|
||||
}
|
||||
}
|
||||
@@ -1,735 +0,0 @@
|
||||
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
|
||||
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
|
||||
//! the short list of rules that can touch it; meshing a face then walks only those lists
|
||||
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
|
||||
//!
|
||||
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
|
||||
|
||||
use crate::sprites::Sprites;
|
||||
use horizon_optifine::ctm::{
|
||||
self, Connect, Method, Orient, Rule, Tile,
|
||||
connect::{self as geometry, Axes, Axis, Face},
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// A tile as the mesher draws it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TileLayer {
|
||||
Layer {
|
||||
layer: u16,
|
||||
/// The sprite has fractional alpha (drawn in the translucent pass).
|
||||
translucent: bool,
|
||||
/// Every texel is opaque (drawn without an alpha test).
|
||||
opaque: bool,
|
||||
},
|
||||
/// Try the next rule.
|
||||
Skip,
|
||||
/// The face's own sprite.
|
||||
Default,
|
||||
}
|
||||
|
||||
/// What a face draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pick {
|
||||
/// Its own sprite.
|
||||
Keep,
|
||||
Tile(TileLayer),
|
||||
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
|
||||
/// bottom-right).
|
||||
Quarters([TileLayer; 4]),
|
||||
}
|
||||
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct StateInfo {
|
||||
/// Index of the state's block (`connect=block`).
|
||||
block: u32,
|
||||
axis: Option<Axis>,
|
||||
/// `half=upper`: `linked` random tiles follow the lower half.
|
||||
upper: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ConnectedTextures {
|
||||
rules: Vec<LoadedRule>,
|
||||
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
|
||||
by_state: Vec<(u32, u32)>,
|
||||
by_layer: Vec<(u32, u32)>,
|
||||
lists: Vec<u16>,
|
||||
states: Vec<StateInfo>,
|
||||
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
|
||||
/// when no rule connects by tile.
|
||||
face_layers: Vec<[u16; 6]>,
|
||||
}
|
||||
|
||||
/// `list` appended to `lists`, as its range there.
|
||||
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
|
||||
let start = lists.len() as u32;
|
||||
lists.extend_from_slice(list);
|
||||
(start, list.len() as u32)
|
||||
}
|
||||
|
||||
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
|
||||
/// itself (as [`Sprites::insert_file`] names a tile).
|
||||
fn sprite_name(resource: &str) -> String {
|
||||
let parts = resource
|
||||
.strip_prefix("assets/")
|
||||
.and_then(|p| p.split_once('/'))
|
||||
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
|
||||
match parts {
|
||||
Some((ns, path)) => format!("{ns}:{path}"),
|
||||
None => resource.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectedTextures {
|
||||
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
|
||||
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
|
||||
// meshes see no block entity).
|
||||
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
rules.truncate(usize::from(u16::MAX));
|
||||
if rules.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
// The tiles decode side by side; the loop below inserts them in rule order, under the
|
||||
// same names.
|
||||
let tiles: Vec<String> = rules
|
||||
.iter()
|
||||
.flat_map(|rule| &rule.tiles)
|
||||
.filter_map(|tile| match tile {
|
||||
Tile::Texture(path) => Some(sprite_name(path)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
sprites.prefetch(root, tiles.iter().map(String::as_str));
|
||||
let loaded = rules
|
||||
.into_iter()
|
||||
.filter_map(|rule| {
|
||||
let tiles = rule
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|tile| match tile {
|
||||
Tile::Skip => Ok(TileLayer::Skip),
|
||||
Tile::Default => Ok(TileLayer::Default),
|
||||
Tile::Texture(path) => {
|
||||
// A tile among the textures is that sprite, under its own name.
|
||||
let layer = match sprite_name(path) {
|
||||
name if name != *path => sprites.insert(root, &name)?,
|
||||
_ => sprites.insert_file(root, path)?,
|
||||
};
|
||||
Ok(TileLayer::Layer {
|
||||
layer: u16::try_from(layer)?,
|
||||
translucent: sprites.translucent[layer as usize],
|
||||
opaque: sprites.opaque[layer as usize],
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>();
|
||||
match tiles {
|
||||
Ok(tiles) => Some(LoadedRule { rule, tiles }),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let states = (0..registry.len() as u32).map(|id| {
|
||||
registry
|
||||
.state(BlockStateId(id))
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
|
||||
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
|
||||
out.face_layers = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
Face::ALL
|
||||
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
|
||||
/// registry skips), `layer` answering a sprite name's layer.
|
||||
fn build<'a>(
|
||||
rules: Vec<LoadedRule>,
|
||||
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
|
||||
layer: impl Fn(&str) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut out = Self {
|
||||
rules,
|
||||
..Self::default()
|
||||
};
|
||||
let mut blocks = HashMap::new();
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
|
||||
for (i, r) in out.rules.iter().enumerate() {
|
||||
let i = i as u16;
|
||||
for tile in &r.rule.match_tiles {
|
||||
if let Some(l) = layer(&sprite_name(tile)) {
|
||||
let l = l as usize;
|
||||
if by_layer.len() <= l {
|
||||
by_layer.resize(l + 1, Vec::new());
|
||||
}
|
||||
if by_layer[l].last() != Some(&i) {
|
||||
by_layer[l].push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &r.rule.match_blocks {
|
||||
let list = by_block.entry(m.id.as_str()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for list in by_layer {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_layer.push(range);
|
||||
}
|
||||
let mut list = Vec::new();
|
||||
for state in states {
|
||||
let Some((name, props)) = state else {
|
||||
out.by_state.push((0, 0));
|
||||
out.states.push(StateInfo::default());
|
||||
continue;
|
||||
};
|
||||
let next = blocks.len() as u32;
|
||||
let block = *blocks.entry(name).or_insert(next);
|
||||
let prop = |k: &str| props.get(k).map(String::as_str);
|
||||
out.states.push(StateInfo {
|
||||
block,
|
||||
axis: match prop("axis") {
|
||||
Some("x") => Some(Axis::X),
|
||||
Some("y") => Some(Axis::Y),
|
||||
Some("z") => Some(Axis::Z),
|
||||
_ => None,
|
||||
},
|
||||
upper: prop("half") == Some("upper"),
|
||||
});
|
||||
list.clear();
|
||||
if let Some(candidates) = by_block.get(name) {
|
||||
list.extend(
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
|
||||
);
|
||||
}
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
})
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
|
||||
/// by rules that test one.
|
||||
pub fn pick<'b>(
|
||||
&self,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
pos: [i32; 3],
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
) -> Pick {
|
||||
let (mut by_state, mut by_layer) = (
|
||||
self.list(&self.by_state, state as usize),
|
||||
self.list(&self.by_layer, layer as usize),
|
||||
);
|
||||
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
|
||||
// both (it is in both lists); one naming either needs that one.
|
||||
loop {
|
||||
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
|
||||
(None, None) => return Pick::Keep,
|
||||
(Some((&x, rest)), Some((&y, other))) if x == y => {
|
||||
(by_state, by_layer) = (rest, other);
|
||||
(x, true, true)
|
||||
}
|
||||
(Some((&x, rest)), Some((&y, _))) if x < y => {
|
||||
by_state = rest;
|
||||
(x, true, false)
|
||||
}
|
||||
(Some((&x, rest)), None) => {
|
||||
by_state = rest;
|
||||
(x, true, false)
|
||||
}
|
||||
(_, Some((&y, rest))) => {
|
||||
by_layer = rest;
|
||||
(y, false, true)
|
||||
}
|
||||
};
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
|
||||
{
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply<'b>(
|
||||
&self,
|
||||
r: &LoadedRule,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
pos: [i32; 3],
|
||||
neighbour: &impl Fn([i32; 3]) -> u32,
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
// a custom name are not loaded.
|
||||
if rule.faces & face.bit() == 0
|
||||
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(biomes) = &rule.biomes
|
||||
&& !biome().is_some_and(|b| biomes.matches(b))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let info = self.states.get(state as usize).copied().unwrap_or_default();
|
||||
let joins = |offset: [i32; 3]| {
|
||||
let other = neighbour(offset);
|
||||
match rule.connect {
|
||||
Connect::Block => {
|
||||
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
|
||||
}
|
||||
Connect::State => other == state,
|
||||
Connect::Tile => self
|
||||
.face_layers
|
||||
.get(other as usize)
|
||||
.is_some_and(|f| f[face as usize] == layer),
|
||||
}
|
||||
};
|
||||
let axes = |face| match (rule.orient, info.axis) {
|
||||
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
|
||||
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
|
||||
_ => Axes::cube(face),
|
||||
};
|
||||
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
|
||||
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
|
||||
let index = match rule.method {
|
||||
Method::Ctm => geometry::ctm_index(mask()),
|
||||
Method::CtmCompact => {
|
||||
let mask = mask();
|
||||
let case = geometry::ctm_index(mask) as u8;
|
||||
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
|
||||
Some(&(_, t)) => t,
|
||||
None => {
|
||||
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
|
||||
return if quarters.contains(&TileLayer::Skip) {
|
||||
None
|
||||
} else {
|
||||
Some(Pick::Quarters(quarters))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::Horizontal => geometry::horizontal(mask()),
|
||||
Method::Vertical => geometry::vertical(mask()),
|
||||
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
|
||||
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
|
||||
Method::Top => {
|
||||
// Only sides join the block above: the top and bottom go on to the next rule.
|
||||
if matches!(face, Face::Up | Face::Down) {
|
||||
return None;
|
||||
}
|
||||
if !joins([0, 1, 0]) {
|
||||
return Some(Pick::Keep);
|
||||
}
|
||||
0
|
||||
}
|
||||
Method::Fixed => 0,
|
||||
Method::Random => {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
let pos = if rule.linked && info.upper {
|
||||
[pos[0], pos[1] - 1, pos[2]]
|
||||
} else {
|
||||
pos
|
||||
};
|
||||
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
|
||||
}
|
||||
Method::Repeat => {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
|
||||
}
|
||||
// Overlays are not loaded yet.
|
||||
_ => return None,
|
||||
};
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
t => Some(Pick::Tile(t)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
|
||||
/// `1000 * (r + 1) + i`.
|
||||
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(path, text)| {
|
||||
Rule::parse(
|
||||
&format!("assets/minecraft/optifine/ctm/{path}"),
|
||||
&Properties::parse(text),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
ctm::sort(&mut rules);
|
||||
rules
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| match t {
|
||||
Tile::Skip => TileLayer::Skip,
|
||||
Tile::Default => TileLayer::Default,
|
||||
Tile::Texture(_) => TileLayer::Layer {
|
||||
layer: (1000 * (n + 1) + i) as u16,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
rule,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
|
||||
/// upper); the stone sprite is layer 7.
|
||||
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
};
|
||||
let states = [
|
||||
("minecraft:air", props(&[])),
|
||||
("minecraft:glass", props(&[])),
|
||||
("minecraft:stone", props(&[])),
|
||||
("minecraft:oak_log", props(&[("axis", "y")])),
|
||||
("minecraft:oak_log", props(&[("axis", "x")])),
|
||||
("minecraft:oak_door", props(&[("half", "lower")])),
|
||||
("minecraft:oak_door", props(&[("half", "upper")])),
|
||||
];
|
||||
ConnectedTextures::build(
|
||||
rules(files),
|
||||
states.iter().map(|(n, p)| Some((*n, p))),
|
||||
|name| (name == "minecraft:block/stone").then_some(7),
|
||||
)
|
||||
}
|
||||
|
||||
fn tile(layer: u16) -> Pick {
|
||||
Pick::Tile(TileLayer::Layer {
|
||||
layer,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
|
||||
fn pick_in(
|
||||
ctm: &ConnectedTextures,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> Pick {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
|
||||
}
|
||||
|
||||
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
|
||||
|
||||
#[test]
|
||||
fn glass_joins_its_neighbours() {
|
||||
let ctm = world(&[("glass.properties", GLASS)]);
|
||||
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1001)
|
||||
);
|
||||
// Glass all around in the face's plane: the middle tile, 26.
|
||||
let ring: Vec<_> = [-1, 0, 1]
|
||||
.into_iter()
|
||||
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
|
||||
.collect();
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
|
||||
// Stone beside it doesn't join; stone has no rule.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_order_faces_and_skips() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a_sides.properties",
|
||||
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
|
||||
),
|
||||
("b_glass.properties", GLASS),
|
||||
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
|
||||
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
|
||||
]);
|
||||
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
|
||||
// Sides: a_sides skips, so z draws.
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
|
||||
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
|
||||
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_quarters_and_overrides() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
|
||||
)]);
|
||||
let layer = |i: u16| TileLayer::Layer {
|
||||
layer: 1000 + i,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[]),
|
||||
Pick::Quarters([layer(0); 4])
|
||||
);
|
||||
// Joined right only is case 1, replaced whole by tile 5.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1005)
|
||||
);
|
||||
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
|
||||
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_turn_with_their_axis() {
|
||||
let ctm = world(&[(
|
||||
"log.properties",
|
||||
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
|
||||
)]);
|
||||
// An upright log with a log above: the bottom of a column.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
|
||||
tile(1000)
|
||||
);
|
||||
// A log lying along x: on its north face the texture's up points east, so a log to the
|
||||
// east (same block, other axis state) is above it.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
|
||||
tile(1003)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_and_connect_state() {
|
||||
let ctm = world(&[(
|
||||
"block_oak_log.properties",
|
||||
"method=top\ntiles=0\nconnect=state",
|
||||
)]);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
|
||||
tile(1000)
|
||||
);
|
||||
// The other axis is another state.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
|
||||
Pick::Keep
|
||||
);
|
||||
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_random_follows_the_lower_half() {
|
||||
let ctm = world(&[(
|
||||
"door.properties",
|
||||
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
|
||||
)]);
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
for x in 0..20 {
|
||||
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
|
||||
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
|
||||
assert_eq!(lower, upper, "{x}");
|
||||
tiles.insert(format!("{lower:?}"));
|
||||
}
|
||||
assert!(tiles.len() > 3, "the doors are not all alike");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_and_biomes() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
|
||||
)]);
|
||||
let at =
|
||||
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
|
||||
assert_eq!(at(10, "minecraft:desert"), tile(1000));
|
||||
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
|
||||
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_by_tile_reads_the_neighbours_same_side() {
|
||||
let mut ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
|
||||
)]);
|
||||
// Stone shows the glass sprite (3) on its north side; the door only on its south.
|
||||
let mut faces = vec![[9u16; 6]; 7];
|
||||
faces[1][Face::North as usize] = 3;
|
||||
faces[2][Face::North as usize] = 3;
|
||||
faces[5][Face::South as usize] = 3;
|
||||
ctm.face_layers = faces;
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
|
||||
tile(1003)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_and_rows_then_columns() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
|
||||
)]);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
|
||||
assert_eq!(at([0, 64, 0]), tile(1000));
|
||||
// The north face's right is west; rows run down.
|
||||
assert_eq!(at([-1, 64, 0]), tile(1001));
|
||||
assert_eq!(at([0, 63, 0]), tile(1002));
|
||||
let hv = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
|
||||
)]);
|
||||
// Alone in its row, glass above: the column's bottom, tile 4.
|
||||
assert_eq!(
|
||||
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
|
||||
tile(1004)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_defaults_and_narrowed_rules() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a.properties",
|
||||
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
|
||||
),
|
||||
(
|
||||
"b.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
|
||||
),
|
||||
(
|
||||
"c.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
|
||||
),
|
||||
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
|
||||
(
|
||||
"e.properties",
|
||||
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
|
||||
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
|
||||
// glass itself, which stops d.
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
|
||||
let both = world(&[(
|
||||
"e.properties",
|
||||
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
|
||||
)]);
|
||||
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
|
||||
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
|
||||
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
|
||||
// `top` passes the top face on.
|
||||
let top = world(&[
|
||||
("block_glass.properties", "method=top\ntiles=0"),
|
||||
(
|
||||
"z.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
|
||||
),
|
||||
]);
|
||||
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
sprite_name("assets/minecraft/textures/block/dirt.png"),
|
||||
"minecraft:block/dirt"
|
||||
);
|
||||
assert_eq!(
|
||||
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
|
||||
"assets/minecraft/optifine/ctm/glass/0.png"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,64 +4,11 @@
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
|
||||
ModEnergyBuffer, ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// a content mod's menu type (`data/<id>/horizon/menu/`, vanilla's
|
||||
/// `MenuType`): its container slots, its data slots (`ContainerData`) and the
|
||||
/// slots players only take from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ModMenuType {
|
||||
pub id: &'static str,
|
||||
pub slots: usize,
|
||||
pub data_slots: usize,
|
||||
pub results: Vec<usize>,
|
||||
}
|
||||
|
||||
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
|
||||
|
||||
/// registers the content mods' menu types, `(id, slots, data slots,
|
||||
/// results)`, a list set once per process (false when it already was).
|
||||
pub fn register_mod_menu_types(
|
||||
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
|
||||
) -> bool {
|
||||
if MOD_MENU_TYPES.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let menus = menus
|
||||
.into_iter()
|
||||
.map(|(id, slots, data_slots, results)| ModMenuType {
|
||||
id: Box::leak(id.into_boxed_str()),
|
||||
slots,
|
||||
data_slots,
|
||||
results,
|
||||
});
|
||||
MOD_MENU_TYPES.set(menus.collect()).is_ok()
|
||||
}
|
||||
|
||||
/// the content mods' menu types, in registry order.
|
||||
pub fn mod_menu_types() -> &'static [ModMenuType] {
|
||||
MOD_MENU_TYPES.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// the mod menu type named `id`, kept out of line: a vanilla menu never
|
||||
/// looks one up.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
pub fn mod_menu_type(id: &str) -> Option<&'static ModMenuType> {
|
||||
mod_menu_types().iter().find(|m| m.id == id)
|
||||
}
|
||||
|
||||
/// whether a client of `brand` can be sent menu type `menu`: a vanilla
|
||||
/// client reads the type through `ByteBufCodecs.registry(Registries.MENU)`,
|
||||
/// and an id it lacks (`IdMap.byIdOrThrow`) disconnects it, so it is sent
|
||||
/// vanilla's only. A Horizon client has the server's (the content identity).
|
||||
pub fn menu_reaches(brand: &str, menu: &str) -> bool {
|
||||
brand == "horizon" || menu.starts_with("minecraft:")
|
||||
}
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
match self {
|
||||
@@ -135,17 +82,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
|
||||
@@ -249,7 +185,6 @@ impl SkullSkin {
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
texture: crate::skins::profile_texture_nbt(profile),
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
@@ -475,7 +410,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(_)
|
||||
@@ -508,6 +443,7 @@ impl BlockEntity {
|
||||
Self::Furnace(f) => &f.items,
|
||||
Self::Brewing(b) => &b.items,
|
||||
Self::Sign(_)
|
||||
| Self::Jukebox(_)
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
@@ -521,7 +457,6 @@ impl BlockEntity {
|
||||
| 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,
|
||||
@@ -542,6 +477,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(_)
|
||||
@@ -555,7 +491,6 @@ impl BlockEntity {
|
||||
| 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,
|
||||
@@ -664,15 +599,6 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// a client that isn't Horizon's is sent vanilla's menu types only.
|
||||
#[test]
|
||||
fn a_mod_menu_type_reaches_horizon_clients_only() {
|
||||
assert!(menu_reaches("horizon", "ruby:press"));
|
||||
assert!(!menu_reaches("vanilla", "ruby:press"));
|
||||
assert!(!menu_reaches("fabric", "ruby:press"));
|
||||
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
@@ -708,26 +634,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() {
|
||||
|
||||
@@ -212,7 +212,7 @@ fn operating_system() -> String {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
|
||||
} else if cfg!(windows) {
|
||||
horizon_telemetry::system::windows_version()
|
||||
command_output("cmd", &["/C", "ver"])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -227,11 +227,14 @@ fn operating_system() -> String {
|
||||
pub fn cpu() -> String {
|
||||
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
|
||||
read("/proc/cpuinfo").and_then(|t| {
|
||||
t.lines()
|
||||
.find(|l| l.starts_with("model name"))
|
||||
.and_then(|l| l.split_once(':'))
|
||||
.map(|(_, v)| v.trim().to_owned())
|
||||
})
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
@@ -351,8 +354,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
);
|
||||
crate::logging::flush();
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
|
||||
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
|
||||
match report.save(home) {
|
||||
Ok(path) => {
|
||||
eprintln!("{SAVED_LINE}{}", path.display());
|
||||
Some(path)
|
||||
@@ -384,30 +386,6 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
)
|
||||
}
|
||||
|
||||
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
|
||||
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
|
||||
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
|
||||
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
|
||||
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let report = Report {
|
||||
time: Local::now(),
|
||||
description: "RAM limit reached".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
};
|
||||
match report.save(home) {
|
||||
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
|
||||
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
|
||||
@@ -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>)> {
|
||||
@@ -348,11 +230,7 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
|
||||
/// 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 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())
|
||||
@@ -575,7 +453,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/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` 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,21 +461,17 @@ pub struct DataStore {
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// path so a nested tag kind stays addressable.
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` 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" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" | "data_maps" => 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" | "dialog" => 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,
|
||||
};
|
||||
@@ -623,17 +497,13 @@ impl DataStore {
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
"predicate",
|
||||
"recipe",
|
||||
"recipe_filter",
|
||||
"tags",
|
||||
"trade_set",
|
||||
"villager_trade",
|
||||
]) {
|
||||
if !seen.insert(f.clone()) {
|
||||
continue;
|
||||
@@ -747,25 +617,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());
|
||||
@@ -784,32 +639,9 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -883,15 +715,6 @@ pub fn worldgen_overrides(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
|
||||
assert_eq!(
|
||||
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
|
||||
Some("data_maps/mymod:item/fuel")
|
||||
);
|
||||
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
@@ -929,7 +752,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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -235,12 +235,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 +247,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 +269,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,
|
||||
};
|
||||
@@ -511,7 +508,7 @@ mod tests {
|
||||
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,
|
||||
};
|
||||
@@ -553,39 +550,12 @@ mod tests {
|
||||
.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
|
||||
};
|
||||
|
||||
@@ -22,10 +22,6 @@ 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>,
|
||||
}
|
||||
@@ -94,15 +90,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 +103,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;
|
||||
@@ -157,9 +141,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>,
|
||||
@@ -312,21 +294,11 @@ 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);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
|
||||
// vertex colour, with fog fading both RGB and alpha before additive blending.
|
||||
if in.overlay == 37u {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return in.color * (1.0 - fog);
|
||||
}
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
|
||||
@@ -141,8 +141,6 @@ pub struct Sky {
|
||||
pub fog_end: f32,
|
||||
/// `visual/cloud_fog_end_distance`.
|
||||
pub cloud_fog_end: f32,
|
||||
/// `visual/cloud_height`.
|
||||
pub cloud_height: f32,
|
||||
}
|
||||
impl Environment {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
@@ -311,10 +309,6 @@ impl Environment {
|
||||
.base
|
||||
.get("minecraft:visual/cloud_fog_end_distance")
|
||||
.map_or(2048.0, |v| v[0]),
|
||||
cloud_height: self
|
||||
.base
|
||||
.get("minecraft:visual/cloud_height")
|
||||
.map_or(crate::clouds::CLOUD_HEIGHT as f32, |v| v[0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,13 +96,8 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// Positional sounds leave a few static sound channels free for UI sounds
|
||||
/// and others played to the listener (`sound_engine::HEARD_RESERVE`).
|
||||
pub ui_sound_reserve: bool,
|
||||
/// The player's own name tag over its model in third person.
|
||||
pub own_name_tag: bool,
|
||||
/// No particles from breaking or mining a block (other particles stay).
|
||||
pub hide_block_particles: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
@@ -111,10 +106,6 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
|
||||
pub chat_animation: bool,
|
||||
/// The indicator bar at the left of a chat line (on by default, as vanilla).
|
||||
pub chat_indicator: bool,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
@@ -122,8 +113,6 @@ pub struct Extras {
|
||||
pub agent_control: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -149,19 +138,14 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
ui_sound_reserve: false,
|
||||
own_name_tag: false,
|
||||
hide_block_particles: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
chat_animation: false,
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,19 +213,14 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"uiSoundReserve" => e.ui_sound_reserve = flag(e.ui_sound_reserve),
|
||||
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
|
||||
"hideBlockParticles" => e.hide_block_particles = flag(e.hide_block_particles),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +228,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -275,20 +254,15 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.ui_sound_reserve),
|
||||
bool_text(self.own_name_tag),
|
||||
bool_text(self.hide_block_particles),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
bool_text(self.chat_animation),
|
||||
bool_text(self.chat_indicator),
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -409,139 +383,64 @@ pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
HideBlockParticles,
|
||||
AutoReconnect,
|
||||
UiSoundReserve,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// A new chat line slides in and fades in (`Chat::layout`).
|
||||
ChatAnimation,
|
||||
/// The coloured bar at the left of a chat line.
|
||||
ChatIndicator,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Err("horizon.extras.header.lighting"),
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("horizon.extras.header.third_person"),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("horizon.extras.header.particles"),
|
||||
Ok(HideBlockParticles),
|
||||
Err("horizon.extras.header.inventory"),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
Ok(Sort),
|
||||
Ok(SortKey),
|
||||
Err("horizon.extras.header.chat"),
|
||||
Ok(ChatAnimation),
|
||||
Ok(ChatIndicator),
|
||||
Err("horizon.extras.header.multiplayer"),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Ok(UiSoundReserve),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
pub fn caption_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
|
||||
Item::Sort => "horizon.extras.sort",
|
||||
Item::SortKey => "horizon.extras.sort_key",
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
pub fn description_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright.tooltip",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
|
||||
Item::Sort => "horizon.extras.sort.tooltip",
|
||||
Item::SortKey => "horizon.extras.sort_key.tooltip",
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
pub fn caption_text(self) -> String {
|
||||
crate::lang::get_or(self.caption_key(), self.caption())
|
||||
}
|
||||
/// The description in the current language (English when the key is missing).
|
||||
pub fn description_text(self) -> String {
|
||||
crate::lang::get_or(self.description_key(), self.description())
|
||||
}
|
||||
/// The English caption: the fallback of [`Item::caption_text`], and the text of
|
||||
/// `horizon.extras.*` in en_us.json.
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::OwnNameTag => "Own name tag",
|
||||
Item::HideBlockParticles => "Hide block-break particles",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::UiSoundReserve => "Keep channels for UI sounds",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::ChatAnimation => "Smooth chat",
|
||||
Item::ChatIndicator => "Message indicator",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
pub fn parent(self) -> Option<Item> {
|
||||
use Item::*;
|
||||
Some(match self {
|
||||
UiSoundReserve => return None,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
}
|
||||
/// The English description: the fallback of [`Item::description_text`].
|
||||
/// What the row says under its caption.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
|
||||
@@ -551,15 +450,9 @@ impl Item {
|
||||
Item::OwnNameTag => {
|
||||
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
|
||||
}
|
||||
Item::HideBlockParticles => {
|
||||
"Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay."
|
||||
}
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
Item::UiSoundReserve => {
|
||||
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
|
||||
}
|
||||
Item::MouseTweaks => {
|
||||
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
|
||||
}
|
||||
@@ -572,19 +465,12 @@ impl Item {
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::ChatAnimation => {
|
||||
"A new chat line slides in from the left while it fades in, instead of appearing whole."
|
||||
}
|
||||
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
@@ -595,15 +481,10 @@ impl Item {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::UiSoundReserve => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::ChatAnimation => Some(Cost::None),
|
||||
Item::OwnNameTag
|
||||
| Item::HideBlockParticles
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -613,18 +494,13 @@ impl Item {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag,
|
||||
Item::HideBlockParticles => e.hide_block_particles,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::UiSoundReserve => e.ui_sound_reserve,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::ChatAnimation => e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -633,8 +509,8 @@ impl Item {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => crate::lang::get_or("On", "On"),
|
||||
_ => crate::lang::get_or("Off", "Off"),
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -645,18 +521,13 @@ impl Item {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
|
||||
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -678,8 +549,7 @@ mod tests {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
@@ -705,22 +575,11 @@ mod tests {
|
||||
e.auto_reconnect = true;
|
||||
Item::OwnNameTag.cycle(&mut e, false);
|
||||
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
|
||||
assert_eq!(Item::HideBlockParticles.switch(&e), Some(false));
|
||||
Item::HideBlockParticles.cycle(&mut e, false);
|
||||
assert!(e.hide_block_particles);
|
||||
assert_eq!(Item::HideBlockParticles.switch(&e), Some(true));
|
||||
let only = Extras::parse("hideBlockParticles:true\n");
|
||||
assert!(only.hide_block_particles && !only.own_name_tag);
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,85 @@
|
||||
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
|
||||
//! (the scheduled ticks use it too), and reachable here under its old path.
|
||||
pub use horizon_core::fast_hash::*;
|
||||
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
|
||||
//! keyed by small integers, such as chunk coordinates. Iteration order of
|
||||
//! such maps carries no meaning: the default hasher's is random anyway.
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FxHasher {
|
||||
hash: u64,
|
||||
}
|
||||
impl FxHasher {
|
||||
#[inline]
|
||||
fn add(&mut self, word: u64) {
|
||||
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
|
||||
}
|
||||
}
|
||||
impl Hasher for FxHasher {
|
||||
#[inline]
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
let (words, rest) = bytes.as_chunks::<8>();
|
||||
for word in words {
|
||||
self.add(u64::from_le_bytes(*word));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let mut word = [0u8; 8];
|
||||
word[..rest.len()].copy_from_slice(rest);
|
||||
self.add(u64::from_le_bytes(word));
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn write_u8(&mut self, i: u8) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u16(&mut self, i: u16) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u32(&mut self, i: u32) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.add(i);
|
||||
}
|
||||
#[inline]
|
||||
fn write_usize(&mut self, i: usize) {
|
||||
self.add(i as u64);
|
||||
}
|
||||
#[inline]
|
||||
fn write_i32(&mut self, i: i32) {
|
||||
self.add(u64::from(i as u32));
|
||||
}
|
||||
#[inline]
|
||||
fn finish(&self) -> u64 {
|
||||
// The multiply leaves the low bits weakest; hash tables use them.
|
||||
self.hash.rotate_left(26)
|
||||
}
|
||||
}
|
||||
pub type FxBuild = BuildHasherDefault<FxHasher>;
|
||||
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
|
||||
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearby_chunks_spread_over_buckets() {
|
||||
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
for x in -40..40 {
|
||||
for z in -40..40 {
|
||||
map.insert((x, z), x * z);
|
||||
}
|
||||
}
|
||||
assert_eq!(map.len(), 6400);
|
||||
assert_eq!(map[&(-7, 13)], -91);
|
||||
let mut low = std::collections::HashSet::new();
|
||||
for x in 0..64 {
|
||||
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
|
||||
}
|
||||
assert!(low.len() > 32, "low bits vary: {}", low.len());
|
||||
}
|
||||
}
|
||||
@@ -112,8 +112,6 @@ struct Region {
|
||||
cursor: (u32, u32),
|
||||
row: u32,
|
||||
pending: Vec<Patch>,
|
||||
/// A glyph did not fit since the last `Font::begin_frame`.
|
||||
full: bool,
|
||||
}
|
||||
struct Cache {
|
||||
/// Every font by id, the default one first (the common lookup).
|
||||
@@ -548,7 +546,6 @@ impl Region {
|
||||
self.row = 0;
|
||||
}
|
||||
if y + h > region.1 {
|
||||
self.full = true;
|
||||
return None;
|
||||
}
|
||||
let (ax, ay) = (x, region.0 + y);
|
||||
@@ -606,7 +603,6 @@ impl Font {
|
||||
cursor: (0, 0),
|
||||
row: 0,
|
||||
pending: Vec::new(),
|
||||
full: false,
|
||||
},
|
||||
random: horizon_core::random::LegacyRandom::new(
|
||||
std::time::SystemTime::now()
|
||||
@@ -673,19 +669,8 @@ impl Font {
|
||||
}
|
||||
cache.region.cursor = (0, 0);
|
||||
cache.region.row = 0;
|
||||
cache.region.full = false;
|
||||
self.generation.set(next_generation());
|
||||
}
|
||||
/// Call once per frame before any text is laid out. When the dynamic
|
||||
/// region ran out of room (vanilla's glyph textures grow instead), the
|
||||
/// glyphs are forgotten so they are rasterized again from the start; it
|
||||
/// waits for the frame boundary because quads already queued hold the old
|
||||
/// rects.
|
||||
pub fn begin_frame(&self) {
|
||||
if self.cache.borrow().region.full {
|
||||
self.reset_cache();
|
||||
}
|
||||
}
|
||||
/// Changes whenever a glyph may come out different: another font, a
|
||||
/// cache reset (glyphs move in the atlas) or other font options. Text laid
|
||||
/// out under one epoch can be kept until it changes.
|
||||
@@ -721,16 +706,7 @@ impl Font {
|
||||
if let Some(g) = set.glyphs.get(&c) {
|
||||
return *g;
|
||||
}
|
||||
let was_full = std::mem::replace(&mut region.full, false);
|
||||
let found = self.resolve(set, region, c);
|
||||
// No room: the missing glyph for now, not remembered, so the
|
||||
// character is tried again once `begin_frame` cleared the region.
|
||||
let overflowed = std::mem::replace(&mut region.full, was_full);
|
||||
region.full |= overflowed;
|
||||
if overflowed {
|
||||
return self.missing;
|
||||
}
|
||||
let glyph = found.unwrap_or(self.missing);
|
||||
let glyph = self.resolve(set, region, c).unwrap_or(self.missing);
|
||||
set.glyphs.insert(c, glyph);
|
||||
glyph
|
||||
}
|
||||
@@ -1442,56 +1418,4 @@ mod tests {
|
||||
font.reset_cache();
|
||||
assert_eq!(font.glyph('a').advance, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_glyph_region_recovers_at_the_next_frame() {
|
||||
// Two 8x8 glyphs from a sheet outside the atlas; the region holds one row.
|
||||
let sheet = png(16, 8, |x, _| x % 8 < 4);
|
||||
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"full",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/pack/textures/font/s.png", &sheet),
|
||||
],
|
||||
);
|
||||
// A 10 wide atlas fits one 8 pixel glyph per row; the region two rows.
|
||||
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let epoch = font.epoch();
|
||||
let a = font.glyph('a');
|
||||
assert!(a.rect.is_some());
|
||||
// No room for 'b': missing for now, and asked again next time.
|
||||
assert!(font.glyph('b').rect.is_none());
|
||||
assert!(font.glyph('b').rect.is_none());
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
font.begin_frame();
|
||||
assert_ne!(font.epoch(), epoch);
|
||||
let b = font.glyph('b');
|
||||
assert!(b.rect.is_some());
|
||||
assert_eq!(b.advance, a.advance);
|
||||
// Nothing overflowed since: the next frame keeps the glyphs.
|
||||
let epoch = font.epoch();
|
||||
font.begin_frame();
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_without_a_provider_does_not_reset_the_region() {
|
||||
let sheet = png(8, 8, |x, _| x < 4);
|
||||
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["a"]}]}"#;
|
||||
let root = root(
|
||||
"nofill",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/pack/textures/font/s.png", &sheet),
|
||||
],
|
||||
);
|
||||
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let epoch = font.epoch();
|
||||
assert!(font.glyph('z').rect.is_none());
|
||||
font.begin_frame();
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
}
|
||||
}
|
||||
+489
-2736
File diff suppressed because it is too large.
Load diff
@@ -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,11 +147,6 @@ 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 groups = keys
|
||||
.iter()
|
||||
@@ -175,7 +168,6 @@ impl SpriteAnimator {
|
||||
},
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
is_animated,
|
||||
};
|
||||
};
|
||||
let frames: Vec<_> = frames
|
||||
@@ -255,23 +247,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,17 +269,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]>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if self.collect(tick, active) == 0 {
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
let (jobs, pipeline) = match &self.writer {
|
||||
@@ -342,7 +317,7 @@ impl SpriteAnimator {
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Animated sprites")),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
for group in &mut self.groups {
|
||||
@@ -361,18 +336,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;
|
||||
@@ -860,7 +827,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, None);
|
||||
animator.animate(&mut encoder, &mut uploads, tick);
|
||||
writes.push(
|
||||
animator
|
||||
.sprites
|
||||
@@ -897,56 +864,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]);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
//! One vertex buffer for every terrain section: sections own ranges of it, so
|
||||
//! a frame binds it once and draws every section with indirect multi-draw.
|
||||
//! Translucent quads can have an arena of their own (`Arena::translucent`),
|
||||
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
pub const GRAIN: u64 = 256;
|
||||
/// The largest page of the section arena, and of arenas shaders bind whole
|
||||
/// as storage, in bytes.
|
||||
/// The largest page, in bytes.
|
||||
pub const PAGE_BYTES: u64 = 256 << 20;
|
||||
/// The largest page of an arena no shader binds as storage: under 2 GiB, where
|
||||
/// DirectX 12 and OpenGL cap buffers (`i32::MAX`), with room for the bytes
|
||||
/// wgpu-core adds to vertex buffers; the device's own limit still applies.
|
||||
pub const LARGE_PAGE_BYTES: u64 = (1 << 31) - (1 << 20);
|
||||
|
||||
/// Free vertex ranges: start -> length, never adjacent.
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -85,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 {
|
||||
@@ -109,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.
|
||||
@@ -127,7 +102,7 @@ impl Arena {
|
||||
} else {
|
||||
wgpu::BufferUsages::VERTEX
|
||||
};
|
||||
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
|
||||
Self::with_usage(device, stride, capacity, usage)
|
||||
}
|
||||
/// Sorted translucent indices; `storage` when compute passes read them.
|
||||
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
|
||||
@@ -136,54 +111,16 @@ impl Arena {
|
||||
} else {
|
||||
wgpu::BufferUsages::INDEX
|
||||
};
|
||||
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
|
||||
}
|
||||
/// Translucent quads' vertices, which no shader reads as storage: pages
|
||||
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
|
||||
/// it (`Renderer::compact_arena`): a page this large has no others to
|
||||
/// empty into, and the compaction moves sections' `page` ranges, not their
|
||||
/// `translucent_at`.
|
||||
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
let usage = wgpu::BufferUsages::VERTEX;
|
||||
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
|
||||
}
|
||||
/// Sorted indices of the translucent arena's quads: pages up to
|
||||
/// `LARGE_PAGE_BYTES` as its own, so its draws keep to one index page too.
|
||||
pub fn translucent_indices(device: &wgpu::Device, capacity: u64) -> Self {
|
||||
let usage = wgpu::BufferUsages::INDEX;
|
||||
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
|
||||
}
|
||||
/// The sorted-index arena and, when `split`, the translucent arena
|
||||
/// (vertices of `stride`), both with large pages, which no shader binds;
|
||||
/// otherwise translucent quads stay in the section arena, and the indices
|
||||
/// in pages flat draws (`flat`) bind as storage.
|
||||
pub fn translucent_arenas(
|
||||
device: &wgpu::Device,
|
||||
stride: u64,
|
||||
flat: bool,
|
||||
split: bool,
|
||||
) -> (Self, Option<Self>) {
|
||||
if split {
|
||||
let indices = Self::translucent_indices(device, 1 << 16);
|
||||
(indices, Some(Self::translucent(device, stride, 1 << 18)))
|
||||
} else {
|
||||
(Self::indices(device, 1 << 16, flat), None)
|
||||
}
|
||||
Self::with_usage(device, 4, capacity, usage)
|
||||
}
|
||||
fn with_usage(
|
||||
device: &wgpu::Device,
|
||||
stride: u64,
|
||||
capacity: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
page_bytes: u64,
|
||||
) -> Self {
|
||||
// Pages of `page_bytes`, or less where the device's buffers (or the
|
||||
// storage bindings that read them whole) are smaller.
|
||||
let limits = device.limits();
|
||||
let mut bytes = limits.max_buffer_size.min(page_bytes);
|
||||
if usage.contains(wgpu::BufferUsages::STORAGE) {
|
||||
bytes = bytes.min(limits.max_storage_buffer_binding_size);
|
||||
}
|
||||
// 256 MiB pages, or less where the device's buffers are smaller.
|
||||
let bytes = device.limits().max_buffer_size.min(PAGE_BYTES);
|
||||
let page_limit = (bytes / stride) / GRAIN * GRAIN;
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
|
||||
Self {
|
||||
@@ -192,22 +129,16 @@ impl Arena {
|
||||
page_limit,
|
||||
initial_capacity: capacity,
|
||||
usage,
|
||||
closing: None,
|
||||
spilled: false,
|
||||
#[cfg(test)]
|
||||
video_memory_full: false,
|
||||
}
|
||||
}
|
||||
/// A buffer of `capacity` vertices in video memory or, when that is full,
|
||||
/// `spill` allows it and so does the device, 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,
|
||||
spill: bool,
|
||||
) -> Option<(wgpu::Buffer, bool)> {
|
||||
/// A buffer of `capacity` vertices in video memory or, when that is full
|
||||
/// 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> {
|
||||
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
|
||||
#[cfg(test)]
|
||||
let local = (!self.video_memory_full)
|
||||
@@ -215,14 +146,12 @@ 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.
|
||||
if !spill
|
||||
|| !device
|
||||
.features()
|
||||
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
|
||||
if !device
|
||||
.features()
|
||||
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -237,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.
|
||||
@@ -252,18 +181,6 @@ impl Arena {
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
self.page_limit
|
||||
}
|
||||
/// `page`'s buffer up to the end of its last range in use, as draws bind it:
|
||||
/// wgpu clears what a binding covers and nothing wrote yet when the frame is
|
||||
/// submitted, most of a page just made or grown.
|
||||
pub fn binding(&self, page: u32) -> wgpu::BufferSlice<'_> {
|
||||
let p = self.pages[page as usize].as_ref().expect("live arena page");
|
||||
let end = match p.free.0.last_key_value() {
|
||||
Some((&start, &len)) if start + len == p.capacity => start,
|
||||
_ => p.capacity,
|
||||
};
|
||||
// A page a draw names holds a range; wgpu refuses an empty slice.
|
||||
p.buffer.slice(..end.max(GRAIN) * self.stride)
|
||||
}
|
||||
pub fn has_page(&self, page: u32) -> bool {
|
||||
self.pages.get(page as usize).is_some_and(Option::is_some)
|
||||
}
|
||||
@@ -290,16 +207,19 @@ 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 = self.grown(current, len);
|
||||
let grown = (current * 2).max(current + len).min(self.page_limit);
|
||||
if (grown - current >= len || grown == self.page_limit)
|
||||
&& self.grow(device, encoder, last, grown)
|
||||
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
|
||||
@@ -315,9 +235,9 @@ 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, true) {
|
||||
let (buffer, capacity) = match self.create(device, capacity) {
|
||||
Some(buffer) => (buffer, capacity),
|
||||
None if capacity > len => (self.create(device, len, true)?, len),
|
||||
None if capacity > len => (self.create(device, len)?, len),
|
||||
None => return None,
|
||||
};
|
||||
let mut free = FreeList::new(capacity);
|
||||
@@ -326,7 +246,6 @@ impl Arena {
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
};
|
||||
let index = self
|
||||
.pages
|
||||
@@ -339,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]
|
||||
@@ -457,17 +286,8 @@ impl Arena {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The capacity the newest page grows to for a range of `len`: double, but
|
||||
/// by at most `PAGE_BYTES` past that size, so a large page is never left
|
||||
/// half empty (nor held twice during the copy) by hundreds of MiB.
|
||||
fn grown(&self, current: u64, len: u64) -> u64 {
|
||||
let step = current.min(PAGE_BYTES / self.stride);
|
||||
(current + step).max(current + len).min(self.page_limit)
|
||||
}
|
||||
/// Moves a page into a larger buffer; false (the page unchanged) when
|
||||
/// there is no memory for it. A page past `PAGE_BYTES` grows in video
|
||||
/// memory only: moving all of it to system memory would slow every frame
|
||||
/// that draws it, so a new page takes the range instead (and may spill).
|
||||
/// there is no memory for it.
|
||||
fn grow(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -476,8 +296,7 @@ impl Arena {
|
||||
capacity: u64,
|
||||
) -> bool {
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
|
||||
let spill = capacity * self.stride <= PAGE_BYTES;
|
||||
let Some((buffer, host)) = self.create(device, capacity, spill) else {
|
||||
let Some(buffer) = self.create(device, capacity) else {
|
||||
return false;
|
||||
};
|
||||
let old = self.pages[page].as_mut().expect("live arena page");
|
||||
@@ -485,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
|
||||
@@ -500,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();
|
||||
@@ -510,7 +325,6 @@ impl Arena {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.closing = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,26 +363,6 @@ mod tests {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A device with the adapter's own limits, as the renderer opens it: page
|
||||
/// caps past the default 256 MiB buffer limit show there.
|
||||
fn device_at_limits() -> 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(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// `device` and `device_at_limits`: the default limits bind 128 MiB of a storage
|
||||
/// buffer, half their 256 MiB buffers, so storage pages shrink to the binding there;
|
||||
/// the adapter's own let large pages past 256 MiB.
|
||||
fn devices() -> Vec<(wgpu::Device, wgpu::Queue)> {
|
||||
let devices = [device(), device_at_limits()];
|
||||
devices.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// `Arena::alloc`, with the copies of a grow submitted at once.
|
||||
fn alloc(
|
||||
arena: &mut Arena,
|
||||
@@ -613,215 +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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer's half of that compaction (`move_off_page`) moves what each
|
||||
/// section holds in the section arena, data and all. Split, that is its
|
||||
/// solid quads alone, and nothing of a section without any, whose (0, 0)
|
||||
/// names no range of page 0; else solid and translucent quads together,
|
||||
/// `translucent_at` along.
|
||||
#[test]
|
||||
fn compaction_moves_what_sections_hold_in_the_section_arena() {
|
||||
use super::super::{SectionGpu, move_off_page};
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let section = |(page, start), translucent_at, solid, translucent| SectionGpu {
|
||||
page,
|
||||
start,
|
||||
translucent_at,
|
||||
solid,
|
||||
sides: crate::mesher::Sides::unsorted(solid),
|
||||
translucent,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
};
|
||||
for split in [true, false] {
|
||||
let mut arena = Arena::new(&device, stride, 4 * GRAIN, false);
|
||||
arena.page_limit = 4 * GRAIN;
|
||||
// Page 0: two sections' grains and two free ones; page 1: one taken.
|
||||
let (_, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
let (_, b) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
let (_, rest) = alloc(&mut arena, &device, &queue, 2 * GRAIN).unwrap();
|
||||
assert_eq!(alloc(&mut arena, &device, &queue, GRAIN), Some((1, 0)));
|
||||
arena.release(0, rest, 2 * GRAIN);
|
||||
// Split, a grain of solid quads each (the first's translucent ones, and
|
||||
// all of a water section's, in the translucent arena); else a grain of
|
||||
// solid and translucent quads, and one of translucent quads alone.
|
||||
let mut sections = if split {
|
||||
vec![
|
||||
section((0, a), (0, 0), 64, 64),
|
||||
section((0, 0), (0, GRAIN), 0, 64),
|
||||
section((0, b), (0, 0), 64, 0),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
section((0, a), (0, a + 128), 32, 32),
|
||||
section((0, b), (0, b), 0, 64),
|
||||
]
|
||||
};
|
||||
// A range's first and last vertex hold its section's number, and the
|
||||
// first translucent one of a mixed range that plus 100.
|
||||
let write = |(page, at): (u32, u64), value: usize| {
|
||||
let bytes = (value as u32).to_le_bytes();
|
||||
queue.write_buffer(arena.buffer(page), at * stride, &bytes);
|
||||
};
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
if let Some((page, start, len)) = s.vertex_ranges(split).0 {
|
||||
write((page, start), i);
|
||||
write((page, start + len - 1), i);
|
||||
if !split && s.solid > 0 && s.translucent > 0 {
|
||||
write(s.translucent_at, 100 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let before: Vec<_> = sections
|
||||
.iter()
|
||||
.map(|s| (s.page, s.start, s.translucent_at))
|
||||
.collect();
|
||||
arena.close(0);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let meshes = sections.iter_mut();
|
||||
assert!(move_off_page(
|
||||
&mut arena,
|
||||
&mut encoder,
|
||||
None,
|
||||
meshes,
|
||||
0,
|
||||
split,
|
||||
u64::MAX
|
||||
));
|
||||
queue.submit([encoder.finish()]);
|
||||
let word =
|
||||
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
|
||||
assert_eq!(
|
||||
(s.page, s.start, s.translucent_at),
|
||||
before[i],
|
||||
"split {split}"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
assert_eq!(page, 1, "split {split}");
|
||||
assert_eq!(word((page, start)), i as u32);
|
||||
assert_eq!(word((page, start + len - 1)), i as u32);
|
||||
if !split {
|
||||
assert_eq!(s.translucent_at, (page, start + u64::from(s.solid) * 4));
|
||||
if s.solid > 0 {
|
||||
assert_eq!(word(s.translucent_at), 100 + i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Page 0 empties without a range given twice, and page 1 took a grain
|
||||
// per section moved: one is left.
|
||||
arena.trim();
|
||||
assert!(!arena.has_page(0), "split {split}");
|
||||
assert!(arena.alloc_existing(GRAIN).is_some(), "split {split}");
|
||||
assert_eq!(arena.alloc_existing(GRAIN), None, "split {split}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws bind a large page up to the end of its last range in use, a grain at
|
||||
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
|
||||
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
|
||||
#[test]
|
||||
fn large_pages_are_bound_up_to_their_last_range_in_use() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::translucent(&device, 24, GRAIN);
|
||||
let ranges: Vec<_> = (0..3)
|
||||
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
|
||||
.collect();
|
||||
// The third grain grew the page to four.
|
||||
assert_eq!(arena.buffer(0).size(), 4 * GRAIN * 24);
|
||||
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
|
||||
// A free range before the last in use stays bound, none after it; an empty
|
||||
// page binds a grain (wgpu refuses an empty slice).
|
||||
arena.release(0, ranges[1].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
|
||||
arena.release(0, ranges[2].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), GRAIN * 24);
|
||||
arena.release(0, ranges[0].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), GRAIN * 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -864,96 +449,6 @@ mod tests {
|
||||
assert!(arena.pages.is_empty());
|
||||
}
|
||||
|
||||
/// Every arena's pages stay within its cap and the device's limits: the
|
||||
/// whole-page storage bindings of flat draws and shader packs included.
|
||||
#[test]
|
||||
fn pages_stay_within_their_caps_and_the_device_limits() {
|
||||
let devices = devices();
|
||||
if devices.is_empty() {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
}
|
||||
let bytes = |a: &Arena| a.page_vertices() * a.stride;
|
||||
for (device, _) in &devices {
|
||||
let limits = device.limits();
|
||||
for storage in [false, true] {
|
||||
for arena in [
|
||||
Arena::new(device, 24, GRAIN, storage),
|
||||
Arena::indices(device, GRAIN, storage),
|
||||
] {
|
||||
assert!(bytes(&arena) <= PAGE_BYTES.min(limits.max_buffer_size));
|
||||
let binding = limits.max_storage_buffer_binding_size;
|
||||
assert!(!storage || bytes(&arena) <= binding);
|
||||
}
|
||||
}
|
||||
for large in [
|
||||
Arena::translucent(device, 24, GRAIN),
|
||||
Arena::translucent_indices(device, GRAIN),
|
||||
] {
|
||||
assert!(bytes(&large) <= LARGE_PAGE_BYTES.min(limits.max_buffer_size));
|
||||
assert!(!large.usage.contains(wgpu::BufferUsages::STORAGE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The translucent arena comes with the split layout alone, and only then
|
||||
/// are the index pages large. Otherwise, as while a shader pack's terrain
|
||||
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
|
||||
/// section arena, and flat draws (`flat`) bind the index pages as storage.
|
||||
#[test]
|
||||
fn translucent_arenas_follow_the_layout() {
|
||||
let devices = devices();
|
||||
if devices.is_empty() {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
}
|
||||
let mut exercised = false;
|
||||
for (device, _) in &devices {
|
||||
let limits = device.limits();
|
||||
let large = limits.max_buffer_size > PAGE_BYTES;
|
||||
exercised |= large;
|
||||
for flat in [false, true] {
|
||||
for split in [false, true] {
|
||||
let case = format!("large {large}, flat {flat}, split {split}");
|
||||
let (indices, translucent) = Arena::translucent_arenas(device, 24, flat, split);
|
||||
assert_eq!(translucent.is_some(), split, "{case}");
|
||||
let bytes = indices.page_vertices() * 4;
|
||||
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
|
||||
assert_eq!(storage, flat && !split, "{case}");
|
||||
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
|
||||
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
|
||||
if let Some(t) = translucent {
|
||||
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !exercised {
|
||||
eprintln!("buffers end at PAGE_BYTES here: large pages not exercised");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages double up to `PAGE_BYTES`, then grow by `PAGE_BYTES` at most.
|
||||
#[test]
|
||||
fn large_pages_grow_by_bounded_steps() {
|
||||
let Some((device, _)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::translucent(&device, 24, GRAIN);
|
||||
// Exercise the policy past the test device's own buffer limit.
|
||||
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
|
||||
let step = PAGE_BYTES / 24;
|
||||
assert_eq!(arena.grown(1 << 18, GRAIN), 1 << 19);
|
||||
assert_eq!(arena.grown(step, GRAIN), 2 * step);
|
||||
assert_eq!(arena.grown(3 * step, GRAIN), 4 * step);
|
||||
assert_eq!(arena.grown(3 * step, step + GRAIN), 4 * step + GRAIN);
|
||||
assert_eq!(
|
||||
arena.grown(arena.page_limit - GRAIN, GRAIN),
|
||||
arena.page_limit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -962,9 +457,7 @@ mod tests {
|
||||
};
|
||||
for mut arena in [
|
||||
Arena::new(&device, 28, GRAIN, false),
|
||||
Arena::translucent(&device, 24, GRAIN),
|
||||
Arena::indices(&device, GRAIN, false),
|
||||
Arena::translucent_indices(&device, GRAIN),
|
||||
] {
|
||||
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(arena.bytes(), arena.stride * GRAIN);
|
||||
@@ -1079,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]
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
|
||||
//! a grid of cells around the camera, each holding the colour of the light sources that
|
||||
//! reach it, averaged by how near each is, which the material stage reads through the
|
||||
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
|
||||
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
|
||||
//!
|
||||
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
|
||||
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
|
||||
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
use super::SectionKey;
|
||||
|
||||
/// Cells along each side of the grid.
|
||||
pub const SIZE: u32 = 64;
|
||||
/// The side of a cell, in blocks: the grid spans 128 blocks.
|
||||
pub const CELL: f32 = 2.0;
|
||||
/// The grid moves in steps of this many blocks.
|
||||
const STEP: i32 = 16;
|
||||
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
|
||||
const MAX_SOURCES: usize = 2048;
|
||||
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
|
||||
const COLOR_RANGE: f32 = 4.0;
|
||||
|
||||
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
|
||||
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
|
||||
|
||||
pub struct BlockLight {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
|
||||
pub grid: wgpu::Buffer,
|
||||
table: LightTable,
|
||||
/// The world corner and the sources' revision the texture was last built for.
|
||||
built: Option<([i32; 3], u64)>,
|
||||
}
|
||||
|
||||
impl BlockLight {
|
||||
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack block light colours"),
|
||||
size: wgpu::Extent3d {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
depth_or_array_layers: SIZE,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D3,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let grid = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack block light grid"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
grid,
|
||||
table,
|
||||
built: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
|
||||
/// `sections`' sources) changed.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
camera: DVec3,
|
||||
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
revision: u64,
|
||||
) {
|
||||
let half = (SIZE as f32 * CELL / 2.0) as f64;
|
||||
let origin = ((camera - half) / f64::from(STEP))
|
||||
.floor()
|
||||
.as_ivec3()
|
||||
.to_array()
|
||||
.map(|v| v * STEP);
|
||||
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
|
||||
queue.write_buffer(
|
||||
&self.grid,
|
||||
0,
|
||||
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
|
||||
);
|
||||
if self.built == Some((origin, revision)) {
|
||||
return;
|
||||
}
|
||||
self.built = Some((origin, revision));
|
||||
let cells = build(&self.table, origin, camera, sections);
|
||||
queue.write_texture(
|
||||
self.texture.as_image_copy(),
|
||||
&cells,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: Some(SIZE),
|
||||
},
|
||||
self.texture.size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
|
||||
fn build(
|
||||
table: &LightTable,
|
||||
origin: [i32; 3],
|
||||
camera: DVec3,
|
||||
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
) -> Vec<u8> {
|
||||
let n = SIZE as usize;
|
||||
let mut cells = vec![0u8; n * n * n * 4];
|
||||
if table.is_empty() {
|
||||
return cells;
|
||||
}
|
||||
let span = SIZE as i32 * CELL as i32;
|
||||
// Sections the grid or the light reaching into it (15 blocks) touches.
|
||||
let lo = origin.map(|v| (v - 15).div_euclid(16));
|
||||
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
|
||||
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
|
||||
for (key, lights) in sections {
|
||||
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
|
||||
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
|
||||
continue;
|
||||
}
|
||||
for &(pos, state) in lights {
|
||||
let Some(&(color, level)) = table.get(&state) else {
|
||||
continue;
|
||||
};
|
||||
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
|
||||
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
|
||||
sources.push((
|
||||
local,
|
||||
color,
|
||||
f32::from(level),
|
||||
center.distance_squared(camera),
|
||||
));
|
||||
}
|
||||
}
|
||||
if sources.len() > MAX_SOURCES {
|
||||
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
|
||||
sources.truncate(MAX_SOURCES);
|
||||
}
|
||||
let mut sum = vec![[0.0f32; 4]; n * n * n];
|
||||
for (at, color, reach, _) in sources {
|
||||
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
|
||||
let last = ((at + reach) / CELL)
|
||||
.ceil()
|
||||
.min(Vec3::splat(SIZE as f32 - 1.0))
|
||||
.as_ivec3();
|
||||
for z in first.z..=last.z {
|
||||
for y in first.y..=last.y {
|
||||
for x in first.x..=last.x {
|
||||
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
|
||||
let d = cell.distance(at);
|
||||
if d >= reach {
|
||||
continue;
|
||||
}
|
||||
// As block light falls off: strongest at the source, gone at its level.
|
||||
let w = (1.0 - d / reach).powi(2);
|
||||
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
|
||||
s[0] += color[0] * w;
|
||||
s[1] += color[1] * w;
|
||||
s[2] += color[2] * w;
|
||||
s[3] += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
for i in 0..3 {
|
||||
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
|
||||
}
|
||||
cell[3] = byte(s[3]);
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
|
||||
let mut table = LightTable::default();
|
||||
table.insert(7, ([2.0, 1.0, 0.0], 14));
|
||||
let mut sections = FxHashMap::default();
|
||||
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
|
||||
let sy = (64 - crate::chunk::MIN_Y) / 16;
|
||||
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
|
||||
let camera = DVec3::new(8.0, 64.0, 8.0);
|
||||
let origin = [-64, 0, -64];
|
||||
let cells = build(&table, origin, camera, §ions);
|
||||
let n = SIZE as usize;
|
||||
let at = |p: [i32; 3]| {
|
||||
let c = p
|
||||
.iter()
|
||||
.zip(origin)
|
||||
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
|
||||
.collect::<Vec<_>>();
|
||||
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
|
||||
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
|
||||
};
|
||||
let near = at([8, 64, 8]);
|
||||
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
|
||||
assert_eq!(&near[..3], &[128, 64, 0]);
|
||||
assert!(near[3] > 150);
|
||||
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
|
||||
assert!(
|
||||
build(&LightTable::default(), origin, camera, §ions)
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -528,11 +528,7 @@ impl Cull {
|
||||
|
||||
/// Between the phases (`InShader`): draw the tested boxes in a pass of
|
||||
/// their own that reads phase 1's depth, bound by `bind_depth`.
|
||||
pub fn test_pass(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
|
||||
(&self.group, &self.depth_group)
|
||||
else {
|
||||
@@ -543,7 +539,6 @@ impl Cull {
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Section culling"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Occlusion test")),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
@@ -585,25 +580,23 @@ impl Cull {
|
||||
self.current = Some(self.next);
|
||||
}
|
||||
|
||||
/// The frame's results to read back, started by calling it right after the frame's
|
||||
/// submission (on the submit thread, when the frame goes there).
|
||||
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
|
||||
let i = self.current.take()?;
|
||||
/// After the frame's submission: start reading its results back.
|
||||
pub fn submitted(&mut self) {
|
||||
let Some(i) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let r = &self.readbacks[i];
|
||||
let state = r.state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
|
||||
r.buffer
|
||||
.slice(..r.slots.len() as u64 * 4)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
self.next = (i + 1) % READBACKS;
|
||||
Some(move || {
|
||||
buffer
|
||||
.slice(..bytes)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn make_group(
|
||||
|
||||
@@ -20,8 +20,6 @@ const MATERIALS: usize = 10;
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
/// `breezeWind`). Only the improved transparency path reads it.
|
||||
pub const OIT_ENTITY: u32 = 1 << 24;
|
||||
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
|
||||
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
|
||||
/// 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]) {
|
||||
@@ -48,13 +46,12 @@ 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.
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32, oit: bool) -> usize {
|
||||
if oit && layer & OIT_ENTITY != 0 {
|
||||
return 9;
|
||||
}
|
||||
match layer & 0xffff {
|
||||
match layer {
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
@@ -81,11 +78,6 @@ pub(super) struct EntityBatch {
|
||||
}
|
||||
|
||||
impl EntityBatch {
|
||||
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
|
||||
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
|
||||
self.combined.extend_from_slice(shadow_only);
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
&mut self,
|
||||
entities: &[Vertex],
|
||||
@@ -221,15 +213,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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| {
|
||||
|
||||
@@ -258,20 +258,13 @@ impl EntityOutline {
|
||||
frame: &wgpu::TextureView,
|
||||
entity_group: &wgpu::BindGroup,
|
||||
indices: &wgpu::Buffer,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
|
||||
return;
|
||||
};
|
||||
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
{
|
||||
let mut pass = begin(
|
||||
encoder,
|
||||
"Entity outline",
|
||||
&t.outline,
|
||||
clear,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
|
||||
pass.set_pipeline(&self.silhouette);
|
||||
pass.set_bind_group(0, entity_group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -287,7 +280,7 @@ impl EntityOutline {
|
||||
("Entity outline blit", frame, wgpu::LoadOp::Load),
|
||||
];
|
||||
for (i, (label, view, load)) in steps.into_iter().enumerate() {
|
||||
let mut pass = begin(encoder, label, view, load, timing.as_deref_mut());
|
||||
let mut pass = begin(encoder, label, view, load);
|
||||
pass.set_pipeline(&self.passes[i]);
|
||||
pass.set_bind_group(0, &t.groups[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
@@ -300,7 +293,6 @@ fn begin<'e>(
|
||||
label: &str,
|
||||
view: &wgpu::TextureView,
|
||||
load: wgpu::LoadOp<wgpu::Color>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -313,7 +305,6 @@ fn begin<'e>(
|
||||
})];
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -62,15 +62,7 @@ pub struct Call {
|
||||
}
|
||||
|
||||
pub struct Flat {
|
||||
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
|
||||
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
|
||||
/// the quads with no cutout first, through a shader that never discards, then the rest.
|
||||
pub solid_opaque: wgpu::RenderPipeline,
|
||||
pub solid_cutout: 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,
|
||||
@@ -125,52 +117,29 @@ pub struct Flat {
|
||||
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
|
||||
pub 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)
|
||||
match std::env::var("HORIZON_FLAT_DRAWS").as_deref() {
|
||||
Ok("0") => return false,
|
||||
Ok("1") => return supported(adapter, device),
|
||||
_ => {}
|
||||
}
|
||||
let info = adapter.get_info();
|
||||
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
|
||||
&& 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.
|
||||
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
|
||||
/// vertex attributes (#3529), 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)))
|
||||
&& (info.name.contains("ICL")
|
||||
|| info.name.contains("Ice Lake")
|
||||
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
|
||||
}
|
||||
|
||||
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
@@ -292,7 +261,7 @@ fn plan(
|
||||
}
|
||||
|
||||
impl Flat {
|
||||
/// `pipeline(layout, label, vertex entry, 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,
|
||||
@@ -303,9 +272,7 @@ impl Flat {
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
Option<wgpu::BlendState>,
|
||||
Option<wgpu::Face>,
|
||||
) -> wgpu::RenderPipeline,
|
||||
) -> Self {
|
||||
let vertex = wgpu::ShaderStages::VERTEX;
|
||||
@@ -359,48 +326,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)",
|
||||
"vs_terrain_flat",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
solid_opaque: pipeline(
|
||||
&layout,
|
||||
"Terrain solid opaque (flat)",
|
||||
"vs_terrain_flat_opaque",
|
||||
"fs_opaque",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
solid_cutout: pipeline(
|
||||
&layout,
|
||||
"Terrain solid cutout (flat)",
|
||||
"vs_terrain_flat_cutout",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
// The mirror flips the winding: its solid terrain culls the front.
|
||||
solid_mirror: pipeline(
|
||||
&layout,
|
||||
"Terrain solid mirrored (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_solid_mirror",
|
||||
None,
|
||||
Some(wgpu::Face::Front),
|
||||
),
|
||||
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
|
||||
translucent: pipeline(
|
||||
&layout,
|
||||
"Terrain translucent (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_translucent",
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
back,
|
||||
),
|
||||
flatten,
|
||||
compute_layout,
|
||||
@@ -522,7 +454,6 @@ impl Flat {
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
@@ -701,7 +632,7 @@ impl Flat {
|
||||
.collect();
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for (group, count) in &groups {
|
||||
@@ -733,17 +664,13 @@ impl Flat {
|
||||
true
|
||||
}
|
||||
/// After occlusion culling's test: copies the phase 2 draws it saw.
|
||||
pub fn flatten_phase2(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some((group, count)) = &self.phase2_group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.flatten_seen);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
@@ -781,23 +708,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,572 +1,7 @@
|
||||
//! Frame generation (a Horizon setting, off by default): FSR 3.1's frame interpolation
|
||||
//! makes a frame halfway between each two real ones. The world, without the hand and
|
||||
//! HUD, is kept at the screen's size each frame; optical flow and the interpolation run
|
||||
//! on it, the hand and HUD are drawn over the made frame as over the real one, and a
|
||||
//! present thread shows the made frame, then the real one half a real frame later.
|
||||
//!
|
||||
//! The renderer draws into frames of its own while this is on; the present thread
|
||||
//! copies each to the swapchain, which it alone acquires and presents.
|
||||
//! 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; the interpolation and the setting
|
||||
//! that runs them come next.
|
||||
|
||||
mod interpolation;
|
||||
mod motion;
|
||||
// Unused until the interpolation and the renderer use it.
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Frames to draw into: a pair being drawn, a pair waiting and a pair being shown.
|
||||
const FRAMES: usize = 6;
|
||||
|
||||
/// `HORIZON_FRAMEGEN_DEBUG=generated`: show only made frames (AMD's debug view), so a
|
||||
/// screenshot or an offscreen frame is the made one; `motion`: the same, each made frame
|
||||
/// showing what moved on its own in blue (its motion vectors in red and green).
|
||||
pub(super) fn generated_only() -> bool {
|
||||
debug().is_some()
|
||||
}
|
||||
|
||||
fn debug() -> Option<&'static str> {
|
||||
static DEBUG: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
DEBUG
|
||||
.get_or_init(|| {
|
||||
std::env::var("HORIZON_FRAMEGEN_DEBUG")
|
||||
.ok()
|
||||
.filter(|v| v == "generated" || v == "motion")
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
pub struct FrameGen {
|
||||
pub on: bool,
|
||||
/// The present thread failed to show a frame (or died): until the renderer has
|
||||
/// acquired a frame itself, the normal path runs and handles the surface.
|
||||
pub paused: bool,
|
||||
gpu: Option<Gpu>,
|
||||
presenter: Option<Presenter>,
|
||||
/// When the last real frame was handed over, and the time between real frames.
|
||||
last: Option<Instant>,
|
||||
interval: Duration,
|
||||
/// The last frame's unjittered `view_proj` and camera.
|
||||
previous: Option<(DMat4, DVec3)>,
|
||||
/// The last frame's hand vertices and the bob and view they were drawn with.
|
||||
hand: Vec<crate::mesher::Vertex>,
|
||||
hand_view: Mat4,
|
||||
/// Offscreen, the frame the made frame is drawn into.
|
||||
offscreen_generated: Option<wgpu::Texture>,
|
||||
/// Motion vectors for what moves on its own, made on the first frame.
|
||||
motion: Option<motion::ObjectMotion>,
|
||||
/// Real frames and made ones handed to the screen since `shown_since`, and the share
|
||||
/// of real frames that had a made frame over the last half second (the F3 screen's),
|
||||
/// `None` while off.
|
||||
real: u32,
|
||||
made: u32,
|
||||
shown_since: Instant,
|
||||
pub made_share: Option<f32>,
|
||||
}
|
||||
|
||||
/// What runs on the GPU for one screen size.
|
||||
struct Gpu {
|
||||
size: [u32; 2],
|
||||
flow: optical_flow::OpticalFlow,
|
||||
interpolation: interpolation::Interpolation,
|
||||
/// This frame's world at the screen's size, and bindings that copy it or the
|
||||
/// interpolation's output (`Post::finish_with`).
|
||||
world: wgpu::TextureView,
|
||||
world_group: wgpu::BindGroup,
|
||||
output_group: wgpu::BindGroup,
|
||||
/// The next interpolation has no previous frame.
|
||||
reset: bool,
|
||||
}
|
||||
|
||||
/// This frame's camera, for the interpolation.
|
||||
pub struct Camera {
|
||||
/// The world's projection this frame (with TAA's jitter) and without the jitter.
|
||||
pub jittered: Mat4,
|
||||
pub view_proj: Mat4,
|
||||
pub depth: wgpu::TextureView,
|
||||
pub render_size: [u32; 2],
|
||||
pub jitter: [f32; 2],
|
||||
/// This frame's unjittered clip space to the previous frame's, `None` without one.
|
||||
pub reproject: Option<Mat4>,
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
pub fov_y: f32,
|
||||
}
|
||||
|
||||
impl FrameGen {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
on: false,
|
||||
paused: false,
|
||||
gpu: None,
|
||||
presenter: None,
|
||||
last: None,
|
||||
interval: Duration::from_millis(16),
|
||||
previous: None,
|
||||
hand: Vec::new(),
|
||||
hand_view: Mat4::IDENTITY,
|
||||
offscreen_generated: None,
|
||||
motion: None,
|
||||
real: 0,
|
||||
made: 0,
|
||||
shown_since: Instant::now(),
|
||||
made_share: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends frame generation's frames and present thread: before the surface is
|
||||
/// reconfigured or the renderer acquires from it itself.
|
||||
pub fn stop(&mut self) {
|
||||
self.presenter = None;
|
||||
self.made_share = None;
|
||||
(self.real, self.made) = (0, 0);
|
||||
self.last = None;
|
||||
self.previous = None;
|
||||
self.hand.clear();
|
||||
if let Some(motion) = &mut self.motion {
|
||||
motion.reset();
|
||||
}
|
||||
if let Some(gpu) = &mut self.gpu {
|
||||
gpu.reset = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The hand for the made frame, appended to `out`: each vertex halfway between where
|
||||
/// it was on screen last frame and where it is now (the hand swings and bobs on its
|
||||
/// own, so the world's interpolation can't move it), in this frame's space
|
||||
/// (`view`: this frame's bob and view). This frame's hand where the two differ in
|
||||
/// shape (another item, the first frame).
|
||||
pub fn halfway_hand(
|
||||
&mut self,
|
||||
hand: &[crate::mesher::Vertex],
|
||||
view: Mat4,
|
||||
out: &mut Vec<crate::mesher::Vertex>,
|
||||
) {
|
||||
let same = self.hand.len() == hand.len()
|
||||
&& self
|
||||
.hand
|
||||
.iter()
|
||||
.zip(hand)
|
||||
.all(|(a, b)| a.layer == b.layer && a.uv == b.uv);
|
||||
if same {
|
||||
let back = view.inverse();
|
||||
out.extend(self.hand.iter().zip(hand).map(|(before, now)| {
|
||||
let then = self.hand_view.transform_point3(before.pos.into());
|
||||
let mid = (then + view.transform_point3(now.pos.into())) * 0.5;
|
||||
crate::mesher::Vertex {
|
||||
pos: back.transform_point3(mid).into(),
|
||||
..*now
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
out.extend_from_slice(hand);
|
||||
}
|
||||
self.hand.clear();
|
||||
self.hand.extend_from_slice(hand);
|
||||
self.hand_view = view;
|
||||
}
|
||||
|
||||
/// This frame's entity vertices (relative to `camera`) and the entity ids of their
|
||||
/// spans (`Frame::entity_motion`), for their own motion vectors; `atlas`: the
|
||||
/// entities' textures. Before `generate`, every frame.
|
||||
pub fn track_entities(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
atlas: &wgpu::TextureView,
|
||||
entities: &[crate::mesher::Vertex],
|
||||
spans: &[(u32, u64)],
|
||||
camera: DVec3,
|
||||
) {
|
||||
self.motion
|
||||
.get_or_insert_with(|| motion::ObjectMotion::new(device, atlas.clone()))
|
||||
.track(entities, spans, camera);
|
||||
}
|
||||
|
||||
/// This frame's unjittered clip space to the last frame's, from their `view_proj`s
|
||||
/// (relative to `camera`); `None` on the first frame.
|
||||
pub fn camera_motion(&mut self, view_proj: Mat4, camera: DVec3) -> Option<Mat4> {
|
||||
let current = view_proj.as_dmat4();
|
||||
let previous = self.previous.replace((current, camera));
|
||||
previous.map(|(p, pc)| super::world_post::reprojection(p, pc, current, camera).as_mat4())
|
||||
}
|
||||
|
||||
/// Frames to draw the real and the made frame into, from the present thread's
|
||||
/// (started for this `config`'s surface) or, offscreen, the offscreen target and
|
||||
/// one of its own. `None` once the present thread failed or died: it is stopped and
|
||||
/// frame generation `paused`.
|
||||
pub fn frames(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
surface: Option<&Arc<wgpu::Surface<'static>>>,
|
||||
offscreen: Option<&wgpu::Texture>,
|
||||
window: &Arc<winit::window::Window>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let Some(surface) = surface else {
|
||||
let real = offscreen.cloned().unwrap_or_else(|| frame(device, config));
|
||||
if self
|
||||
.offscreen_generated
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != real.size())
|
||||
{
|
||||
self.offscreen_generated = Some(frame(device, config));
|
||||
}
|
||||
let generated = self.offscreen_generated.clone().expect("made above");
|
||||
return Some((real, generated));
|
||||
};
|
||||
if self.presenter.as_ref().is_some_and(|p| p.config != *config) {
|
||||
self.presenter = None;
|
||||
}
|
||||
let presenter = self.presenter.get_or_insert_with(|| {
|
||||
Presenter::start(device, queue, surface.clone(), window.clone(), config)
|
||||
});
|
||||
// A closed channel: the thread died.
|
||||
let pair = if presenter.failed() {
|
||||
None
|
||||
} else {
|
||||
presenter.free.recv().ok().zip(presenter.free.recv().ok())
|
||||
};
|
||||
if pair.is_none() {
|
||||
self.stop();
|
||||
self.paused = true;
|
||||
}
|
||||
pair
|
||||
}
|
||||
|
||||
/// The world's target at the screen's size this frame, where `Post::finish` draws.
|
||||
pub fn world(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
post: &super::world_post::Post,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> wgpu::TextureView {
|
||||
let size = [config.width, config.height];
|
||||
if self.gpu.as_ref().is_none_or(|g| g.size != size) {
|
||||
let world = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation world"),
|
||||
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: config.format.remove_srgb_suffix(),
|
||||
// COPY_SRC: the interpolation keeps it as the previous frame.
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let interpolation = interpolation::Interpolation::new(device, size);
|
||||
self.gpu = Some(Gpu {
|
||||
size,
|
||||
flow: optical_flow::OpticalFlow::new(device, size),
|
||||
world_group: post.bind(device, &world),
|
||||
output_group: post.bind(device, interpolation.output()),
|
||||
interpolation,
|
||||
world,
|
||||
reset: true,
|
||||
});
|
||||
}
|
||||
self.gpu.as_ref().expect("made above").world.clone()
|
||||
}
|
||||
|
||||
/// Copy the world (`world`) onto `screen` for the real frame, and draw the made
|
||||
/// frame's world onto `generated`. Returns false when there is no made frame (the
|
||||
/// first after a reset): `generated` then holds this frame's world too.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn generate(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
post: &super::world_post::Post,
|
||||
screen: &wgpu::TextureView,
|
||||
generated: &wgpu::TextureView,
|
||||
camera: &Camera,
|
||||
) -> bool {
|
||||
let gpu = self.gpu.as_mut().expect("world() ran this frame");
|
||||
post.finish_with(encoder, screen, &gpu.world_group, None);
|
||||
let reset = gpu.reset || camera.reproject.is_none();
|
||||
let reproject = camera.reproject.unwrap_or(Mat4::IDENTITY);
|
||||
let object_motion = self
|
||||
.motion
|
||||
.as_mut()
|
||||
.expect("track_entities() ran this frame")
|
||||
.draw(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
&camera.depth,
|
||||
camera.render_size,
|
||||
camera.jittered,
|
||||
camera.view_proj,
|
||||
reproject * camera.view_proj,
|
||||
);
|
||||
gpu.flow.dispatch(device, queue, encoder, &gpu.world, reset);
|
||||
gpu.interpolation.dispatch(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
&interpolation::Inputs {
|
||||
color: &gpu.world,
|
||||
depth: &camera.depth,
|
||||
render_size: camera.render_size,
|
||||
jitter: camera.jitter,
|
||||
reproject,
|
||||
object_motion: &object_motion,
|
||||
near: camera.near,
|
||||
far: camera.far,
|
||||
fov_y: camera.fov_y,
|
||||
reset,
|
||||
flow_vectors: gpu.flow.vectors(),
|
||||
scene_change: gpu.flow.scene_change(),
|
||||
},
|
||||
);
|
||||
gpu.reset = false;
|
||||
if debug() == Some("motion") {
|
||||
post.finish_with(encoder, generated, &post.bind(device, &object_motion), None);
|
||||
return true;
|
||||
}
|
||||
if reset {
|
||||
post.finish_with(encoder, generated, &gpu.world_group, None);
|
||||
} else {
|
||||
post.finish_with(encoder, generated, &gpu.output_group, None);
|
||||
}
|
||||
!reset
|
||||
}
|
||||
|
||||
/// Hand this frame's pair to the present thread, after the frame's submission: the
|
||||
/// made frame (when `made`) goes first.
|
||||
pub fn present(&mut self, real: wgpu::Texture, generated: wgpu::Texture, made: bool) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last {
|
||||
// A real frame's time, smoothed: the made frame shows for half of it.
|
||||
let dt = (now - last).min(Duration::from_millis(250));
|
||||
self.interval = self.interval.mul_f32(0.9) + dt.mul_f32(0.1);
|
||||
}
|
||||
self.last = Some(now);
|
||||
self.real += 1;
|
||||
self.made += u32::from(made && !generated_only());
|
||||
if now - self.shown_since >= Duration::from_millis(500) {
|
||||
self.made_share = Some(self.made as f32 / self.real as f32);
|
||||
(self.real, self.made, self.shown_since) = (0, 0, now);
|
||||
}
|
||||
let Some(send) = self.presenter.as_ref().and_then(|p| p.send.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let pair = if generated_only() {
|
||||
Pair {
|
||||
first: None,
|
||||
second: generated,
|
||||
spare: Some(real),
|
||||
interval: self.interval,
|
||||
}
|
||||
} else if made {
|
||||
Pair {
|
||||
first: Some(generated),
|
||||
second: real,
|
||||
spare: None,
|
||||
interval: self.interval,
|
||||
}
|
||||
} else {
|
||||
Pair {
|
||||
first: None,
|
||||
second: real,
|
||||
spare: Some(generated),
|
||||
interval: self.interval,
|
||||
}
|
||||
};
|
||||
let _ = send.send(pair);
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame to draw into, like the swapchain's.
|
||||
fn frame(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation frame"),
|
||||
size: wgpu::Extent3d {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &config.view_formats,
|
||||
})
|
||||
}
|
||||
|
||||
/// One real frame's frames for the present thread: `first` (the made frame) now,
|
||||
/// `second` (the real one) half an `interval` later; `spare` goes straight back.
|
||||
struct Pair {
|
||||
first: Option<wgpu::Texture>,
|
||||
second: wgpu::Texture,
|
||||
spare: Option<wgpu::Texture>,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
/// The thread that copies frames to the swapchain and presents them on time.
|
||||
struct Presenter {
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
send: Option<mpsc::SyncSender<Pair>>,
|
||||
free: mpsc::Receiver<wgpu::Texture>,
|
||||
/// The surface gave something other than a frame: the renderer takes it back.
|
||||
failed: Arc<AtomicBool>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
fn start(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
surface: Arc<wgpu::Surface<'static>>,
|
||||
window: Arc<winit::window::Window>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Self {
|
||||
// One pair waiting at most: the renderer then waits as for a swapchain image.
|
||||
let (send, receive) = mpsc::sync_channel::<Pair>(1);
|
||||
let (give, free) = mpsc::channel();
|
||||
for _ in 0..FRAMES {
|
||||
let _ = give.send(frame(device, config));
|
||||
}
|
||||
let (device, queue) = (device.clone(), queue.clone());
|
||||
let vsync = config.present_mode == wgpu::PresentMode::Fifo;
|
||||
let failed = Arc::new(AtomicBool::new(false));
|
||||
let failed_here = failed.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("Frame generation present".into())
|
||||
.spawn(move || {
|
||||
let show = |texture: &wgpu::Texture| {
|
||||
if failed_here.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Anything but a frame (outdated, lost, occluded...): the renderer
|
||||
// stops this thread and acquires itself, handling it as it does
|
||||
// without frame generation. A suboptimal frame still shows.
|
||||
let target = match surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(t) => t,
|
||||
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
|
||||
failed_here.store(true, Ordering::Relaxed);
|
||||
t
|
||||
}
|
||||
_ => {
|
||||
failed_here.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
target.texture.as_image_copy(),
|
||||
target.texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
if vsync {
|
||||
window.pre_present_notify();
|
||||
}
|
||||
queue.present(target);
|
||||
};
|
||||
while let Ok(pair) = receive.recv() {
|
||||
if let Some(first) = pair.first {
|
||||
let shown = Instant::now();
|
||||
show(&first);
|
||||
let _ = give.send(first);
|
||||
// ponytail: sleep pacing; a spin for the last half millisecond
|
||||
// if it shows uneven on some systems.
|
||||
let due = shown + pair.interval / 2;
|
||||
if let Some(wait) = due.checked_duration_since(Instant::now()) {
|
||||
thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
show(&pair.second);
|
||||
let _ = give.send(pair.second);
|
||||
if let Some(spare) = pair.spare {
|
||||
let _ = give.send(spare);
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn the frame generation present thread");
|
||||
Self {
|
||||
config: config.clone(),
|
||||
send: Some(send),
|
||||
free,
|
||||
failed,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface failed it or the thread died (a panic).
|
||||
fn failed(&self) -> bool {
|
||||
self.failed.load(Ordering::Relaxed) || self.thread.as_ref().is_none_or(|t| t.is_finished())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Presenter {
|
||||
fn drop(&mut self) {
|
||||
self.send = None;
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::Vertex;
|
||||
use glam::Vec3;
|
||||
|
||||
fn vertex(pos: Vec3) -> Vertex {
|
||||
Vertex {
|
||||
pos: pos.into(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}
|
||||
}
|
||||
|
||||
/// A swing lands halfway; a hand still on screen while the camera turns stays still;
|
||||
/// another item shows as it is.
|
||||
#[test]
|
||||
fn the_made_frame_s_hand_is_halfway_on_screen() {
|
||||
let mut fg = FrameGen::new();
|
||||
let mut out = Vec::new();
|
||||
fg.halfway_hand(
|
||||
&[vertex(Vec3::new(0.0, 0.0, -1.0))],
|
||||
Mat4::IDENTITY,
|
||||
&mut out,
|
||||
);
|
||||
fg.halfway_hand(
|
||||
&[vertex(Vec3::new(0.4, 0.0, -1.0))],
|
||||
Mat4::IDENTITY,
|
||||
&mut out,
|
||||
);
|
||||
assert!((Vec3::from(out[1].pos) - Vec3::new(0.2, 0.0, -1.0)).length() < 1e-6);
|
||||
// The camera turned: the vertex turned with it, the same on screen.
|
||||
let turned = Mat4::from_rotation_y(0.3);
|
||||
let on_screen = Vec3::new(0.4, 0.0, -1.0);
|
||||
let pos = turned.inverse().transform_point3(on_screen);
|
||||
fg.halfway_hand(&[vertex(pos)], turned, &mut out);
|
||||
assert!((Vec3::from(out[2].pos) - pos).length() < 1e-6);
|
||||
let mut other = vertex(Vec3::ZERO);
|
||||
other.uv = [0.5, 0.5];
|
||||
fg.halfway_hand(&[other], turned, &mut out);
|
||||
assert_eq!(out[3].pos, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -1,862 +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 the camera's, from the depth and `Inputs::reproject`,
|
||||
//! except where `Inputs::object_motion` has an entity's own; 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,
|
||||
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
|
||||
/// (FSR's convention, in UV) where z is 1, used over the camera's.
|
||||
pub object_motion: &'a wgpu::TextureView,
|
||||
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(¶ms));
|
||||
}
|
||||
};
|
||||
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)),
|
||||
(34, view(inputs.object_motion)),
|
||||
(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'.
|
||||
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
|
||||
fn interpolate(
|
||||
seed: u32,
|
||||
shifts: &[[i32; 2]],
|
||||
reproject: Mat4,
|
||||
object: Option<[f32; 2]>,
|
||||
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 object_motion = 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: super::super::motion::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
if let Some(mv) = object {
|
||||
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
|
||||
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
|
||||
queue.write_texture(
|
||||
object_motion.as_image_copy(),
|
||||
bytemuck::cast_slice(&texels),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size[0] * 8),
|
||||
rows_per_image: None,
|
||||
},
|
||||
object_motion.size(),
|
||||
);
|
||||
}
|
||||
let object_motion = object_motion.create_view(&Default::default());
|
||||
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 },
|
||||
object_motion: &object_motion,
|
||||
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, None, [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, None, [-40, 0])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
made * 4.0 < previous.min(current),
|
||||
"{made} against {previous} and {current}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Something moving on its own with its own motion vectors (the camera still): the
|
||||
/// made frame is halfway, as with the camera's.
|
||||
#[test]
|
||||
fn moves_what_has_its_own_motion_vectors_halfway() {
|
||||
let size = [512.0, 288.0];
|
||||
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
|
||||
let mv = [-16.0 / size[0], -8.0 / size[1]];
|
||||
let Some([made, previous, current]) =
|
||||
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
made * 4.0 < previous.min(current),
|
||||
"{made} against {previous} and {current}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,810 +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 are the camera's, from the depth (`prepare`), already unjittered,
|
||||
// or an entity's own where `object_motion` has one;
|
||||
// - 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>;
|
||||
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
|
||||
@group(0) @binding(34) var object_motion: texture_2d<f32>;
|
||||
|
||||
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 object = textureLoad(object_motion, nearest_coord, 0);
|
||||
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
|
||||
if object.z > 0.5 {
|
||||
mv = object.xy;
|
||||
}
|
||||
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,306 +0,0 @@
|
||||
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
|
||||
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
|
||||
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
|
||||
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
|
||||
//! own motion, limbs and spin included, drawn into a texture at the render size
|
||||
//! (`motion.wgsl`) that the interpolation reads before the camera's.
|
||||
|
||||
use crate::mesher::Vertex;
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `motion.wgsl`'s `Params`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
jittered: [[f32; 4]; 4],
|
||||
current: [[f32; 4]; 4],
|
||||
previous: [[f32; 4]; 4],
|
||||
}
|
||||
|
||||
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct MotionVertex {
|
||||
position: [f32; 3],
|
||||
previous: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
/// 1 for a translucent item, whose texture says where it shows.
|
||||
translucent: u32,
|
||||
}
|
||||
|
||||
pub struct ObjectMotion {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
atlas: wgpu::TextureView,
|
||||
sampler: wgpu::Sampler,
|
||||
params: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
/// Triangles of the entities matched this frame.
|
||||
staged: Vec<MotionVertex>,
|
||||
/// Last frame's vertices by entity, and its camera.
|
||||
tracked: HashMap<u64, Vec<Vertex>>,
|
||||
camera: DVec3,
|
||||
target: Option<([u32; 2], wgpu::TextureView)>,
|
||||
}
|
||||
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
impl ObjectMotion {
|
||||
/// `atlas`: the entities' texture atlas.
|
||||
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_motion"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<MotionVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_motion"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(FORMAT.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
atlas,
|
||||
sampler: device.create_sampler(&Default::default()),
|
||||
params: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
size: std::mem::size_of::<Params>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
vertices: vertex_buffer(device, 1 << 12),
|
||||
staged: Vec::new(),
|
||||
tracked: HashMap::new(),
|
||||
camera: DVec3::ZERO,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget last frame's entities: the next frame has no motion of its own.
|
||||
pub fn reset(&mut self) {
|
||||
self.tracked.clear();
|
||||
}
|
||||
|
||||
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
|
||||
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
|
||||
/// keep them for the next frame.
|
||||
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
|
||||
self.staged.clear();
|
||||
let shift = (self.camera - camera).as_vec3();
|
||||
let mut tracked = HashMap::with_capacity(self.tracked.len());
|
||||
for (i, &(start, id)) in spans.iter().enumerate() {
|
||||
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
|
||||
let Some(now) = entities
|
||||
.get(start as usize..end)
|
||||
.filter(|v| id != 0 && !v.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(then) = self.tracked.remove(&id)
|
||||
&& then.len() == now.len()
|
||||
&& then
|
||||
.iter()
|
||||
.zip(now)
|
||||
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
|
||||
{
|
||||
let pair = |i: usize| MotionVertex {
|
||||
position: now[i].pos,
|
||||
previous: (Vec3::from(then[i].pos) + shift).into(),
|
||||
uv: now[i].uv,
|
||||
translucent: u32::from(matches!(
|
||||
now[i].layer,
|
||||
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
|
||||
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)),
|
||||
};
|
||||
for q in (0..now.len() / 4).map(|q| q * 4) {
|
||||
self.staged
|
||||
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
|
||||
}
|
||||
}
|
||||
tracked.insert(id, now.to_vec());
|
||||
}
|
||||
self.tracked = tracked;
|
||||
self.camera = camera;
|
||||
}
|
||||
|
||||
/// Draw the matched entities' motion vectors at `size` (the world's render size),
|
||||
/// where `depth` (the world's) shows them; the rest of the texture is 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
jittered: Mat4,
|
||||
current: Mat4,
|
||||
previous: Mat4,
|
||||
) -> wgpu::TextureView {
|
||||
if self.target.as_ref().is_none_or(|t| t.0 != size) {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
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: FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.target = Some((size, texture.create_view(&Default::default())));
|
||||
}
|
||||
let target = self.target.as_ref().expect("made above").1.clone();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
|
||||
if bytes.len() as u64 > self.vertices.size() {
|
||||
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
|
||||
}
|
||||
if !bytes.is_empty() {
|
||||
queue.write_buffer(&self.vertices, 0, bytes);
|
||||
}
|
||||
let params = Params {
|
||||
jittered: jittered.to_cols_array_2d(),
|
||||
current: current.to_cols_array_2d(),
|
||||
previous: previous.to_cols_array_2d(),
|
||||
};
|
||||
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(¶ms));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
layout: &self.pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&self.atlas),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
if !self.staged.is_empty() {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
|
||||
pass.draw(0..self.staged.len() as u32, 0..1);
|
||||
}
|
||||
drop(pass);
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame generation motion vertices"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
|
||||
(0..4)
|
||||
.map(|i| Vertex {
|
||||
pos: [x + i as f32, 0.0, -5.0],
|
||||
uv: [uv, 0.0],
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
|
||||
/// space; a new one, one whose shape changed and untagged geometry get none.
|
||||
#[test]
|
||||
fn tracks_each_entity_by_id() {
|
||||
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
|
||||
return;
|
||||
};
|
||||
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
|
||||
let mut motion = ObjectMotion::new(&device, atlas);
|
||||
let mut entities = quad(0.0, 0.0);
|
||||
entities.extend(quad(10.0, 0.5));
|
||||
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
|
||||
assert!(motion.staged.is_empty());
|
||||
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
|
||||
// another item; 9 is new; then untagged geometry.
|
||||
let mut entities = quad(-1.0, 0.0);
|
||||
entities.extend(quad(10.0, 0.25));
|
||||
entities.extend(quad(20.0, 0.0));
|
||||
entities.extend(quad(30.0, 0.0));
|
||||
motion.track(
|
||||
&entities,
|
||||
&[(0, 7), (4, 8), (8, 9), (12, 0)],
|
||||
DVec3::new(2.0, 0.0, 0.0),
|
||||
);
|
||||
assert_eq!(motion.staged.len(), 6);
|
||||
let v = motion.staged[0];
|
||||
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
|
||||
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
|
||||
assert_eq!(motion.tracked.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
|
||||
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
|
||||
// the world shows it at keep its vector: where the depth is its own, or, for translucent
|
||||
// items (which don't write depth), where its texture shows and nothing is in front.
|
||||
|
||||
struct Params {
|
||||
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
|
||||
// and last frame's projection from this frame's camera.
|
||||
jittered: mat4x4<f32>,
|
||||
current: mat4x4<f32>,
|
||||
previous: mat4x4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(3) var atlas_sampler: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) current: vec4<f32>,
|
||||
@location(1) previous: vec4<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
// 1: a translucent item.
|
||||
@location(3) @interpolate(flat) translucent: u32,
|
||||
};
|
||||
|
||||
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
|
||||
var out: Out;
|
||||
out.uv = uv;
|
||||
out.translucent = translucent;
|
||||
out.clip = params.jittered * vec4(position, 1.0);
|
||||
out.current = params.current * vec4(position, 1.0);
|
||||
out.previous = params.previous * vec4(previous, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
|
||||
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
|
||||
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
|
||||
let tolerance = stored * 1e-3 + 1e-7;
|
||||
// Not what the world shows here: a transparent texel, or behind something.
|
||||
if in.translucent != 0u {
|
||||
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
|
||||
if alpha < 0.1 || in.clip.z < stored - tolerance {
|
||||
discard;
|
||||
}
|
||||
} else if abs(in.clip.z - stored) > tolerance {
|
||||
discard;
|
||||
}
|
||||
if in.previous.w <= 0.0 {
|
||||
discard;
|
||||
}
|
||||
let now = in.current.xy / in.current.w;
|
||||
let then = in.previous.xy / in.previous.w;
|
||||
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
|
||||
}
|
||||
@@ -91,19 +91,6 @@ 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)]
|
||||
@@ -146,8 +133,6 @@ 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>,
|
||||
}
|
||||
@@ -291,7 +276,6 @@ impl Hdr {
|
||||
black: 0.0,
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
@@ -366,11 +350,7 @@ impl Hdr {
|
||||
/// 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.
|
||||
pub fn keep_world(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
@@ -391,7 +371,6 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR UI mask"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR UI mask")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -402,11 +381,7 @@ impl Hdr {
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
pub fn mark_hand(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
@@ -422,7 +397,6 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR hand mark"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR hand mark")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -444,7 +418,6 @@ impl Hdr {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::Texture,
|
||||
world: bool,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
@@ -454,7 +427,7 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
@@ -505,7 +478,7 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
@@ -539,7 +512,6 @@ impl Hdr {
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -553,7 +525,6 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -615,7 +586,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,159 +0,0 @@
|
||||
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
|
||||
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
|
||||
//! An ICC profile has no values above white, so those programs show a correct SDR picture
|
||||
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
|
||||
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
|
||||
|
||||
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
|
||||
const WHITE: f64 = 203.0;
|
||||
/// PQ's values from 0 to 1, in the curve's steps.
|
||||
const STEPS: usize = 4096;
|
||||
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
|
||||
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
|
||||
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
|
||||
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
|
||||
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
|
||||
|
||||
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
|
||||
fn pq_nits(signal: f64) -> f64 {
|
||||
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
|
||||
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
|
||||
let p = signal.powf(1.0 / m2);
|
||||
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
|
||||
}
|
||||
|
||||
fn s15_fixed16(v: f64) -> [u8; 4] {
|
||||
((v * 65536.0).round() as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
fn xyz(v: [f64; 3]) -> Vec<u8> {
|
||||
let mut tag = b"XYZ \0\0\0\0".to_vec();
|
||||
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
|
||||
tag
|
||||
}
|
||||
|
||||
fn curve() -> Vec<u8> {
|
||||
let mut tag = b"curv\0\0\0\0".to_vec();
|
||||
tag.extend((STEPS as u32).to_be_bytes());
|
||||
for i in 0..STEPS {
|
||||
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
|
||||
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
|
||||
fn description(text: &str) -> Vec<u8> {
|
||||
let mut tag = b"desc\0\0\0\0".to_vec();
|
||||
tag.extend((text.len() as u32 + 1).to_be_bytes());
|
||||
tag.extend(text.as_bytes());
|
||||
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
|
||||
tag
|
||||
}
|
||||
|
||||
/// An ICC v2 matrix-shaper display profile.
|
||||
pub(super) fn profile() -> Vec<u8> {
|
||||
let trc = curve();
|
||||
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
|
||||
let tags: [(&[u8; 4], &[u8]); 9] = [
|
||||
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
|
||||
(b"cprt", ©right),
|
||||
(b"wtpt", &xyz(D50)),
|
||||
(b"rXYZ", &xyz(RED)),
|
||||
(b"gXYZ", &xyz(GREEN)),
|
||||
(b"bXYZ", &xyz(BLUE)),
|
||||
(b"rTRC", &trc),
|
||||
(b"gTRC", &trc),
|
||||
(b"bTRC", &trc),
|
||||
];
|
||||
let data_start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut data = Vec::new();
|
||||
let mut trc_at = None;
|
||||
for (name, tag) in tags {
|
||||
// The three channels share one curve.
|
||||
let is_trc = tag == &trc[..];
|
||||
let at = match trc_at.filter(|_| is_trc) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let at = data_start + data.len();
|
||||
data.extend(tag);
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
at
|
||||
}
|
||||
};
|
||||
if is_trc {
|
||||
trc_at = Some(at);
|
||||
}
|
||||
table.extend(name);
|
||||
table.extend((at as u32).to_be_bytes());
|
||||
table.extend((tag.len() as u32).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0; 128];
|
||||
let size = data_start + data.len();
|
||||
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
|
||||
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
|
||||
out[12..16].copy_from_slice(b"mntr");
|
||||
out[16..20].copy_from_slice(b"RGB ");
|
||||
out[20..24].copy_from_slice(b"XYZ ");
|
||||
out[36..40].copy_from_slice(b"acsp");
|
||||
// Relative colorimetric, and the connection space's illuminant.
|
||||
out[64..68].copy_from_slice(&1u32.to_be_bytes());
|
||||
for (i, &c) in D50.iter().enumerate() {
|
||||
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
|
||||
}
|
||||
out.extend(table);
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
|
||||
let at = (0..u32_at(128))
|
||||
.map(|i| 132 + 12 * i)
|
||||
.find(|&e| &profile[e..e + 4] == name)
|
||||
.unwrap();
|
||||
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_is_whole() {
|
||||
let p = profile();
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
|
||||
p.len()
|
||||
);
|
||||
assert_eq!(&p[36..40], b"acsp");
|
||||
assert_eq!(p.len() % 4, 0);
|
||||
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
|
||||
// One curve, shared: the profile stays small.
|
||||
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
|
||||
assert!(p.len() < 10_000, "{}", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primaries_add_up_to_white() {
|
||||
for i in 0..3 {
|
||||
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_reaches_white_at_reference_white_and_stays() {
|
||||
let p = profile();
|
||||
let trc = tag(&p, b"rTRC");
|
||||
let value = |signal: f64| {
|
||||
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
|
||||
u16::from_be_bytes([trc[i], trc[i + 1]])
|
||||
};
|
||||
assert_eq!(value(0.0), 0);
|
||||
// 203 nits in PQ is a signal of 0.5807.
|
||||
assert!(value(0.57) < 65535);
|
||||
assert_eq!(value(0.59), 65535);
|
||||
assert_eq!(value(1.0), 65535);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms);
|
||||
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,
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Far meshes share the terrain arenas and passes, with their actual bounds
|
||||
//! Far meshes share the terrain arena and passes, with their actual bounds
|
||||
//! rather than the 16³ boxes used by the near-section residency walk.
|
||||
use super::{Frustum, Renderer, SectionGpu};
|
||||
use crate::{
|
||||
@@ -96,8 +96,21 @@ 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),
|
||||
@@ -152,10 +165,6 @@ impl Tile {
|
||||
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,
|
||||
@@ -410,8 +419,7 @@ impl Renderer {
|
||||
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)
|
||||
let keep = around_new.contains(&key)
|
||||
|| taken.iter().any(|&(cx, cz)| {
|
||||
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
|
||||
});
|
||||
@@ -503,7 +511,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();
|
||||
|
||||
@@ -18,14 +18,6 @@ const PREFIX: &str = "horizon_terrain_";
|
||||
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
|
||||
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
|
||||
const ENTRIES: &str = "
|
||||
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
|
||||
override horizon_material_fog: bool = true;
|
||||
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
if horizon_material_fog {
|
||||
return horizon_terrain_apply_fog(color, spherical, cylindrical);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
struct HorizonMaterialOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -36,13 +28,10 @@ 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 {
|
||||
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
|
||||
let pos = position + origin;
|
||||
var out: HorizonMaterialOut;
|
||||
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
|
||||
out.uv = uv;
|
||||
@@ -56,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(
|
||||
@@ -87,89 +63,21 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
}
|
||||
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
|
||||
// in the pack's frame group (3), one array each, a blank 1x1 without them.
|
||||
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
|
||||
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
|
||||
fn horizon_block_pbr_present() -> bool {
|
||||
return textureDimensions(horizon_pbr_normals).x > 1u;
|
||||
}
|
||||
fn horizon_block_pbr_size() -> vec2<f32> {
|
||||
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
|
||||
}
|
||||
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
|
||||
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
|
||||
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
|
||||
}
|
||||
let s = horizon_terrain_sprite_sampler;
|
||||
// The maps may be smaller than the colour: the same footprint is fewer of their texels.
|
||||
let scale = f32(textureDimensions(horizon_terrain_sprites).x)
|
||||
/ f32(textureDimensions(horizon_pbr_normals).x);
|
||||
let l = max(lod - log2(scale), 0.0);
|
||||
if map == 0u {
|
||||
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), l);
|
||||
}
|
||||
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), l);
|
||||
}
|
||||
// The prelude's `block_texture`: a sprite of the terrain's arrays at a mip level.
|
||||
fn horizon_block_size() -> vec2<f32> {
|
||||
return vec2<f32>(textureDimensions(horizon_terrain_sprites).xy);
|
||||
}
|
||||
fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
|
||||
let s = horizon_terrain_sprite_sampler;
|
||||
let l = i32(layer % horizon_terrain_SPRITE_SPLIT);
|
||||
switch layer / horizon_terrain_SPRITE_SPLIT {
|
||||
case 0u: { return textureSampleLevel(horizon_terrain_sprites, s, uv, l, lod); }
|
||||
case 1u: { return textureSampleLevel(horizon_terrain_sprites_1, s, uv, l, lod); }
|
||||
case 2u: { return textureSampleLevel(horizon_terrain_sprites_2, s, uv, l, lod); }
|
||||
case 3u: { return textureSampleLevel(horizon_terrain_sprites_3, s, uv, l, lod); }
|
||||
case 4u: { return textureSampleLevel(horizon_terrain_sprites_4, s, uv, l, lod); }
|
||||
case 5u: { return textureSampleLevel(horizon_terrain_sprites_5, s, uv, l, lod); }
|
||||
case 6u: { return textureSampleLevel(horizon_terrain_sprites_6, s, uv, l, lod); }
|
||||
default: { return textureSampleLevel(horizon_terrain_sprites_7, s, uv, l, lod); }
|
||||
}
|
||||
}
|
||||
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
|
||||
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
horizon_block_layer = in.layer & 0x7fffu;
|
||||
// The mip level of this point in the texture: its texels, as the sampler would pick.
|
||||
let texels = in.uv * horizon_block_size();
|
||||
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
|
||||
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
|
||||
// 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);
|
||||
horizon_material_face_normal = normal;
|
||||
horizon_surface = vec4<f32>(0.0);
|
||||
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),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu))).color;
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@location(0) color: vec4<f32>,
|
||||
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
|
||||
// stage's `surface_at` compares with the final depth.
|
||||
@location(1) surface: vec4<f32>,
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, false);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
if horizon_surface.w == 0.0 {
|
||||
set_surface(horizon_material_face_normal, 0.0, 0.0);
|
||||
}
|
||||
var out: HorizonMaterialSolid;
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
out.surface = vec4(horizon_surface.xyz, in.clip.z);
|
||||
return out;
|
||||
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
if color.a < 0.01 { discard; }
|
||||
return horizon_material_fogged(color, in.spherical, in.cylindrical);
|
||||
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
";
|
||||
|
||||
@@ -186,16 +94,6 @@ pub struct TerrainShape<'a> {
|
||||
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
|
||||
/// (`Manifest::sprite_tags`).
|
||||
pub sprite_tags: &'a [(u32, u32)],
|
||||
/// The pack's material id of each sprite layer with one, as `(layer, id)`
|
||||
/// (`Manifest::sprite_materials`).
|
||||
pub sprite_materials: &'a [(u32, u32)],
|
||||
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
|
||||
pub entities: &'a wgpu::BindGroupLayout,
|
||||
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
|
||||
/// stage.
|
||||
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
|
||||
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
|
||||
pub pbr: Option<&'a [wgpu::Texture; 2]>,
|
||||
}
|
||||
|
||||
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
|
||||
@@ -204,8 +102,6 @@ pub struct NativeMaterial {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
|
||||
pub writes_surface: bool,
|
||||
}
|
||||
|
||||
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
|
||||
@@ -217,14 +113,12 @@ fn vanilla_terrain() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
|
||||
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
|
||||
/// (`MaterialInput.material`).
|
||||
pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
|
||||
let mut table = table.to_vec();
|
||||
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
|
||||
for group in table.chunk_by(|a, b| a.1 == b.1) {
|
||||
/// `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",
|
||||
@@ -235,60 +129,18 @@ pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
out + " default: { return 0u; }\n }\n}\n"
|
||||
}
|
||||
|
||||
/// Where a corner of terrain is drawn without a vertex stage.
|
||||
pub(super) const DISPLACE_VANILLA: &str = "
|
||||
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>) -> vec3<f32> {
|
||||
return pos;
|
||||
}
|
||||
";
|
||||
|
||||
/// The vertex stage's call, with its inputs from terrain's vertex.
|
||||
pub(super) const DISPLACE_PACK: &str = "
|
||||
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>) -> vec3<f32> {
|
||||
let flags = u32(round(color.a * 255.0));
|
||||
let high = flags >> 4u;
|
||||
let fluid = high >= 8u && (high & 7u) < 6u;
|
||||
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
|
||||
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
|
||||
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
|
||||
}
|
||||
";
|
||||
|
||||
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
|
||||
/// bits), y smoothness, z the pack's data, w the fragment's depth.
|
||||
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`, and the vertex stage in its module when `vertex`.
|
||||
fn shader_source(
|
||||
source: &str,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
) -> String {
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
if vertex {
|
||||
DISPLACE_PACK
|
||||
} else {
|
||||
DISPLACE_VANILLA
|
||||
},
|
||||
layer_table("horizon_sprite_tags", tags),
|
||||
layer_table("horizon_sprite_material", materials)
|
||||
sprite_tags(tags)
|
||||
)
|
||||
}
|
||||
|
||||
/// The material's naga module, its errors in the stage's file.
|
||||
fn module(
|
||||
stage: &CheckedStage,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials, vertex);
|
||||
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -301,18 +153,14 @@ impl NativeMaterial {
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
vertex: bool,
|
||||
) -> Result<Self, String> {
|
||||
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
|
||||
let module = module(stage, shape.sprite_tags)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
"horizon_terrain_SPRITE_SPLIT",
|
||||
];
|
||||
constants.extend(sprite_names.into_iter().zip(shape.sprites));
|
||||
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
@@ -351,18 +199,6 @@ impl NativeMaterial {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
let solid_targets = [
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: SURFACE_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.label),
|
||||
layout: Some(layout),
|
||||
@@ -388,11 +224,11 @@ impl NativeMaterial {
|
||||
module: &module,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: options,
|
||||
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
|
||||
&solid_targets
|
||||
} else {
|
||||
&solid_targets[..1]
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
@@ -437,7 +273,6 @@ impl NativeMaterial {
|
||||
solid,
|
||||
translucent,
|
||||
flat,
|
||||
writes_surface,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -482,28 +317,17 @@ 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)];
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials, false).unwrap();
|
||||
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags).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, &materials, false))
|
||||
)
|
||||
});
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
|
||||
let entries: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
|
||||
@@ -5,7 +5,6 @@ use horizon_shader_api::{Report, StageKind};
|
||||
|
||||
use super::native_material::{NativeMaterial, TerrainShape};
|
||||
use super::native_post::{NativeFrame, NativePost};
|
||||
use super::native_shadow::NativeShadow;
|
||||
use super::native_sky::NativeSky;
|
||||
use super::pack_timing::PassTiming;
|
||||
|
||||
@@ -14,13 +13,6 @@ pub struct NativePack {
|
||||
pub sky: Option<NativeSky>,
|
||||
pub material: Option<NativeMaterial>,
|
||||
pub post: Option<NativePost>,
|
||||
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
|
||||
pub vanilla_clouds: bool,
|
||||
pub vanilla_sun: bool,
|
||||
pub vanilla_stars: bool,
|
||||
pub weather: Option<wgpu::RenderPipeline>,
|
||||
/// The draw of the shadow map the stages read, if the pack asks for one.
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
@@ -32,76 +24,12 @@ impl NativePack {
|
||||
/// refused.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
terrain: &TerrainShape<'_>,
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
lights: super::block_light::LightTable,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
let settings = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
let shadow = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
|
||||
NativeShadow::new(
|
||||
device,
|
||||
terrain,
|
||||
&frame,
|
||||
&s.solid_materials,
|
||||
vertex,
|
||||
constants,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
// 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 fog = report.material_fog;
|
||||
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
|
||||
report.sky.as_ref().and_then(key).unwrap_or(true)
|
||||
};
|
||||
let vanilla_clouds = keeps(|s| s.clouds);
|
||||
let vanilla_sun = keeps(|s| s.sun);
|
||||
let vanilla_stars = keeps(|s| s.stars);
|
||||
let weather = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|stage| {
|
||||
super::weather::native_pipeline(
|
||||
device,
|
||||
format,
|
||||
terrain.weather_groups[0],
|
||||
terrain.weather_groups[1],
|
||||
&frame,
|
||||
stage,
|
||||
constants,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let weather_label = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|s| s.label.clone());
|
||||
let has_vertex = report.stages.iter().any(|s| s.kind == StageKind::Vertex);
|
||||
let frame = NativeFrame::new(device);
|
||||
let (post, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
@@ -114,12 +42,10 @@ impl NativePack {
|
||||
let material = others
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.map(|stage| {
|
||||
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
|
||||
})
|
||||
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
|
||||
.then(|| NativePost::new(device, post, constants, format, &frame))
|
||||
.transpose()?;
|
||||
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
|
||||
labels.extend(
|
||||
@@ -128,7 +54,6 @@ impl NativePack {
|
||||
.filter(|s| material.is_some() && s.kind == StageKind::Material)
|
||||
.map(|s| s.label.clone()),
|
||||
);
|
||||
labels.extend(weather_label);
|
||||
if let Some(post) = &post {
|
||||
labels.extend(post.labels().into_iter().map(str::to_owned));
|
||||
}
|
||||
@@ -137,11 +62,6 @@ impl NativePack {
|
||||
sky,
|
||||
material,
|
||||
post,
|
||||
vanilla_clouds,
|
||||
vanilla_sun,
|
||||
vanilla_stars,
|
||||
weather,
|
||||
shadow,
|
||||
timing: None,
|
||||
};
|
||||
Ok((pack, labels))
|
||||
@@ -149,9 +69,6 @@ impl NativePack {
|
||||
|
||||
/// Whether the pack draws anything the game runs today.
|
||||
pub fn draws(&self) -> bool {
|
||||
self.sky.is_some()
|
||||
|| self.material.is_some()
|
||||
|| self.weather.is_some()
|
||||
|| self.post.is_some()
|
||||
self.sky.is_some() || self.material.is_some() || self.post.is_some()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,87 +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};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The game was built with native mods (the game sets it at start): one of them may draw in
|
||||
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
|
||||
pub fn set_mods_built_in(on: bool) {
|
||||
MODS_BUILT_IN.store(on, Ordering::Relaxed);
|
||||
}
|
||||
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
|
||||
pub fn world_drawn_hooked() -> bool {
|
||||
MODS_BUILT_IN.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
|
||||
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
|
||||
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
|
||||
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
|
||||
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
|
||||
const PREFIX: &str = "horizon_terrain_";
|
||||
|
||||
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
|
||||
/// cutout test (leaves' and plants' holes let the light through).
|
||||
const ENTRIES: &str = "
|
||||
struct HorizonShadowOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) layer: u32,
|
||||
@location(2) tint: vec3<f32>,
|
||||
@location(3) @interpolate(flat) fluid: u32,
|
||||
}
|
||||
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
|
||||
// (mesher.rs's `fluid_face`).
|
||||
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
|
||||
-> HorizonShadowOut {
|
||||
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
|
||||
let xy = shadow_distort(clip.xy);
|
||||
var out: HorizonShadowOut;
|
||||
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
out.tint = color.rgb;
|
||||
let high = u32(round(color.a * 255.0)) >> 4u;
|
||||
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
|
||||
return out;
|
||||
}
|
||||
@vertex fn horizon_shadow_vs(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
|
||||
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
|
||||
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
|
||||
return horizon_shadow_vertex(pos, atlas, layer, color);
|
||||
}
|
||||
@vertex fn horizon_shadow_vs_flat(
|
||||
@builtin(vertex_index) index: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
|
||||
@location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> HorizonShadowOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
|
||||
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
|
||||
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
|
||||
return horizon_shadow_vertex(pos, atlas, layer, color);
|
||||
}
|
||||
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
|
||||
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
|
||||
}
|
||||
// Translucent terrain: its colour where it is the nearest thing the light passes through;
|
||||
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
|
||||
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
if color.a < 0.01 { discard; }
|
||||
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
|
||||
}
|
||||
";
|
||||
|
||||
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
|
||||
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
|
||||
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
|
||||
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
|
||||
const ENTITY_ENTRIES: &str = "
|
||||
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
|
||||
struct HorizonEntityShadowOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) alpha: f32,
|
||||
}
|
||||
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
|
||||
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
|
||||
-> HorizonEntityShadowOut {
|
||||
let clip = frame.shadow_view_proj * vec4(position, 1.0);
|
||||
let xy = shadow_distort(clip.xy);
|
||||
let overlay = layer & 0xffffu;
|
||||
// As entity.wgsl's `unlit`.
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|
||||
|| (overlay >= 9u && overlay <= 12u);
|
||||
var out: HorizonEntityShadowOut;
|
||||
// Unlit geometry lands outside the clip volume.
|
||||
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
|
||||
out.uv = uv;
|
||||
out.alpha = color.a;
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
|
||||
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
|
||||
if alpha * in.alpha < 0.5 { discard; }
|
||||
}
|
||||
";
|
||||
|
||||
/// `shadow_color`'s format.
|
||||
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
|
||||
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
|
||||
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
|
||||
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
|
||||
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
|
||||
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
|
||||
/// shadow, and ends past the build height and the slant across the map below the camera.
|
||||
pub fn camera(settings: &Settings) -> Shadow {
|
||||
Shadow {
|
||||
resolution: settings.resolution,
|
||||
distance: settings.distance,
|
||||
render_mul: 1.0,
|
||||
interval: SNAP,
|
||||
euclidean_snap: true,
|
||||
near: 256.0,
|
||||
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
|
||||
..Shadow::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `settings` with the resolution `device` can make.
|
||||
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
|
||||
Settings {
|
||||
resolution: settings
|
||||
.resolution
|
||||
.min(device.limits().max_texture_dimension_2d),
|
||||
..settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
|
||||
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
|
||||
/// three times as fine as at its edge.
|
||||
const SNAP: f32 = 8.0;
|
||||
|
||||
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
|
||||
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
|
||||
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
|
||||
|
||||
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
|
||||
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
|
||||
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
|
||||
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
|
||||
pub fn distortion(distance: f32, resolution: u32) -> f32 {
|
||||
let texel = 2.0 * distance / resolution.max(1) as f32;
|
||||
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
|
||||
}
|
||||
|
||||
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
|
||||
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
|
||||
/// in, and that terrain's colour.
|
||||
pub struct ShadowMap {
|
||||
depth: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
translucent: wgpu::Texture,
|
||||
pub translucent_view: wgpu::TextureView,
|
||||
pub color_view: wgpu::TextureView,
|
||||
pub sampler: wgpu::Sampler,
|
||||
/// The 1×1 map still holds the zeroes it was made with.
|
||||
blank_uncleared: bool,
|
||||
}
|
||||
|
||||
impl ShadowMap {
|
||||
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
|
||||
let size = settings.map_or(1, |s| s.resolution);
|
||||
let texture = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let depth = texture(
|
||||
"Native shader pack shadow map",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let translucent = texture(
|
||||
"Native shader pack translucent shadow map",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let color = texture(
|
||||
"Native shader pack shadow colour",
|
||||
SHADOW_COLOR,
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Native shader pack shadow comparison"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
view: depth.create_view(&Default::default()),
|
||||
depth,
|
||||
translucent_view: translucent.create_view(&Default::default()),
|
||||
translucent,
|
||||
color_view: color.create_view(&Default::default()),
|
||||
sampler,
|
||||
blank_uncleared: settings.is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth pass into the map that clears it to "far".
|
||||
pub fn begin<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0), timing)
|
||||
}
|
||||
|
||||
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
|
||||
/// copied: where one casts, the solid map is the darker and decides.
|
||||
pub fn begin_entities<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Load, None)
|
||||
}
|
||||
|
||||
fn depth_pass<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
load: wgpu::LoadOp<f32>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
|
||||
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
|
||||
pub fn begin_translucent<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.copy_texture_to_texture(
|
||||
self.depth.as_image_copy(),
|
||||
self.translucent.as_image_copy(),
|
||||
self.depth.size(),
|
||||
);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.color_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.translucent_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Clears the 1×1 map of a pack without `[shadow]` once.
|
||||
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if std::mem::take(&mut self.blank_uncleared) {
|
||||
drop(self.begin(encoder, "Native shader pack shadow map clear", None));
|
||||
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear", None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
|
||||
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
|
||||
/// stages' own group 3 also holds the map being drawn).
|
||||
pub struct NativeShadow {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub flat: Option<wgpu::RenderPipeline>,
|
||||
/// The translucent terrain's draws into the translucent map and the colour, instanced and
|
||||
/// flat.
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat_translucent: Option<wgpu::RenderPipeline>,
|
||||
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
|
||||
pub entity: wgpu::RenderPipeline,
|
||||
pub group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// The entity draw's shader.
|
||||
fn entity_source() -> String {
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
)
|
||||
}
|
||||
|
||||
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
|
||||
fn entity_pipeline(
|
||||
device: &wgpu::Device,
|
||||
entities: &wgpu::BindGroupLayout,
|
||||
frame_layout: &wgpu::BindGroupLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[0] = Some(entities);
|
||||
groups[GROUP as usize] = Some(frame_layout);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_entity_shadow_vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 1.5,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_entity_shadow_fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
|
||||
/// (`(layer, id)`), is one of the pack's `solid` ones.
|
||||
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
|
||||
let layers: Vec<String> = materials
|
||||
.iter()
|
||||
.filter(|(_, id)| solid.contains(id))
|
||||
.map(|(layer, _)| format!("{layer}u"))
|
||||
.collect();
|
||||
let cases = if layers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" case {}: {{ return true; }}\n", layers.join(", "))
|
||||
};
|
||||
format!(
|
||||
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
|
||||
default: {{ return false; }}\n }}\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
|
||||
/// `solid` of `shadow_solid`.
|
||||
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
|
||||
let (head, displace) = match vertex {
|
||||
Some(stage) => (
|
||||
stage.to_owned()
|
||||
+ &super::native_material::layer_table("horizon_sprite_material", materials),
|
||||
super::native_material::DISPLACE_PACK,
|
||||
),
|
||||
None => (
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
),
|
||||
super::native_material::DISPLACE_VANILLA,
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
PREFIX
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
impl NativeShadow {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shape: &TerrainShape<'_>,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
solid_materials: &[u32],
|
||||
vertex: Option<&horizon_shader_api::check::CheckedStage>,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<Self, String> {
|
||||
let text = shader_source(
|
||||
&shadow_solid(shape.sprite_materials, solid_materials),
|
||||
vertex.map(|s| s.source.as_str()),
|
||||
shape.sprite_materials,
|
||||
);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
let mut constants = super::native_post::present_constants(&module, constants);
|
||||
constants.extend([
|
||||
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
|
||||
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
|
||||
]);
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack shadow frame"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack shadow frame"),
|
||||
layout: &frame_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: frame.buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
|
||||
Default::default();
|
||||
groups[0] = Some(shape.globals);
|
||||
groups[1] = flat;
|
||||
groups[GROUP as usize] = Some(&frame_layout);
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: SHADOW_COLOR,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})];
|
||||
let pipeline = |layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
translucent: bool| {
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: options.clone(),
|
||||
buffers,
|
||||
},
|
||||
// Both sides: the quads that face the light are drawn (`facing_runs`), and
|
||||
// plants' crossed quads cast from either side.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 1.5,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(if translucent {
|
||||
"horizon_shadow_fs_translucent"
|
||||
} else {
|
||||
"horizon_shadow_fs"
|
||||
}),
|
||||
compilation_options: options,
|
||||
targets: if translucent { &color_target } else { &[] },
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let instanced = layout(None);
|
||||
let solid = pipeline(
|
||||
&instanced,
|
||||
"horizon_shadow_vs",
|
||||
&[Some(vertices.clone()), Some(instances.clone())],
|
||||
false,
|
||||
);
|
||||
let translucent = pipeline(
|
||||
&instanced,
|
||||
"horizon_shadow_vs",
|
||||
&[Some(vertices.clone()), Some(instances)],
|
||||
true,
|
||||
);
|
||||
let flat_layout = shape.flat.map(|group| layout(Some(group)));
|
||||
let flat_pipeline = |translucent| {
|
||||
flat_layout.as_ref().map(|layout| {
|
||||
pipeline(
|
||||
layout,
|
||||
"horizon_shadow_vs_flat",
|
||||
&[Some(vertices.clone())],
|
||||
translucent,
|
||||
)
|
||||
})
|
||||
};
|
||||
let flat = flat_pipeline(false);
|
||||
let flat_translucent = flat_pipeline(true);
|
||||
let entity = entity_pipeline(device, shape.entities, &frame_layout);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("shadow pass: {e}"));
|
||||
}
|
||||
Ok(Self {
|
||||
solid,
|
||||
flat,
|
||||
translucent,
|
||||
flat_translucent,
|
||||
entity,
|
||||
group,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
|
||||
let materials = [(3, 7), (4, 8), (9, 7)];
|
||||
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
|
||||
assert!(!shadow_solid(&materials, &[]).contains("case"));
|
||||
assert!(!shadow_solid(&materials, &[5]).contains("case"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entity_shader_validates() {
|
||||
let text = entity_source();
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
&shadow,
|
||||
);
|
||||
// Up to the build height's span below a player flying high, as well as above them.
|
||||
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
|
||||
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
|
||||
assert!(clip.is_finite(), "{clip}");
|
||||
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
|
||||
let centre = view.inverse().w_axis.truncate();
|
||||
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
|
||||
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
|
||||
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
|
||||
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
|
||||
// Already that fine: no distortion.
|
||||
assert_eq!(distortion(32.0, 2048), 0.0);
|
||||
assert_eq!(distortion(10_000.0, 1024), 0.95);
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,7 @@ mod tests {
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let frame = NativeFrame::new(&device);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
|
||||
@@ -620,7 +620,6 @@ impl Oit {
|
||||
depth: &wgpu::TextureView,
|
||||
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
|
||||
extras: &Extras<'_>,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else { return };
|
||||
let attachment = |view, load| {
|
||||
@@ -646,7 +645,7 @@ impl Oit {
|
||||
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, timing.as_deref_mut());
|
||||
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;
|
||||
@@ -681,7 +680,6 @@ impl Oit {
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
|
||||
color_attachments: &first,
|
||||
depth_stencil_attachment: read_only(terrain_depth),
|
||||
..Default::default()
|
||||
@@ -694,7 +692,6 @@ impl Oit {
|
||||
// the terrain's: the masks only hide water.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("OIT features")),
|
||||
color_attachments: &[attachment(
|
||||
match stage {
|
||||
Stage::DepthBounds => &t.views.bounds,
|
||||
@@ -713,7 +710,7 @@ impl Oit {
|
||||
features(&mut pass, stage);
|
||||
}
|
||||
if stage == Stage::DepthBounds {
|
||||
self.execute_depth_bounds_cull(encoder, t, depth, timing.as_deref_mut());
|
||||
self.execute_depth_bounds_cull(encoder, t, depth);
|
||||
}
|
||||
if let Some((clouds, cloud_depth)) = cloud_depth {
|
||||
self.render_clouds(
|
||||
@@ -723,18 +720,16 @@ impl Oit {
|
||||
depth,
|
||||
(label, group, &then),
|
||||
(clouds, cloud_depth),
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
if stage == Stage::DepthBounds
|
||||
&& let Some((mask, _)) = water_mask
|
||||
{
|
||||
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT composite"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT composite")),
|
||||
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
@@ -754,7 +749,6 @@ impl Oit {
|
||||
/// `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.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_clouds(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -770,7 +764,6 @@ impl Oit {
|
||||
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
|
||||
&Depth,
|
||||
),
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if stage == Stage::DepthBounds {
|
||||
encoder.copy_texture_to_texture(
|
||||
@@ -781,7 +774,6 @@ impl Oit {
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT clouds")),
|
||||
color_attachments: targets,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &cloud_depth.view,
|
||||
@@ -806,7 +798,6 @@ impl Oit {
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(terrain) = &t.terrain_depth else {
|
||||
return;
|
||||
@@ -819,7 +810,6 @@ impl Oit {
|
||||
// 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"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT water mask")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: color,
|
||||
resolve_target: None,
|
||||
@@ -850,7 +840,6 @@ impl Oit {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
depth: &wgpu::TextureView,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.bounds.as_image_copy(),
|
||||
@@ -863,7 +852,6 @@ impl Oit {
|
||||
);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT cull depth bounds"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT cull depth bounds")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &t.views.culled,
|
||||
resolve_target: None,
|
||||
|
||||
@@ -176,7 +176,7 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("text", "entity", Some(wgpu::Face::Back), offset),
|
||||
kind("text", "entity", None, offset),
|
||||
),
|
||||
crumbling: set(
|
||||
&entity_module,
|
||||
@@ -188,12 +188,7 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind(
|
||||
"entity shadows",
|
||||
"shadow",
|
||||
Some(wgpu::Face::Back),
|
||||
shadow_bias,
|
||||
),
|
||||
kind("entity shadows", "shadow", None, shadow_bias),
|
||||
),
|
||||
swirl: set(
|
||||
&entity_module,
|
||||
@@ -217,12 +212,7 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind(
|
||||
"additive entities",
|
||||
"lightning",
|
||||
Some(wgpu::Face::Back),
|
||||
PLAIN,
|
||||
),
|
||||
kind("additive entities", "lightning", None, PLAIN),
|
||||
),
|
||||
weather: set(
|
||||
&entity_module,
|
||||
|
||||
@@ -113,16 +113,6 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
|
||||
/// inputs, for vertex `hz_vertex_index`.
|
||||
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
|
||||
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
|
||||
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
|
||||
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
|
||||
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
|
||||
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
|
||||
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
|
||||
|
||||
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
|
||||
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
|
||||
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
@@ -204,15 +194,8 @@ impl PackAtlas {
|
||||
} else {
|
||||
"vec4(-1.0, -1.0, 0.0, 0.0)"
|
||||
};
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal).
|
||||
let mid_block = if flat {
|
||||
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, 0.0)"
|
||||
} else {
|
||||
"vec4(0.0)"
|
||||
};
|
||||
let attributes = [
|
||||
("mc_Entity", entity),
|
||||
("at_midBlock", mid_block),
|
||||
(
|
||||
"mc_midTexCoord",
|
||||
"vec4((hz_cell + 0.5) * hz_scale, 0.0, 1.0)",
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
|
||||
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
|
||||
//! target, copied to one of a few small buffers and read back frames later without waiting for
|
||||
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
const SLOTS: usize = 3;
|
||||
const FREE: u8 = 0;
|
||||
/// Copied this frame: mapped once the frame is submitted.
|
||||
const COPIED: u8 = 1;
|
||||
const PENDING: u8 = 2;
|
||||
const MAPPED: u8 = 3;
|
||||
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
|
||||
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
|
||||
const SHADER: &str = "
|
||||
@group(0) @binding(0) var depth: texture_depth_2d;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let centre = vec2<i32>(textureDimensions(depth) / 2u);
|
||||
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
state: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
pub struct CenterDepth {
|
||||
slots: Vec<Slot>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// The 1x1 target the centre texel is loaded into.
|
||||
texel: wgpu::Texture,
|
||||
texel_view: wgpu::TextureView,
|
||||
/// The bind group of the depth texture it reads, made again when that texture changes.
|
||||
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
|
||||
/// The depth last read back: GL window depth, 0..1.
|
||||
latest: Option<f32>,
|
||||
}
|
||||
|
||||
impl CenterDepth {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let slots = (0..SLOTS)
|
||||
.map(|_| Slot {
|
||||
buffer: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
size: u64::from(ROW),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
state: Arc::new(AtomicU8::new(FREE)),
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let format = wgpu::TextureFormat::R32Float;
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let texel = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let texel_view = texel.create_view(&Default::default());
|
||||
Self {
|
||||
slots,
|
||||
pipeline,
|
||||
layout,
|
||||
texel,
|
||||
texel_view,
|
||||
group: None,
|
||||
latest: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
|
||||
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
|
||||
pub fn copy(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::Texture,
|
||||
) {
|
||||
let Some(slot) = self
|
||||
.slots
|
||||
.iter()
|
||||
.find(|s| s.state.load(Ordering::Acquire) == FREE)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
|
||||
let view = depth.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
layout: &self.layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
}],
|
||||
});
|
||||
self.group = Some((depth.clone(), group));
|
||||
}
|
||||
let Some((_, group)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.texel_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
self.texel.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &slot.buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(ROW),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
self.texel.size(),
|
||||
);
|
||||
slot.state.store(COPIED, Ordering::Release);
|
||||
}
|
||||
|
||||
/// After the frame's submission: start reading back what it copied.
|
||||
pub fn submitted(&mut self) {
|
||||
for slot in &self.slots {
|
||||
if slot.state.load(Ordering::Acquire) != COPIED {
|
||||
continue;
|
||||
}
|
||||
slot.state.store(PENDING, Ordering::Release);
|
||||
let state = slot.state.clone();
|
||||
slot.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
let next = if result.is_ok() { MAPPED } else { FREE };
|
||||
state.store(next, Ordering::Release);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest depth read back so far, `None` before the first arrives.
|
||||
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
for slot in &self.slots {
|
||||
if slot.state.load(Ordering::Acquire) != MAPPED {
|
||||
continue;
|
||||
}
|
||||
let depth = slot
|
||||
.buffer
|
||||
.slice(..4)
|
||||
.get_mapped_range()
|
||||
.ok()
|
||||
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
if let Some(depth) = depth.filter(|d| d.is_finite()) {
|
||||
self.latest = Some(depth.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
self.latest
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
|
||||
/// later, without the frame waiting for it.
|
||||
#[test]
|
||||
fn the_centre_depth_comes_back() {
|
||||
let Some((device, queue)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 9,
|
||||
height: 7,
|
||||
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 view = depth.create_view(&Default::default());
|
||||
let mut center = CenterDepth::new(&device);
|
||||
assert_eq!(center.collect(&device), None);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.375),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted();
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
assert_eq!(center.collect(&device), Some(0.375));
|
||||
// Its slot is free again: every slot can be used once more.
|
||||
for _ in 0..SLOTS {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted();
|
||||
}
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
assert_eq!(center.collect(&device), Some(0.375));
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,6 @@ impl DrawUniforms {
|
||||
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
|
||||
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
|
||||
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
|
||||
// `blockEntityId` outside block entities, as Iris has it.
|
||||
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
}
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
|
||||
@@ -106,7 +106,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
|
||||
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
|
||||
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
|
||||
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
|
||||
/// full sky light. Set 1 holds the clouds' uniform (`clouds::Uniform`).
|
||||
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
@@ -115,7 +115,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.to_owned();
|
||||
let body = " hz_Vertex = vec4(hz_v_pos + hz_cloud_offset.xyz, 1.0);\n \
|
||||
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = hz_cloud_colors[min(hz_v_light.x, 3u)];\n \
|
||||
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz) : vec3(0.0, 1.0, 0.0);\n"
|
||||
@@ -123,7 +123,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let attributes = [
|
||||
("vaPosition", "hz_Vertex.xyz"),
|
||||
("vaUV0", "hz_v_uv"),
|
||||
("vaUV2", "ivec2(240, 240)"),
|
||||
("vaUV2", "ivec2(0, 240)"),
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
];
|
||||
@@ -349,8 +349,10 @@ impl PackEntities {
|
||||
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
|
||||
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
|
||||
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
|
||||
for (i, c) in entity_color(run[0].layer & 0xffff).iter().enumerate() {
|
||||
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
self.uniforms.extend_from_slice(&bytes);
|
||||
@@ -409,9 +411,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
|
||||
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
|
||||
/// shader draws in its own way; entity.wgsl).
|
||||
fn plain(run: &[Vertex]) -> bool {
|
||||
run.iter()
|
||||
.step_by(4)
|
||||
.all(|v| !matches!(v.layer & 0xffff, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
|
||||
run.iter().step_by(4).all(|v| {
|
||||
!matches!(
|
||||
v.layer & !super::entity_batch::OIT_ENTITY,
|
||||
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The programs of entity runs, in the order they draw.
|
||||
|
||||
@@ -27,11 +27,12 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
inputs
|
||||
}
|
||||
|
||||
/// The hand's projection in GL's conventions: `hud_fov` degrees, depth scaled by
|
||||
/// `MC_HAND_DEPTH` (the view bob is in `gbufferModelView`).
|
||||
pub fn projection(hud_fov: f32, aspect: f32, far: f32) -> Mat4 {
|
||||
/// The hand's projection in GL's conventions: `hud_fov` degrees, the view bob, depth scaled by
|
||||
/// `MC_HAND_DEPTH`.
|
||||
pub fn projection(hud_fov: f32, aspect: f32, far: f32, bob: Mat4) -> Mat4 {
|
||||
Mat4::from_scale(glam::Vec3::new(1.0, 1.0, HAND_DEPTH))
|
||||
* Mat4::perspective_rh_gl(hud_fov.to_radians(), aspect, 0.05, far)
|
||||
* bob
|
||||
}
|
||||
|
||||
/// The hand's vertices (also read at set 1 for normals and tangents), its uniform and quad
|
||||
@@ -152,7 +153,7 @@ mod tests {
|
||||
/// apart by it), and x, y match the plain projection.
|
||||
#[test]
|
||||
fn hand_depth_is_scaled_around_the_middle() {
|
||||
let p = projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let p = projection(70.0, 16.0 / 9.0, 512.0, Mat4::IDENTITY);
|
||||
let plain = Mat4::perspective_rh_gl(70f32.to_radians(), 16.0 / 9.0, 0.05, 512.0);
|
||||
for z in [-0.06f32, -0.5, -3.0, -400.0] {
|
||||
let v = glam::Vec4::new(0.1, -0.2, z, 1.0);
|
||||
|
||||
Loaded 100 of 1370 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user