Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 f85ccb2bd2 Main builds again: one Attack arm in the hub, anticheat first, then the queued-move break
#5123 added a second S::Attack arm after the anticheat's, so clippy -D warnings
fails on main (unreachable pattern) and its queued-move break never ran. The
break now sits in the anticheat's arm, after an attack it forwards.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:20:43 +01:00
822 changed files with 8377 additions and 193235 deletions

No files matched your search

-1
View File
@@ -7,4 +7,3 @@ kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
+1 -3
View File
@@ -9,9 +9,7 @@ permissions:
actions: write
jobs:
cancel:
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
steps:
- name: Cancel the pull request's unfinished runs
env:
+70 -41
View File
@@ -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:
+2 -4
View File
@@ -29,8 +29,7 @@ jobs:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
@@ -56,8 +55,7 @@ jobs:
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+2 -14
View File
@@ -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
-55
View File
@@ -1,55 +0,0 @@
name: native hook stamp
# Opens a PR that numbers pending fragments after they merge. Only pushes to
# main run main's own tool; fragments are read as data, not PR code.
on:
push:
branches: [main]
paths:
- 'tools/native-hook-fragments/*.json'
permissions:
contents: read
concurrency:
group: native-hook-stamp
cancel-in-progress: false
jobs:
stamp:
if: github.repository == 'horizon-rust/horizon'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Open a stamp pull request
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
branch=automation/native-hook-stamp
if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
echo "A stamp pull request is already open."
exit 0
fi
python3 tools/stamp_native_hooks.py
if git diff --quiet; then
echo "No fragments to stamp."
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git checkout -b "$branch"
git add -u
git commit -m 'Stamp native hook API fragments'
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "$branch"
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
+1 -3
View File
@@ -19,9 +19,7 @@ jobs:
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+1 -3
View File
@@ -16,9 +16,7 @@ jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
-10
View File
@@ -68,16 +68,6 @@ unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Write tests with signal
- Test observable behavior, not words in docs, comments or source. Keep checks for machine-consumed contracts.
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
- Make slow tests faster without removing cases or weakening their coverage.
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
## Build and test fast
```sh
-4
View File
@@ -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.
Generated
+14 -523
View File
@@ -34,17 +34,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -174,45 +163,6 @@ dependencies = [
"libloading",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -231,12 +181,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -251,9 +195,6 @@ name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
@@ -276,15 +217,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -369,15 +301,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.6.0"
@@ -416,18 +339,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
"cpufeatures",
]
[[package]]
@@ -437,7 +349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20 0.9.1",
"chacha20",
"cipher",
"poly1305",
"zeroize",
@@ -474,18 +386,6 @@ dependencies = [
"error-code",
]
[[package]]
name = "cms"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
dependencies = [
"const-oid",
"der",
"spki",
"x509-cert",
]
[[package]]
name = "cobs"
version = "0.3.0"
@@ -525,12 +425,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -626,15 +520,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -663,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"rand_core",
"typenum",
]
@@ -686,10 +571,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"curve25519-dalek-derive",
"fiat-crypto",
"rand_core 0.6.4",
"rand_core",
"rustc_version",
"subtle",
]
@@ -711,65 +596,6 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"der_derive",
"flagset",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "deranged"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "des"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
dependencies = [
"cipher",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -919,12 +745,6 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1077,7 +897,6 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1216,12 +1035,6 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hidapi"
version = "2.6.7"
@@ -1288,15 +1101,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"toml",
]
[[package]]
name = "horizon-client"
version = "0.1.0"
@@ -1311,7 +1115,6 @@ dependencies = [
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
@@ -1322,7 +1125,6 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1381,7 +1183,6 @@ dependencies = [
"cpal",
"flate2",
"glam",
"half",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
@@ -1390,7 +1191,6 @@ dependencies = [
"horizon-core",
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1408,7 +1208,6 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"smallvec",
"symphonia",
"unicode-bidi",
"ureq",
@@ -1494,18 +1293,6 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-narrator"
version = "0.1.0"
dependencies = [
"libloading",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"windows 0.62.2",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1566,23 +1353,14 @@ dependencies = [
name = "horizon-server"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"chrono",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-release-sign",
"horizon-server-files",
"libc",
"p12-keystore",
"png",
"rcgen",
"rustls",
"serde",
"serde_json",
"sha1",
"sha2",
"toml",
"toml_edit",
@@ -1620,10 +1398,8 @@ dependencies = [
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"naga",
"png",
"zip",
]
@@ -1843,7 +1619,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -2135,12 +1910,6 @@ dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.3.0"
@@ -2176,6 +1945,8 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set",
@@ -2252,16 +2023,6 @@ dependencies = [
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
@@ -2271,22 +2032,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -2298,15 +2043,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2683,15 +2419,6 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2753,29 +2480,6 @@ dependencies = [
"ttf-parser",
]
[[package]]
name = "p12-keystore"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
dependencies = [
"base64",
"cbc",
"cms",
"der",
"des",
"hex",
"hmac",
"pkcs12",
"pkcs5",
"rand",
"rc2",
"sha1",
"sha2",
"thiserror 2.0.21",
"x509-parser",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2799,25 +2503,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2861,36 +2546,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs12"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2"
dependencies = [
"cms",
"const-oid",
"der",
"digest",
"spki",
"x509-cert",
"zeroize",
]
[[package]]
name = "pkcs5"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6"
dependencies = [
"aes",
"cbc",
"der",
"pbkdf2",
"scrypt",
"sha2",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -2942,7 +2597,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
@@ -2983,12 +2638,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "pp-rs"
version = "0.2.1"
@@ -3058,17 +2707,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20 0.10.2",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3078,12 +2716,6 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3108,28 +2740,6 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rc2"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd"
dependencies = [
"cipher",
]
[[package]]
name = "rcgen"
version = "0.14.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
dependencies = [
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -3221,15 +2831,6 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -3306,15 +2907,6 @@ dependencies = [
"twox-hash",
]
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3336,17 +2928,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
@@ -3425,7 +3006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@@ -3436,7 +3017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@@ -3551,7 +3132,7 @@ checksum = "c5482afe85a0b6ce956c945401598dbc527593c77ba51d0a87a586938b1b893a"
dependencies = [
"curve25519-dalek",
"hkdf",
"rand_core 0.6.4",
"rand_core",
"sha2",
]
@@ -3564,16 +3145,6 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3691,17 +3262,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -3762,36 +3322,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -3911,7 +3441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"nom",
"petgraph",
]
@@ -4981,35 +4511,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"der",
"spki",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "xcursor"
version = "0.3.11"
@@ -5054,16 +4555,6 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec",
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5084,7 +4575,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure 0.14.0",
"synstructure",
]
[[package]]
@@ -5125,7 +4616,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure 0.14.0",
"synstructure",
]
[[package]]
+1 -5
View File
@@ -1,9 +1,8 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-narrator",
"crates/horizon-threads",
"crates/horizon-hdr",
"crates/horizon-world",
@@ -15,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",
@@ -61,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.
+3 -3
View File
@@ -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 -11
View File
@@ -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
File diff suppressed because it is too large. Load diff
+13 -86
View File
@@ -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 {
@@ -326,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)));
@@ -390,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();
+6 -13
View File
@@ -33,16 +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> },
/// 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.
@@ -393,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());
+26 -334
View File
@@ -6,12 +6,6 @@
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
//!
//! `--behaviour fight` is the exception: the bots play in survival (`gamemode=survival`, `force-gamemode=true`,
//! and `allow-flight=true` because they hold their spawn height instead of standing on terrain they cannot
//! see). They track the entities the server sends, walk to the nearest living one and attack it at vanilla's
//! bare-hand cooldown (every 5 ticks). With `--pvp true` (the default) the other bots are targets too, which
//! hurts only when the server has `pvp=true`.
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
@@ -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),
@@ -85,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
@@ -107,7 +99,6 @@ enum Behaviour {
Stand,
Walk,
Explore,
Fight,
FloodMove,
FloodAcks,
FloodPackets,
@@ -127,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",
@@ -152,7 +142,6 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
pvp: bool,
/// The player to attack, and how long after joining to start.
attack: Option<String>,
attack_after: f64,
@@ -187,7 +176,6 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
pvp: true,
attack: None,
attack_after: 5.0,
no_swing: false,
@@ -243,7 +231,6 @@ 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"))?,
@@ -307,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",
@@ -327,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
));
}
@@ -348,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.
@@ -364,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>>,
}
@@ -416,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)
@@ -807,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;
@@ -870,9 +716,6 @@ fn read(
}
}
}
if track {
lock(&bot.entities).update(id, &body);
}
Ok(())
}
cb::SET_HEALTH => {
@@ -925,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 {
@@ -964,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>>>,
) {
@@ -989,8 +821,7 @@ fn run_bot(
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.
@@ -1041,13 +872,7 @@ fn run_bot(
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
let cruise = if behaviour == Behaviour::Fight {
spawn[1]
} else {
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let cruise = spawn[1] + o.height;
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -1101,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
@@ -1219,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, |_| {}));
@@ -1249,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,
@@ -1280,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
@@ -1333,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;
@@ -1353,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(),
@@ -1361,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(
@@ -1414,123 +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))],
})
}
#[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() {
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "horizon-chat-filter"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
[lints]
workspace = true
-63
View File
@@ -1,63 +0,0 @@
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
# words are written here only so the filter can recognise them, because players spammed them on the
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
# aren't slurs out of it (moderators handle those).
#
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
# doubled in the text: "niger", the river and the country, isn't "nigger".
# word matches inside a longer word too
# =word only as a whole word ("spic", not "suspicious")
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
~nigger
~nigga
nigguh
niggah
niqqa
niqqer
nlgger
nlgga
kneegrow
=nig
=nigs
=niglet
=niglets
=nibba
=nibbas
=nibber
=nibbers
jigaboo
porchmonkey
=coon
=coons
~faggot
=fag
=fags
=faggy
=fagot
=fagots
=tranny
=trannies
=kike
=kikes
=chink
=chinks
=gook
=gooks
=spic
=spics
wetback
=beaner
=beaners
raghead
towelhead
zipperhead
=paki
=pakis
!snigger*
!niggard*
-472
View File
@@ -1,472 +0,0 @@
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
//! player's text and carries out what it decides.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
mod normalise;
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
pub const DEFAULT_WORDS: &str = include_str!("../data/slurs.txt");
/// The mutes, beside server.properties.
pub const MUTES_FILE: &str = "chat-filter-mutes.json";
/// `server.toml`'s `[chat_filter]`: on by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub enabled: bool,
/// The hit, counted within `window_minutes`, that mutes the player; 0: never.
pub mute_after: u32,
/// The hit that bans the player for `tempban_hours`; 0: never.
pub tempban_after: u32,
/// How long a hit counts towards the next ones (the time spent muted doesn't count).
pub window_minutes: u32,
pub mute_minutes: u32,
pub tempban_hours: u32,
/// The slur list, relative to the server's folder; empty: the list built into the game.
pub words_file: String,
/// Every refused text with the player and what followed, relative to the server's folder.
pub log: String,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
mute_after: 2,
tempban_after: 3,
window_minutes: 10,
mute_minutes: 10,
tempban_hours: 24,
words_file: String::new(),
log: "logs/chat-filter.log".into(),
}
}
}
impl Config {
/// The `[chat_filter]` section of a whole `server.toml`; the default without it.
pub fn from_server_toml(text: &str) -> Result<Self, String> {
#[derive(Deserialize)]
struct File {
#[serde(default)]
chat_filter: Config,
}
// Other sections are the supervisor's to check: only this one is read here.
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let mut only = toml::Table::new();
if let Some(section) = table.remove("chat_filter") {
only.insert("chat_filter".into(), section);
}
let file: File = only
.try_into()
.map_err(|e: toml::de::Error| format!("[chat_filter]: {e}"))?;
file.chat_filter.check()?;
Ok(file.chat_filter)
}
pub fn check(&self) -> Result<(), String> {
if self.window_minutes == 0 {
return Err("chat_filter.window_minutes must be 1 or more".into());
}
Ok(())
}
}
/// How an entry of the list matches.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
/// Inside a longer word too.
Embedded,
/// Only as a whole word.
Whole,
/// Inside a word, or spread over several words from the start of one.
Spread,
}
#[derive(Debug)]
struct Entry {
runs: Vec<Run>,
kind: Kind,
}
/// The slur list, compiled: each entry's letters as runs, indexed by first letter.
#[derive(Debug)]
pub struct Filter {
entries: Vec<Entry>,
by_first: [Vec<u16>; 27],
/// Words never matched: exact (`false`) or as a prefix (`true`).
allowed: Vec<(Vec<Run>, bool)>,
}
impl Filter {
/// Compiles a list in `data/slurs.txt`'s format.
pub fn new(list: &str) -> Self {
let mut f = Filter {
entries: Vec::new(),
by_first: Default::default(),
allowed: Vec::new(),
};
for line in list.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(word) = line.strip_prefix('!') {
let (word, prefix) = match word.strip_suffix('*') {
Some(w) => (w, true),
None => (word, false),
};
f.allowed.push((normalise::word(word), prefix));
continue;
}
let (kind, word) = match line.as_bytes()[0] {
b'=' => (Kind::Whole, &line[1..]),
b'~' => (Kind::Spread, &line[1..]),
_ => (Kind::Embedded, line),
};
let runs = normalise::word(word);
let Some(first) = runs.first() else {
continue;
};
f.by_first[usize::from(first.letter)].push(f.entries.len() as u16);
f.entries.push(Entry { runs, kind });
}
f
}
/// The built-in list.
pub fn default_list() -> Self {
Self::new(DEFAULT_WORDS)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Whether `text` holds a slur of the list.
pub fn matches(&self, text: &str) -> bool {
let t = Text::new(text);
let runs = &t.runs;
for &(start, end) in t.words.iter().chain(&t.spaced) {
let word = &runs[start..end];
if self.is_allowed(word) {
continue;
}
for at in 0..word.len() {
for &e in &self.by_first[usize::from(word[at].letter)] {
let e = &self.entries[usize::from(e)];
let whole = e.kind == Kind::Whole;
if (!whole || at == 0) && fits(&e.runs, &word[at..], whole) {
return true;
}
}
}
}
// Spread entries over the whole text, from the start of a word, across at least two.
for (w, &at) in t.joined_at.iter().enumerate() {
for &e in &self.by_first[usize::from(t.joined[at].letter)] {
let e = &self.entries[usize::from(e)];
if e.kind == Kind::Spread
&& fits(&e.runs, &t.joined[at..], false)
&& t.spans_words(w, e.runs.len())
{
return true;
}
}
}
false
}
fn is_allowed(&self, word: &[Run]) -> bool {
self.allowed.iter().any(|(a, prefix)| {
if *prefix {
// A prefix: its last run may be part of a longer one.
word.len() >= a.len() && fits(a, word, false)
} else {
fits(a, word, true)
}
})
}
}
/// Whether `entry`'s runs start `runs` (are all of it with `whole`): the same letters, each at least
/// as many times as the entry has it.
fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
if runs.len() < entry.len() || (whole && runs.len() != entry.len()) {
return false;
}
entry
.iter()
.zip(runs)
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
}
/// What a hit leads to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// The text is refused, nothing more.
Refused,
/// Refused, and the player can't chat until then (epoch milliseconds).
Muted { until: i64 },
/// Refused, and the player is banned until then.
Banned { until: i64 },
}
impl Action {
pub fn name(self) -> &'static str {
match self {
Action::Refused => "refused",
Action::Muted { .. } => "muted",
Action::Banned { .. } => "banned",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
}
#[derive(Default, Serialize, Deserialize)]
struct MutesFile {
mutes: Vec<Mute>,
}
/// One player's hits: how many, and when the last one stops counting.
#[derive(Clone, Copy, Debug)]
struct Strikes {
count: u32,
counts_until: i64,
}
/// The filter, the players' hits and the mutes.
pub struct Guard {
pub config: Config,
pub filter: Filter,
strikes: HashMap<u128, Strikes>,
mutes: HashMap<u128, Mute>,
/// Where the mutes are kept; `None` keeps them in memory only (tests).
file: Option<PathBuf>,
}
const MINUTE: i64 = 60_000;
impl Guard {
pub fn new(config: Config, filter: Filter, file: Option<PathBuf>) -> Self {
let mut mutes = HashMap::new();
if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok())
&& let Ok(saved) = serde_json::from_str::<MutesFile>(&text)
{
for m in saved.mutes {
if let Some(uuid) = parse_uuid(&m.uuid) {
mutes.insert(uuid, m);
}
}
}
Self {
config,
filter,
strikes: HashMap::new(),
mutes,
file,
}
}
/// The server's guard: `words_file` or the built-in list, the mutes in `dir`. An unreadable
/// `words_file` falls back to the built-in list, with why.
pub fn load(config: Config, dir: &Path) -> (Self, Option<String>) {
let mut problem = None;
let filter = if config.words_file.is_empty() {
Filter::default_list()
} else {
match std::fs::read_to_string(dir.join(&config.words_file)) {
Ok(text) => Filter::new(&text),
Err(e) => {
problem = Some(format!(
"chat_filter.words_file {}: {e}; using the built-in list",
config.words_file
));
Filter::default_list()
}
}
};
(
Self::new(config, filter, Some(dir.join(MUTES_FILE))),
problem,
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
pub fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
let c = &self.config;
let window = i64::from(c.window_minutes) * MINUTE;
let s = self.strikes.entry(uuid).or_insert(Strikes {
count: 0,
counts_until: 0,
});
if now > s.counts_until {
s.count = 0;
}
s.count += 1;
s.counts_until = now + window;
let count = s.count;
let action = if c.tempban_after > 0 && count >= c.tempban_after {
self.strikes.remove(&uuid);
Action::Banned {
until: now + i64::from(c.tempban_hours) * 60 * MINUTE,
}
} else if c.mute_after > 0 && count >= c.mute_after {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
Action::Muted { until }
} else {
Action::Refused
};
(count, action)
}
/// Lifts a player's mute (and forgets their hits), by name; the name as muted, if they were.
pub fn unmute(&mut self, name: &str) -> Option<String> {
let uuid = *self
.mutes
.iter()
.find(|(_, m)| m.name.eq_ignore_ascii_case(name))?
.0;
let mute = self.mutes.remove(&uuid)?;
self.strikes.remove(&uuid);
self.save();
Some(mute.name)
}
fn save(&mut self) {
let Some(file) = &self.file else {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
let tmp = file.with_extension("json.tmp");
if std::fs::write(&tmp, text).is_ok() {
let _ = std::fs::rename(&tmp, file);
}
}
}
}
/// One line of `logs/chat-filter.log`, without its newline.
pub fn log_line(
now: i64,
name: &str,
uuid: u128,
place: &str,
count: u32,
action: Action,
text: &str,
) -> String {
let text: String = text
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.collect();
format!(
"{} {name} {} {place} hit {count} {}: {text}",
utc(now),
uuid_string(uuid),
action.name()
)
}
/// Appends a line to the log at `path` (its folder made if needed).
pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
use std::io::Write;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(f, "{line}")
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
rest / 3600,
rest / 60 % 60,
rest % 60
)
}
fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
fn parse_uuid(s: &str) -> Option<u128> {
u128::from_str_radix(&s.replace('-', ""), 16).ok()
}
#[cfg(test)]
mod tests;
-255
View File
@@ -1,255 +0,0 @@
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
const OTHER: u8 = 26;
/// One letter and how many times it repeats (`gg` is `g` twice).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Run {
/// 0..=25 for `a`..`z`, 26 for any other letter.
pub letter: u8,
pub count: u8,
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
pub weak: bool,
}
/// A whole message, normalised.
#[derive(Debug, Default)]
pub struct Text {
pub runs: Vec<Run>,
/// Each word's runs, `runs[start..end]`.
pub words: Vec<(usize, usize)>,
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
/// without the punctuation they end with (`fag!`).
pub spaced: Vec<(usize, usize)>,
/// Every word one after the other, a letter that ends one and starts the next merged.
pub joined: Vec<Run>,
/// Where each word starts in `joined`.
pub joined_at: Vec<usize>,
/// The last word each run of `joined` belongs to.
joined_last: Vec<usize>,
}
enum Fold {
Letter(u8, bool),
Separator,
Space,
Skip,
}
impl Text {
pub fn new(text: &str) -> Self {
let mut t = Text::default();
let mut start = 0;
for c in text.chars() {
match fold(c) {
Fold::Letter(letter, weak) => {
let in_word = t.runs.len() > start;
match t.runs.last_mut() {
Some(last) if in_word && last.letter == letter => {
last.count = last.count.saturating_add(1);
last.weak &= weak;
}
_ => t.runs.push(Run {
letter,
count: 1,
weak,
}),
}
}
Fold::Space => {
t.end_word(start);
start = t.runs.len();
}
Fold::Separator | Fold::Skip => {}
}
}
t.end_word(start);
t.add_spaced();
t.join();
t
}
fn end_word(&mut self, start: usize) {
if self.runs.len() > start {
self.words.push((start, self.runs.len()));
}
}
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
/// copied after the real words').
fn add_spaced(&mut self) {
let words = self.words.len();
let mut w = 0;
while w < words {
let mut end = w;
while end < words && self.words[end].1 - self.words[end].0 == 1 {
end += 1;
}
if end - w >= 2 {
let first = self.runs.len();
for k in w..end {
let run = self.runs[self.words[k].0];
let in_word = self.runs.len() > first;
match self.runs.last_mut() {
Some(last) if in_word && last.letter == run.letter => {
last.count = last.count.saturating_add(run.count);
}
_ => self.runs.push(run),
}
}
self.spaced.push((first, self.runs.len()));
w = end;
} else {
w += 1;
}
}
for k in 0..words {
let (start, mut end) = self.words[k];
while end > start + 1 && self.runs[end - 1].weak {
end -= 1;
}
if end < self.words[k].1 {
self.spaced.push((start, end));
}
}
}
fn join(&mut self) {
for (w, &(start, end)) in self.words.iter().enumerate() {
let mut first = true;
for run in &self.runs[start..end] {
match self.joined.last_mut() {
Some(last) if first && last.letter == run.letter => {
last.count = last.count.saturating_add(run.count);
self.joined_at.push(self.joined.len() - 1);
*self.joined_last.last_mut().expect("beside joined") = w;
}
_ => {
if first {
self.joined_at.push(self.joined.len());
}
self.joined.push(*run);
self.joined_last.push(w);
}
}
first = false;
}
}
}
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
pub fn spans_words(&self, w: usize, len: usize) -> bool {
self.joined_last
.get(self.joined_at[w] + len - 1)
.is_some_and(|&last| last > w)
}
}
/// An entry of the list, normalised the same way (one word, whitespace ignored).
pub fn word(text: &str) -> Vec<Run> {
let t = Text::new(&text.replace(char::is_whitespace, ""));
t.words
.first()
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
}
fn letter(c: char) -> Fold {
Fold::Letter(c as u8 - b'a', false)
}
fn fold(c: char) -> Fold {
if c.is_ascii_alphabetic() {
return letter(c.to_ascii_lowercase());
}
if c.is_ascii() {
return match c {
'0' => letter('o'),
'1' => letter('i'),
'3' => letter('e'),
'4' => letter('a'),
'5' => letter('s'),
'6' | '9' => letter('g'),
'7' => letter('t'),
'8' => letter('b'),
'!' | '|' => Fold::Letter(b'i' - b'a', true),
'@' => Fold::Letter(0, true),
'$' => Fold::Letter(b's' - b'a', true),
c if c.is_ascii_whitespace() => Fold::Space,
_ => Fold::Separator,
};
}
let u = c as u32;
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
let mapped = match u {
// Zero-width characters, soft hyphen, combining marks, variation selectors.
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
0x300..=0x36F
| 0x1AB0..=0x1AFF
| 0x1DC0..=0x1DFF
| 0x20D0..=0x20FF
| 0xFE00..=0xFE0F
| 0xFE20..=0xFE2F
| 0xE0100..=0xE01EF => return Fold::Skip,
// Full-width Latin and digits.
0xFF21..=0xFF3A => from(0xFF21, 26),
0xFF41..=0xFF5A => from(0xFF41, 26),
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
// Mathematical alphanumerics: 52 letters a style, then digits.
0x1D400..=0x1D6A3 => from(0x1D400, 26),
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
// Circled, parenthesised, squared and negative letters, regional indicators.
0x24B6..=0x24CF => from(0x24B6, 26),
0x24D0..=0x24E9 => from(0x24D0, 26),
0x249C..=0x24B5 => from(0x249C, 26),
0x1F130..=0x1F149 => from(0x1F130, 26),
0x1F150..=0x1F169 => from(0x1F150, 26),
0x1F170..=0x1F189 => from(0x1F170, 26),
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
_ => {
if c.is_whitespace() {
return Fold::Space;
}
let lower = c.to_lowercase().next().unwrap_or(c);
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
Some((base, _)) => *base,
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
None => return Fold::Separator,
}
}
};
letter(mapped)
}
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
const LOOK_ALIKES: &[(char, &str)] = &[
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
('b', "ƀɓḃḅḇвьъβʙᵇ"),
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
('f', "ƒḟꜰᶠ"),
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
('j', "ĵǰјᴊʲ"),
('k', "ķĸǩḱḳḵкκᴋᵏ"),
('l', "ĺļľŀłḷḹḻḽʟˡ"),
('m', "мᴍᵐ"),
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
('p', "ṕṗрρᴘᵖ"),
('q', "ԛ"),
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
('v', "νᴠᵛ"),
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
('x', "хχˣ"),
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
('z', "źżžƶẑẓẕᴢᶻ"),
];
-253
View File
@@ -1,253 +0,0 @@
// The test texts spell the slurs the filter must refuse: that is what they test.
use super::*;
fn filter() -> Filter {
Filter::default_list()
}
#[test]
fn the_list_compiles() {
let f = filter();
assert!(f.len() > 30, "{} entries", f.len());
assert_eq!(f.allowed.len(), 2);
}
#[test]
fn normalising_catches_the_usual_disguises() {
let f = filter();
for text in [
"nigger",
"NIGGER",
"shut up nigga",
"Niggers",
"n1gg3r",
"ni99a",
"N!GGA",
"nigg@",
"n.i.g.g.e.r",
"n_i_g_g_a",
"n i g g e r",
"n i gg a",
"nig ger",
"nig gaaa",
"niiiiggggggerrrrr",
"ñíggér",
"nigger",
"Niggeг", // Cyrillic ghe for r
"nіgger", // Cyrillic i
"nigg\u{0435}r", // Cyrillic e
"n\u{200B}igger", // a zero-width space
"n\u{0301}igger", // a combining accent
"𝐧𝐢𝐠𝐠𝐞𝐫",
"ⓝⓘⓖⓖⓐ",
"ɴɪɢɢᴀ",
"hi nig",
"f a g",
"fag!",
"you $pic",
"FAGGOTINO-CHAN",
"sandnigger",
"lol kike",
] {
assert!(f.matches(text), "{text:?} should be refused");
}
}
#[test]
fn ordinary_words_pass() {
let f = filter();
for text in [
"hello",
"Niger",
"I live in Niger",
"Nigeria and the Niger river",
"nigerian food",
"proud2benigerian",
"don't snigger",
"sniggering at that",
"a niggardly offer",
"knight",
"qKnights",
"its still night",
"anyone able to boot the latest nightly version?",
"i hate nighbours",
"can someune give mi night vision",
"suspicious stew",
"spicy food",
"a raccoon in a cocoon",
"Scunthorpe",
"bigger trigger digger",
"gg",
"gg ez",
"mini gg all",
"kniga",
"shenanigans",
"dignity and signing",
"Pakistan",
"chinking glasses",
"the trannys? no: transmission",
"1nicerOli",
"ask me evernig",
"冰淇淋",
"привет",
"",
"!!!",
] {
assert!(!f.matches(text), "{text:?} should pass");
}
}
#[test]
fn a_custom_list_has_the_same_syntax() {
let f = Filter::new("# comment\n=bad\n~worse\nmeh\n!mehish*\n");
assert!(f.matches("so bad"));
assert!(!f.matches("badly"));
assert!(f.matches("wor se"));
assert!(f.matches("xxmehxx"));
assert!(!f.matches("mehishness"));
assert!(!f.matches("me h"));
}
fn guard(config: Config) -> Guard {
Guard::new(config, filter(), None)
}
const NOW: i64 = 1_793_469_939_000;
const MIN: i64 = 60_000;
#[test]
fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
(
3,
Action::Banned {
until: until + 8 * MIN + 24 * 60 * MIN
}
)
);
// Then it starts again.
assert_eq!(g.strike(id, "Ann", until + 9 * MIN), (1, Action::Refused));
}
#[test]
fn hits_far_apart_dont_add_up() {
let mut g = guard(Config::default());
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
assert_eq!(g.strike(1, "Bo", NOW + 11 * MIN), (1, Action::Refused));
// Players are counted apart.
assert_eq!(g.strike(2, "Cy", NOW + 12 * MIN), (1, Action::Refused));
}
#[test]
fn thresholds_are_configurable() {
let mut g = guard(Config {
mute_after: 0,
tempban_after: 2,
tempban_hours: 1,
..Config::default()
});
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
assert_eq!(
g.strike(1, "Bo", NOW + MIN),
(
2,
Action::Banned {
until: NOW + MIN + 60 * MIN
}
)
);
let mut off = guard(Config {
mute_after: 0,
tempban_after: 0,
..Config::default()
});
for k in 1..5 {
assert_eq!(off.strike(1, "Bo", NOW + k), (k as u32, Action::Refused));
}
}
#[test]
fn mutes_survive_a_restart_and_unmute_lifts_them() {
let dir = std::env::temp_dir().join(format!("chat-filter-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let now = now_millis();
let (mut g, problem) = Guard::load(Config::default(), &dir);
assert_eq!(problem, None);
g.strike(5, "Dee", now);
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
words_file: "nope.txt".into(),
..Config::default()
};
let (g, problem) = Guard::load(config, Path::new("/nonexistent-horizon"));
assert!(problem.unwrap().contains("nope.txt"));
assert!(g.filter.matches("nigger"));
}
#[test]
fn the_section_reads_from_server_toml() {
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
assert_eq!(c, Config::default());
assert!(c.enabled);
let c = Config::from_server_toml(
"[chat_filter]\nenabled = true\nmute_after = 3\ntempban_after = 5\nwords_file = \"slurs.txt\"\n",
)
.unwrap();
assert_eq!((c.mute_after, c.tempban_after), (3, 5));
assert_eq!(c.words_file, "slurs.txt");
assert!(
!Config::from_server_toml("[chat_filter]\nenabled = false\n")
.unwrap()
.enabled
);
assert!(Config::from_server_toml("[chat_filter]\nmute = 2\n").is_err());
assert!(Config::from_server_toml("[chat_filter]\nwindow_minutes = 0\n").is_err());
}
#[test]
fn log_lines_have_the_time_the_player_and_the_text() {
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(utc(951_782_400_000), "2000-02-29T00:00:00Z");
assert_eq!(
log_line(NOW, "Ann", 1, "chat", 2, Action::Muted { until: 0 }, "a\nb"),
"2026-10-31T18:05:39Z Ann 00000000-0000-0000-0000-000000000001 chat hit 2 muted: a b"
);
}
#[test]
fn a_message_costs_little() {
// A long message through the whole list stays far below a tick (debug builds included).
let f = filter();
let text = "the quick brown fox jumps over the lazy dog ".repeat(6);
let start = std::time::Instant::now();
for _ in 0..1000 {
assert!(!f.matches(&text));
}
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
}
-5
View File
@@ -69,9 +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/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
@@ -312,8 +309,6 @@ mod tests {
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
let tr = Language::load(&[&empty], "tr_tr");
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
let es_ve = Language::load(&[&empty], "es_ve");
+1 -34
View File
@@ -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::*;
-1
View File
@@ -10,6 +10,5 @@ pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod window;
@@ -1,232 +0,0 @@
//! OS modifier reconciliation and modifiers synthesized by agent control.
use crate::{
keybinds::{KeyBinds, key_code},
keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys},
};
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
const MODIFIERS: [(ModifiersState, [KeyCode; 2]); 4] = [
(
ModifiersState::SHIFT,
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
),
(
ModifiersState::CONTROL,
[KeyCode::ControlLeft, KeyCode::ControlRight],
),
(ModifiersState::ALT, [KeyCode::AltLeft, KeyCode::AltRight]),
(
ModifiersState::SUPER,
[KeyCode::SuperLeft, KeyCode::SuperRight],
),
];
/// A synthesized key updates the same modifier word as window events.
/// Releasing one side must preserve the other side's hold.
pub fn set_key(
modifiers: &mut ModifiersState,
physical: &mut HashSet<KeyCode>,
key: KeyCode,
down: bool,
) {
let Some((bit, pair)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&key)) else {
return;
};
if down {
physical.insert(key);
} else {
physical.remove(&key);
}
modifiers.set(*bit, pair.iter().any(|key| physical.contains(key)));
}
/// A modifier held for one synthesized click (the pad's Y, an agent's
/// shift-click): what [`restore`] puts back once the click is done.
#[derive(Clone, Copy, Debug)]
pub struct Held {
key: KeyCode,
was_down: bool,
bit: bool,
}
/// Holds `key` in both the physical keys and the modifier word, which is
/// what screens read (`InputWithModifiers.hasShiftDown`).
pub fn hold(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, key: KeyCode) -> Held {
let bit = MODIFIERS
.iter()
.find(|(_, pair)| pair.contains(&key))
.is_some_and(|(bit, _)| modifiers.contains(*bit));
let held = Held {
key,
was_down: physical.contains(&key),
bit,
};
set_key(modifiers, physical, key, true);
held
}
/// Undoes [`hold`]: a key or bit that was already down stays down.
pub fn restore(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, held: Held) {
if !held.was_down {
physical.remove(&held.key);
}
if let Some((bit, _)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&held.key)) {
modifiers.set(*bit, held.bit);
}
}
/// Recover missing modifier releases without releasing a rebound key or a pad.
/// ToggleKeyMapping itself preserves keyboard and controller toggles on release.
pub fn reconcile(
modifiers: ModifiersState,
physical: &mut HashSet<KeyCode>,
bindings: &KeyBinds,
toggles: &mut ToggleKeys,
options: [bool; 2],
pad_held: [bool; 2],
) {
for (bit, pair) in MODIFIERS {
if modifiers.contains(bit) {
continue;
}
for key in pair {
if !physical.remove(&key) {
continue;
}
for (slot, name) in [(SNEAK, "key.sneak"), (SPRINT, "key.sprint")] {
if key_code(bindings.get(name)) == Some(key) && !pad_held[slot] {
toggles.key(slot, KeyAction::Release, false, options[slot]);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modifier_taps_release_both_bits() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, pair) in MODIFIERS {
for key in pair {
set_key(&mut modifiers, &mut physical, key, true);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, key, false);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
}
}
}
#[test]
fn releasing_one_side_keeps_the_other_modifier_held() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
for (bit, [left, right]) in MODIFIERS {
set_key(&mut modifiers, &mut physical, left, true);
set_key(&mut modifiers, &mut physical, right, true);
set_key(&mut modifiers, &mut physical, left, false);
assert!(modifiers.contains(bit));
set_key(&mut modifiers, &mut physical, right, false);
assert!(modifiers.is_empty());
}
}
#[test]
fn a_held_click_restores_the_previous_state() {
let mut modifiers = ModifiersState::empty();
let mut physical = HashSet::new();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
assert!(modifiers.shift_key());
restore(&mut modifiers, &mut physical, held);
assert!(modifiers.is_empty());
assert!(physical.is_empty());
// Right Shift already held, or the OS word set with no physical key.
for start in [HashSet::from([KeyCode::ShiftRight]), HashSet::new()] {
let mut modifiers = ModifiersState::SHIFT;
let mut physical = start.clone();
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
restore(&mut modifiers, &mut physical, held);
assert_eq!(modifiers, ModifiersState::SHIFT);
assert_eq!(physical, start);
}
}
#[test]
fn lost_releases_respect_rebindings_and_pad_holds() {
for slot in [SNEAK, SPRINT] {
for rebound in [false, true] {
for pad in [false, true] {
for toggle in [false, true] {
let mut bindings = KeyBinds::default();
let (name, modifier) = if slot == SNEAK {
("key.sneak", KeyCode::ShiftLeft)
} else {
("key.sprint", KeyCode::ControlLeft)
};
if rebound {
bindings.set(name, "key.keyboard.c");
}
let mut physical = HashSet::from([modifier]);
if rebound {
physical.insert(KeyCode::KeyC);
}
let mut toggles = ToggleKeys::default();
toggles.key(slot, KeyAction::Press, false, toggle);
let mut options = [false; 2];
options[slot] = toggle;
let mut pad_held = [false; 2];
pad_held[slot] = pad;
reconcile(
ModifiersState::ALT,
&mut physical,
&bindings,
&mut toggles,
options,
pad_held,
);
assert_eq!(toggles.is_down(slot), rebound || pad || toggle);
assert!(!physical.contains(&modifier));
assert_eq!(physical.contains(&KeyCode::KeyC), rebound);
}
}
}
}
}
#[test]
fn unrelated_modifier_change_does_not_release_an_action() {
let mut toggles = ToggleKeys::default();
toggles.key(SNEAK, KeyAction::Press, false, false);
reconcile(
ModifiersState::ALT,
&mut HashSet::new(),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
#[test]
fn lost_modifier_release_preserves_a_controller_toggle() {
let mut toggles = ToggleKeys::default();
toggles.pad_toggle(SNEAK, false);
reconcile(
ModifiersState::empty(),
&mut HashSet::from([KeyCode::ShiftLeft]),
&KeyBinds::default(),
&mut toggles,
[false; 2],
[false; 2],
);
assert!(toggles.is_down(SNEAK));
}
}
-55
View File
@@ -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(
@@ -729,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);
}
}
-4
View File
@@ -15,8 +15,6 @@ horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
@@ -35,14 +33,12 @@ 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"] }
@@ -235,9 +235,6 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
crate::native_emissive::load(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -1336,13 +1336,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 {
-284
View File
@@ -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"}}}}});
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::chunk::block_entity::{BlockEntity, ModContainer};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -73,9 +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>>,
}
impl std::ops::Deref for BlockEntities {
@@ -165,13 +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)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
+1 -2
View File
@@ -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 {
@@ -434,10 +434,6 @@ pub struct Camera {
pub up: [f32; 3],
pub left: [f32; 3],
pub detached: bool,
/// The entity's eye and view rotation (yaw, pitch), where a detached camera backs off
/// from.
pub eye: [f64; 3],
pub eye_rotation: [f32; 2],
}
impl Default for Camera {
fn default() -> Self {
@@ -454,8 +450,6 @@ impl Default for Camera {
up: [0.0, 1.0, 0.0],
left: [-1.0, 0.0, 0.0],
detached: false,
eye: [0.0; 3],
eye_rotation: [0.0; 2],
}
}
}
@@ -511,8 +505,6 @@ impl Camera {
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
lerp_d(p, entity.previous[2], entity.position[2]),
];
self.eye = self.position;
self.eye_rotation = [self.y_rot, self.x_rot];
self.detached = !camera_type.is_first_person();
if self.detached {
if camera_type.is_mirrored() {
+4 -97
View File
@@ -878,22 +878,6 @@ impl Chat {
}
}
/// `SuggestionsList.getNarrationMessage` for entry `index` of `count`.
fn suggestion_narration(index: usize, count: usize, text: &str, tooltip: Option<&str>) -> Text {
let mut args = vec![
Text::literal((index + 1).to_string()),
Text::literal(count.to_string()),
Text::literal(text),
];
match tooltip {
Some(tooltip) => {
args.push(Text::literal(tooltip));
Text::translate("narration.suggestion.tooltip", &args)
}
None => Text::translate("narration.suggestion", &args),
}
}
/// `CommandSuggestions.SuggestionsList`.
#[derive(Clone, Debug)]
struct SuggestionList {
@@ -905,17 +889,8 @@ struct SuggestionList {
current: usize,
last_mouse: (i32, i32),
tab_cycles: bool,
/// `lastNarratedEntry`: the entry the narrator last said (-1: none yet).
last_narrated: isize,
}
impl SuggestionList {
/// `getNarrationMessage`: "Selected suggestion 2 out of 5: …", with its
/// tooltip when it has one. It marks the entry said.
fn narration_message(&mut self) -> Text {
self.last_narrated = self.current as isize;
let s = &self.list[self.current];
suggestion_narration(self.current, self.list.len(), &s.text, s.tooltip.as_deref())
}
fn contains(&self, x: i32, y: i32) -> bool {
let [rx, ry, w, h] = self.rect;
x >= rx && x <= rx + w && y >= ry && y <= ry + h
@@ -1038,53 +1013,6 @@ fn last_word_index(s: &str) -> usize {
}
if prev_space { s.len() } else { out }
}
/// `ChatScreen.normalizeChatMessage`: `String.trim`, then
/// `StringUtils.normalizeSpace` (one space for every run of whitespace), then
/// `StringUtil.trimChatMessage` (the first 256 UTF-16 code units, the length
/// `ServerboundChatPacket`'s `stringUtf8(256)` decodes). The chat screen sends
/// this, not what was typed: a line of spaces sends nothing, and a message the
/// packet cannot hold is cut on the client, as vanilla's is, instead of by the
/// server.
pub fn normalize_chat_message(message: &str) -> String {
// String.trim: the code points up to U+0020, which is not `str::trim`'s
// Unicode whitespace (U+00A0 is not Java whitespace and stays).
let trimmed = message.trim_matches(|c: char| c <= ' ');
truncate_utf16(&normalize_space(trimmed), 256)
}
/// `StringUtils.normalizeSpace`: one space for every run of
/// `Character.isWhitespace`, and none at either end.
fn normalize_space(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
if crate::ui::java_whitespace(c) {
space = !out.is_empty();
} else {
if space {
out.push(' ');
space = false;
}
out.push(c);
}
}
out
}
/// `StringUtil.truncateStringIfNecessary(s, 256, false)`: Java counts UTF-16
/// code units (`String.length`), so 129 emoji are cut to 128 of them, where
/// `chars().take(256)` would keep all 129 (258 units) for the server to
/// reject. A cut between the halves of a surrogate pair keeps the character
/// below it: vanilla's `substring` leaves half a pair, which a Java `String`
/// may hold and a Rust `String` may not.
fn truncate_utf16(s: &str, max_units: usize) -> String {
let mut units = 0;
for (byte, c) in s.char_indices() {
units += c.len_utf16();
if units > max_units {
return s[..byte].to_owned();
}
}
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
pub fn new(
@@ -1174,14 +1102,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`.
@@ -1405,12 +1331,11 @@ impl ChatScreen {
};
self.suggestions = None;
if self.allow_suggestions && self.auto_suggestions {
self.show_suggestions(m, false);
self.show_suggestions(m);
}
}
/// `CommandSuggestions.showSuggestions`: `immediate` (Tab asked for
/// them) has the narrator say the first one at once.
fn show_suggestions(&mut self, m: &Measure, immediate: bool) {
/// `CommandSuggestions.showSuggestions`.
fn show_suggestions(&mut self, m: &Measure) {
let Some((start, list)) = self.pending.clone() else {
return;
};
@@ -1453,7 +1378,6 @@ impl ChatScreen {
current: 0,
last_mouse: (0, 0),
tab_cycles: false,
last_narrated: if immediate { -1 } else { 0 },
};
self.select(&mut l, 0);
self.suggestions = Some(l);
@@ -1473,9 +1397,6 @@ impl ChatScreen {
self.input.suggestion = applied
.strip_prefix(self.input.value.as_str())
.map(str::to_owned);
if l.last_narrated != l.current as isize {
horizon_narrator::say_system_now(&l.narration_message().plain());
}
}
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
self.select(l, l.current as isize + dir);
@@ -1586,7 +1507,7 @@ impl ChatScreen {
self.suggestions = Some(l);
handled
} else if key == K::Tab {
self.show_suggestions(m, true);
self.show_suggestions(m);
true
} else {
false
@@ -1819,18 +1740,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",
})
}
}
@@ -578,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));
}
+1 -5
View File
@@ -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(),
@@ -120,21 +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 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
@@ -291,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 {
+11 -41
View File
@@ -169,13 +169,11 @@ impl Mesh {
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
let mut quad = |p: [[f32; 3]; 4], shade: u32| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
normal: [0; 4],
pos: v,
uv,
layer: 4,
@@ -188,41 +186,17 @@ impl Mesh {
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
quad(p, 0);
}
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
([[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]], 1),
([[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]], 0),
([[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]], 2),
([[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]], 2),
([[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]], 3),
([[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]], 3),
];
let mask = face_mask(
dx as i32,
@@ -236,12 +210,8 @@ impl Mesh {
],
);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
let (p, shade) = quads[usize::from(direction)];
quad(if inside { [p[3], p[2], p[1], p[0]] } else { p }, shade);
}
}
self.key = Some(key);
+4 -158
View File
@@ -343,15 +343,8 @@ impl EntityMovementWorld for SceneCollisions<'_, '_> {
}
impl DynamicSupportWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
/// `ShulkerBoxBlockEntity.isClosed`: whether the lid is still shut. A
/// closed box collides as its plain block (`ShulkerBoxBlock.getShape`
/// asks the block entity, which has grown once the lid starts to open).
fn shulker_is_closed(&mut self, p: [i32; 3]) -> io::Result<Option<bool>> {
Ok(self
.world
.shulkers
.get(&p)
.map(|s| s.status == crate::block_entity_anim::ShulkerStatus::Closed))
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
/// `MovingPistonBlock.getCollisionShape`: the block entity's moving shape
/// (`PistonMovingBlockEntity.getCollisionShape`), outside any push.
@@ -380,17 +373,11 @@ impl PistonCollisionWorld for SceneCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for SceneCollisions<'_, '_> {
/// `ShulkerBoxBlockEntity.getBoundingBox`: the box's collision shape
/// (`getShape`) is the free shape grown by the lid's progress, which is
/// what lets an entity sink into an opening box. None: no such box here.
fn shulker_collision_state(
&mut self,
p: [i32; 3],
_: [i32; 3],
) -> io::Result<Option<ShulkerCollisionState>> {
Ok(self.world.shulkers.get(&p).map(|s| ShulkerCollisionState {
previous_progress: s.progress_old,
progress: s.progress,
}))
Ok(None)
}
}
impl LiquidCollisionWorld for SceneCollisions<'_, '_> {
@@ -510,8 +497,6 @@ impl EntityMovementWorld for LookupCollisions<'_, '_> {
}
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
/// No block-entity data: a lookup world has no shulker box to ask, so the
/// box collides as a shut one (`VoxelShape::block()`).
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
@@ -520,8 +505,6 @@ impl DynamicSupportWorld for LookupCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
/// No block-entity data: the block table's own shape stands in for the lid
/// (`ShulkerBoxBlockEntity.getBoundingBox` needs a block entity).
fn shulker_collision_state(
&mut self,
_: [i32; 3],
@@ -628,140 +611,3 @@ mod border_movement_tests {
);
}
}
#[cfg(test)]
mod shulker_collision_tests {
use super::*;
use crate::block_entity_anim::{Shulker, ShulkerStatus, progress_delta_aabb};
use horizon_core::geometry::{Aabb, Axis, Direction, Vec3, VoxelShape};
/// A shulker box facing up at progress 0.0 (closed): collision is a 1x1x1 block.
#[test]
fn shulker_closed_collision_is_full_block() {
let shulker = Shulker {
status: ShulkerStatus::Closed,
progress: 0.0,
progress_old: 0.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Closed box: height 1.0
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.0).abs() < 1.0e-9);
// Full horizontal extent
assert!((aabb.min(Axis::X) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::X) - 1.0).abs() < 1.0e-9);
}
/// A shulker box facing up at progress 1.0 (fully open): collision is 1.5 tall.
#[test]
fn shulker_open_collision_grows_upward() {
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Fully open: height 1.5 (lid extends 0.5 above the block)
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.5).abs() < 1.0e-9);
}
/// A shulker box halfway open: collision height is 1.25.
#[test]
fn shulker_half_open_collision_height() {
let shulker = Shulker {
status: ShulkerStatus::Opening,
progress: 0.5,
progress_old: 0.5,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
// Half open: height 1.25
assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
assert!((aabb.max(Axis::Y) - 1.25).abs() < 1.0e-9);
}
/// Vanilla clip quirk: an entity whose feet are *inside* the grown lid box
/// moving DOWN is not stopped by the box (collision only triggers when
/// approaching from outside). An entity ABOVE the box moving DOWN is stopped.
#[test]
fn shulker_grown_box_allows_clip_when_inside() {
// Box at progress 1.0: spans y=0..1.5
let shulker = Shulker {
status: ShulkerStatus::Opened,
progress: 1.0,
progress_old: 1.0,
..Shulker::default()
};
let state = ShulkerCollisionState {
previous_progress: shulker.progress_old,
progress: shulker.progress,
};
let aabb = state.bounding_box(Direction::Up);
let box_shape = VoxelShape::from_box(aabb).expect("shulker box is a valid shape");
// Entity box whose feet are at y=0.5 (inside the grown box), height 1.8
let entity = Aabb::new(Vec3::new(0.2, 0.5, 0.2), Vec3::new(0.8, 2.3, 0.8));
// Moving DOWN from inside the box: collide() should NOT stop it
// (the entity is already intersecting the box, so the downward movement
// sees no face to hit from above).
let down = -0.1;
let moved_down = box_shape.collide(Axis::Y, entity, down);
assert!(
(moved_down - down).abs() < 1.0e-9,
"entity inside grown box should fall through (clip): moved_down={moved_down}, expected={down}"
);
// Entity box whose feet are at y=1.6 (just ABOVE the grown box)
let entity_above = Aabb::new(Vec3::new(0.2, 1.6, 0.2), Vec3::new(0.8, 3.4, 0.8));
// Moving DOWN from above: collide() SHOULD stop it at y=1.5
let moved_down_above = box_shape.collide(Axis::Y, entity_above, -0.2);
// The box's top is at y=1.5, entity bottom at 1.6, so max down is -0.1
assert!(
(moved_down_above - (-0.1)).abs() < 1.0e-9,
"entity above grown box should be stopped: moved_down_above={moved_down_above}, expected=-0.1"
);
}
/// An entity standing on the lid (y=1.0 when closed, y=1.5 when open)
/// and pushed up by the lid: the lid's swept AABB pushes it along the facing.
/// This is tested at the integration level via `push_shulker_entities`;
/// here we only verify the swept box geometry matches vanilla's
/// `Shulker.getProgressDeltaAabb`.
#[test]
fn shulker_swept_box_matches_vanilla_delta() {
// From closed (0.0) to progress 0.5: the lid sweeps from y=0..1 (closed box)
// to y=0..1.5 (half open), so the swept volume in world coords at position
// [0.5, 0.0, 0.5] (bottom center of block at y=0) is y=1.0..1.5.
let delta = progress_delta_aabb(1.0, Direction::Up as u8, 0.0, 0.5, [0.5, 0.0, 0.5]);
assert!((delta[1] - 1.0).abs() < 1.0e-9); // minY
assert!((delta[4] - 1.5).abs() < 1.0e-9); // maxY
// From progress 0.5 to 1.0: swept y=1.5..2.0 in world coords
let delta2 = progress_delta_aabb(1.0, Direction::Up as u8, 0.5, 1.0, [0.5, 0.0, 0.5]);
assert!((delta2[1] - 1.5).abs() < 1.0e-9);
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
+4 -35
View File
@@ -4,7 +4,7 @@
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -82,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
@@ -421,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(_)
@@ -454,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(_)
@@ -467,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,
@@ -488,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(_)
@@ -501,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,
@@ -645,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() {
+15 -180
View File
@@ -23,7 +23,7 @@ enum Source {
}
impl DataPack {
/// Every file path under `prefix` (relative to the pack root).
pub fn list(&self, prefix: &str) -> Vec<String> {
fn list(&self, prefix: &str) -> Vec<String> {
match &self.source {
Source::Zip(z) => z
.names()
@@ -58,13 +58,12 @@ impl DataPack {
}
out
}
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
Source::Dir(root) => std::fs::read(root.join(path)).ok(),
}
}
/// The `description` of `pack.mcmeta`, as text.
pub fn description(&self) -> String {
let Some(bytes) = self.read("pack.mcmeta") else {
@@ -76,24 +75,6 @@ impl DataPack {
other => other["text"].as_str().unwrap_or_default().to_owned(),
}
}
/// The `features.enabled` flag ids of `pack.mcmeta`
/// (`FeatureFlagsMetadataSection`): the flags the pack asks to turn on.
pub fn features(&self) -> Vec<String> {
self.mcmeta()["features"]["enabled"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| f.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
/// The `pack.mcmeta` as JSON, empty when it is missing or unreadable.
fn mcmeta(&self) -> serde_json::Value {
self.read("pack.mcmeta")
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -146,43 +127,14 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The resources root's built-in feature packs (`ServerPacksSource`
/// scanning the game's `data/minecraft/datapacks`): every folder with a
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
/// selects them.
pub fn feature_packs(resources: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(resources.join("data").join("minecraft").join("datapacks"))
else {
return Vec::new();
};
let mut packs = Vec::new();
for e in entries.flatten() {
let path = e.path();
if path.join("pack.mcmeta").exists() {
packs.push(DataPack {
id: e.file_name().to_string_lossy().into_owned(),
source: Source::Dir(path),
});
}
}
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
/// The packs of `<world>/datapacks` plus the resources root's feature packs:
/// the world's whole repository, as `PackRepository.reload` sees it.
pub fn discover_with_features(world_root: &Path, resources: &Path) -> Vec<DataPack> {
let mut packs = discover(world_root);
packs.extend(feature_packs(resources));
packs
}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`), the
/// built-in one (`resources` holds its `pack.png`), and the feature packs
/// of `resources` (`PackSource.FEATURE`), available at the bottom and off
/// until picked — the Experiments.
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
/// built-in one (`resources` holds its `pack.png`), which can be unselected
/// and stays at the bottom. Horizon has no experiments, so their feature
/// packs are not offered.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
@@ -220,23 +172,6 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
position: Position::Bottom,
fixed: false,
});
for p in feature_packs(resources) {
let meta = p.mcmeta();
let (description, description_style) =
crate::packs::component_root(&meta["pack"]["description"]);
infos.push(PackInfo {
icon: None,
id: p.id.clone(),
title: Text::translate(&format!("dataPack.{}.name", p.id), &[]),
description,
description_style,
source: Source::Feature,
compat: crate::packs::compatibility_for(&meta, format),
required: false,
position: Position::Bottom,
fixed: false,
});
}
infos
}
/// A new empty folder for Create World's packs
@@ -250,59 +185,6 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
/// does (a folder zipped by the system, which adds a wrapper). Copied into
/// `dir`; false when `path` is no pack.
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
fn copy_dir(from: &Path, at: &Path) {
let _ = std::fs::create_dir_all(at);
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
let path = entry.path();
let to = at.join(entry.file_name());
if path.is_dir() {
copy_dir(&path, &to);
} else {
let _ = std::fs::copy(&path, &to);
}
}
}
let name = || {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
};
if path.is_file() {
return path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
&& {
let _ = std::fs::create_dir_all(dir);
std::fs::copy(path, dir.join(name())).is_ok()
};
}
if !path.is_dir() {
return false;
}
if path.join("pack.mcmeta").is_file() {
copy_dir(path, &dir.join(name()));
return true;
}
let own: Vec<PathBuf> = std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
.collect();
match own.as_slice() {
[one] => {
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
true
}
_ => false,
}
}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
@@ -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())
@@ -583,20 +461,17 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
/// `recipe/`, `loot_table/`, `advancement/`, `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" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
"recipe" | "loot_table" | "advancement" | "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,
};
@@ -629,8 +504,6 @@ impl DataStore {
"predicate",
"recipe",
"tags",
"trade_set",
"villager_trade",
]) {
if !seen.insert(f.clone()) {
continue;
@@ -744,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());
@@ -781,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());
}
}
@@ -917,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);
+9 -39
View File
@@ -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
};
+2 -23
View File
@@ -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,10 +294,7 @@ fn shadow_color(in: Out) -> vec4<f32> {
// way terrain.wgsl marks a light source's, drawn again after the entities with
// only alpha written, so its colour stays as blended.
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
// Beams wrap their v in the sprite as fs_entity does: sample where they draw.
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.tier == 0u || textureSample(atlas, atlas_sampler, uv).a < 0.1 { discard; }
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
}
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,16 +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]>,
) {
if self.collect(tick, active) == 0 {
if self.collect(tick) == 0 {
return;
}
let (jobs, pipeline) = match &self.writer {
@@ -360,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;
@@ -859,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);
animator.animate(&mut encoder, &mut uploads, tick);
writes.push(
animator
.sprites
@@ -896,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]);
}
}
+10 -194
View File
@@ -76,22 +76,8 @@ struct Page {
/// Capacity in vertices.
capacity: u64,
free: FreeList,
/// The buffer went to system memory (`Arena::create`'s fallback): meshes
/// are never moved into it, and it is the first to empty.
host: bool,
}
impl Page {
/// Vertices in the page's free ranges.
fn free_vertices(&self) -> u64 {
self.free.0.values().sum()
}
}
/// An arena with this many bytes in free ranges, at least half of its pages,
/// has a page worth emptying (`Arena::sparsest`).
const COMPACT_MIN_FREE: u64 = 128 << 20;
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
pub struct Arena {
@@ -100,8 +86,6 @@ pub struct Arena {
page_limit: u64,
initial_capacity: u64,
usage: wgpu::BufferUsages,
/// A page being emptied (`Arena::close`): nothing is placed in it.
closing: Option<u32>,
/// A page had to go to system memory (logged once).
spilled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
@@ -145,7 +129,6 @@ impl Arena {
page_limit,
initial_capacity: capacity,
usage,
closing: None,
spilled: false,
#[cfg(test)]
video_memory_full: false,
@@ -155,7 +138,7 @@ impl Arena {
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -163,7 +146,6 @@ impl Arena {
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
@@ -184,7 +166,7 @@ impl Arena {
(lower the render distance if frames slow down)"
);
}
Some((buffer, true))
Some(buffer)
})
}
/// The rounded length a request of `vertices` occupies.
@@ -225,13 +207,16 @@ impl Arena {
vertices: u64,
) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
if let Some(found) = self.alloc_existing(len) {
return Some(found);
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some(page) = page
&& let Some(start) = page.free.take(len)
{
return Some((i as u32, start));
}
}
let last = self.pages.len().saturating_sub(1);
if let Some(page) = self.pages.last().and_then(Option::as_ref)
&& page.capacity < self.page_limit
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
@@ -250,7 +235,7 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let ((buffer, host), capacity) = match self.create(device, capacity) {
let (buffer, capacity) = match self.create(device, capacity) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None => return None,
@@ -261,7 +246,6 @@ impl Arena {
buffer,
capacity,
free,
host,
};
let index = self
.pages
@@ -274,96 +258,6 @@ impl Arena {
self.pages[index] = Some(page);
Some((index as u32, start))
}
/// A range of `vertices` in a page that exists already and is not being
/// emptied: never grows the arena.
pub fn alloc_existing(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, _)| closing != Some(*i as u32))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// `alloc_existing` for a mesh moved off the closing page: only pages in
/// video memory, so compaction never pushes terrain into system memory.
pub fn alloc_moved(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, p)| closing != Some(*i as u32) && p.as_ref().is_some_and(|p| !p.host))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// The page to empty when much of the arena is free ranges (video memory
/// that fragmentation keeps: a page goes back only once all of it is free):
/// the one with the fewest vertices in use that the other pages' free
/// ranges can take. `None` when the arena is dense enough or already
/// emptying a page.
pub fn sparsest(&self) -> Option<u32> {
self.sparsest_from(COMPACT_MIN_FREE)
}
/// `sparsest` for an arena with at least `min_free` bytes in free ranges.
fn sparsest_from(&self, min_free: u64) -> Option<u32> {
if self.closing.is_some() {
return None;
}
let pages = || {
self.pages
.iter()
.enumerate()
.filter_map(|(i, p)| Some((i, p.as_ref()?)))
};
let free: u64 = pages().map(|(_, p)| p.free_vertices()).sum();
let capacity: u64 = pages().map(|(_, p)| p.capacity).sum();
if free * self.stride < min_free || free * 2 < capacity {
return None;
}
// Moves only go to video-memory pages (`alloc_moved`).
let local_free: u64 = pages()
.filter(|(_, p)| !p.host)
.map(|(_, p)| p.free_vertices())
.sum();
pages()
.filter_map(|(i, p)| {
let used = p.capacity - p.free_vertices();
let own = if p.host { 0 } else { p.free_vertices() };
// The room elsewhere, less a quarter for first fit's gaps.
let elsewhere = (local_free - own) / 4 * 3;
// A page in system memory is emptied first.
(used > 0 && used <= elsewhere).then_some((!p.host, used, i as u32))
})
.min()
.map(|(_, _, i)| i)
}
/// Stops placing vertices in `page` until `reopen`.
pub fn close(&mut self, page: u32) {
self.closing = Some(page);
}
pub fn reopen(&mut self) {
self.closing = None;
}
pub fn closing(&self) -> Option<u32> {
self.closing
}
/// Records the copy of `vertices` from one range to another of other pages.
pub fn copy(
&self,
encoder: &mut wgpu::CommandEncoder,
from: (u32, u64),
to: (u32, u64),
vertices: u64,
) {
debug_assert_ne!(from.0, to.0);
encoder.copy_buffer_to_buffer(
self.buffer(from.0),
from.1 * self.stride,
self.buffer(to.0),
to.1 * self.stride,
vertices * self.stride,
);
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
self.pages[page as usize]
@@ -402,7 +296,7 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some((buffer, host)) = self.create(device, capacity) else {
let Some(buffer) = self.create(device, capacity) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -410,7 +304,6 @@ impl Arena {
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
let previous = old.capacity;
old.buffer = buffer;
old.host = host;
old.capacity = capacity;
old.free.give(previous, capacity - previous);
true
@@ -425,9 +318,6 @@ impl Arena {
*page = None;
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
self.closing = None;
}
// Keep live page indices stable; later allocations reuse interior holes.
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
@@ -435,7 +325,6 @@ impl Arena {
}
pub fn clear(&mut self) {
self.pages.clear();
self.closing = None;
}
}
@@ -518,76 +407,6 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Eight ranges fill two pages, each holding its own number.
let ranges: Vec<(u32, u64)> = (0..8)
.map(|i| {
let range = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
queue.write_buffer(
arena.buffer(range.0),
range.1 * 4,
&(100 + i as u32).to_le_bytes(),
);
range
})
.collect();
assert_eq!(arena.pages.iter().flatten().count(), 2);
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// Fragmented: one range live in the first page, two in the second.
for (i, &(page, start)) in ranges.iter().enumerate() {
if ![0, 6, 7].contains(&i) {
arena.release(page, start, GRAIN);
}
}
// Dense enough arenas, and ones without room elsewhere, are left alone.
assert_eq!(arena.sparsest_from(u64::MAX), None);
assert_eq!(arena.sparsest_from(0), Some(first));
arena.close(first);
assert_eq!(
arena.sparsest_from(0),
None,
"one page is emptied at a time"
);
// New ranges avoid the closing page though it has room.
let (page, extra) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(page, second);
arena.release(page, extra, GRAIN);
// Its live range moves, data included, and the page goes back.
let (page, start) = ranges[0];
let to = arena.alloc_existing(GRAIN).unwrap();
assert_eq!(to.0, second);
let mut encoder = device.create_command_encoder(&Default::default());
arena.copy(&mut encoder, (page, start), to, GRAIN);
queue.submit([encoder.finish()]);
arena.release(page, start, GRAIN);
assert_eq!(read(&device, &queue, arena.buffer(to.0), to.1 * 4), 100);
// Emptied but not trimmed yet: it stays closed, so uploads can't refill it.
assert_eq!(arena.closing(), Some(page));
let refill = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_ne!(refill.0, page);
arena.release(refill.0, refill.1, GRAIN);
let before = arena.bytes();
arena.trim();
assert_eq!(arena.bytes(), before - 4 * GRAIN * 4);
assert_eq!(arena.closing(), None);
// The others kept their ranges.
for i in [6, 7] {
let (page, start) = ranges[i];
assert_eq!(
read(&device, &queue, arena.buffer(page), start * 4),
100 + i as u32
);
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -753,9 +572,6 @@ mod tests {
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
// Compaction never moves terrain into a page in system memory.
assert!(arena.pages[page as usize].as_ref().unwrap().host);
assert_eq!(arena.alloc_moved(GRAIN), None);
}
#[test]
@@ -46,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,
@@ -214,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| {
+18 -71
View File
@@ -63,8 +63,6 @@ pub struct Call {
pub struct Flat {
pub solid: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
@@ -119,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 {
@@ -286,7 +261,7 @@ fn plan(
}
impl Flat {
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
/// `pipeline(layout, label, vertex entry, fragment entry, blend)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
@@ -298,7 +273,6 @@ impl Flat {
&str,
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
) -> wgpu::RenderPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
@@ -352,23 +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)", "fs_solid", None, back),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
),
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
flatten,
compute_layout,
@@ -744,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,9 +1,7 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow and interpolation; the setting that
//! runs them comes next.
//! two real ones. So far this holds its optical flow; the interpolation and the setting
//! that runs them come next.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
// Unused until the interpolation and the renderer use it.
#[allow(dead_code)]
mod optical_flow;
@@ -1,806 +0,0 @@
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
//! (`optical_flow.rs`) and the depth.
//!
//! The passes and their order are `ffxFrameInterpolationPrepare` then
//! `ffxFrameInterpolationDispatch`'s: dilate the depth and motion vectors and estimate
//! the previous frame's depth; estimate the made frame's depth; splat the game's and the
//! optical flow's vectors to the made frame's pixels (atomic maximums of packed
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
use glam::Mat4;
/// One real frame's inputs.
pub struct Inputs<'a> {
/// This frame's colour without the hand and HUD: `display` size, a filterable
/// float texture (gamma-encoded RGB, the swapchain's values) that can be copied from.
pub color: &'a wgpu::TextureView,
/// This frame's depth: `Depth32Float` at `render_size`, reversed (1 at the near
/// plane, 0 at the far one), finite, jittered by `jitter`.
pub depth: &'a wgpu::TextureView,
pub render_size: [u32; 2],
/// The projection's sub-pixel jitter this frame, in render pixels (FSR's
/// `jitterOffset`); zero without TAA.
pub jitter: [f32; 2],
/// This frame's unjittered clip space to the previous frame's (camera-relative, as
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
pub fov_y: f32,
/// The previous frame is unrelated (first frame, resize, teleport): show this one.
pub reset: bool,
/// The optical flow from the previous frame's `color` to this one's
/// (`OpticalFlow::vectors`, `scene_change`).
pub flow_vectors: &'a wgpu::TextureView,
pub scene_change: &'a wgpu::Buffer,
}
/// `interpolation.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
reproject: [[f32; 4]; 4],
device_to_view: [f32; 4],
render_size: [i32; 2],
display_size: [i32; 2],
jitter: [f32; 2],
optical_flow_scale: [f32; 2],
optical_flow_size: [i32; 2],
reset: u32,
tan_half_fov: f32,
level_size: [i32; 2],
_pad: [u32; 2],
}
/// The game vector field pyramid's levels (`LoadInpaintedGameFieldMv`) and the
/// inpainting pyramid's (`ComputeInpainting`).
const GAME_LEVELS: u32 = 11;
const INPAINTING_LEVELS: u32 = 10;
struct Pipelines {
prepare: wgpu::ComputePipeline,
setup: wgpu::ComputePipeline,
reconstruct_previous_depth: wgpu::ComputePipeline,
game_motion_vector_field: wgpu::ComputePipeline,
game_field_pyramid_first: wgpu::ComputePipeline,
game_field_pyramid: wgpu::ComputePipeline,
optical_flow_vector_field: wgpu::ComputePipeline,
disocclusion_mask: wgpu::ComputePipeline,
interpolate: wgpu::ComputePipeline,
inpainting_pyramid_first: wgpu::ComputePipeline,
inpainting_pyramid: wgpu::ComputePipeline,
inpainting: wgpu::ComputePipeline,
}
/// A texture with a view of each of its levels (`levels`, for writing one from the one
/// above) and of all of them.
struct Pyramid {
all: wgpu::TextureView,
levels: Vec<wgpu::TextureView>,
sizes: Vec<[u32; 2]>,
}
/// What is sized like the world's render.
struct RenderTargets {
size: [u32; 2],
dilated_depth: wgpu::TextureView,
dilated_mv: wgpu::TextureView,
rec_prev_depth: wgpu::Buffer,
rec_interp_depth: wgpu::Buffer,
game_x: wgpu::Buffer,
game_y: wgpu::Buffer,
of_x: wgpu::Buffer,
of_y: wgpu::Buffer,
disocclusion: wgpu::TextureView,
game_pyramid: Pyramid,
}
pub struct Interpolation {
display: [u32; 2],
pipelines: Pipelines,
sampler: wgpu::Sampler,
/// The passes' constants, and each pyramid level's.
params: wgpu::Buffer,
game_level_params: Vec<wgpu::Buffer>,
inpainting_level_params: Vec<wgpu::Buffer>,
counters: wgpu::Buffer,
interpolated: wgpu::TextureView,
output: wgpu::TextureView,
inpainting_pyramid: Pyramid,
/// The last dispatch's colour (`PREVIOUS_INTERPOLATION_SOURCE`), made on the first.
previous: Option<wgpu::Texture>,
render: Option<RenderTargets>,
}
fn texture(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
levels: u32,
) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
fn storage(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(4),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn uniform(device: &wgpu::Device) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame interpolation constants"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
/// Levels of `base >> (i + 1)` for `i` below `max`, while both sides are 1 or more.
fn pyramid(device: &wgpu::Device, label: &str, base: [u32; 2], max: u32) -> Pyramid {
let sizes: Vec<[u32; 2]> = (0..max)
.map(|i| base.map(|v| v >> (i + 1)))
.take_while(|s| s[0] >= 1 && s[1] >= 1)
.collect();
let levels = (sizes.len() as u32).max(1);
let t = texture(
device,
label,
base.map(|v| (v / 2).max(1)),
wgpu::TextureFormat::Rgba16Float,
levels,
);
Pyramid {
all: t.create_view(&Default::default()),
levels: (0..levels)
.map(|i| {
t.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: i,
mip_level_count: Some(1),
..Default::default()
})
})
.collect(),
sizes,
}
}
impl RenderTargets {
fn new(device: &wgpu::Device, size: [u32; 2]) -> Self {
let view = |t: wgpu::Texture| t.create_view(&Default::default());
let field = |label| storage(device, label, u64::from(size[0] * size[1]) * 4);
Self {
size,
dilated_depth: view(texture(
device,
"Frame interpolation dilated depth",
size,
wgpu::TextureFormat::R32Float,
1,
)),
dilated_mv: view(texture(
device,
"Frame interpolation dilated motion vectors",
size,
wgpu::TextureFormat::Rg32Float,
1,
)),
rec_prev_depth: field("Frame interpolation previous depth"),
rec_interp_depth: field("Frame interpolation interpolated depth"),
game_x: field("Frame interpolation game vectors x"),
game_y: field("Frame interpolation game vectors y"),
of_x: field("Frame interpolation flow vectors x"),
of_y: field("Frame interpolation flow vectors y"),
disocclusion: view(texture(
device,
"Frame interpolation disocclusion",
size,
wgpu::TextureFormat::Rgba8Unorm,
1,
)),
game_pyramid: pyramid(
device,
"Frame interpolation game vector pyramid",
size,
GAME_LEVELS,
),
}
}
}
fn bind(
device: &wgpu::Device,
pipeline: &wgpu::ComputePipeline,
entries: &[(u32, wgpu::BindingResource)],
) -> wgpu::BindGroup {
let entries: Vec<_> = entries
.iter()
.map(|(binding, resource)| wgpu::BindGroupEntry {
binding: *binding,
resource: resource.clone(),
})
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame interpolation"),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
})
}
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
wgpu::BindingResource::TextureView(v)
}
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
b.as_entire_binding()
}
/// `setupDeviceDepthToViewSpaceDepthParams` for inverted, finite depth, in blocks
/// (`viewSpaceToMetersFactor` 1).
fn device_to_view(near: f32, far: f32, fov_y: f32, aspect: f32) -> [f32; 4] {
let (f_min, f_max) = (near.max(far), near.min(far));
let q = f_max / (f_min - f_max);
let cot_half_fov = (0.5 * fov_y).cos() / (0.5 * fov_y).sin();
[-q, q * f_min, aspect / cot_half_fov, 1.0 / cot_half_fov]
}
impl Interpolation {
/// For frames of `display` size; the render size may change between dispatches.
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame interpolation"),
source: wgpu::ShaderSource::Wgsl(include_str!("interpolation.wgsl").into()),
});
let pipeline = |entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let view = |t: wgpu::Texture| t.create_view(&Default::default());
Self {
display,
pipelines: Pipelines {
prepare: pipeline("prepare_pass"),
setup: pipeline("setup_pass"),
reconstruct_previous_depth: pipeline("reconstruct_previous_depth_pass"),
game_motion_vector_field: pipeline("game_motion_vector_field_pass"),
game_field_pyramid_first: pipeline("game_field_pyramid_first_pass"),
game_field_pyramid: pipeline("game_field_pyramid_pass"),
optical_flow_vector_field: pipeline("optical_flow_vector_field_pass"),
disocclusion_mask: pipeline("disocclusion_mask_pass"),
interpolate: pipeline("interpolate_pass"),
inpainting_pyramid_first: pipeline("inpainting_pyramid_first_pass"),
inpainting_pyramid: pipeline("inpainting_pyramid_pass"),
inpainting: pipeline("inpainting_pass"),
},
sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Frame interpolation"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
}),
params: uniform(device),
game_level_params: (0..GAME_LEVELS).map(|_| uniform(device)).collect(),
inpainting_level_params: (0..INPAINTING_LEVELS).map(|_| uniform(device)).collect(),
counters: storage(device, "Frame interpolation counters", 8),
interpolated: view(texture(
device,
"Frame interpolation",
display,
wgpu::TextureFormat::Rgba16Float,
1,
)),
output: view(texture(
device,
"Interpolated frame",
display,
wgpu::TextureFormat::Rgba16Float,
1,
)),
inpainting_pyramid: pyramid(
device,
"Frame interpolation inpainting pyramid",
display,
INPAINTING_LEVELS,
),
previous: None,
render: None,
}
}
/// The frame halfway between the previous dispatch's `color` and this one's, into
/// `output`. Keeps what it needs of this frame for the next dispatch.
pub fn dispatch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
inputs: &Inputs,
) {
let color = inputs.color.texture();
let mut reset = inputs.reset;
if self
.previous
.as_ref()
.is_none_or(|p| p.size() != color.size() || p.format() != color.format())
{
self.previous = Some(device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame interpolation previous frame"),
size: color.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: color.format(),
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
}));
reset = true;
}
if self
.render
.as_ref()
.is_none_or(|r| r.size != inputs.render_size)
{
self.render = Some(RenderTargets::new(device, inputs.render_size));
reset = true;
}
let r = self.render.as_ref().expect("made above");
let previous_texture = self.previous.as_ref().expect("made above");
let previous = previous_texture.create_view(&Default::default());
let display = self.display.map(|v| v as i32);
let render = inputs.render_size.map(|v| v as i32);
let flow_size = self
.display
.map(|v| v.div_ceil(super::optical_flow::BLOCK_SIZE) as i32);
let base = Params {
reproject: inputs.reproject.to_cols_array_2d(),
device_to_view: device_to_view(
inputs.near,
inputs.far,
inputs.fov_y,
render[0] as f32 / render[1].max(1) as f32,
),
render_size: render,
display_size: display,
jitter: inputs.jitter,
optical_flow_scale: self.display.map(|v| 1.0 / v as f32),
optical_flow_size: flow_size,
reset: u32::from(reset),
tan_half_fov: (0.5 * inputs.fov_y).tan() * render[0] as f32 / render[1].max(1) as f32,
level_size: [0; 2],
_pad: [0; 2],
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&base));
let level_params = |buffers: &[wgpu::Buffer], sizes: &[[u32; 2]]| {
for (buffer, size) in buffers.iter().zip(sizes) {
let params = Params {
level_size: size.map(|v| v as i32),
..base
};
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&params));
}
};
level_params(&self.game_level_params, &r.game_pyramid.sizes);
level_params(
&self.inpainting_level_params,
&self.inpainting_pyramid.sizes,
);
for b in [
&r.rec_prev_depth,
&r.rec_interp_depth,
&r.game_x,
&r.game_y,
&r.of_x,
&r.of_y,
] {
encoder.clear_buffer(b, 0, None);
}
if reset {
encoder.clear_buffer(&self.counters, 0, None);
}
let p = &self.pipelines;
let params = || whole(&self.params);
let sampler = || wgpu::BindingResource::Sampler(&self.sampler);
let groups =
|pipeline, entries: &[(u32, wgpu::BindingResource)]| bind(device, pipeline, entries);
let prepare = groups(
&p.prepare,
&[
(0, params()),
(2, view(inputs.depth)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
],
);
let setup = groups(
&p.setup,
&[
(0, params()),
(22, whole(inputs.scene_change)),
(23, whole(&self.counters)),
],
);
let colors = || {
[
(3, view(inputs.color)),
(4, view(&previous)),
(1, sampler()),
]
};
let reconstruct = groups(
&p.reconstruct_previous_depth,
&[
(0, params()),
(7, view(&r.dilated_depth)),
(8, view(&r.dilated_mv)),
(10, whole(&r.rec_interp_depth)),
],
);
let mut entries = vec![
(0, params()),
(7, view(&r.dilated_depth)),
(8, view(&r.dilated_mv)),
(13, whole(&r.game_x)),
(14, whole(&r.game_y)),
];
entries.extend(colors());
let game_field = groups(&p.game_motion_vector_field, &entries);
let game_pyramid: Vec<_> = (0..r.game_pyramid.sizes.len())
.map(|i| {
let mut entries = vec![
(0, whole(&self.game_level_params[i])),
(26, view(&r.game_pyramid.levels[i])),
];
if i == 0 {
entries.extend([(15, whole(&r.game_x)), (16, whole(&r.game_y))]);
groups(&p.game_field_pyramid_first, &entries)
} else {
entries.push((25, view(&r.game_pyramid.levels[i - 1])));
groups(&p.game_field_pyramid, &entries)
}
})
.collect();
let mut entries = vec![
(0, params()),
(21, view(inputs.flow_vectors)),
(17, whole(&r.of_x)),
(18, whole(&r.of_y)),
];
entries.extend(colors());
let flow_field = groups(&p.optical_flow_vector_field, &entries);
let game_mv = || {
[
(15, whole(&r.game_x)),
(16, whole(&r.game_y)),
(27, view(&r.game_pyramid.all)),
]
};
let mut entries = vec![
(0, params()),
(7, view(&r.dilated_depth)),
(11, whole(&r.rec_prev_depth)),
(12, whole(&r.rec_interp_depth)),
(29, view(&r.disocclusion)),
];
entries.extend(game_mv());
let disocclusion = groups(&p.disocclusion_mask, &entries);
let mut entries = vec![
(0, params()),
(19, whole(&r.of_x)),
(20, whole(&r.of_y)),
(24, whole(&self.counters)),
(30, view(&r.disocclusion)),
(31, view(&self.interpolated)),
];
entries.extend(game_mv());
entries.extend(colors());
let interpolate = groups(&p.interpolate, &entries);
let inpainting_pyramid: Vec<_> = (0..self.inpainting_pyramid.sizes.len())
.map(|i| {
let mut entries = vec![
(0, whole(&self.inpainting_level_params[i])),
(26, view(&self.inpainting_pyramid.levels[i])),
];
if i == 0 {
entries.push((32, view(&self.interpolated)));
groups(&p.inpainting_pyramid_first, &entries)
} else {
entries.push((25, view(&self.inpainting_pyramid.levels[i - 1])));
groups(&p.inpainting_pyramid, &entries)
}
})
.collect();
let inpainting = groups(
&p.inpainting,
&[
(0, params()),
(28, view(&self.inpainting_pyramid.all)),
(32, view(&self.interpolated)),
(33, view(&self.output)),
],
);
let groups_for = |size: [u32; 2]| (size[0].div_ceil(8), size[1].div_ceil(8));
let (rx, ry) = groups_for(inputs.render_size);
let (dx, dy) = groups_for(self.display);
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Frame interpolation"),
timestamp_writes: None,
});
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, (x, y)| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(x, y, 1);
};
run(&p.prepare, &prepare, (rx, ry));
run(&p.setup, &setup, (1, 1));
if !reset {
run(&p.reconstruct_previous_depth, &reconstruct, (rx, ry));
run(&p.game_motion_vector_field, &game_field, (rx, ry));
for (i, (group, size)) in game_pyramid.iter().zip(&r.game_pyramid.sizes).enumerate() {
let pipeline = if i == 0 {
&p.game_field_pyramid_first
} else {
&p.game_field_pyramid
};
run(pipeline, group, groups_for(*size));
}
run(
&p.optical_flow_vector_field,
&flow_field,
groups_for(flow_size.map(|v| v as u32)),
);
run(&p.disocclusion_mask, &disocclusion, (rx, ry));
}
run(&p.interpolate, &interpolate, (dx, dy));
for (i, (group, size)) in inpainting_pyramid
.iter()
.zip(&self.inpainting_pyramid.sizes)
.enumerate()
{
let pipeline = if i == 0 {
&p.inpainting_pyramid_first
} else {
&p.inpainting_pyramid
};
run(pipeline, group, groups_for(*size));
}
run(&p.inpainting, &inpainting, (dx, dy));
drop(pass);
encoder.copy_texture_to_texture(
color.as_image_copy(),
previous_texture.as_image_copy(),
color.size(),
);
}
/// The interpolated frame: `display` size, `Rgba16Float`, sampled; its colour is
/// encoded as `Inputs::color`'s is.
pub fn output(&self) -> &wgpu::TextureView {
&self.output
}
}
#[cfg(test)]
mod tests {
use super::super::optical_flow::{OpticalFlow, tests as of};
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("interpolation.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn f16(bits: u16) -> f32 {
let (sign, exp, frac) = (bits >> 15, (bits >> 10) & 0x1f, bits & 0x3ff);
let v = match exp {
0 => f32::from(frac) / 1024.0 * 2f32.powi(-14),
31 => f32::INFINITY,
e => (1.0 + f32::from(frac) / 1024.0) * 2f32.powi(i32::from(e) - 15),
};
if sign == 1 { -v } else { v }
}
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
let size = [512, 288];
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let depth = depth.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.01),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
queue.submit([encoder.finish()]);
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
.iter()
.map(|s| {
let pixels = of::pattern(size, seed, *s);
// COPY_SRC as the frame generation's world texture has.
let t = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
queue.write_texture(
t.as_image_copy(),
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
t.size(),
);
(t.create_view(&Default::default()), pixels)
})
.collect();
for (i, (color, _)) in frames.iter().enumerate() {
let mut encoder = device.create_command_encoder(&Default::default());
flow.dispatch(&device, &queue, &mut encoder, color, i == 0);
interpolation.dispatch(
&device,
&queue,
&mut encoder,
&Inputs {
color,
depth: &depth,
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
near: 0.05,
far: 512.0,
fov_y: 1.2,
reset: i == 0,
flow_vectors: flow.vectors(),
scene_change: flow.scene_change(),
},
);
queue.submit([encoder.finish()]);
}
let out = of::read(&device, &queue, interpolation.output.texture(), 8);
let made: Vec<f32> = out
.chunks(2)
.map(|c| f16(u16::from_le_bytes([c[0], c[1]])))
.collect();
let truth = of::pattern(size, seed, expected);
let n = frames.len();
let error = |pixels: &dyn Fn(usize) -> f32| {
let (mut sum, mut count) = (0.0, 0.0);
for y in 32..size[1] as usize - 32 {
for x in 32..size[0] as usize - 32 {
for c in 0..3 {
let i = (y * size[0] as usize + x) * 4 + c;
sum += (pixels(i) - f32::from(truth[i]) / 255.0).abs();
count += 1.0;
}
}
}
sum / count
};
Some([
error(&|i| made[i]),
error(&|i| f32::from(frames[n - 2].1[i]) / 255.0),
error(&|i| f32::from(frames[n - 1].1[i]) / 255.0),
])
}
/// Something moving on its own (the camera still): the optical flow puts the made
/// frame's blocks halfway where its colours match better than the still camera's
/// (FSR prefers the game's vectors unless they clearly don't: primaries make it
/// clear). The first 10 frames after a reset use the game's vectors only.
#[test]
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
else {
return;
};
// Where the still camera's two colours happen to be the same primary (a third of
// the pixels), FSR keeps the game's vectors.
assert!(
made * 3.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// The camera turning: the game's motion vectors put the made frame halfway.
#[test]
fn moves_the_view_halfway_with_the_camera() {
let size = [512.0, 288.0];
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
}
@@ -1,803 +0,0 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// FidelityFX SDK 1.1.4's frame interpolation (ffx_frameinterpolation_*.h), ported to WGSL
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
// be read_write everywhere);
// - NaNs AMD's hardware turns into 0 (max(0, NaN)) are avoided up front.
struct Params {
// This frame's unjittered clip space to the previous frame's.
reproject: mat4x4<f32>,
device_to_view: vec4<f32>,
render_size: vec2<i32>,
display_size: vec2<i32>,
// FSR's jitterOffset: render pixels.
jitter: vec2<f32>,
optical_flow_scale: vec2<f32>,
// GetOpticalFlowSize2.
optical_flow_size: vec2<i32>,
reset: u32,
tan_half_fov: f32,
// A mip level's size, for the pyramid passes.
level_size: vec2<i32>,
_pad: vec2<u32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var linear_clamp: sampler;
@group(0) @binding(2) var input_depth: texture_depth_2d;
@group(0) @binding(3) var current_color: texture_2d<f32>;
@group(0) @binding(4) var previous_color: texture_2d<f32>;
@group(0) @binding(5) var dilated_depth_out: texture_storage_2d<r32float, write>;
@group(0) @binding(6) var dilated_mv_out: texture_storage_2d<rg32float, write>;
@group(0) @binding(7) var dilated_depth: texture_2d<f32>;
@group(0) @binding(8) var dilated_mv: texture_2d<f32>;
@group(0) @binding(9) var<storage, read_write> rec_prev_depth: array<atomic<u32>>;
@group(0) @binding(10) var<storage, read_write> rec_interp_depth: array<atomic<u32>>;
@group(0) @binding(11) var<storage, read> rec_prev_depth_in: array<u32>;
@group(0) @binding(12) var<storage, read> rec_interp_depth_in: array<u32>;
@group(0) @binding(13) var<storage, read_write> game_field_x: array<atomic<u32>>;
@group(0) @binding(14) var<storage, read_write> game_field_y: array<atomic<u32>>;
@group(0) @binding(15) var<storage, read> game_field_x_in: array<u32>;
@group(0) @binding(16) var<storage, read> game_field_y_in: array<u32>;
@group(0) @binding(17) var<storage, read_write> of_field_x: array<atomic<u32>>;
@group(0) @binding(18) var<storage, read_write> of_field_y: array<atomic<u32>>;
@group(0) @binding(19) var<storage, read> of_field_x_in: array<u32>;
@group(0) @binding(20) var<storage, read> of_field_y_in: array<u32>;
@group(0) @binding(21) var optical_flow: texture_2d<i32>;
@group(0) @binding(22) var<storage, read> scene_change: array<u32, 3>;
@group(0) @binding(23) var<storage, read_write> counters: array<u32, 2>;
@group(0) @binding(24) var<storage, read> counters_in: array<u32, 2>;
// A pyramid's previous level, and the level a pyramid pass writes.
@group(0) @binding(25) var pyramid_in: texture_2d<f32>;
@group(0) @binding(26) var pyramid_out: texture_storage_2d<rgba16float, write>;
// The whole game vector field and inpainting pyramids.
@group(0) @binding(27) var game_pyramid: texture_2d<f32>;
@group(0) @binding(28) var inpainting_pyramid: texture_2d<f32>;
@group(0) @binding(29) var disocclusion_out: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(30) var disocclusion: texture_2d<f32>;
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
const COUNTER_FRAME_INDEX_SINCE_LAST_RESET: u32 = 1u;
fn render_index(p: vec2<i32>) -> u32 {
return u32(p.y * params.render_size.x + p.x);
}
fn is_on_screen(p: vec2<i32>, size: vec2<i32>) -> bool {
return all(vec2<u32>(p) < vec2<u32>(size));
}
fn is_uv_inside(uv: vec2<f32>) -> bool {
return uv.x > 0.0 && uv.x < 1.0 && uv.y > 0.0 && uv.y < 1.0;
}
fn min_divided_by_max(v0: f32, v1: f32) -> f32 {
let m = max(v0, v1);
return select(0.0, min(v0, v1) / m, m != 0.0);
}
fn normalized_dot3(v0: vec3<f32>, v1: vec3<f32>) -> f32 {
let m = max(length(v0), length(v1));
return select(1.0, dot(v0 / m, v1 / m), m > 0.0);
}
fn linear_from_srgb(c: vec3<f32>) -> vec3<f32> {
return select(pow(c / 1.055 + 0.055 / 1.055, vec3(2.4)), c / 12.92, c < vec3(0.04045));
}
fn raw_rgb_to_luminance(c: vec3<f32>) -> f32 {
return dot(linear_from_srgb(c), vec3(0.2126, 0.7152, 0.0722));
}
struct Bilinear {
base: vec2<i32>,
weights: vec4<f32>,
};
fn bilinear(uv: vec2<f32>, size: vec2<i32>) -> Bilinear {
let px = uv * vec2<f32>(size) - 0.5;
let f = fract(px);
var b: Bilinear;
b.base = vec2<i32>(floor(px));
b.weights = vec4(
(1.0 - f.x) * (1.0 - f.y),
f.x * (1.0 - f.y),
(1.0 - f.x) * f.y,
f.x * f.y,
);
return b;
}
fn bilinear_offset(i: i32) -> vec2<i32> {
return vec2(i & 1, i >> 1u);
}
fn device_to_view_depth(d: f32) -> f32 {
return params.device_to_view.y / (d - params.device_to_view.x);
}
fn view_space_position(px: vec2<i32>, size: vec2<i32>, d: f32) -> vec3<f32> {
let z = device_to_view_depth(d);
let ndc = vec2<f32>(px) / vec2<f32>(size) * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
return vec3(params.device_to_view.z * ndc.x * z, params.device_to_view.w * ndc.y * z, z);
}
// MOTION VECTOR FIELD (ffx_frameinterpolation_common.h)
const PRIMARY_VECTOR_BIT: u32 = 0x80000000u;
const PRIORITY_LOW_MAX: u32 = 31u;
const PRIORITY_HIGH_MAX: u32 = 1023u;
const PRIORITY_LOW_OFFSET: u32 = 16u;
const PRIORITY_HIGH_OFFSET: u32 = 21u;
struct VectorFieldEntry {
motion_vector: vec2<f32>,
high_priority: f32,
low_priority: f32,
valid: bool,
primary: bool,
secondary: bool,
in_painted: bool,
velocity: f32,
neg_outside: bool,
pos_outside: bool,
};
fn new_entry() -> VectorFieldEntry {
return VectorFieldEntry(vec2(0.0), 0.0, 0.0, false, false, false, false, 0.0, false, false);
}
fn is_primary(packed: u32) -> bool {
return (packed & PRIMARY_VECTOR_BIT) != 0u;
}
// ffxF32ToF16 and its inverse in integer arithmetic: pack2x16float needs a capability
// some GPUs lack.
fn f32_to_f16(v: f32) -> u32 {
let x = bitcast<u32>(v);
let sign = (x >> 16u) & 0x8000u;
let e = i32((x >> 23u) & 0xffu) - 112;
let m = x & 0x7fffffu;
if e >= 31 {
return sign | 0x7c00u;
}
if e <= 0 {
if e < -10 {
return sign;
}
let full = m | 0x800000u;
let shift = u32(14 - e);
return sign | ((full >> shift) + ((full >> (shift - 1u)) & 1u));
}
return sign | (((u32(e) << 10u) | (m >> 13u)) + ((m >> 12u) & 1u));
}
fn f16_to_f32(h: u32) -> f32 {
let sign = (h & 0x8000u) << 16u;
let e = (h >> 10u) & 0x1fu;
let m = h & 0x3ffu;
if e == 0u {
let v = f32(m) * (1.0 / 16777216.0);
return select(v, -v, sign != 0u);
}
if e == 31u {
return bitcast<f32>(sign | 0x7f800000u);
}
return bitcast<f32>(sign | ((e + 112u) << 23u) | (m << 13u));
}
fn pack_entries(primary: bool, high: u32, low: u32, mv: vec2<f32>) -> vec2<u32> {
let priority = select(0u, PRIMARY_VECTOR_BIT, primary)
| ((high & PRIORITY_HIGH_MAX) << PRIORITY_HIGH_OFFSET)
| ((low & PRIORITY_LOW_MAX) << PRIORITY_LOW_OFFSET);
return vec2(priority | f32_to_f16(mv.x), priority | f32_to_f16(mv.y));
}
fn unpack_entries(packed: vec2<u32>) -> VectorFieldEntry {
var e = new_entry();
e.high_priority = f32((packed.x >> PRIORITY_HIGH_OFFSET) & PRIORITY_HIGH_MAX) / f32(PRIORITY_HIGH_MAX);
e.low_priority = f32((packed.x >> PRIORITY_LOW_OFFSET) & PRIORITY_LOW_MAX) / f32(PRIORITY_LOW_MAX);
e.primary = is_primary(packed.x);
e.valid = e.high_priority > 0.0;
e.secondary = e.valid && !e.primary;
if e.secondary {
e.high_priority = 1.0 - e.high_priority;
}
e.motion_vector = vec2(f16_to_f32(packed.x & 0xffffu), f16_to_f32(packed.y & 0xffffu));
return e;
}
fn load_game_field(p: vec2<i32>) -> vec2<u32> {
if !is_on_screen(p, params.render_size) {
return vec2(0u);
}
let i = render_index(p);
return vec2(game_field_x_in[i], game_field_y_in[i]);
}
fn compute_mv_inpainting_level(uv: vec2<f32>, level: i32, size: vec2<i32>) -> vec4<f32> {
let b = bilinear(uv, size);
var sum = 0.0;
var color = vec4(0.0);
for (var i = 0; i < 4; i++) {
let p = b.base + bilinear_offset(i);
if is_on_screen(p, size) {
let s = textureLoad(game_pyramid, p, level);
let weight = b.weights[i] * f32(s.z > 0.0) * s.z;
sum += weight;
color += s * weight;
}
}
return color / select(1.0, sum, sum > 0.0);
}
fn load_inpainted_game_field_mv(uv: vec2<f32>) -> VectorFieldEntry {
let px = vec2<i32>(uv * vec2<f32>(params.render_size));
var e = unpack_entries(load_game_field(px));
if !e.valid {
var size = params.render_size;
var v = vec4(0.0);
let levels = i32(textureNumLevels(game_pyramid));
for (var level = 0; level < min(11, levels) && v.w == 0.0; level++) {
size /= 2;
v = compute_mv_inpainting_level(uv, level, size);
}
e.motion_vector = v.xy;
e.high_priority = v.z;
e.low_priority = v.w;
e.in_painted = true;
}
e.neg_outside = !is_uv_inside(uv - e.motion_vector);
e.pos_outside = !is_uv_inside(uv + e.motion_vector);
e.velocity = length(e.motion_vector);
return e;
}
fn sample_optical_flow_field(uv: vec2<f32>) -> VectorFieldEntry {
let size = params.optical_flow_size;
let b = bilinear(uv, size);
var e = new_entry();
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let p = b.base + bilinear_offset(i);
if is_on_screen(p, size) {
let w = b.weights[i];
let j = render_index(p);
let s = unpack_entries(vec2(of_field_x_in[j], of_field_y_in[j]));
e.motion_vector += s.motion_vector * w;
e.high_priority += s.high_priority * w;
e.low_priority += s.low_priority * w;
weight_sum += w;
}
}
if weight_sum > 0.0 {
e.motion_vector /= weight_sum;
e.high_priority /= weight_sum;
e.low_priority /= weight_sum;
}
e.neg_outside = !is_uv_inside(uv - e.motion_vector);
e.pos_outside = !is_uv_inside(uv + e.motion_vector);
e.velocity = length(e.motion_vector);
return e;
}
fn has_scene_changed() -> bool {
return (scene_change[1] & 0xfu) != 0u;
}
// ffx_frameinterpolation_reconstruct_dilated_velocity_and_previous_depth.h, with the
// motion vectors computed from the depth and the camera.
fn camera_motion_vector(p: vec2<i32>, depth: f32) -> vec2<f32> {
let size = vec2<f32>(params.render_size);
let uv = (vec2<f32>(p) + 0.5) / size - params.jitter / size;
let ndc = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
let previous = params.reproject * vec4(ndc, depth, 1.0);
let previous_uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
return previous_uv - uv;
}
fn load_input_depth(p: vec2<i32>) -> f32 {
if !is_on_screen(p, params.render_size) {
return 0.0;
}
return textureLoad(input_depth, p, 0);
}
@compute @workgroup_size(8, 8)
fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let offsets = array(
vec2(0, 0), vec2(1, 0), vec2(0, 1), vec2(0, -1), vec2(-1, 0),
vec2(-1, 1), vec2(1, 1), vec2(-1, -1), vec2(1, -1),
);
var nearest = load_input_depth(p);
var nearest_coord = p;
for (var i = 1; i < 9; i++) {
let q = p + offsets[i];
if is_on_screen(q, params.render_size) {
let d = load_input_depth(q);
if d > nearest {
nearest_coord = q;
nearest = d;
}
}
}
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
let moving = mv * f32(length(mv * vec2<f32>(params.display_size)) > 0.1);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let b = bilinear(uv + moving, params.render_size);
for (var i = 0; i < 4; i++) {
if b.weights[i] > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.render_size) {
atomicMax(&rec_prev_depth[render_index(q)], bitcast<u32>(nearest));
}
}
}
}
// ffx_frameinterpolation_setup.h: the frames since the last reset (the vector fields are
// cleared by the encoder).
@compute @workgroup_size(1)
fn setup_pass() {
if params.reset != 0u || has_scene_changed() {
counters[COUNTER_FRAME_INDEX_SINCE_LAST_RESET] = 0u;
} else {
counters[COUNTER_FRAME_INDEX_SINCE_LAST_RESET] += 1u;
}
}
// ffx_frameinterpolation_reconstruct_previous_depth.h: the interpolated frame's depth.
@compute @workgroup_size(8, 8)
fn reconstruct_previous_depth_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let mv = textureLoad(dilated_mv, p, 0).xy * 0.5;
let depth = textureLoad(dilated_depth, p, 0).x;
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let b = bilinear(uv + mv, params.render_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if b.weights[i] > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD && is_on_screen(q, params.render_size) {
atomicMax(&rec_interp_depth[render_index(q)], bitcast<u32>(depth));
}
}
}
// ffx_frameinterpolation_game_motion_vector_field.h
fn priority_from_view_depth(view_depth_in: f32) -> u32 {
let d = pow(view_depth_in, 0.33);
let factor = u32((1.0 - d * (1.0 / (1.0 + d))) * f32(PRIORITY_HIGH_MAX));
return max(1u, factor);
}
fn update_game_field(p: vec2<i32>, packed: vec2<u32>) -> u32 {
let i = render_index(p);
let x = atomicMax(&game_field_x[i], packed.x);
let y = atomicMax(&game_field_y[i], packed.y);
return max(x, y);
}
@compute @workgroup_size(8, 8)
fn game_motion_vector_field_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let size = vec2<f32>(params.render_size);
let uv = (vec2<f32>(p) + 0.5) / size;
let depth = textureLoad(dilated_depth, p, 0).x;
let mv = textureLoad(dilated_mv, p, 0).xy;
let half_mv = mv * 0.5;
let interpolated_uv = uv + half_mv;
let high_primary = priority_from_view_depth(device_to_view_depth(depth));
let previous = textureSampleLevel(previous_color, linear_clamp, uv + mv, 0.0).rgb;
let current = textureSampleLevel(current_color, linear_clamp, uv, 0.0).rgb;
let previous_luma = 0.001 + raw_rgb_to_luminance(previous);
let current_luma = 0.001 + raw_rgb_to_luminance(current);
var low = u32(round(min_divided_by_max(previous_luma, current_luma) * f32(PRIORITY_LOW_MAX)))
* u32(is_uv_inside(uv + mv));
let primary = pack_entries(true, high_primary, low, half_mv);
let b = bilinear(interpolated_uv, params.render_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.render_size) {
update_game_field(q, primary);
}
}
if length(half_mv * size) > EPSILON {
var write_secondary = true;
var primary_hits = 0u;
let step_scale = length(1.0 / size);
let step_mv = normalize(mv);
let break_dist = min(length(half_mv), length(vec2(0.5, 0.5)));
for (var s = step_scale; s <= break_dist && write_secondary; s += step_scale) {
let secondary_uv = interpolated_uv - step_mv * s;
let sb = bilinear(secondary_uv, params.render_size);
let high_secondary = max(1u, PRIORITY_HIGH_MAX - high_primary);
let to_centre_raw = vec2(0.5, 0.5) - secondary_uv;
let to_centre = select(vec2(0.0), normalize(to_centre_raw), length(to_centre_raw) > 0.0);
low = u32(max(0.0, dot(to_centre, step_mv)) * f32(PRIORITY_LOW_MAX));
let secondary = pack_entries(false, high_secondary, low, half_mv);
let q = sb.base;
write_secondary = write_secondary && is_on_screen(q, params.render_size);
if write_secondary {
let existing = update_game_field(q, secondary);
primary_hits += u32(is_primary(existing));
write_secondary = write_secondary && primary_hits <= 3u;
}
}
}
}
// ffx_frameinterpolation_compute_game_vector_field_inpainting_pyramid.h: SPD, a level per
// pass. Level 0 reads the field, the others the level above.
fn reduce_game_field(v: array<vec4<f32>, 4>) -> vec4<f32> {
var sum = vec4(0.0);
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let w = f32(v[i].z > 0.0);
sum += v[i] * w;
weight_sum += w;
}
return sum / select(1.0, weight_sum, weight_sum > EPSILON);
}
@compute @workgroup_size(8, 8)
fn game_field_pyramid_first_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
let e = unpack_entries(load_game_field(p * 2 + bilinear_offset(i)));
v[i] = vec4(e.motion_vector, e.high_priority, e.low_priority);
}
textureStore(pyramid_out, p, reduce_game_field(v));
}
@compute @workgroup_size(8, 8)
fn game_field_pyramid_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
v[i] = textureLoad(pyramid_in, p * 2 + bilinear_offset(i), 0);
}
textureStore(pyramid_out, p, reduce_game_field(v));
}
// ffx_frameinterpolation_optical_flow_vector_field.h
fn load_optical_flow(p: vec2<i32>) -> vec2<f32> {
let size = vec2<i32>(textureDimensions(optical_flow));
if !is_on_screen(p, size) {
return vec2(0.0);
}
return vec2<f32>(textureLoad(optical_flow, p, 0).xy) * params.optical_flow_scale;
}
@compute @workgroup_size(8, 8)
fn optical_flow_vector_field_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
let rect = vec2<f32>(params.display_size);
var avg = vec2(0.0);
var sw = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let vs = load_optical_flow(p + vec2(x, y));
let len = length(vs * rect);
let w = max(0.0, 512.0 - len) * f32(len > 1.0);
avg += vs * w;
sw += w;
}
}
// AMD divides by 0 here, and its max(0, NaN) below gives 0 weights: a zero vector.
if sw > 0.0 {
avg /= sw;
}
var v = vec2(0.0);
sw = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let vs = load_optical_flow(p + vec2(x, y));
let len = length(vs * rect);
let len_factor = max(0.0, 512.0 - len) * f32(len > 1.0);
let w = pow(max(0.0, dot(avg, vs)), 1.25) * len_factor;
v += vs * w;
sw += w;
}
}
if sw > EPSILON {
v /= sw;
}
// computeOpticalFlowFieldMvs
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.optical_flow_size);
let half_mv = v * 0.5;
let previous = textureSampleLevel(previous_color, linear_clamp, uv + v, 0.0).rgb;
let current = textureSampleLevel(current_color, linear_clamp, uv, 0.0).rgb;
let previous_luma = 0.001 + raw_rgb_to_luminance(previous);
let current_luma = 0.001 + raw_rgb_to_luminance(current);
let velocity = length(v * rect);
let high = u32(velocity > 1.0)
* u32(saturate(velocity / length(rect * 0.05)) * f32(PRIORITY_HIGH_MAX));
if high > 0u {
let low = u32(round(min_divided_by_max(previous_luma, current_luma) * f32(PRIORITY_LOW_MAX)))
* u32(is_uv_inside(uv + v));
let packed = pack_entries(true, high, low, half_mv);
let b = bilinear(uv + half_mv, params.optical_flow_size);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, params.optical_flow_size) {
let j = render_index(q);
atomicMax(&of_field_x[j], packed.x);
atomicMax(&of_field_y[j], packed.y);
}
}
}
}
// ffx_frameinterpolation_disocclusion_mask.h
fn load_estimated_depth(which: u32, p: vec2<i32>) -> f32 {
if which == 0u {
return bitcast<f32>(rec_prev_depth_in[render_index(p)]);
}
return textureLoad(dilated_depth, p, 0).x;
}
fn compute_depth_clip(which: u32, uv: vec2<f32>, current_depth: f32) -> f32 {
let current_view = device_to_view_depth(current_depth);
let b = bilinear(uv, params.render_size);
var depth = 0.0;
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
let w = b.weights[i];
if is_on_screen(q, params.render_size) && w > RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD {
let previous_depth = load_estimated_depth(which, q);
let previous_view = device_to_view_depth(previous_depth);
let diff = current_view - previous_view;
if diff > 0.0 {
let plane_depth = min(previous_depth, current_depth);
let centre = view_space_position(params.render_size / 2, params.render_size, plane_depth);
let corner = view_space_position(vec2(0), params.render_size, plane_depth);
let half_viewport_width = length(vec2<f32>(params.render_size));
let threshold = min(current_view, previous_view);
let k_fov = length(corner) / length(centre);
let required = 1.37e-05 * k_fov * half_viewport_width * threshold;
depth += f32(required / diff >= 1.0) * w;
weight_sum += w;
}
}
}
return select(0.0, saturate(1.0 - depth / weight_sum), weight_sum > 0.0);
}
@compute @workgroup_size(8, 8)
fn disocclusion_mask_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.render_size) {
return;
}
let depth = bitcast<f32>(rec_interp_depth_in[render_index(p)]);
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.render_size);
let game = load_inpainted_game_field_mv(uv);
var mask = vec2(
1.0 - compute_depth_clip(0u, uv + game.motion_vector, depth),
1.0 - compute_depth_clip(1u, uv - game.motion_vector, depth),
);
mask = vec2<f32>(mask >= vec2(EPSILON));
let source_mv = game.motion_vector * 2.0;
let size = vec2<f32>(params.render_size);
let previous = vec2<i32>((uv + source_mv) * size);
mask.x = saturate(mask.x + f32(!is_on_screen(previous, params.render_size)));
let current = vec2<i32>((uv - source_mv) * size);
mask.y = saturate(mask.y + f32(!is_on_screen(current, params.render_size)));
textureStore(disocclusion_out, p, vec4(mask, 0.0, 0.0));
}
// ffx_frameinterpolation.h
struct SourceColor {
raw: vec3<f32>,
weight_sum: f32,
};
fn sample_bilinear(is_current: bool, uv: vec2<f32>, mv: vec2<f32>) -> SourceColor {
let size = params.display_size;
let b = bilinear(uv + mv, size);
var color = vec3(0.0);
var weight_sum = 0.0;
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, size) {
let w = b.weights[i];
if is_current {
color += textureLoad(current_color, q, 0).rgb * w;
} else {
color += textureLoad(previous_color, q, 0).rgb * w;
}
weight_sum += w;
}
}
return SourceColor(select(vec3(0.0), color / weight_sum, weight_sum != 0.0), weight_sum);
}
@compute @workgroup_size(8, 8)
fn interpolate_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.display_size) {
return;
}
let frame_index = counters_in[COUNTER_FRAME_INDEX_SINCE_LAST_RESET];
if frame_index == 0u {
textureStore(interpolated_out, p, vec4(textureLoad(current_color, p, 0).rgb, 0.0));
return;
}
var in_painting = 0.0;
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.display_size);
let game = load_inpainted_game_field_mv(uv);
let flow = sample_optical_flow_field(uv);
var disocclusion_factor = vec2<f32>(
saturate(textureSampleLevel(disocclusion, linear_clamp, uv, 0.0).xy) == vec2(1.0),
);
let previous_game = sample_bilinear(false, uv, game.motion_vector);
let current_game = sample_bilinear(true, uv, -game.motion_vector);
let previous_of = sample_bilinear(false, uv, flow.motion_vector);
let current_of = sample_bilinear(true, uv, -flow.motion_vector);
disocclusion_factor.x *= f32(!game.pos_outside);
disocclusion_factor.y *= f32(!game.neg_outside);
in_painting = saturate(max(in_painting, f32(length(disocclusion_factor) <= EPSILON)));
var t = 0.5;
t += 0.5 * (1.0 - disocclusion_factor.x);
t -= 0.5 * (1.0 - disocclusion_factor.y);
var color = mix(previous_game.raw, current_game.raw, saturate(t));
let disoccluded = saturate(1.0 - min(disocclusion_factor.x, disocclusion_factor.y));
if previous_game.weight_sum == 0.0 {
color = current_game.raw;
} else if current_game.weight_sum == 0.0 {
color = previous_game.raw;
}
if previous_game.weight_sum == 0.0 && current_game.weight_sum == 0.0 {
in_painting = 1.0;
}
var of_t = 1.0;
if previous_of.weight_sum > 0.0 && current_of.weight_sum > 0.0 {
of_t = 0.5;
} else if previous_of.weight_sum > 0.0 {
of_t = 0.0;
}
let of_color = mix(previous_of.raw, current_of.raw, of_t);
let of_sim = normalized_dot3(previous_of.raw, current_of.raw);
var game_sim = normalized_dot3(previous_game.raw, current_game.raw);
game_sim = mix(max(EPSILON, game_sim), 1.0, disoccluded);
var game_bias = saturate(game_sim / max(EPSILON, of_sim));
game_bias = mix(game_bias, 1.0, f32(frame_index < 10u));
color = mix(of_color, color, saturate(game_bias));
textureStore(interpolated_out, p, vec4(color, in_painting));
}
// ffx_frameinterpolation_compute_inpainting_pyramid.h: SPD, a level per pass.
fn reduce_inpainting(v: array<vec4<f32>, 4>) -> vec4<f32> {
let sum = v[0].w + v[1].w + v[2].w + v[3].w;
if sum == 0.0 {
return vec4(0.0);
}
return (v[0] * v[0].w + v[1] * v[1].w + v[2] * v[2].w + v[3] * v[3].w) / sum;
}
@compute @workgroup_size(8, 8)
fn inpainting_pyramid_first_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
let q = p * 2 + bilinear_offset(i);
var c = vec4(0.0);
if is_on_screen(q, params.display_size) {
c = textureLoad(interpolated, q, 0);
c.w = saturate(1.0 - c.w);
}
v[i] = c;
}
textureStore(pyramid_out, p, reduce_inpainting(v));
}
@compute @workgroup_size(8, 8)
fn inpainting_pyramid_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.level_size) {
return;
}
var v: array<vec4<f32>, 4>;
for (var i = 0; i < 4; i++) {
v[i] = textureLoad(pyramid_in, p * 2 + bilinear_offset(i), 0);
}
textureStore(pyramid_out, p, reduce_inpainting(v));
}
// ffx_frameinterpolation_inpainting.h (no HUD-less source: Horizon draws its HUD over
// the made frame itself).
fn compute_inpainting_level(uv: vec2<f32>, level: i32, size: vec2<i32>) -> vec4<f32> {
let b = bilinear(uv, size);
var color = vec4(0.0);
for (var i = 0; i < 4; i++) {
let q = b.base + bilinear_offset(i);
if is_on_screen(q, size) {
let s = textureLoad(inpainting_pyramid, q, level);
let w = b.weights[i] * f32(s.w > 0.0);
color += vec4(s.rgb * w, w);
}
}
return color;
}
fn compute_inpainting(p: vec2<i32>) -> vec3<f32> {
let uv = (vec2<f32>(p) + 0.5) / vec2<f32>(params.display_size);
var color = vec4(0.0);
var size = params.display_size;
let levels = i32(textureNumLevels(inpainting_pyramid));
for (var level = 0; level < min(10, levels); level++) {
size /= 2;
let mip = compute_inpainting_level(uv, level, size);
if mip.w > 0.0 {
let weight = pow(1.0 - f32(level) / 10.0, 3.0) * mip.w;
color += vec4(mip.rgb / mip.w, 1.0) * weight;
}
}
return select(vec3(0.0), color.rgb / color.w, color.w > 0.0);
}
@compute @workgroup_size(8, 8)
fn inpainting_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if !is_on_screen(p, params.display_size) {
return;
}
var c = textureLoad(interpolated, p, 0);
if c.w > EPSILON {
c = vec4(mix(c.rgb, compute_inpainting(p), c.w), 1.0);
}
textureStore(output, p, c);
}
@@ -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,
}
}
@@ -602,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,158 +0,0 @@
//! HDR emission thresholds (`hdr_stats.wgsl`): for every block sprite, the brightness its
//! pixels must reach to shine in HDR output, measured on the GPU from the sprite's own
//! darkest and brightest opaque pixel when HDR output turns on. Until then (and with HDR
//! off) the texture holds zeros: every pixel of a light source may shine, and hdr.wgsl's
//! tier windows alone decide, as before.
use super::hdr::Masks;
pub struct EmissionStats {
/// One texel per sprite: x the layer in its array, y the array.
pub view: wgpu::TextureView,
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
params: wgpu::Buffer,
}
impl EmissionStats {
pub fn new(device: &wgpu::Device, split: u32, arrays: u32) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon HDR emission thresholds"),
size: wgpu::Extent3d {
width: split,
height: arrays,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR emission thresholds"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr_stats.wgsl").into()),
});
let mut entries: Vec<_> = (0..super::SPRITE_ARRAYS)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
})
.collect();
entries.push(wgpu::BindGroupLayoutEntry {
binding: super::SPRITE_ARRAYS,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR emission thresholds"),
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR emission thresholds"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR emission thresholds"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_stats"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_stats"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR emission threshold parameters"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view,
layout,
pipeline,
params,
}
}
/// Measures every sprite of `sprites` (terrain.wgsl's arrays) at `masks`.
pub fn measure(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
sprites: &[wgpu::TextureView],
masks: Masks,
) {
let mut params = [0u8; 16];
params[..4].copy_from_slice(&masks.parts.to_le_bytes());
params[4..8].copy_from_slice(&masks.whole.to_le_bytes());
queue.write_buffer(&self.params, 0, &params);
let mut entries: Vec<_> = sprites
.iter()
.enumerate()
.map(|(binding, view)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: wgpu::BindingResource::TextureView(view),
})
.collect();
entries.push(wgpu::BindGroupEntry {
binding: super::SPRITE_ARRAYS,
resource: self.params.as_entire_binding(),
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR emission thresholds"),
layout: &self.layout,
entries: &entries,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR emission thresholds"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
queue.submit([encoder.finish()]);
}
}
@@ -83,8 +83,7 @@ fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
normal: [0; 4],
pos: c,
uv,
// HDR output: the bolt shines like the sun (entity.wgsl fs_hdr_mark).
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
layer: 2,
color,
light: 240 | 240 << 8,
@@ -57,8 +57,7 @@ fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex
normal: [0; 4],
pos: (o + glam::Vec3::from_array(c)).to_array(),
uv,
// The HDR tier lightning.rs adds; the geometry is vanilla's.
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
layer: 2,
color,
light: 240 | 240 << 8,
+16 -9
View File
@@ -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();
@@ -28,9 +28,6 @@ struct HorizonMaterialOut {
@location(5) position: vec3<f32>,
@location(6) spherical: f32,
@location(7) cylindrical: f32,
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
@@ -48,19 +45,6 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.position = pos;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
// The colour's alpha byte (mesher.rs's `fluid_face` and `emitting`): a fluid quad's
// high nibble is 8 + its face (0..5), every other quad keeps 0xF there; the low nibble
// is 15 minus the face's HDR tier. Every corner of a quad carries the same byte, so the
// interpolated value rounds back exactly.
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
out.face = select(0u, high & 7u, fluid);
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
let tier = 15u - (flags & 15u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@vertex fn horizon_material_vs(
@@ -81,13 +65,8 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
translucent, horizon_sprite_tags(in.layer & 0x7fffu))).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
@@ -338,12 +317,7 @@ mod tests {
fn apply_fog(c: vec4<f32>) -> vec4<f32> { return c * gain; }\n\
fn material(input: MaterialInput) -> Surface {\n \
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
let t = vec2<i32>(input.screen * frame.resolution);\n \
let behind = textureLoad(opaque_color, t, 0) * textureLoad(opaque_depth, t, 0);\n \
let mirrored = textureSampleLevel(reflection, reflection_sampler, input.screen, 0.0);\n \
let kind = f32(input.face + input.fluid) * input.emission * mirrored.a;\n \
let frame_of = vec4(cross(input.normal, input.tangent.xyz) * input.tangent.w, kind);\n \
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
return Surface(apply_fog(sprite(lit)));\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
@@ -29,20 +29,7 @@ impl NativePack {
constants: &[(String, f64)],
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let mut frame = NativeFrame::new(device);
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.is_some_and(|s| s.reads("reflection"))
&& option.is_none_or(|id| {
constants
.iter()
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let frame = NativeFrame::new(device);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -24,89 +24,24 @@ pub struct NativePost {
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
history_blank: wgpu::Texture,
/// One bind group per pass, for these world views and either the valid history or black.
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// One bind group per pass, for these world views.
groups: Option<(WorldViews, Vec<wgpu::BindGroup>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame.
pub struct NativeFrame {
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
/// Group 3 of the stages that read only the frame (the sky).
pub layout: wgpu::BindGroupLayout,
pub group: wgpu::BindGroup,
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
/// binding 6), copied each frame.
pub opaque_depth: wgpu::Texture,
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
blank: wgpu::TextureView,
reflection_sampler: wgpu::Sampler,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
/// Last frame's view-projection, camera and time (`write`).
last: Option<([[f32; 4]; 4], glam::DVec3, std::time::Instant)>,
/// The eased eye light (block, sky) and being in water and lava (`write`); NaN until the
/// first frame sets them.
eye: [f32; 4],
/// A post stage must use black history after a long gap until it has rendered a frame.
history_reset: bool,
start: std::time::Instant,
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
}
/// A depth texture of `size` the world's depth is copied into.
fn depth_copy(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
copy_target(
device,
"Native shader pack opaque depth",
size,
wgpu::TextureFormat::Depth32Float,
)
}
/// A colour texture of `size` and `format` the world's colour is copied into.
fn color_copy(device: &wgpu::Device, size: [u32; 2], format: wgpu::TextureFormat) -> wgpu::Texture {
copy_target(device, "Native shader pack opaque colour", size, format)
}
fn copy_target(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
}
/// The uniform `frame` at binding 0, read by `visibility`.
fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
@@ -121,24 +56,6 @@ fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
}
}
/// The size of the water mirror for a world of `size`: half of it.
pub fn reflection_size(size: [u32; 2]) -> [u32; 2] {
size.map(|s| s.div_ceil(2).max(1))
}
/// Seconds between frames past which the last one is forgotten (`NativeFrame::write`).
const MAX_FRAME_GAP: f32 = 0.5;
/// `value` moved toward `target` over `dt` seconds by a half-life in seconds; a NaN `value`
/// (the first frame) starts at `target`.
fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
if value.is_nan() {
target
} else {
target + (value - target) * 0.5f32.powf(dt / half_life)
}
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -149,202 +66,37 @@ impl NativeFrame {
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
entries: &[frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT)],
});
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
device,
"Native shader pack reflection blank",
[1, 1],
wgpu::TextureFormat::Rgba8Unorm,
)
.create_view(&Default::default());
let reflection_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack reflection sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&opaque_depth,
&opaque_color,
);
Self {
buffer,
layout,
group,
opaque_depth,
opaque_color,
reflection: blank.clone(),
blank,
reflection_sampler,
reflects: false,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
start: std::time::Instant::now(),
frames: 0,
wetness: (0.0, None),
}
}
fn group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
&opaque_depth.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
})
}
/// Keeps the opaque copies the size of the world's depth `depth` and, where the world's
/// colour `color` can be copied (the scaled target, not the window's own), its size and
/// format; the group is made again when they change.
pub fn fit_opaque(
&mut self,
device: &wgpu::Device,
depth: &wgpu::Texture,
color: Option<&wgpu::Texture>,
) {
let size = [depth.width(), depth.height()];
let (color_size, format) = match color {
Some(c) => ([c.width(), c.height()], c.format()),
None => ([1, 1], self.opaque_color.format()),
};
let color_fits = [self.opaque_color.width(), self.opaque_color.height()] == color_size
&& self.opaque_color.format() == format;
let depth_fits = [self.opaque_depth.width(), self.opaque_depth.height()] == size;
if depth_fits && color_fits {
return;
}
if !color_fits {
self.opaque_color = color_copy(device, color_size, format);
}
if !depth_fits {
self.opaque_depth = depth_copy(device, size);
}
self.regroup(device);
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
let view = view.unwrap_or(&self.blank);
if *view != self.reflection {
self.reflection = view.clone();
self.regroup(device);
}
}
fn regroup(&mut self, device: &wgpu::Device) {
self.group = Self::group(
device,
&self.layout,
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.opaque_depth,
&self.opaque_color,
);
}
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
let now = std::time::Instant::now();
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let dt = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
self.wetness = (wetness(self.wetness.0, frame.pack.rain, dt), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
}
let view_proj = (Mat4::from_cols_array_2d(&inputs.projection)
* Mat4::from_cols_array_2d(&inputs.view))
.to_cols_array_2d();
inputs.previous_view_projection = view_proj;
// `write` skips title frames, so a gap means a pause or another world: the frame
// before says nothing about this one, and the eased values start over.
match self.last {
Some((previous, camera, time)) if (now - time).as_secs_f32() < MAX_FRAME_GAP => {
inputs.previous_view_projection = previous;
inputs.camera_motion = (frame.camera - camera).as_vec3().to_array();
inputs.delta_time = (now - time).as_secs_f32();
}
_ => {
self.eye = [f32::NAN; 4];
self.history_reset = true;
}
}
self.last = Some((view_proj, frame.camera, now));
// The eye's light and medium eased by half-lives in seconds, from where they are at
// first.
let targets = [
(inputs.eye_light[0], 2.0),
(inputs.eye_light[1], 2.0),
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
];
let dt = inputs.delta_time;
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
*value = eased(*value, target, half_life, dt);
}
let [block, sky, water, lava] = self.eye;
inputs.eye_light_smooth = [block, sky];
inputs.in_water = water;
inputs.in_lava = lava;
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
/// Takes the post stage's pending history reset. It stays pending if post rendering is
/// skipped for a frame, so the first frame it does render still sees black history.
pub(super) fn take_history_reset(&mut self) -> bool {
let reset = self.history_reset;
self.history_reset = false;
reset
}
}
/// The option values of `constants` that `module` declares as overrides (wgpu refuses others).
@@ -406,7 +158,6 @@ impl NativePost {
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(8, float),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -474,10 +225,7 @@ impl NativePost {
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
history: None,
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
format,
})
}
@@ -492,7 +240,6 @@ impl NativePost {
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
@@ -508,10 +255,8 @@ impl NativePost {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
// COPY_SRC: the last pass's input is kept as next frame's `history`.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
@@ -523,28 +268,15 @@ impl NativePost {
target("Native shader pack B"),
],
));
self.history = Some(history_target(device, "Native shader pack history", size));
self.history_valid = false;
self.groups = None;
}
let blank_history = history_reset || !self.history_valid;
if self
.groups
.as_ref()
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
})
.is_some_and(|((color, depth), _)| color == world.0 && depth == world.1)
{
return;
}
let history = if blank_history {
&self.history_blank
} else {
self.history
.as_ref()
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
@@ -580,15 +312,11 @@ impl NativePost {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
],
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
self.groups = Some(((world.0.clone(), world.1.clone()), groups));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -600,14 +328,13 @@ impl NativePost {
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size, history_reset);
self.prepare(device, world, size);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, _, groups)) = &self.groups else {
let Some((_, groups)) = &self.groups else {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
@@ -634,40 +361,9 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
self.history_valid = true;
}
}
}
/// A target of `size` the post stage's `history` is copied into.
fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
}
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
@@ -738,28 +434,11 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
temperature: pack.temperature,
downfall: pack.rainfall,
wetness: pack.rain,
// Filled by `NativeFrame::write`, which remembers the frames before.
previous_view_projection: m(Mat4::IDENTITY),
camera_motion: [0.0; 3],
delta_time: 0.0,
eye_light_smooth: [level(pack.eye_light[0]), level(pack.eye_light[1])],
in_water: f32::from(u8::from(pack.eye_in as u32 == 1)),
in_lava: f32::from(u8::from(pack.eye_in as u32 == 2)),
water_plane: frame.water_plane.unwrap_or(0.0),
has_reflection: u32::from(frame.water_plane.is_some()),
reflection_resolution: reflection_size(size).map(|s| s as f32),
}
}
#[cfg(test)]
mod tests {
#[test]
fn eased_values_start_at_the_target_and_halve_the_distance() {
use super::eased;
assert_eq!(eased(f32::NAN, 0.25, 2.0, 0.0), 0.25);
assert_eq!(eased(1.0, 0.0, 2.0, 2.0), 0.5);
assert_eq!(eased(1.0, 0.0, 2.0, 0.0), 1.0);
}
use super::*;
use horizon_shader_api::{MANIFEST, Pack, check};
@@ -867,15 +546,7 @@ mod tests {
..Default::default()
});
let view = screen.create_view(&Default::default());
post.run(
&device,
&mut encoder,
(&color, &depth),
&view,
[4, 4],
true,
None,
);
post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
@@ -906,111 +577,4 @@ mod tests {
"{pixel:?}"
);
}
/// `history` is black on the first frame, then what the pass before the last wrote the
/// frame before.
#[test]
fn the_last_pass_reads_last_frames_input_as_history() {
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1
id = \"t\"
name = \"T\"
version = \"1\"
api = \"0.1\"
[[post]]
file = \"a.wgsl\"
[[post]]
file = \"b.wgsl\"
";
let a = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 0.5, 1.0); }
";
let b = "fn post(input: PostInput) -> vec4<f32> { return textureLoad(history, vec2<i32>(input.pixel), 0); }
";
let files = [(MANIFEST, manifest), ("a.wgsl", a), ("b.wgsl", b)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = screen.create_view(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut frame_pixel = |history_reset| {
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
post.run(
&device,
&mut encoder,
(&color, &depth),
&view,
[4, 4],
history_reset,
None,
);
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
buffer.unmap();
pixel
};
assert_eq!(
frame_pixel(true)[..3],
[0, 0, 0],
"black on the first frame"
);
let second = frame_pixel(false);
assert!(
second[..3].iter().all(|&v| v.abs_diff(128) <= 1),
"{second:?}"
);
assert_eq!(frame_pixel(true)[..3], [0, 0, 0], "black after a frame gap");
let fourth = frame_pixel(false);
assert!(
fourth[..3].iter().all(|&v| v.abs_diff(128) <= 1),
"{fourth:?}"
);
}
}
@@ -1,74 +0,0 @@
//! Render hook points for native mods (docs/native-mods.md): empty functions the renderer calls at
//! fixed places of a frame, which a mod's mixins hook like those of
//! `horizon-client/src/game/native_hooks.rs`, and listed with them in `native-hooks.json`. API
//! 58.
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
pub struct WorldDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
pub encoder: &'a mut wgpu::CommandEncoder,
/// The frame's target and a view of it in `color_format`.
pub color: &'a wgpu::Texture,
pub color_view: &'a wgpu::TextureView,
pub color_format: wgpu::TextureFormat,
/// The size of `color`.
pub size: [u32; 2],
/// The world's depth (Depth32Float, sampleable), and its size: smaller than `size` when the
/// world draws at a lower resolution. It holds the frame's depth only when a mod asked for
/// it ([`RenderNeeds::world_depth`]) or something else kept it.
pub depth: &'a wgpu::TextureView,
pub depth_size: [u32; 2],
/// Camera-relative world to clip space, without TAA's jitter, and with it (what the world
/// was drawn with).
pub view_proj: Mat4,
pub jittered: Mat4,
/// The camera's position in the world.
pub camera: DVec3,
/// The camera-relative entity geometry of the frame (`Frame::entities`), quads of 4
/// vertices, and its runs by entity when a mod asked for them
/// ([`RenderNeeds::entity_spans`]) or a shader pack draws them.
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
}
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
/// needs and leaves the rest, so several can ask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RenderNeeds {
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
/// their type's name, block entity runs their block state.
pub entity_spans: bool,
/// Keep the world pass's depth for [`native_world_drawn`] when nothing else does.
pub world_depth: bool,
}
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
#[inline(never)]
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
let _ = world;
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
let _ = needs;
}
/// What the mods need this frame ([`native_render_needs`]).
pub fn render_needs() -> RenderNeeds {
let mut needs = RenderNeeds::default();
native_render_needs(&mut needs);
needs
}
@@ -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,
@@ -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)",
@@ -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);
@@ -1,460 +0,0 @@
//! A shader pack's own images (`image.<name>`) and storage buffers (`bufferObject.<n>`) on the
//! GPU: created at the render size they depend on, kept across frames, images cleared before a
//! frame when the pack asks.
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
pub(super) struct PackImage {
pub(super) def: CustomImage,
pub(super) format: wgpu::TextureFormat,
pub(super) dimension: wgpu::TextureViewDimension,
pub(super) view: Option<wgpu::TextureView>,
/// Last frame's contents, for a pass that samples and stores the image (`copied`).
pub(super) copy: Option<wgpu::TextureView>,
pub(super) copied: bool,
/// The contents before a compute pass that reads them so (`snapshot`), apart from `copy`,
/// which a later pass may still sample.
pub(super) before: Option<wgpu::TextureView>,
pub(super) snapshotted: bool,
/// The render size the image was made for (relative images follow it).
made_for: [u32; 2],
}
impl PackImage {
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
self.format
.sample_type(None, Some(features))
.unwrap_or(wgpu::TextureSampleType::Float { filterable: false })
}
}
/// The images `image.*` declares.
pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
defs.into_iter()
.map(|def| PackImage {
format: format(def.format),
dimension: if def.is_3d() {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
},
def,
view: None,
copy: None,
copied: false,
before: None,
snapshotted: false,
made_for: [0; 2],
})
.collect()
}
/// The wgpu format of an image's internal format: a storage format of the same channels.
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let pick = |r, rg, rgba| match f.channels {
1 => r,
2 => rg,
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Float, _) => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
(Kind::Snorm, _) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Unorm, 16) => pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm),
(Kind::Unorm, _) => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
}
}
/// Whether this device can make `def`: its format's features, and a size in pixels within the
/// device's limits.
pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), String> {
let format = format(def.format);
let missing = format.required_features() - device.features();
if !missing.is_empty() {
return Err(format!(
"{format:?} needs {missing:?}, which this GPU lacks"
));
}
let limits = device.limits();
let largest = if def.is_3d() {
limits.max_texture_dimension_3d
} else {
limits.max_texture_dimension_2d
};
let [width, height, depth] = def.extent([1, 1]);
let relative = matches!(def.size, horizon_shaders::ImageSize::Relative(_));
if !relative && width.max(height).max(depth) > largest {
return Err(format!(
"{width}x{height}x{depth} is larger than this GPU's {largest}"
));
}
Ok(())
}
/// Makes the images that are missing or sized for another render size; whether it made any.
pub(super) fn resize_images(
device: &wgpu::Device,
images: &mut [PackImage],
size: [u32; 2],
) -> bool {
let largest = device.limits().max_texture_dimension_2d;
let mut made = false;
for image in images {
let relative = matches!(image.def.size, horizon_shaders::ImageSize::Relative(_));
if image.view.is_some() && (!relative || image.made_for == size) {
continue;
}
made = true;
// A relative image larger than the render size stops at the device's limit.
let [width, height, depth] = image.def.extent(size);
let [width, height] = [width.min(largest), height.min(largest)];
let make = |usage| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(&image.def.name),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: depth,
},
mip_level_count: 1,
sample_count: 1,
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
format: image.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | usage,
view_formats: &[],
})
.create_view(&Default::default())
};
// Atomics on a texture need it made for them (Metal ignores them otherwise).
let atomic = device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
&& matches!(
image.format,
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
);
let atomic = if atomic {
wgpu::TextureUsages::STORAGE_ATOMIC
} else {
wgpu::TextureUsages::empty()
};
image.view = Some(make(
wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC
| atomic,
));
image.copy = image
.copied
.then(|| make(wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::STORAGE_BINDING));
image.before = image
.snapshotted
.then(|| make(wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::STORAGE_BINDING));
image.made_for = size;
}
made
}
/// The most images a pack may have.
pub(super) const IMAGES: usize = 64;
/// Copies `image` as it is before a compute pass whose loads read it so.
pub(super) fn snapshot(encoder: &mut wgpu::CommandEncoder, image: &PackImage) {
if let (Some(view), Some(before)) = (&image.view, &image.before) {
encoder.copy_texture_to_texture(
view.texture().as_image_copy(),
before.texture().as_image_copy(),
view.texture().size(),
);
}
}
/// Copies the images of `mask` (bit n: image n), which a pass samples and stores, for that pass
/// to sample.
pub(super) fn copy(encoder: &mut wgpu::CommandEncoder, images: &[PackImage], mask: u64) {
for (_, image) in images
.iter()
.enumerate()
.filter(|(k, _)| mask & 1 << k != 0)
{
if let (Some(view), Some(copy)) = (&image.view, &image.copy) {
encoder.copy_texture_to_texture(
view.texture().as_image_copy(),
copy.texture().as_image_copy(),
view.texture().size(),
);
}
}
}
/// Clears the images the pack clears before each frame: with `clear_texture` where the device
/// has it (`clear`), else by copying from `zeros`, a buffer of zeros made large enough.
pub(super) fn clear(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
images: &[PackImage],
clear: bool,
zeros: &mut Option<wgpu::Buffer>,
) {
for view in images
.iter()
.filter(|i| i.def.clear)
.filter_map(|i| i.view.as_ref())
{
let texture = view.texture();
if clear {
encoder.clear_texture(texture, &Default::default());
continue;
}
let size = texture.size();
let texel = u64::from(texture.format().block_copy_size(None).unwrap_or(4));
let row = (u64::from(size.width) * texel).next_multiple_of(256);
// Zeros for a slice of rows at a time, the whole image taken in slices, so a large
// volume doesn't need a buffer of its size.
let rows = (ZEROS / row).clamp(1, u64::from(size.height)) as u32;
let bytes = row * u64::from(rows);
if zeros.as_ref().is_none_or(|z| z.size() < bytes) {
// New buffers start zeroed.
*zeros = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack image zeros"),
size: bytes,
usage: wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
}));
}
let Some(buffer) = zeros.as_ref() else {
continue;
};
for z in 0..size.depth_or_array_layers {
for y in (0..size.height).step_by(rows as usize) {
let height = rows.min(size.height - y);
encoder.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row as u32),
rows_per_image: Some(height),
},
},
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d { x: 0, y, z },
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width: size.width,
height,
depth_or_array_layers: 1,
},
);
}
}
}
}
/// The most bytes of zeros kept to clear images without `CLEAR_TEXTURE`.
const ZEROS: u64 = 4 << 20;
/// The pack's storage buffers, bound in set 4.
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
/// The layout of `objects`, bound in `stages`.
pub(super) fn new(
device: &wgpu::Device,
objects: BTreeMap<u32, BufferObject>,
stages: wgpu::ShaderStages,
) -> Self {
let entries: Vec<_> = objects
.keys()
.map(|&binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: stages,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack buffers"),
entries: &entries,
});
Self {
layout,
objects,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
}
}
/// Makes the buffers (again, when one depends on the render size and it changed): their
/// contents start zeroed and persist across frames.
pub(super) fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
let relative = self
.objects
.values()
.any(|o| matches!(o, BufferObject::PerPixel { .. }));
if self.group.is_some() && (!relative || self.made_for == size) {
return;
}
let limits = device.limits();
let largest = limits
.max_buffer_size
.min(limits.max_storage_buffer_binding_size)
& !3;
self.buffers = self
.objects
.iter()
.map(|(binding, o)| {
let wanted = o.size(size).next_multiple_of(4);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
binds at most {largest}"
);
}
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size: wanted.min(largest),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
})
})
.collect();
let entries: Vec<_> = self
.objects
.keys()
.zip(&self.buffers)
.map(|(&binding, b)| wgpu::BindGroupEntry {
binding,
resource: b.as_entire_binding(),
})
.collect();
self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack buffers"),
layout: &self.layout,
entries: &entries,
}));
self.made_for = size;
}
/// Whether the pack declares buffer `binding`.
pub(super) fn has(&self, binding: u32) -> bool {
self.objects.contains_key(&binding)
}
}
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
/// frames, how many of its texels are not zero, logged.
pub(super) struct Dump {
name: String,
frame: u32,
pending: Option<(wgpu::Buffer, usize, [u32; 3], u32)>,
}
impl Dump {
pub(super) fn new() -> Option<Self> {
let name = std::env::var("HORIZON_SHADER_PACK_DUMP").ok()?;
Some(Self {
name,
frame: 0,
pending: None,
})
}
/// Reads last frame's copy, once its commands ran.
pub(super) fn read(&mut self, device: &wgpu::Device) {
let Some((buffer, texel, [w, h, d], row)) = self.pending.take() else {
return;
};
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
let Ok(data) = buffer.slice(..).get_mapped_range() else {
return;
};
let (mut set, mut total) = (0usize, 0usize);
for z in 0..d as usize {
for y in 0..h as usize {
let start = (z * h as usize + y) * row as usize;
for x in 0..w as usize {
let at = start + x * texel;
total += 1;
if data[at..at + texel].iter().any(|&b| b != 0) {
set += 1;
}
}
}
}
log::info!(
"shader pack image {}: {set} of {total} texels set",
self.name
);
}
/// Copies the image for `read` every 60 frames.
pub(super) fn copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
images: &[PackImage],
) {
self.frame += 1;
if !self.frame.is_multiple_of(60) {
return;
}
let Some(image) = images.iter().find(|i| i.def.name == self.name) else {
return;
};
let Some(view) = &image.view else { return };
let size = view.texture().size();
let texel = image.format.block_copy_size(None).unwrap_or(4) as usize;
let row = (size.width as usize * texel).next_multiple_of(256) as u32;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack image dump"),
size: u64::from(row) * u64::from(size.height) * u64::from(size.depth_or_array_layers),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
view.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: Some(size.height),
},
},
size,
);
let dims = [size.width, size.height, size.depth_or_array_layers];
self.pending = Some((buffer, texel, dims, row));
}
}
File diff suppressed because it is too large. Load diff
@@ -82,9 +82,9 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}}\n \
hz_Vertex = vec4(hz_p, 1.0);\n \
hz_MultiTexCoord0 = vec4(0.0, 0.0, 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_Normal = vec3(0.0, 0.0, 1.0);\n"
hz_Normal = vec3(0.0, 1.0, 0.0);\n"
);
horizon_shaders::GbuffersInputs {
declarations,
@@ -109,7 +109,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
.into(),
body: " hz_Vertex = vec4(hz_v_pos, 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_v_color;\n \
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
@@ -1,339 +0,0 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
View(wgpu::TextureView),
Lightmap,
Atlas,
}
pub(super) struct PackTexture {
pub(super) stage: String,
pub(super) name: String,
pub(super) dimension: wgpu::TextureViewDimension,
pub(super) sample_type: wgpu::TextureSampleType,
pub(super) blur: bool,
pub(super) clamp: bool,
pub(super) image: Image,
}
/// Uploads `textures`, with a note for each the game can't provide.
pub(super) fn upload(
device: &wgpu::Device,
queue: &wgpu::Queue,
textures: Vec<CustomTexture>,
notes: &mut Vec<String>,
) -> Vec<PackTexture> {
let features = device.features();
let mut out = Vec::new();
for t in textures {
let (image, dimension, sample_type, blur, clamp) = match t.source {
TextureSource::Image(texture) => {
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
Err(e) => {
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
continue;
}
};
let dimension = match texture.dim {
TextureDim::D1 => wgpu::TextureViewDimension::D1,
TextureDim::D2 => wgpu::TextureViewDimension::D2,
TextureDim::D3 => wgpu::TextureViewDimension::D3,
};
(
Image::View(view),
dimension,
sample_type,
texture.blur,
texture.clamp,
)
}
TextureSource::Resource(location) => {
let path = location.strip_prefix("minecraft:").unwrap_or(&location);
let image = if path.starts_with("dynamic/lightmap") {
Image::Lightmap
} else if path.starts_with("textures/atlas/blocks") || path == "dynamic/atlas" {
Image::Atlas
} else {
notes.push(format!(
"texture.{}.{}: `{location}` is not provided yet",
t.stage, t.name
));
continue;
};
let float = wgpu::TextureSampleType::Float { filterable: true };
(image, wgpu::TextureViewDimension::D2, float, true, true)
}
};
out.push(PackTexture {
stage: t.stage,
name: t.name,
dimension,
sample_type,
blur,
clamp,
image,
});
}
out
}
/// The stage name of `texture.<stage>.*` a program of `group` reads.
fn stage(group: Group) -> &'static str {
match group {
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
Group::Prepare => "prepare",
Group::Deferred => "deferred",
Group::Composite | Group::Final => "composite",
Group::ShadowComp => "shadowcomp",
}
}
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
/// may hold one texture per dimension.
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
let dimension = if ty.contains("1D") {
wgpu::TextureViewDimension::D1
} else if ty.contains("3D") {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
})
}
/// `texture.noise`, if the pack has one.
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
textures
.iter()
.find(|t| t.stage == "noise" && t.dimension == wgpu::TextureViewDimension::D2)
.and_then(|t| match &t.image {
Image::View(v) => Some(v),
_ => None,
})
}
/// The wgpu format a texture of `texture.format` is stored in: sampled, never rendered to, so
/// any format whose texels hold the values.
fn format(texture: &Texture) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let f = texture.format;
let pick = |r, rg, rgba| match texture.components() {
1 => r,
2 => rg,
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
(Kind::Snorm, 8) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
// 16-bit and packed normalized formats, half floats and the small floats: halves hold
// them closely enough.
_ => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
}
}
/// `texture`'s texels as the bytes of `format`, row after row.
fn bytes(texture: &Texture, format: wgpu::TextureFormat) -> Vec<u8> {
match (&texture.texels, format) {
(Texels::Float(v), f) if f.block_copy_size(None) == Some(texture.components() as u32) => {
if matches!(
f,
wgpu::TextureFormat::R8Snorm
| wgpu::TextureFormat::Rg8Snorm
| wgpu::TextureFormat::Rgba8Snorm
) {
v.iter()
.map(|x| (x.clamp(-1.0, 1.0) * 127.0).round() as i8 as u8)
.collect()
} else {
v.iter()
.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect()
}
}
(Texels::Float(v), f)
if f.block_copy_size(None) == Some(4 * texture.components() as u32) =>
{
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
(Texels::Float(v), _) => v
.iter()
.flat_map(|&x| half::f16::from_f32(x).to_le_bytes())
.collect(),
(Texels::Int(v), f) => {
let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
v.iter()
.flat_map(|x| x.to_le_bytes().into_iter().take(width))
.collect()
}
(Texels::Uint(v), f) => {
let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
v.iter()
.flat_map(|x| x.to_le_bytes().into_iter().take(width))
.collect()
}
}
}
fn create(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &Texture,
features: wgpu::Features,
) -> Result<(wgpu::TextureView, wgpu::TextureSampleType), String> {
let format = format(texture);
let bytes = bytes(texture, format);
let [width, height, depth] = texture.size;
let limits = device.limits();
let largest = match texture.dim {
TextureDim::D1 => limits.max_texture_dimension_1d,
TextureDim::D2 => limits.max_texture_dimension_2d,
TextureDim::D3 => limits.max_texture_dimension_3d,
};
if [width, height, depth].iter().any(|&n| n > largest) {
return Err(format!(
"{width}x{height}x{depth} is larger than this device's {largest}"
));
}
let row = width * format.block_copy_size(None).unwrap_or(4);
if bytes.len() as u64 != u64::from(row) * u64::from(height) * u64::from(depth) {
return Err(format!(
"{} bytes of texels for {width}x{height}x{depth}",
bytes.len()
));
}
let size = wgpu::Extent3d {
width,
height,
depth_or_array_layers: depth,
};
let dimension = match texture.dim {
TextureDim::D1 => wgpu::TextureDimension::D1,
TextureDim::D2 => wgpu::TextureDimension::D2,
TextureDim::D3 => wgpu::TextureDimension::D3,
};
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack custom texture"),
size,
mip_level_count: 1,
sample_count: 1,
dimension,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
tex.as_image_copy(),
&bytes,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: Some(height),
},
size,
);
let sample_type = format
.sample_type(None, Some(features))
.unwrap_or(wgpu::TextureSampleType::Float { filterable: false });
Ok((tex.create_view(&Default::default()), sample_type))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shaders::Format;
fn lightmap(stage: &str, name: &str, dimension: wgpu::TextureViewDimension) -> PackTexture {
PackTexture {
stage: stage.into(),
name: name.into(),
dimension,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
blur: false,
clamp: false,
image: Image::Lightmap,
}
}
fn texture(format: &str, texels: Texels) -> Texture {
Texture {
dim: TextureDim::D2,
size: [1, 1, 1],
format: Format::parse(format).unwrap(),
texels,
blur: false,
clamp: false,
}
}
#[test]
fn samplers_find_their_stage_name_and_dimension() {
use wgpu::TextureViewDimension as D;
let textures = [
lightmap("composite", "colortex4", D::D2),
lightmap("composite", "noisetex", D::D3),
lightmap("gbuffers", "gaux2", D::D2),
lightmap("composite", "noisetex", D::D1),
];
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), Some(0));
assert_eq!(
find(&textures, Group::Composite, "sampler3D", "noisetex"),
Some(1)
);
assert_eq!(
find(&textures, Group::Composite, "sampler1D", "noisetex"),
Some(3)
);
assert_eq!(
find(&textures, Group::Composite, "sampler2D", "noisetex"),
None
);
assert_eq!(
find(&textures, Group::Shadow, "sampler2D", "colortex5"),
Some(2)
);
assert_eq!(
find(&textures, Group::Deferred, "sampler2D", "colortex4"),
None
);
}
#[test]
fn texels_pack_into_their_format() {
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
assert_eq!(format(&t), wgpu::TextureFormat::Rgba8Unorm);
assert_eq!(bytes(&t, format(&t)), [0, 128, 255, 255]);
let t = texture("RG8_SNORM", Texels::Float(vec![-1.0, 0.5]));
assert_eq!(format(&t), wgpu::TextureFormat::Rg8Snorm);
assert_eq!(bytes(&t, format(&t)), [0x81, 64]);
let t = texture("R32F", Texels::Float(vec![1.0]));
assert_eq!(bytes(&t, format(&t)), 1.0f32.to_le_bytes());
let t = texture("R16F", Texels::Float(vec![1.0]));
assert_eq!(format(&t), wgpu::TextureFormat::R16Float);
assert_eq!(bytes(&t, format(&t)), [0x00, 0x3c]);
let t = texture("RG16I", Texels::Int(vec![-2, 3]));
assert_eq!(format(&t), wgpu::TextureFormat::Rg16Sint);
assert_eq!(bytes(&t, format(&t)), [0xfe, 0xff, 3, 0]);
let t = texture("R8UI", Texels::Uint(vec![7]));
assert_eq!(format(&t), wgpu::TextureFormat::R8Uint);
assert_eq!(bytes(&t, format(&t)), [7]);
}
}
@@ -30,8 +30,6 @@ pub struct PackInputs {
pub biome_precipitation: i32,
pub temperature: f32,
pub rainfall: f32,
/// Whether the biome at the camera is known (its chunk is loaded).
pub biome_known: bool,
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
@@ -162,15 +160,6 @@ pub const NAMES: &[&str] = &[
"shadowModelViewInverse",
"shadowProjection",
"shadowProjectionInverse",
"cameraPositionInt",
"cameraPositionFract",
"previousCameraPositionInt",
"previousCameraPositionFract",
"eyePosition",
"relativeEyePosition",
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
];
/// Values carried from frame to frame.
@@ -181,7 +170,7 @@ pub struct FrameUniforms {
start: std::time::Instant,
last: Option<std::time::Instant>,
frame: u32,
previous: Option<(glam::DVec3, Mat4, Mat4)>,
previous: Option<(Vec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// The pack's shadow settings (7.1).
@@ -293,33 +282,19 @@ impl FrameUniforms {
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
let aspect = size[0] as f32 / size[1].max(1) as f32;
// Packs take gbufferProjection for a plain perspective (they invert only its perspective
// terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
let view = frame.bob * frame.spin * crate::camera::view_matrix(frame.yaw, frame.pitch);
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = Mat4::perspective_rh_gl(
frame.fov_degrees.to_radians(),
aspect,
0.05,
frame.depth_far,
);
) * frame.bob
* frame.spin;
let camera = frame.camera.as_vec3();
let (previous_exact, previous_view, previous_projection) =
let (previous_camera, previous_view, previous_projection) =
self.previous
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
let previous_camera = previous_exact.as_vec3();
self.previous = Some((frame.camera, view, projection));
// The camera's whole blocks and the rest, exact however far from the origin.
let whole = |p: glam::DVec3| p.floor();
let int = |p: glam::DVec3| Value::i(&whole(p).as_ivec3().to_array());
let fract = |p: glam::DVec3| Value::f(&(p - whole(p)).as_vec3().to_array());
// The player's, not the camera's: in a third-person view the camera is behind (or in
// front of, turned around) the eye the pack lights from.
let [yaw, pitch] = frame.eye_rotation.map(f32::to_radians);
let look = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
.unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO));
self.previous = Some((camera, view, projection));
let to_view = |v: Vec3| view.transform_vector3(v);
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
@@ -364,15 +339,6 @@ impl FrameUniforms {
match name {
"cameraPosition" => v3(camera),
"previousCameraPosition" => v3(previous_camera),
"cameraPositionInt" => int(frame.camera),
"cameraPositionFract" => fract(frame.camera),
"previousCameraPositionInt" => int(previous_exact),
"previousCameraPositionFract" => fract(previous_exact),
// The camera is the eye in first person.
"eyePosition" => v3(frame.eye.as_vec3()),
"relativeEyePosition" => v3((frame.camera - frame.eye).as_vec3()),
"playerLookVector" | "playerBodyVector" => v3(look),
"firstPersonCamera" => Value::i(&[i32::from(frame.first_person)]),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
@@ -484,14 +450,6 @@ fn components(name: &str) -> usize {
match name {
"cameraPosition"
| "previousCameraPosition"
| "cameraPositionInt"
| "cameraPositionFract"
| "previousCameraPositionInt"
| "previousCameraPositionFract"
| "eyePosition"
| "relativeEyePosition"
| "playerLookVector"
| "playerBodyVector"
| "upPosition"
| "sunPosition"
| "moonPosition"
@@ -2,9 +2,7 @@
//! per-frame culling walk: a column whose box misses the frustum or lies past
//! the render distance rejects all of its sections with one test, and the
//! sections that remain are reached by index rather than by hashing keys.
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use super::{Frustum, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use glam::{DVec3, Vec3};
@@ -61,9 +59,6 @@ impl<T> Resident<T> {
pub fn values(&self) -> impl Iterator<Item = &T> {
self.items.iter().map(|(_, v)| v)
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
@@ -231,7 +226,7 @@ fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
frustum: &impl Cull,
frustum: &Frustum,
inside: bool,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
@@ -263,7 +258,7 @@ impl<T> Resident<T> {
&self,
camera: DVec3,
radius: i32,
frustum: &impl Cull,
frustum: &Frustum,
reachable: impl Fn(SectionKey) -> bool,
beyond: impl Fn(i32, i32) -> bool,
out: &mut Vec<Visible>,
@@ -53,7 +53,13 @@ impl Weather {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(depth_stencil()),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
@@ -151,33 +157,11 @@ impl Weather {
}
}
/// RenderPipelines.WEATHER: depth-tested, but no depth write, so the water
/// and glass drawn behind the rain still show through it.
fn depth_stencil() -> wgpu::DepthStencilState {
wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}
}
#[cfg(test)]
mod tests {
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
#[test]
fn weather_writes_no_depth() {
let depth = super::depth_stencil();
assert_eq!(depth.depth_write_enabled, Some(false));
assert_eq!(
depth.depth_compare,
Some(wgpu::CompareFunction::GreaterEqual)
);
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Case {
@@ -335,11 +335,8 @@ impl Post {
view_formats: &[],
})
};
// COPY_SRC: a native pack's material stage reads copies of them (`opaque_depth`,
// `opaque_color`).
let sampled = wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC;
let sampled =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = make_tex("Scaled world", size, self.format, sampled)
.create_view(&Default::default());
// TEXTURE_BINDING: occlusion culling and TAA read it.
+1 -9
View File
@@ -399,10 +399,6 @@ pub struct Graphics {
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
pub battery: bool,
/// Reuse the shader pack's last shadow map when sun, view, scene revision and
/// pack uniforms still match. Off by default: vanilla loaders redraw every frame
/// (or every `shadowInterval`), and a hit with a stale key shows old shadows.
pub shadow_cache: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
@@ -439,7 +435,6 @@ impl Default for Graphics {
hdr_ui_brightness: 100,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
}
@@ -521,7 +516,6 @@ impl Graphics {
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -538,7 +532,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -572,7 +566,6 @@ impl Graphics {
self.hdr_ui_brightness,
self.hdr_key,
bool_text(self.battery),
bool_text(self.shadow_cache),
self.api.name(),
self.shader_pack,
)
@@ -717,7 +710,6 @@ mod tests {
hdr_ui_brightness: 60,
hdr_key: "key.keyboard.h".into(),
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
};
@@ -147,34 +147,6 @@ impl Style {
dragging && active,
);
}
pub fn slider_ext(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
ui::slider_ext(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
focused,
can_change_value,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
+6 -472
View File
@@ -52,7 +52,6 @@ pub enum H {
HdrPaperWhite,
HdrUiBrightness,
BatteryMode,
ShadowCache,
GraphicsApi,
}
@@ -314,11 +313,6 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"60 FPS cap (30 in menus or unfocused), half the workers: less power.",
),
h(
H::ShadowCache,
Saves,
"Reuses the pack shadow map when sun, view and world match. Off by default.",
),
h(
H::Workers,
None,
@@ -480,19 +474,6 @@ impl H {
_ => None,
}
}
pub fn slider_steps(self) -> Option<u32> {
match self {
H::ChunkUpdates => Some(64),
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::HdrPaperWhite => Some(41),
H::HdrPeak => Some(37),
H::HdrBlackLevel => Some(100),
H::HdrUiBrightness => Some(15),
_ => None,
}
}
fn label(self, s: &Settings) -> Text {
let g = &s.horizon;
match self {
@@ -577,7 +558,6 @@ impl H {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
"Performance Overlay",
@@ -656,7 +636,6 @@ impl H {
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::Pacing => {
g.pacing = match g.pacing {
@@ -719,7 +698,6 @@ impl Item {
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -779,7 +757,6 @@ impl Item {
| H::PresentMode
| H::Workers
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
) | Item::Vanilla(
Opt::GuiScale
@@ -800,39 +777,6 @@ impl Item {
s.horizon.preset = Preset::Custom;
}
}
pub fn slider_steps(self) -> Option<u32> {
match self {
Item::Vanilla(o) => o.slider_steps(),
Item::Horizon(h) => h.slider_steps(),
}
}
pub fn step_slider(self, s: &mut Settings, forward: bool, width: f32) {
if !self.available() || self.blocked(s).is_some() {
return;
}
let Some(pos) = self.slider(s) else {
return;
};
let next_pos = if let Some(steps) = self.slider_steps() {
let steps = steps as f32;
let current = (pos * steps).round();
let next = if forward {
current + 1.0
} else {
current - 1.0
};
(next / steps).clamp(0.0, 1.0)
} else {
let delta = 1.0 / (width - 8.0).max(1.0);
if forward {
(pos + delta).min(1.0)
} else {
(pos - delta).max(0.0)
}
};
self.set_slider(s, next_pos);
self.touched(s);
}
}
/// What the options screens must do after an interaction here.
@@ -851,8 +795,8 @@ pub enum Action {
Done,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
#[derive(Clone, Copy)]
enum Target {
Item(Item),
Button(Action),
}
@@ -870,8 +814,6 @@ pub struct GraphicsUi {
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
scroll: ui::Scroll,
dragging: Option<Item>,
pub focus: Option<Target>,
pub can_change_value: bool,
/// The key binding the options screen is waiting on (`Action::BindKey`).
pub binding: Option<&'static str>,
}
@@ -880,8 +822,6 @@ impl GraphicsUi {
pub fn reset(&mut self) {
self.scroll = ui::Scroll::default();
self.dragging = None;
self.focus = None;
self.can_change_value = false;
}
/// The scrollbar's column: right of the descriptions.
fn scrollbar_x(w: f32) -> f32 {
@@ -999,10 +939,8 @@ impl GraphicsUi {
let mut index = 0;
let mut tooltip = None;
for wd in &widgets {
let is_focused = self.focus == Some(wd.target);
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom)))
|| is_focused;
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
match wd.target {
Target::Item(item) => {
let (cost, text) = descriptions[index];
@@ -1023,15 +961,13 @@ impl GraphicsUi {
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider_ext(
Some(value) => STYLE.slider(
batch,
atlas,
(wd.x, wd.y, wd.w),
&label,
value,
hover || self.dragging == Some(item),
is_focused,
self.can_change_value && is_focused,
live,
self.dragging == Some(item),
),
@@ -1136,16 +1072,11 @@ impl GraphicsUi {
if wd.list && (y < top || y >= bottom || wd.y < top || wd.y + 20.0 > bottom) {
continue;
}
self.focus = Some(wd.target);
return match wd.target {
Target::Button(action) => {
self.can_change_value = false;
vec![Action::ButtonClick, action]
}
Target::Button(action) => vec![Action::ButtonClick, action],
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
Target::Item(Item::Horizon(H::HdrSettings)) => {
self.can_change_value = false;
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
@@ -1164,12 +1095,10 @@ impl GraphicsUi {
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = true;
self.dragging = Some(item);
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
vec![Action::SliderDrag]
} else {
self.can_change_value = false;
item.cycle(s, right, framebuffer);
vec![Action::ButtonClick, Action::Changed]
}
@@ -1189,246 +1118,6 @@ impl GraphicsUi {
self.scroll.clamp(top, bottom, content);
self.scroll.wheel(amount, 18.0, top, bottom, content);
}
/// Mouse wheel over a control cycles it (cycle button). Sliders and disabled
/// rows fall through to scroll the page, matching Vanilla list behavior.
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
/// cycle item is keyboard-focused (`self.focus`), the wheel steps/cycles it
/// regardless of cursor position. Vanilla 26.3 does not intercept the
/// wheel for sliders at all.
pub fn wheel(
&mut self,
level: Level,
s: &mut Settings,
cursor: (f32, f32),
(w, h): (f32, f32),
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Action> {
// Horizon improvement: focused slider/cycle intercepts wheel first.
if amount != 0.0
&& self.can_change_value
&& let Some(Target::Item(item)) = self.focus
&& item.available()
&& item.blocked(s).is_none()
{
if item.slider(s).is_some() {
item.step_slider(s, amount > 0.0, w);
return vec![Action::SliderDrag, Action::Changed];
} else if !matches!(item, Item::Horizon(H::HdrSettings)) {
item.cycle(s, amount > 0.0, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
}
}
let (widgets, _, _) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
let hovered = widgets.iter().find(|wd| {
ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list
|| (cursor.1 >= top
&& cursor.1 < bottom
&& wd.y >= top
&& wd.y + 20.0 <= bottom))
});
if let Some(wd) = hovered
&& let Target::Item(item) = wd.target
&& item.available()
&& item.blocked(s).is_none()
&& item.slider(s).is_none()
&& !matches!(item, Item::Horizon(H::HdrSettings))
{
if amount != 0.0 {
item.cycle(s, amount > 0.0, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
}
return Vec::new();
}
self.scroll(level, s, h, amount);
Vec::new()
}
/// Keyboard navigation and value adjustments.
pub fn key_modifiers(
&mut self,
level: Level,
s: &mut Settings,
key: winit::keyboard::KeyCode,
shift: bool,
(w, h): (f32, f32),
framebuffer: [i32; 2],
) -> (Vec<Action>, bool) {
use winit::keyboard::KeyCode as K;
let (widgets, _, content) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
let focusables: Vec<(Target, [f32; 4], bool)> = widgets
.iter()
.filter_map(|wd| match wd.target {
Target::Item(item) if item.available() && item.blocked(s).is_none() => {
Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list))
}
Target::Button(_) => Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list)),
_ => None,
})
.collect();
if focusables.is_empty() {
return (Vec::new(), false);
}
// 1. Tab / Shift+Tab navigation
if key == K::Tab {
let cur = self
.focus
.and_then(|f| focusables.iter().position(|&(t, ..)| t == f));
let next_idx = match cur {
Some(i) => {
if shift {
if i == 0 { focusables.len() - 1 } else { i - 1 }
} else {
(i + 1) % focusables.len()
}
}
None => {
if shift {
focusables.len() - 1
} else {
0
}
}
};
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
let is_slider = matches!(target, Target::Item(item) if item.slider(s).is_some());
self.can_change_value = is_slider;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
// 2. Left / Right Arrow keys
if matches!(key, K::ArrowLeft | K::ArrowRight) {
let forward = key == K::ArrowRight;
if let Some(Target::Item(item)) = self.focus
&& item.slider(s).is_some()
&& self.can_change_value
{
let width = widgets
.iter()
.find(|wd| wd.target == Target::Item(item))
.map_or(STYLE.control_width, |wd| wd.w);
item.step_slider(s, forward, width);
return (vec![Action::SliderDrag], true);
}
if let Some(cur_target) = self.focus
&& let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
{
let from = focusables[cur_pos].1;
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
let dir = if forward {
crate::gamepad::Button::DPadRight
} else {
crate::gamepad::Button::DPadLeft
};
if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
}
return (Vec::new(), false);
}
// 3. Up / Down Arrow keys
if matches!(key, K::ArrowUp | K::ArrowDown) {
let down = key == K::ArrowDown;
if let Some(cur_target) = self.focus
&& let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
{
let from = focusables[cur_pos].1;
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
let dir = if down {
crate::gamepad::Button::DPadDown
} else {
crate::gamepad::Button::DPadUp
};
if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
} else if !focusables.is_empty() {
let first = if down { 0 } else { focusables.len() - 1 };
let (target, [_, wy, _, wh], is_list) = focusables[first];
self.focus = Some(target);
self.can_change_value = false;
if is_list {
if wy < top + 4.0 {
self.scroll.amount -= top + 4.0 - wy;
} else if wy + wh > bottom - 4.0 {
self.scroll.amount += wy + wh - (bottom - 4.0);
}
self.clamp_scroll(h, content);
}
return (Vec::new(), true);
}
return (Vec::new(), false);
}
// 4. Space / Enter / NumpadEnter
if matches!(key, K::Space | K::Enter | K::NumpadEnter)
&& let Some(target) = self.focus
{
match target {
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = !self.can_change_value;
return (Vec::new(), true);
} else if item == Item::Horizon(H::HdrSettings) {
return (vec![Action::ButtonClick, Action::OpenHdr], true);
} else {
item.cycle(s, shift, framebuffer);
return (vec![Action::ButtonClick, Action::Changed], true);
}
}
Target::Button(action) => {
return (vec![Action::ButtonClick, action], true);
}
}
}
// 5. Escape: the screen closes on the first press, as `Screen.keyPressed` handles it
// before the focused slider.
if key == K::Escape {
self.can_change_value = false;
return (Vec::new(), false);
}
(Vec::new(), false)
}
}
#[cfg(test)]
@@ -1825,159 +1514,4 @@ mod tests {
}
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
}
#[test]
fn graphics_slider_keyboard_navigation_and_selection_toggle_parity() {
use winit::keyboard::KeyCode as K;
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 600.0);
let fb = [854, 480];
s.horizon.chunk_updates = 10;
s.horizon.resolution_scale = 100;
// 1. Tab navigates down to Chunk Updates slider (in Level::Advanced)
let mut found_chunk_updates = false;
for _ in 0..15 {
ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
if ui.focus == Some(Target::Item(Item::Horizon(H::ChunkUpdates))) {
found_chunk_updates = true;
break;
}
}
assert!(found_chunk_updates);
assert!(
ui.can_change_value,
"Tab onto slider activates can_change_value"
);
// 2. Right Arrow steps chunk updates from 10 to 11
let (actions, handled) =
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
assert!(handled);
assert_eq!(actions, vec![Action::SliderDrag]);
assert_eq!(s.horizon.chunk_updates, 11);
// 3. Left Arrow steps back to 10
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
assert_eq!(s.horizon.chunk_updates, 10);
// 4. Space toggles can_change_value to false
ui.key_modifiers(Level::Advanced, &mut s, K::Space, false, size, fb);
assert!(!ui.can_change_value);
// 5. Space toggles can_change_value back to true
ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, fb);
assert!(ui.can_change_value);
// 6. Escape is left to the screen, which closes, even while a slider changes values
let (actions, handled) =
ui.key_modifiers(Level::Advanced, &mut s, K::Escape, false, size, fb);
assert!(!handled);
assert!(actions.is_empty());
assert!(!ui.can_change_value);
// 8. Tab to ResolutionScale (steps by 5%)
let mut found_res = false;
for _ in 0..20 {
ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
if ui.focus == Some(Target::Item(Item::Horizon(H::ResolutionScale))) {
found_res = true;
break;
}
}
assert!(found_res);
assert!(ui.can_change_value);
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
assert_eq!(s.horizon.resolution_scale, 105);
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
assert_eq!(s.horizon.resolution_scale, 100);
}
#[test]
fn graphics_cycle_wheel_cycles_and_slider_falls_through_to_scroll() {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 300.0);
let fb = [854, 480];
// 1. Wheel on a Cycle button: Far Detail cycles mode
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let far = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(Item::Horizon(H::FarDetail))))
.unwrap();
let cursor = (far.x + 5.0, far.y + 5.0);
s.horizon.far_detail = FarDetail::Full;
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
s.horizon.chunk_updates = 16;
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let cu = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(Item::Horizon(H::ChunkUpdates))))
.unwrap();
let cursor = (cu.x + 5.0, cu.y + 5.0);
let scroll_before = ui.scroll.amount;
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
assert!(actions.is_empty());
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.scroll.amount > scroll_before);
}
#[test]
fn graphics_step_slider_matches_set_slider_for_all_options() {
let options = [
H::ChunkUpdates,
H::UploadBudget,
H::Workers,
H::ResolutionScale,
H::HdrPaperWhite,
H::HdrPeak,
H::HdrBlackLevel,
H::HdrUiBrightness,
];
for h in options {
let item = Item::Horizon(h);
let steps = h.slider_steps().expect("steps defined");
for i in 0..steps {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, true, 200.0);
let next_pos = (i + 1) as f32 / steps as f32;
h.set_slider(&mut s2, next_pos);
assert_eq!(
item.slider(&s1),
item.slider(&s2),
"{h:?} step forward from {i}"
);
}
for i in 1..=steps {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, false, 200.0);
let prev_pos = (i - 1) as f32 / steps as f32;
h.set_slider(&mut s2, prev_pos);
assert_eq!(
item.slider(&s1),
item.slider(&s2),
"{h:?} step backward from {i}"
);
}
}
}
}
@@ -1,73 +0,0 @@
// Horizon's HDR emission thresholds (gfx/hdr_stats.rs), drawn when HDR output
// turns on: for every block sprite, the brightness its pixels must reach to
// shine, measured against the sprite itself rather than a fixed level, so a
// resource pack's light sources shine where they are bright in their own
// drawing. One texel per sprite (x the layer, y the array): r for a light
// source whose bright parts shine (a torch's flame, not its handle), g for one
// that shines all over (a froglight), b 1 when the sprite has an opaque pixel.
@group(0) @binding(0) var sprites: texture_2d_array<f32>;
@group(0) @binding(1) var sprites_1: texture_2d_array<f32>;
@group(0) @binding(2) var sprites_2: texture_2d_array<f32>;
@group(0) @binding(3) var sprites_3: texture_2d_array<f32>;
@group(0) @binding(4) var sprites_4: texture_2d_array<f32>;
@group(0) @binding(5) var sprites_5: texture_2d_array<f32>;
@group(0) @binding(6) var sprites_6: texture_2d_array<f32>;
@group(0) @binding(7) var sprites_7: texture_2d_array<f32>;
struct Params {
// Where between the sprite's darkest and brightest opaque pixel the
// threshold lies, for bright parts and for all over (hdr.rs `Masks`).
parts: f32,
whole: f32,
};
@group(0) @binding(8) var<uniform> params: Params;
// hdr.wgsl's brightness: the mean of luminance and the brightest channel, in
// linear light.
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
return 0.5 * (dot(l, vec3(0.2126, 0.7152, 0.0722)) + max(l.r, max(l.g, l.b)));
}
fn texel(array: u32, at: vec2<i32>, layer: i32) -> vec4<f32> {
switch array {
case 0u: { return textureLoad(sprites, at, layer, 0); }
case 1u: { return textureLoad(sprites_1, at, layer, 0); }
case 2u: { return textureLoad(sprites_2, at, layer, 0); }
case 3u: { return textureLoad(sprites_3, at, layer, 0); }
case 4u: { return textureLoad(sprites_4, at, layer, 0); }
case 5u: { return textureLoad(sprites_5, at, layer, 0); }
case 6u: { return textureLoad(sprites_6, at, layer, 0); }
default: { return textureLoad(sprites_7, at, layer, 0); }
}
}
@vertex fn vs_stats(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs_stats(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let layer = i32(pos.x);
let array = u32(pos.y);
// Every array holds sprites of the same size.
let size = vec2<i32>(textureDimensions(sprites));
var lo = 1.0;
var hi = 0.0;
var opaque = false;
for (var y = 0; y < size.y; y++) {
for (var x = 0; x < size.x; x++) {
let c = texel(array, vec2(x, y), layer);
if c.a >= 0.5 {
let b = brightness(c.rgb);
lo = min(lo, b);
hi = max(hi, b);
opaque = true;
}
}
}
if !opaque {
return vec4(1.0, 1.0, 0.0, 1.0);
}
return vec4(mix(lo, hi, params.parts), mix(lo, hi, params.whole), 1.0, 1.0);
}
+28 -118
View File
@@ -300,18 +300,7 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
animated,
)
}
/// `MissingTextureAtlasSprite`: what a sprite the atlas lacks draws as.
pub const MISSING_SPRITE: &str = "horizon/missing";
impl HudAtlas {
/// A sprite, or the missing one when the atlas has no such sprite (an
/// effect without its icon), as a stitched atlas answers.
pub fn sprite_or_missing(&self, name: &str) -> Option<Rect> {
self.sprites
.get(name)
.or_else(|| self.sprites.get(MISSING_SPRITE))
.copied()
}
/// Keep a ready profile skin when another source only has its default.
pub fn set_object_skin(&mut self, key: String, skin: String) {
use std::collections::hash_map::Entry;
@@ -528,17 +517,13 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
let namespaces = horizon_items::content_namespaces::namespaces(root);
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
if folders.contains(&folder) {
for namespace in &namespaces {
walk(
&root.join(format!("assets/{namespace}/textures/{textures}")),
&mut files,
);
}
// Content mods' equipment textures, which their equipment assets name as `<ns>:<path>`.
if folders.contains(&"entity") {
for namespace in horizon_items::content_namespaces::namespaces(root) {
walk(
&root.join(format!("assets/{namespace}/textures/entity/equipment")),
&mut files,
);
}
}
let assets = root.join("assets");
@@ -565,9 +550,7 @@ impl HudAtlas {
format!("{namespace}:{rest}")
}
};
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
let image = decode(path).ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
@@ -643,15 +626,6 @@ impl HudAtlas {
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
if full {
Self::slots(&mut images, crate::trims::slot_size(root));
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
// and black quarters.
let missing = (0..256)
.flat_map(|i| match (i / 16 < 8) ^ (i % 16 < 8) {
true => [248, 0, 248, 255],
false => [0, 0, 0, 255],
})
.collect();
images.push((MISSING_SPRITE.into(), 16, 16, missing));
}
// Blurred sprites are sampled linearly in the atlas (screen.wgsl): a
// border wrapped from their own edges keeps the filter off their
@@ -1898,38 +1872,6 @@ impl HudRenderer {
*previous = state;
}
}
/// Stage `patches` into the shared atlas texture.
pub fn upload_patches(
&self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
patches: &[crate::font::Patch],
) {
if let Some(texture) = &self.atlas_texture {
for p in patches {
if p.x.saturating_add(p.width) <= texture.width()
&& p.y.saturating_add(p.height) <= texture.height()
{
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: p.x,
y: p.y,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
p.width,
p.height,
&p.rgba,
);
}
}
}
}
/// Stage the batch's atlas patches, vertices and uniform: before the
/// frame's first pass, so the copies share one command buffer.
pub fn prepare(
@@ -1940,7 +1882,26 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
) {
self.upload_patches(uploads, encoder, &batch.patches);
if let Some(texture) = &self.atlas_texture {
for p in &batch.patches {
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: p.x,
y: p.y,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
p.width,
p.height,
&p.rgba,
);
}
}
if batch.vertices.is_empty() || size.contains(&0) {
return;
}
@@ -2214,57 +2175,6 @@ mod tests {
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn content_mod_effect_icons_pack_under_their_namespace() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-effect-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let icons = root.join("assets/gems/textures/mob_effect");
fs::create_dir_all(&icons).unwrap();
write_strip(&icons.join("glow.png"), 18, 18, &[[0, 0, 200, 255]], None);
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["gems"]"#,
)
.unwrap();
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites[horizon_client_core::potions::sprite("gems:glow").as_str()];
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
// The HUD and the inventory draw it from the effect's id, as vanilla's.
let effect = crate::effects::EffectInstance::new("gems:glow", 600, 0);
let icon = crate::hud_overlays::EffectIcon::from(&effect);
let draws = crate::hud_overlays::effects(320, false, &[icon]);
let mut batch = HudBatch::new([320, 240]);
crate::hud_layout::paint(&mut batch, &atlas, &draws);
assert!(batch.vertices.iter().any(|v| v.uv == [rect.u0, rect.v0]));
let panels = crate::inventory_effects::panels(
320,
[72, 37],
176,
&[effect],
20.0,
&|_| None,
&|_| 60,
);
batch.vertices.clear();
crate::inventory_effects::paint(&mut batch, &atlas, &panels, [0, 0]);
assert!(batch.vertices.iter().any(|v| v.uv == [rect.u0, rect.v0]));
// An effect without an icon draws the missing sprite.
let missing = atlas.sprites[MISSING_SPRITE];
assert_eq!(texel_at(&atlas, missing, 0, 0), [0, 0, 0, 255]);
assert_eq!(texel_at(&atlas, missing, 8, 0), [248, 0, 248, 255]);
let effect = crate::effects::EffectInstance::new("gems:none", 600, 0);
let draws = crate::hud_overlays::effects(320, false, &[(&effect).into()]);
batch.vertices.clear();
crate::hud_layout::paint(&mut batch, &atlas, &draws);
assert!(
batch
.vertices
.iter()
.any(|v| v.uv == [missing.u0, missing.v0])
);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() {
let root = animated_root();
let atlas = HudAtlas::build(&root).unwrap();
+7 -14
View File
@@ -817,23 +817,16 @@ pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, dra
color,
blend,
} => {
// Effect sprites belong to the mob_effect atlas directory (a
// content mod's under its namespace); the other blitSprite
// ids refer to GUI sprites.
// Effect sprites belong to the mob_effect atlas directory;
// the other blitSprite ids refer to GUI sprites.
name.clear();
let effect = sprite.starts_with("mob_effect/")
|| sprite
.split_once(':')
.is_some_and(|(_, path)| path.starts_with("mob_effect/"));
if !effect {
if sprite.starts_with("mob_effect/") {
name.push_str(sprite);
} else {
name.push_str("gui/sprites/");
name.push_str(sprite);
}
name.push_str(sprite);
let found = match effect {
true => atlas.sprite_or_missing(&name),
false => atlas.sprites.get(name.as_str()).copied(),
};
let Some(mut r) = found else {
let Some(mut r) = atlas.sprites.get(name.as_str()).copied() else {
continue;
};
if let Some([sw, sh, u, v]) = *region {
@@ -179,7 +179,7 @@ pub fn effects(w: i32, demo: bool, effects: &[EffectIcon]) -> Vec<GuiDraw> {
blend: crate::hud_layout::Blend::Textured,
});
out.push(GuiDraw::Sprite {
sprite: Cow::Owned(horizon_client_core::potions::sprite(&e.id)),
sprite: Cow::Owned(format!("mob_effect/{}", e.id)),
x: x + 3,
y: y + 3,
w: 18,
@@ -151,11 +151,17 @@ pub fn paint(
);
}
icon.clear();
horizon_client_core::potions::push_sprite(&mut icon, &panel.icon.id);
if let Some(r) = atlas.sprite_or_missing(&icon) {
let (x, y) = ((panel.x + 7) as f32, (panel.y + 7) as f32);
batch.rect_uv(x, y, 18.0, 18.0, r, u32::MAX);
}
icon.push_str("mob_effect/");
icon.push_str(&panel.icon.id);
batch.sprite_scaled(
atlas,
&icon,
(panel.x + 7) as f32,
(panel.y + 7) as f32,
18.0,
18.0,
u32::MAX,
);
if (text_width <= 0 || clipped)
&& cursor[0] >= panel.x
&& cursor[0] <= panel.x + panel.width
@@ -385,40 +385,9 @@ pub fn draw(
}
}
/// `LoadingOverlay.MOJANG_STUDIOS_LOGO_LOCATION`, from the player's own resources.
pub const MOJANG_STUDIOS_LOGO: &str = "gui/title/mojangstudios";
/// `LoadingOverlay.extractRenderState`'s logo on a GUI `w` x `h`: the left
/// half's corner, then each half's width and height.
pub fn logo_layout(w: f32, h: f32) -> (i32, i32, i32, i32) {
let (w, h) = (f64::from(w), f64::from(h));
let (content_x, logo_y) = ((w * 0.5) as i32, (h * 0.5) as i32);
let logo_height = (w * 0.75).min(h) * 0.25;
let half_height = (logo_height * 0.5) as i32;
let half_width = (logo_height * 4.0 * 0.5) as i32;
(
content_x - half_width,
logo_y - half_height,
half_width,
logo_height as i32,
)
}
/// `GuiGraphicsExtractor.fill(x0, y0, x1, y1, color)`: the corners in either order.
fn fill_corners(batch: &mut HudBatch, atlas: &HudAtlas, [x0, y0, x1, y1]: [i32; 4], color: u32) {
let (left, right) = (x0.min(x1), x0.max(x1));
let (top, bottom) = (y0.min(y1), y0.max(y1));
let (w, h) = ((right - left) as f32, (bottom - top) as f32);
batch.fill(atlas, left as f32, top as f32, w, h, color);
}
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
/// (Mojang red, or black when `darkMojangStudiosBackground` is set), draws the
/// Mojang Studios logo as its two halves side by side, and the progress bar
/// at 83.25% of the height (`extractProgressBar`, unfaded), with the stage's
/// line above it. The logo's `MOJANG_LOGO` pipeline adds it (`SRC_ALPHA, ONE`):
/// its texels are white, which alpha blending matches but for the red channel
/// of the logo's soft edges, a 16/255 difference at most.
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
/// progress line and bar.
pub fn draw_loading_overlay(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -426,58 +395,15 @@ pub fn draw_loading_overlay(
progress: f32,
dark: bool,
) {
let (w, h) = (batch.width, batch.height);
batch.fill(atlas, 0.0, 0.0, w, h, brand_background(dark));
let (x, y, half_width, logo_height) = logo_layout(w, h);
if let Some(r) = atlas.sprites.get(MOJANG_STUDIOS_LOGO).copied() {
// blit(.., u, v, width, height, 120, 60, 120, 120): the left half
// samples the texture's top half from u = -0.0625, the right half its
// bottom half from u = 0.0625 (clamped, as the logo's sampler is).
let at = |u: f32, v: f32| {
let (u, v) = ((u / 120.0).clamp(0.0, 1.0), v / 120.0);
(r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v)
};
for (i, (u, v)) in [(-0.0625, 0.0), (0.0625, 60.0)].into_iter().enumerate() {
let (u0, v0) = at(u, v);
let (u1, v1) = at(u + 120.0, v + 60.0);
let half = crate::hud::Rect {
u0,
v0,
u1,
v1,
..r
};
let left = x + i as i32 * half_width;
let (lw, lh) = (half_width as f32, logo_height as f32);
batch.rect_uv(left as f32, y as f32, lw, lh, half, u32::MAX);
}
}
let bar_y = (f64::from(h) * 0.8325) as i32;
let x_center = (w as i32) / 2;
let [x0, y0, x1, y1] = [
x_center - half_width,
bar_y - 5,
x_center + half_width,
bar_y + 5,
];
let filled = (((x1 - x0) - 2) as f32 * progress.clamp(0.0, 1.0)).ceil() as i32;
let white = u32::MAX;
fill_corners(batch, atlas, [x0 + 2, y0 + 2, x0 + filled, y1 - 2], white);
fill_corners(batch, atlas, [x0 + 1, y0, x1 - 1, y0 + 1], white);
fill_corners(batch, atlas, [x0 + 1, y1, x1 - 1, y1 - 1], white);
fill_corners(batch, atlas, [x0, y0, x0 + 1, y1], white);
fill_corners(batch, atlas, [x1, y0, x1 - 1, y1], white);
let text = Text::literal(line);
let tw = atlas.font.width_text(&text) as i32;
let text_top = (y0 - 9 - 3) as f32;
batch.text_styled(
batch.fill(
atlas,
&text,
(x_center - tw / 2) as f32,
text_top,
white,
true,
0.0,
0.0,
batch.width,
batch.height,
brand_background(dark),
);
draw_progress(batch, atlas, line, progress);
}
/// The startup loading screen: the stage's line and a progress bar, centred
@@ -646,14 +572,6 @@ pub fn draw_file_fixer(
mod tests {
use super::*;
#[test]
fn the_mojang_logo_is_a_quarter_of_the_shorter_side_and_four_times_as_wide() {
// 320x240: min(240, 240) / 4 = 60 high, two halves of 120.
assert_eq!(logo_layout(320.0, 240.0), (40, 90, 120, 60));
// A tall frame sizes the logo by three quarters of its width.
assert_eq!(logo_layout(400.0, 500.0), (50, 213, 150, 75));
}
#[test]
fn progress_weighs_the_prepared_server_and_the_player_chunks() {
assert_eq!(progress(0), 10.0 / 59.0);
-3
View File
@@ -54,7 +54,6 @@ pub mod mod_list;
pub mod model_library;
pub mod models;
pub mod multiline_edit;
pub mod native_emissive;
pub mod options_file;
pub mod options_fixes;
pub mod options_ui;
@@ -74,7 +73,6 @@ pub mod screen_effects;
pub mod sculk;
pub mod section_mask;
pub mod settings;
pub mod shader_options;
pub mod shader_pack_list;
pub mod signs;
pub mod skins;
@@ -83,7 +81,6 @@ pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
pub mod split_profiles;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
File diff suppressed because it is too large. Load diff
+92 -151
View File
@@ -1,8 +1,8 @@
//! Exposed voxel-column faces, merged without changing their block-scale UVs.
use super::{Column, Span, Spans, TileKey};
use super::{Column, Span, TileKey};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex, fluid_face},
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex},
streaming::WorkerContext,
};
use horizon_assets::model::Direction;
@@ -12,10 +12,10 @@ use std::collections::BTreeMap;
const CELLS: usize = 16;
pub(super) struct EdgeColumn<'a> {
pub(super) struct EdgeColumn {
pub start: i32,
pub end: i32,
pub column: &'a Column,
pub column: Column,
}
#[derive(Clone, Copy)]
@@ -60,11 +60,11 @@ impl Mesher {
pub(super) fn mesh(
&mut self,
key: TileKey,
columns: &[&Column],
columns: &[Column],
covered: &FxHashSet<(i32, i32)>,
covered_bounds: Option<[i32; 4]>,
context: &WorkerContext,
neighbors: &[Vec<EdgeColumn<'_>>; 4],
neighbors: &[Vec<EdgeColumn>; 4],
finer: &[TileKey],
) -> SectionMesh {
mesh_columns_with_edges(
@@ -253,13 +253,6 @@ type Faces = BTreeMap<FaceKey, Vec<Rect>>;
fn add_face(faces: &mut Faces, direction: Direction, plane: f32, rect: Rect, material: Material) {
for (overlay, paint) in material.faces.into_iter().enumerate() {
let Some(paint) = paint else { continue };
// The same fluid flags the section mesher sets, so the material stage's water
// reaches the distant tiles too and the LOD boundary keeps one look.
let color = if material.fluid != 0 {
fluid_face(paint.color, direction as usize)
} else {
paint.color
};
faces
.entry(FaceKey {
direction,
@@ -267,7 +260,7 @@ fn add_face(faces: &mut Faces, direction: Direction, plane: f32, rect: Rect, mat
overlay: overlay as u8,
layer: paint.layer,
light: paint.light,
color,
color: paint.color,
translucent: paint.translucent,
})
.or_default()
@@ -296,7 +289,6 @@ fn mesh_columns_with_neighbors(
) -> SectionMesh {
assert!(neighbors.is_empty() || neighbors.len() == 4 * CELLS);
let stride = key.size() / CELLS as i32;
let columns: Vec<&Column> = columns.iter().collect();
let edges = std::array::from_fn(|side| {
neighbors
.get(side * CELLS..(side + 1) * CELLS)
@@ -306,19 +298,11 @@ fn mesh_columns_with_neighbors(
.map(|(row, column)| EdgeColumn {
start: row as i32 * stride,
end: (row as i32 + 1) * stride,
column,
column: column.clone(),
})
.collect()
});
mesh_columns_with_edges(
key,
&columns,
covered,
covered_bounds,
&edges,
&[],
material,
)
mesh_columns_with_edges(key, columns, covered, covered_bounds, &edges, &[], material)
}
/// From this tile level up a cell is 64 blocks wide or more: the strata
/// inside the ground cannot be told apart, so a column is its surface.
@@ -334,7 +318,7 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
min: crate::chunk::MIN_Y as i16,
..*span
};
let mut spans = Spans::with_capacity(2);
let mut spans = Vec::with_capacity(2);
match below.last() {
Some(under) if fluid(top) => {
spans.push(ground(under));
@@ -350,44 +334,33 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
}
fn mesh_columns_with_edges(
key: TileKey,
columns: &[&Column],
columns: &[Column],
covered: &FxHashSet<(i32, i32)>,
covered_bounds: Option<[i32; 4]>,
neighbors: &[Vec<EdgeColumn<'_>>; 4],
neighbors: &[Vec<EdgeColumn>; 4],
finer: &[TileKey],
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
assert_eq!(columns.len(), CELLS * CELLS);
// The heightmap columns are built here and borrowed like the tile's own.
let heightmap_columns: Vec<Column>;
let heightmap_refs: Vec<&Column>;
let heightmap_edges: [Vec<Column>; 4];
let heightmap_neighbors: [Vec<EdgeColumn<'_>>; 4];
let heightmap_columns;
let heightmap_neighbors;
let (columns, neighbors) = if key.level >= HEIGHTMAP_LEVEL {
let [ox, _, oz] = key.origin();
let mut fluid = |span: &Span| material(span, Direction::Up, [ox, oz]).fluid != 0;
heightmap_columns = columns
.iter()
.map(|column| heightmap(column, &mut fluid))
.collect();
heightmap_edges = neighbors.each_ref().map(|side| {
.collect::<Vec<_>>();
heightmap_neighbors = neighbors.each_ref().map(|side| {
side.iter()
.map(|edge| heightmap(edge.column, &mut fluid))
.collect()
});
heightmap_refs = heightmap_columns.iter().collect();
heightmap_neighbors = std::array::from_fn(|side| {
neighbors[side]
.iter()
.zip(&heightmap_edges[side])
.map(|(edge, column)| EdgeColumn {
.map(|edge| EdgeColumn {
start: edge.start,
end: edge.end,
column,
column: heightmap(&edge.column, &mut fluid),
})
.collect()
.collect::<Vec<_>>()
});
(&heightmap_refs[..], &heightmap_neighbors)
(&heightmap_columns[..], &heightmap_neighbors)
} else {
(columns, neighbors)
};
@@ -437,16 +410,18 @@ fn mesh_columns_with_edges(
[ox, oz]
}
};
let mut materials = Vec::new();
let prepared: Vec<Vec<Segment<'_>>> = filled
.iter()
.map(|column| column.as_ref())
.chain(neighbors.iter().flatten().map(|edge| edge.column))
.map(|column| &**column)
.chain(neighbors.iter().flatten().map(|edge| &edge.column))
.enumerate()
.map(|(index, column)| {
let at = centre(index);
materials.clear();
materials.extend(column.spans.iter().map(|s| material(s, Direction::Up, at)));
let materials: Vec<_> = column
.spans
.iter()
.map(|s| material(s, Direction::Up, at))
.collect();
column
.spans
.iter()
@@ -580,6 +555,7 @@ fn mesh_columns_with_edges(
x1,
),
] {
let side = material(segment.span, direction, centre(z * CELLS + x));
// Near meshes may have culled against the real neighbor.
// Keep a finite seal where their full-detail area begins:
// opaque only, as the near water carries on into the far
@@ -594,20 +570,13 @@ fn mesh_columns_with_edges(
u1,
segment,
&[],
|| material(segment.span, direction, centre(z * CELLS + x)),
side,
own_sky,
);
}
} else if !edge {
add_side(
&mut faces,
direction,
plane,
u0,
u1,
segment,
column,
|| material(segment.span, direction, centre(z * CELLS + x)),
&mut faces, direction, plane, u0, u1, segment, column, side,
own_sky,
);
} else if (0..CELLS as i32).contains(&nx)
@@ -621,7 +590,7 @@ fn mesh_columns_with_edges(
u1,
segment,
&prepared[nz as usize * CELLS + nx as usize],
|| material(segment.span, direction, centre(z * CELLS + x)),
side,
own_sky,
);
} else {
@@ -641,7 +610,7 @@ fn mesh_columns_with_edges(
u1,
segment,
&[],
|| material(segment.span, direction, centre(z * CELLS + x)),
side,
own_sky,
);
}
@@ -659,13 +628,7 @@ fn mesh_columns_with_edges(
end,
segment,
&prepared[edge_offsets[edge_side] + i],
|| {
material(
segment.span,
direction,
centre(z * CELLS + x),
)
},
side,
own_sky,
);
}
@@ -684,7 +647,6 @@ fn mesh_columns_with_edges(
emit(&mut mesh, key, rect)
}
}
mesh.finish_sprite_layers();
mesh
}
@@ -714,17 +676,15 @@ mod refinement_tests {
biome: 0,
light: 240,
}]
.into()
};
256
];
let columns: Vec<&Column> = columns.iter().collect();
let mesh = mesh_columns_with_edges(
parent,
&columns,
&FxHashSet::default(),
None,
&[Vec::new(), Vec::new(), Vec::new(), Vec::new()],
&std::array::from_fn(|_| Vec::new()),
&[fine],
|_, _, _| Material {
faces: [
@@ -785,8 +745,8 @@ mod refinement_tests {
/// block. Unknown neighbours, and cells that `whole` rejects (they meet
/// full-detail or finer terrain), count as open; those cells keep their gaps.
fn fill_buried_gaps<'a>(
columns: &'a [&'a Column],
neighbors: &'a [Vec<EdgeColumn<'a>>; 4],
columns: &'a [Column],
neighbors: &[Vec<EdgeColumn>; 4],
stride: i32,
whole: impl Fn(usize, usize) -> bool,
mut fluid: impl FnMut(&Span) -> bool,
@@ -812,17 +772,19 @@ fn fill_buried_gaps<'a>(
};
let meets =
|(lo, hi): (i16, i16), (other_lo, other_hi): (i16, i16)| lo < other_hi && other_lo < hi;
let tops: [i16; CELLS * CELLS] = std::array::from_fn(|i| {
if whole(i % CELLS, i / CELLS) {
top(columns[i])
} else {
i16::MIN
}
});
let gaps: Vec<Vec<(usize, i16, i16)>> = (0..CELLS * CELLS)
let tops: Vec<_> = (0..CELLS * CELLS)
.map(|i| {
if whole(i % CELLS, i / CELLS) {
gaps_of(columns[i])
top(&columns[i])
} else {
i16::MIN
}
})
.collect();
let gaps: Vec<_> = (0..CELLS * CELLS)
.map(|i| {
if whole(i % CELLS, i / CELLS) {
gaps_of(&columns[i])
} else {
Vec::new()
}
@@ -836,7 +798,7 @@ fn fill_buried_gaps<'a>(
};
let edge = |side: usize, cell: usize| {
touching(side, cell)
.map(|edge| top(edge.column))
.map(|edge| top(&edge.column))
.min()
.unwrap_or(i16::MIN)
};
@@ -844,7 +806,7 @@ fn fill_buried_gaps<'a>(
// see: it counts as open.
let edge_gap = |side: usize, cell: usize, gap: (i16, i16)| {
touching(side, cell).any(|edge| {
gaps_of(edge.column)
gaps_of(&edge.column)
.iter()
.any(|&(_, lo, hi)| meets((lo, hi), gap))
})
@@ -909,13 +871,12 @@ fn fill_buried_gaps<'a>(
.iter()
.enumerate()
.map(|(i, column)| {
let column = *column;
if open[i].iter().all(|&open| open) {
return Cow::Borrowed(column);
}
let mut filled = Column {
surface: column.surface,
spans: Spans::with_capacity(column.spans.len()),
spans: Vec::with_capacity(column.spans.len()),
};
for (k, &span) in column.spans.iter().enumerate() {
let mut span = span;
@@ -950,22 +911,13 @@ fn add_side(
u1: f32,
segment: &Segment<'_>,
neighbor: &[Segment<'_>],
mut side: impl FnMut() -> Material,
side: Material,
own_sky: u16,
) {
let (open, sky) = neighbor.last().map_or((f32::NEG_INFINITY, own_sky), |top| {
(f32::from(top.span.max), top.span.light & 0xff)
});
let mut cached = None;
let mut add = |v0: f32, v1: f32| {
let side = match cached {
Some(side) => side,
None => {
let side = side();
cached = Some(side);
side
}
};
let mut lit = side;
for paint in lit.faces.iter_mut().flatten() {
paint.light = (paint.light & 0xff00) | (paint.light & 0xff).max(sky);
@@ -1074,7 +1026,6 @@ fn emit(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
/// `uv_scale` shrinks the UVs, in blocks from the face's corner, of a face
/// too wide for them.
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect, uv_scale: f32) {
mesh.note_sprite_layer(key.layer);
let Rect { u0, v0, u1, v1 } = rect;
let p = f32::from_bits(key.plane);
let mut points = match key.direction {
@@ -1095,8 +1046,8 @@ fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect, uv_scale: f32) {
};
// Tile origins are integer multiples of a block, so local coordinates
// retain the world texture phase without distant-coordinate precision loss;
// a whole number of blocks off each UV keeps it (terrain.wgsl's `tile_uv`
// repeats the sprite per block) and counts it from the face's corner.
// a whole number of blocks off each UV keeps it (the sampler repeats) and
// counts it from the face's corner.
let uvs = points.map(|pos| match key.direction {
Direction::Up | Direction::Down => [pos[0], -pos[2]],
Direction::North | Direction::South => [pos[0], -pos[1]],
@@ -1138,7 +1089,7 @@ mod tests {
(0..CELLS * CELLS)
.map(|_| Column {
surface: None,
spans: Spans::new(),
spans: Vec::new(),
})
.collect()
}
@@ -1218,13 +1169,13 @@ mod tests {
let fluid = |s: &Span| s.state.0 == 2;
let strata = Column {
surface: None,
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)].into(),
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)],
};
let flat = heightmap(&strata, fluid);
assert_eq!(flat.spans, [span(-64, 64, 4)]);
let sea = Column {
surface: None,
spans: vec![span(0, 50, 1), span(50, 62, 2)].into(),
spans: vec![span(0, 50, 1), span(50, 62, 2)],
};
let flat = heightmap(&sea, fluid);
assert_eq!(flat.spans, [span(-64, 50, 1), span(50, 62, 2)]);
@@ -1239,8 +1190,7 @@ mod tests {
span(0, top - 4, 1),
span(top - 4, top - 1, 3),
span(top - 1, top, 4),
]
.into(),
],
}
})
.collect();
@@ -1300,7 +1250,7 @@ mod tests {
fn flat_solid_cube_merges_to_six_faces_with_block_scaled_uvs() {
let mut columns = columns();
for column in &mut columns {
column.spans = vec![span(0, 16, 1)].into()
column.spans = vec![span(0, 16, 1)]
}
let mesh = mesh(&columns);
assert_eq!(mesh.solid.len(), 6 * 4);
@@ -1335,7 +1285,7 @@ mod tests {
#[test]
fn gaps_and_real_bottoms_remain_open_without_floor_curtains() {
let mut columns = columns();
columns[0].spans = vec![span(3, 5, 1), span(8, 9, 1)].into();
columns[0].spans = vec![span(3, 5, 1), span(8, 9, 1)];
let mesh = mesh(&columns);
assert_eq!(mesh.solid.len(), 12 * 4);
assert!(
@@ -1354,8 +1304,8 @@ mod tests {
#[test]
fn neighbor_hides_only_its_occupied_side_intervals() {
let mut columns = columns();
columns[0].spans = vec![span(0, 10, 1)].into();
columns[1].spans = vec![span(0, 3, 1), span(7, 10, 1)].into();
columns[0].spans = vec![span(0, 10, 1)];
columns[1].spans = vec![span(0, 3, 1), span(7, 10, 1)];
let mesh = mesh(&columns);
let opening: Vec<_> = mesh
.solid
@@ -1389,9 +1339,9 @@ mod tests {
};
// A reduced column whose lower blocks were buried (no light), next to
// a lower column, and to one with a cave below its top.
columns[0].spans = vec![dark(0, 9), span(9, 10, 1)].into();
columns[1].spans = vec![span(0, 4, 1)].into();
columns[CELLS].spans = vec![span(0, 2, 1), span(6, 12, 1)].into();
columns[0].spans = vec![dark(0, 9), span(9, 10, 1)];
columns[1].spans = vec![span(0, 4, 1)];
columns[CELLS].spans = vec![span(0, 2, 1), span(6, 12, 1)];
let mesh = mesh(&columns);
let quads = mesh.solid.as_chunks::<4>().0;
let bounds = |q: &[TerrainVertex; 4]| {
@@ -1422,21 +1372,19 @@ mod tests {
fn air_under_a_canopy_stays_open_through_a_clearing() {
let column = |spans: Vec<Span>| Column {
surface: None,
spans: spans.into(),
spans,
};
// The tile's neighbours are solid hills: no open air from the sides.
let hill_column = column(vec![span(0, 20, 1)]);
let hill = || EdgeColumn {
start: 0,
end: CELLS as i32,
column: &hill_column,
column: column(vec![span(0, 20, 1)]),
};
let neighbors = [vec![hill()], vec![hill()], vec![hill()], vec![hill()]];
let fill = |columns: &[Column]| -> Vec<Vec<Span>> {
let columns: Vec<&Column> = columns.iter().collect();
fill_buried_gaps(&columns, &neighbors, 1, |_, _| true, |_| false)
fill_buried_gaps(columns, &neighbors, 1, |_, _| true, |_| false)
.iter()
.map(|filled| filled.spans.as_slice().to_vec())
.map(|filled| filled.spans.clone())
.collect()
};
let mut forest = vec![column(vec![span(0, 5, 1), span(10, 14, 3)]); CELLS * CELLS];
@@ -1447,27 +1395,23 @@ mod tests {
forest[5 * CELLS + 5] = column(vec![span(0, 5, 1)]);
let filled = fill(&forest);
for (filled, column) in filled.iter().zip(&forest) {
assert_eq!(filled.as_slice(), column.spans.as_slice());
assert_eq!(filled, &column.spans);
}
// A cave two columns wide sealed in rock is still filled.
let mut rock = vec![column(vec![span(0, 20, 1)]); CELLS * CELLS];
for cave in [5 * CELLS + 5, 5 * CELLS + 6] {
rock[cave] = column(vec![span(0, 8, 1), span(12, 20, 1)]);
}
assert!(
fill(&rock)
.iter()
.all(|spans| spans.as_slice() == [span(0, 20, 1)])
);
assert!(fill(&rock).iter().all(|spans| spans == &[span(0, 20, 1)]));
}
#[test]
fn caves_no_neighbor_sees_into_add_no_faces() {
let mut columns = columns();
for column in &mut columns {
column.spans = vec![span(0, 20, 1)].into();
column.spans = vec![span(0, 20, 1)];
}
let cave = 5 * CELLS + 5;
columns[cave].spans = vec![span(0, 8, 1), span(12, 20, 1)].into();
columns[cave].spans = vec![span(0, 8, 1), span(12, 20, 1)];
let at = |mesh: &SectionMesh, y: f32| {
mesh.solid
.as_chunks::<4>()
@@ -1486,14 +1430,14 @@ mod tests {
.all(|v| v.pos[1] == 0.0 || v.pos[1] == 20.0)
);
// A lower neighbour's open air looks in: the cave stays.
columns[cave + 1].spans = vec![span(0, 10, 1)].into();
columns[cave + 1].spans = vec![span(0, 10, 1)];
let open = mesh(&columns);
assert_eq!((at(&open, 8.0), at(&open, 12.0)), (1, 1));
// Kelp leaves a gap in water: water fills it, without inner faces.
for column in &mut columns {
column.spans = vec![span(0, 5, 1), span(5, 10, 2)].into();
column.spans = vec![span(0, 5, 1), span(5, 10, 2)];
}
columns[cave].spans = vec![span(0, 5, 1), span(5, 7, 2), span(8, 10, 2)].into();
columns[cave].spans = vec![span(0, 5, 1), span(5, 7, 2), span(8, 10, 2)];
let water = mesh(&columns);
assert!(
water
@@ -1507,13 +1451,12 @@ mod tests {
#[test]
fn greedy_faces_preserve_material_and_light_boundaries() {
let mut columns = columns();
columns[0].spans = vec![span(0, 1, 1)].into();
columns[1].spans = vec![span(0, 1, 3)].into();
columns[0].spans = vec![span(0, 1, 1)];
columns[1].spans = vec![span(0, 1, 3)];
columns[2].spans = vec![Span {
light: 16,
..span(0, 1, 3)
}]
.into();
}];
let mesh = mesh(&columns);
let tops: Vec<_> = mesh
.solid
@@ -1532,7 +1475,7 @@ mod tests {
#[test]
fn water_keeps_ground_surface_and_culls_shared_fluid_faces() {
let mut columns = columns();
columns[0].spans = vec![span(0, 3, 1), span(3, 6, 2)].into();
columns[0].spans = vec![span(0, 3, 1), span(3, 6, 2)];
columns[1].spans = columns[0].spans.clone();
let mesh = mesh(&columns);
assert!(
@@ -1559,44 +1502,42 @@ mod tests {
fn coarse_edge_clips_separate_fine_neighbor_heights() {
let mut columns = columns();
for column in &mut columns {
column.spans = vec![span(0, 16, 1)].into();
column.spans = vec![span(0, 16, 1)];
}
let key = TileKey {
x: -1,
z: -1,
level: 1,
};
let mut neighbors: [Vec<EdgeColumn<'_>>; 4] = std::array::from_fn(|_| {
let mut neighbors: [Vec<EdgeColumn>; 4] = std::array::from_fn(|_| {
vec![EdgeColumn {
start: 0,
end: 32,
column: &columns[0],
column: columns[0].clone(),
}]
});
// One fine column is lower than both the coarse cell and its sibling.
// Sampling only the first neighbor would incorrectly hide this wall.
let low = Column {
surface: None,
spans: vec![span(0, 8, 1)].into(),
};
neighbors[0] = vec![
EdgeColumn {
start: 0,
end: 1,
column: &columns[0],
column: columns[0].clone(),
},
EdgeColumn {
start: 1,
end: 2,
column: &low,
column: Column {
surface: None,
spans: vec![span(0, 8, 1)],
},
},
EdgeColumn {
start: 2,
end: 32,
column: &columns[0],
column: columns[0].clone(),
},
];
let columns: Vec<&Column> = columns.iter().collect();
let mesh = mesh_columns_with_edges(
key,
&columns,
@@ -1624,7 +1565,7 @@ mod tests {
fn tile_halo_culls_shared_walls_but_keeps_steps_and_gaps() {
let mut columns = columns();
for column in &mut columns {
column.spans = vec![span(0, 16, 1)].into();
column.spans = vec![span(0, 16, 1)];
}
let mut neighbors = columns[..4 * CELLS].to_vec();
let key = TileKey {
@@ -1648,10 +1589,10 @@ mod tests {
);
for column in &mut neighbors[CELLS..2 * CELLS] {
column.spans = vec![span(0, 8, 1)].into();
column.spans = vec![span(0, 8, 1)];
}
for column in &mut neighbors[..CELLS] {
column.spans = vec![span(0, 3, 1), span(7, 16, 1)].into();
column.spans = vec![span(0, 3, 1), span(7, 16, 1)];
}
let mesh = mesh_columns_with_neighbors(key, &columns, &covered, None, &neighbors, material);
assert_eq!(mesh.solid.len(), 4 * 4);
@@ -1678,7 +1619,7 @@ mod tests {
fn negative_parent_clips_chunks_without_internal_piece_walls() {
let mut columns = columns();
for column in &mut columns {
column.spans = vec![span(10, 12, 1)].into()
column.spans = vec![span(10, 12, 1)]
}
let key = TileKey {
x: -1,
+25 -142
View File
@@ -125,8 +125,6 @@ mod vertex_tests {
assert_eq!(super::emitting(rgb, 0), rgb);
assert_eq!(super::emitting(rgb, super::INCANDESCENT), 0xFD56_3412);
assert_eq!(super::emitting(rgb, super::VERY_BRIGHT), 0xFB56_3412);
assert_eq!(super::emitting(rgb, super::LOW | super::WHOLE), 0xFA56_3412);
assert_eq!(super::emitting(rgb, super::WHOLE), rgb);
assert_eq!(
super::emitting(rgb, super::VERY_BRIGHT) | super::EMISSION_BITS,
rgb
@@ -134,16 +132,14 @@ mod vertex_tests {
}
#[test]
fn light_sources_shine_by_kind_not_light_level() {
use super::{BRIGHT, INCANDESCENT, LOW, WHOLE, hdr_tier};
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
assert_eq!(hdr_tier("glowstone", 15), BRIGHT);
// A candle's flame is a particle: the block keeps only its wick.
assert_eq!(hdr_tier("torch", 14), INCANDESCENT);
assert_eq!(hdr_tier("red_candle", 3), LOW);
assert_eq!(hdr_tier("glow_lichen", 7), 0);
assert_eq!(hdr_tier("stone", 0), 0);
assert_eq!(hdr_tier("ochre_froglight", 15), LOW | WHOLE);
assert_eq!(hdr_tier("lava", 15), INCANDESCENT | WHOLE);
assert_eq!(hdr_tier("red_candle_cake", 3), 0);
assert_eq!(hdr_tier("ochre_froglight", 15), LOW);
assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT);
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
assert_eq!(hdr_tier("sea_pickle", 15), 0);
@@ -151,36 +147,6 @@ mod vertex_tests {
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
#[test]
fn fluid_faces_keep_the_colour_and_the_emission() {
let lit = super::emitting(super::pack_rgb([0x12, 0x34, 0x56]), super::INCANDESCENT);
assert_eq!(super::fluid_face(lit, 0), 0x8D56_3412);
assert_eq!(super::fluid_face(lit, 5), 0xDD56_3412);
// Another face replaces the first.
assert_eq!(super::fluid_face(super::fluid_face(lit, 5), 1), 0x9D56_3412);
}
/// What `gfx/native_material.rs` reads back from the colour's alpha byte: the face of a
/// fluid quad, and the HDR tier.
fn decoded(color: u32) -> (Option<u32>, u32) {
let flags = color >> 24;
let high = flags >> 4;
let fluid = high >= 8 && (high & 7) < 6;
(fluid.then_some(high & 7), 15 - (flags & 15))
}
#[test]
fn the_material_stage_reads_back_what_the_mesher_encodes() {
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
for tier in 0..=super::VERY_BRIGHT {
let lit = super::emitting(rgb, tier);
assert_eq!(decoded(lit), (None, u32::from(tier)));
for face in 0..6 {
assert_eq!(
decoded(super::fluid_face(lit, face)),
(Some(face as u32), u32::from(tier))
);
}
}
}
}
#[cfg(test)]
@@ -231,14 +197,8 @@ pub struct SectionMesh {
/// each frame by `block_entity_render` like vanilla's per-section
/// `SectionCompiler.Results.blockEntities`.
pub block_entities: Vec<([i32; 3], u32)>,
/// Sprite layers emitted while building this mesh. Kept distinct before
/// upload so the renderer does not rescan every terrain vertex.
pub sprite_layers: Vec<u16>,
/// Axis-aligned bounding box [min, max] of actual terrain mesh vertices in section-local space [0, 16].
pub bounds: Option<[glam::Vec3; 2]>,
/// A far mesh was clipped around finer tiles or resident chunks. Keep
/// the previous GPU mesh while its replacements fade in.
pub keep: bool,
}
impl Default for SectionMesh {
fn default() -> Self {
@@ -248,27 +208,11 @@ impl Default for SectionMesh {
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
sprite_layers: Vec::new(),
bounds: None,
keep: false,
}
}
}
impl SectionMesh {
/// Record a sprite layer used by a quad. Opaque is a vertex-only flag and
/// is removed here so the renderer can compare the actual atlas layer.
pub fn note_sprite_layer(&mut self, layer: u16) {
let layer = layer & !crate::mesher::OPAQUE_LAYER;
if !self.sprite_layers.contains(&layer) {
self.sprite_layers.push(layer);
}
}
/// Finish the compact layer list after meshing has emitted all faces.
pub fn finish_sprite_layers(&mut self) {
self.sprite_layers.sort_unstable();
self.sprite_layers.dedup();
}
/// Padding applied to mesh vertex bounds to prevent clipping at the frustum
/// boundary when shader packs with vertex displacement (e.g. waving foliage, water) are active.
pub const BOUNDS_PAD: f32 = 0.5;
@@ -278,17 +222,14 @@ impl SectionMesh {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
self.sprite_layers.clear();
self.sides = Sides::default();
self.visibility = ALL_VISIBLE;
self.bounds = None;
self.keep = false;
}
pub(crate) fn capacity_bytes(&self) -> usize {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
}
/// Sorts the solid quads by the side they face, for the renderer to
/// leave out those facing away from the eye (`sides`).
@@ -494,7 +435,6 @@ pub struct MeshTables {
water_overlay: u16,
lava_still: u16,
lava_flow: u16,
animated_layers: Vec<bool>,
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
@@ -529,11 +469,6 @@ impl MeshTables {
.map(|&l| l as u16)
.ok_or_else(|| format!("missing fluid sprite {name}").into())
};
let max_animated = render.animations.iter().map(|a| a.layer).max().unwrap_or(0);
let mut animated_layers = vec![false; max_animated as usize + 1];
for animation in &render.animations {
animated_layers[animation.layer as usize] = true;
}
let mut covers = std::collections::HashMap::new();
let mut info = Vec::with_capacity(facts.states.len());
for (id, s) in facts.states.iter().enumerate() {
@@ -656,16 +591,8 @@ impl MeshTables {
water_overlay: layer("minecraft:block/water_overlay")?,
lava_still: layer("minecraft:block/lava_still")?,
lava_flow: layer("minecraft:block/lava_flow")?,
animated_layers,
})
}
/// Whether a terrain sprite layer has an animated clip.
pub fn is_animated(&self, layer: u16) -> bool {
self.animated_layers
.get(layer as usize)
.copied()
.unwrap_or(false)
}
pub fn facts(&self, state: BlockStateId) -> Option<MeshFacts> {
let i = self.info.get(state.0 as usize)?;
Some(MeshFacts {
@@ -866,10 +793,8 @@ mod reuse_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, plus 4 for one that shines all over
/// (`WHOLE`), so the usual 0xFF means none. The byte's high nibble carries the fluid face
/// flags (`fluid_face`); the shaders ignore the vertex alpha, so both travel to the
/// material stage untouched.
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
/// byte's other four bits stay set.
pub const EMISSION_BITS: u32 = 0x0F00_0000;
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
/// a torch's, it only lights less around it. 0 stays as in SDR.
@@ -878,64 +803,34 @@ pub const INCANDESCENT: u8 = 2;
pub const BRIGHT: u8 = 3;
/// The sun and lightning.
pub const VERY_BRIGHT: u8 = 4;
/// Added to a tier: the light source shines all over (a froglight), not only where its
/// sprite is brightest (a torch's flame, not its handle). See `gfx/hdr.rs` `Masks`.
pub const WHOLE: u8 = 8;
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`, plus `WHOLE`).
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
pub fn emitting(color: u32, tier: u8) -> u32 {
let level = (tier & !WHOLE).min(VERY_BRIGHT);
let code = if level > 0 && tier & WHOLE != 0 {
level + 4
} else {
level
};
color & !EMISSION_BITS | u32::from(15 - code) << 24
}
/// The high nibble of `TerrainVertex::color`'s alpha byte: 8 plus the face (`Direction`
/// as usize) on a fluid quad, 0xF on every other. `terrain.wgsl` reads only the low nibble
/// (the emission), so only the material stage of Horizon's own packs reads it
/// (`gfx/native_material.rs`); whether the fluid is water or lava follows from the pass
/// that draws it.
pub const FLUID_FACE_BITS: u32 = 0xF000_0000;
/// `color` marked as a fluid quad of face `face` (`Direction` as usize, 0 to 5).
pub fn fluid_face(color: u32, face: usize) -> u32 {
debug_assert!(face < 6);
color & !FLUID_FACE_BITS | 0x8000_0000 | (face as u32) << 28
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
}
/// The HDR tier of block `name` (no namespace), which emits light `level`.
pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
_ if level == 0 => 0,
"glowstone"
| "soul_lantern"
| "soul_torch"
| "soul_wall_torch"
| "beacon"
| "end_rod"
| "furnace"
| "redstone_lamp"
| "brewing_stand"
| "enchanting_table"
| "medium_amethyst_bud"
| "small_amethyst_bud" => BRIGHT,
"campfire" | "soul_campfire" | "soul_fire" | "lava_cauldron" | "crying_obsidian"
| "amethyst_cluster" | "large_amethyst_bud" => BRIGHT | WHOLE,
"torch" | "wall_torch" | "lantern" | "sea_lantern" => INCANDESCENT,
"fire" | "lava" => INCANDESCENT | WHOLE,
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
| "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace"
| "redstone_lamp" => BRIGHT,
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
| "sea_lantern" => INCANDESCENT,
// Glowing at every charge, not only the brighter ones.
"respawn_anchor" => INCANDESCENT,
"ochre_froglight" | "verdant_froglight" | "pearlescent_froglight" | "jack_o_lantern" => {
LOW | WHOLE
}
"redstone_torch" | "redstone_wall_torch" | "magma_block" | "shroomlight" => LOW,
// Its flame is a particle; the cake's frosting must not shine in daylight.
_ if name.ends_with("candle_cake") => 0,
_ if name.ends_with("candle") => LOW,
"redstone_torch"
| "redstone_wall_torch"
| "magma_block"
| "ochre_froglight"
| "verdant_froglight"
| "pearlescent_froglight"
| "shroomlight" => LOW,
// Its flame is a particle.
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
// One tier at every oxidation stage, waxed or not.
_ if name.ends_with("copper_bulb") => LOW,
// A faint glow in the dark, as in SDR.
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame"
| "ender_chest" => 0,
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0,
_ if level >= 10 => INCANDESCENT,
_ => LOW,
}
@@ -1058,8 +953,6 @@ fn pack_coords(coords: u32) -> u16 {
pub struct Mesher<'a> {
/// CardinalLighting.NETHER: brighter up/down faces.
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
pub shader_pack_lighting: bool,
pub assets: &'a Assets,
pub render: &'a RenderAssets,
pub tables: &'a MeshTables,
@@ -1134,8 +1027,8 @@ impl Mesher<'_> {
}
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let shade = (self.nether_shading, self.shader_pack_lighting);
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
let nether = self.nether_shading;
fluid::tesselate(self.tables, nether, pad, water, &mut out, [x, y, z]);
}
if !state.model {
continue;
@@ -1199,7 +1092,6 @@ impl Mesher<'_> {
}
}
}
out.finish_sprite_layers();
out
}
/// `BlockColors` tint source `index`, or `None` for an untinted (-1) layer.
@@ -1233,15 +1125,9 @@ impl Mesher<'_> {
state: &Info,
) {
let q = &face.quad;
// A quad's own light (a model's glowing part) may raise the tier; whether it
// shines all over stays the block's.
let tier = (state.tier & !WHOLE).max(hdr_tier("", q.light_emission)) | state.tier & WHOLE;
let tier = state.tier.max(hdr_tier("", q.light_emission));
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = if self.shader_pack_lighting {
1.0
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
let directional = lighting::cardinal(self.nether_shading, shade_face);
// Grey level per vertex before tinting, and vanilla light coords.
let (gray, coords) = if ao {
let info = &self.tables.info;
@@ -1257,9 +1143,6 @@ impl Mesher<'_> {
([lighting::gray(directional); 4], [coords; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
if self.tables.is_animated(face.layer as u16) {
out.note_sprite_layer(face.layer as u16);
}
let target = if face.translucent {
&mut out.translucent
} else {
@@ -3,8 +3,7 @@
//! beside glass and leaves, back faces, cardinal shading and max(self, above)
//! light.
use super::{
Info, MeshTables, Padded, SectionMesh, TerrainVertex, fluid_face, lighting, pack_coords,
pack_rgb, pi,
Info, MeshTables, Padded, SectionMesh, TerrainVertex, lighting, pack_coords, pack_rgb, pi,
};
use horizon_core::{
geometry::{Axis, Direction, VoxelShape, trig},
@@ -189,24 +188,15 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
/// the faces (`Mesher::shader_pack_lighting`).
pub(super) fn tesselate(
tables: &MeshTables,
(nether, shader_pack_lighting): (bool, bool),
nether: bool,
pad: &Padded,
water_color: u32,
out: &mut SectionMesh,
[x, y, z]: [i32; 3],
) {
use horizon_assets::model::Direction as D;
let cardinal = |d: D| {
if shader_pack_lighting {
1.0
} else {
lighting::cardinal(nether, d)
}
};
let info = &tables.info;
let at = |p: [i32; 3]| &info[pad.blocks[pi(p[0], p[1], p[2])] as usize];
let me = at([x, y, z]);
@@ -240,7 +230,7 @@ pub(super) fn tesselate(
let emission = if water {
0
} else {
crate::mesher::hdr_tier("lava", 15)
crate::mesher::INCANDESCENT
};
let color = |by: f32| {
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
@@ -320,23 +310,14 @@ pub(super) fn tesselate(
} else {
&mut out.solid
};
let mut sprite_layers = Vec::new();
let mut quad = |v: [([f32; 3], [f32; 2]); 4],
layer: u16,
color: u32,
light: u16,
back: bool,
face: usize| {
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
sprite_layers.push(layer);
}
let color = fluid_face(color, face);
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
}
};
let mut quad =
|v: [([f32; 3], [f32; 2]); 4], layer: u16, color: u32, light: u16, back: bool| {
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
}
};
let (fx, fy, fz) = (x as f32, y as f32, z as f32);
let bottom = if render_down { EPSILON } else { 0.0 };
if render_up && !occluded_by(Direction::Up, nw.min(sw).min(se.min(ne)), up) {
@@ -382,10 +363,9 @@ pub(super) fn tesselate(
([fx + 1.0, fy + ne, fz], uv[3]),
],
layer,
color(cardinal(D::Up)),
color(lighting::cardinal(nether, D::Up)),
light([x, y, z]),
backward,
Direction::Up as usize,
);
}
if render_down {
@@ -398,14 +378,13 @@ pub(super) fn tesselate(
([fx, y0, fz + 1.0], [0.0, 1.0]),
],
still,
color(cardinal(D::Down)),
color(lighting::cardinal(nether, D::Down)),
light([x, y - 1, z]),
false,
Direction::Down as usize,
);
}
let side_light = light([x, y, z]);
let up_shade = cardinal(D::Up);
let up_shade = lighting::cardinal(nether, D::Up);
for (i, d) in [
Direction::North,
Direction::South,
@@ -440,9 +419,9 @@ pub(super) fn tesselate(
_ => (flow, false),
};
let side = if matches!(d, Direction::North | Direction::South) {
cardinal(D::North)
lighting::cardinal(nether, D::North)
} else {
cardinal(D::West)
lighting::cardinal(nether, D::West)
};
let (v0, v1) = ((1.0 - h0) * 0.5, (1.0 - h1) * 0.5);
quad(
@@ -456,10 +435,8 @@ pub(super) fn tesselate(
color(up_shade * side),
side_light,
!is_overlay,
d as usize,
);
}
out.sprite_layers.extend(sprite_layers);
}
#[cfg(test)]
@@ -46,56 +46,6 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_shader_pack_lights_the_faces_itself() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
let mut chunk = Chunk::empty(0, 0);
chunk.set(8, MIN_Y + 8, 8, stone);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let colours = |shader_pack_lighting| {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility: visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
};
let mut colours: Vec<u32> = mesher
.mesh(&region, 0)
.solid
.iter()
.map(|v| v.color)
.collect();
colours.sort_unstable();
colours.dedup();
colours
};
assert_eq!(colours(false).len(), 4);
assert_eq!(colours(true).len(), 1);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
@@ -146,7 +96,6 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -74,13 +74,7 @@ impl Sides {
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
/// (relative to the mesh's origin) can see the front of, nearest the
/// start first. Empty buckets do not break a run.
pub fn visible(&self, eye: [f32; 3], draw: impl FnMut(u32, u32)) {
self.visible_from(eye, eye[1], draw);
}
/// `visible` for every eye from `eye` straight down to the height `low`: the camera and
/// its water mirror's eye below the plane, which differ only in y.
pub fn visible_from(&self, eye: [f32; 3], low: f32, mut draw: impl FnMut(u32, u32)) {
pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) {
let mut start = 0;
let mut run: Option<u32> = None;
for (i, bucket) in BUCKETS.iter().enumerate() {
@@ -90,7 +84,6 @@ impl Sides {
}
let seen = match *bucket {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
};
@@ -319,24 +312,6 @@ mod tests {
assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]);
}
#[test]
fn an_eye_and_its_mirror_below_see_the_faces_of_both() {
let mut solid = cube([0.0; 3], 0);
let sides = sort(&mut solid);
let both = |eye: [f32; 3], low: f32| {
let mut out = Vec::new();
sides.visible_from(eye, low, |first, count| out.push((first, count)));
out
};
// Above the block: +X, +Z and +Y; with the mirror's eye below it, -Y too.
assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]);
assert_eq!(both([8.0, 8.0, 8.0], -8.0), [(0, 3), (5, 1)]);
// A mirror above the block's bottom adds nothing.
assert_eq!(both([8.0, 8.0, 8.0], 0.5), [(0, 3)]);
// The sides depend on x and z alone.
assert_eq!(both([-8.0, 8.0, -8.0], -8.0), [(2, 4)]);
}
#[test]
fn unsorted_meshes_draw_everything() {
let sides = Sides::unsorted(9);
@@ -1,61 +0,0 @@
//! Optional, texture-matched native overlays. No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
pub const MAX: usize = 32;
/// Load only bounded, explicitly named emissive textures from the active pack.
pub fn load(root: &Path, sprites: &mut Sprites) {
let mut names: Vec<_> = crate::atlas_sources::list_textures(root)
.into_iter()
.filter(|name| {
name.split_once(':')
.is_some_and(|(_, p)| p.starts_with("emissive/"))
})
.take(MAX)
.collect();
names.sort();
for name in names {
let file = crate::atlas_sources::texture_path(root, &name, "textures");
if std::fs::metadata(file).is_ok_and(|m| m.len() <= 1 << 20) {
let _ = sprites.insert(root, &name);
}
}
}
/// Only loaded base/overlay pairs are sent to the GPU; sorted for vertex lookup.
pub fn mappings<'a>(
layers: &BTreeMap<String, u32>,
requests: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Vec<[u32; 4]> {
let mut pairs = BTreeMap::new();
for (base, overlay) in requests.into_iter().take(MAX) {
if let (Some(&b), Some(&o)) = (layers.get(base), layers.get(overlay)) {
pairs.entry(b).or_insert(o);
}
}
pairs.into_iter().map(|(b, o)| [b, o, 0, 0]).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_existing_pairs_are_mapped() {
let layers = [
("minecraft:block/ore".into(), 3),
("mine:emissive/ore".into(), 7),
]
.into();
assert_eq!(
mappings(
&layers,
[
("minecraft:block/ore", "mine:emissive/ore"),
("missing", "mine:emissive/ore")
]
),
vec![[3, 7, 0, 0]]
);
}
}
File diff suppressed because it is too large. Load diff
@@ -829,15 +829,6 @@ impl PackScreen {
/// is the time since the search box took the focus.
pub fn draw(&self, m: &Measure, mouse: (i32, i32), blink_ms: u64) -> Vec<Draw> {
let mut out = Vec::new();
// PackSelectionScreen, or the ConfirmScreen over it (its title and
// its message).
crate::widgets::begin_screen(|| match &self.confirm {
Some(c) => crate::widgets::ScreenInfo::new(
"packs/confirm",
crate::widgets::join_for_narration(&[&c.title.plain(), &c.message.plain()]),
),
None => crate::widgets::ScreenInfo::new("packs", self.title.plain()),
});
if let Some(c) = &self.confirm {
let ((tx, ty), (lines, mx, my), buttons) = self.confirm_layout(m, c);
d::string_widget(&mut out, m, &c.title, tx, ty, 0);
@@ -883,11 +874,6 @@ impl PackScreen {
true,
hover || self.focus == Focus::Button(i),
);
if i == 0 {
crate::widgets::last_narratable(|n| {
n.hint = Some(tr("pack.folderInfo").plain());
});
}
if i == 0 && hover {
d::tooltip(&mut out, m, &tr("pack.folderInfo"), mouse);
}
@@ -901,38 +887,6 @@ impl PackScreen {
} else {
None
};
let header = if l.selected_side {
"pack.selected.title"
} else {
"pack.available.title"
};
// TransferableSelectionList: the row under the mouse, else the
// selected one; the header narrates its title, a pack "Selected:
// <title>".
crate::widgets::narratable(|| {
use crate::widgets::{ListKind, Narratable, Row as Narrated};
let narrated = |i: usize| {
let text = match &l.entries[i].row {
Row::Header => tr(header).plain(),
Row::Pack(id) => {
let title = self
.info(id)
.map_or_else(|| Text::literal(id.clone()), |p| p.title.clone());
Text::translate("narrator.select", &[title]).plain()
}
};
Narrated::new(i, text)
};
Narratable::list(
ListKind::Object,
l.entries.len(),
hovered.map(narrated),
l.selected.filter(|i| *i < l.entries.len()).map(narrated),
[l.x as f32, l.y as f32, l.width as f32, l.height as f32],
)
.key(crate::widgets::key_of(&("packs", side)))
.focused(l.focused)
});
let prefix = if self.in_world { "inworld_" } else { "" };
let right = l.x + l.width;
d::blit(
@@ -972,7 +926,12 @@ impl PackScreen {
let (cx, cy) = (left + 2, e.y + 2);
match &e.row {
Row::Header => {
let t = tr(header).with_style(&Style {
let key = if l.selected_side {
"pack.selected.title"
} else {
"pack.available.title"
};
let t = tr(key).with_style(&Style {
underlined: Some(true),
bold: Some(true),
..Style::default()
+4 -75
View File
@@ -448,16 +448,8 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?,
};
parse_meta(&bytes)
}
/// The first JSON value of a `pack.mcmeta`: what follows it is ignored, as some packs sign the
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()?
.ok()
// Gson reads past a UTF-8 byte order mark; serde_json rejects it.
serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok()
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
@@ -968,30 +960,6 @@ impl Selectable {
compatible: true,
}
}
/// A server's downloaded pack (`DownloadedPackSource.DOWNLOADED_PACK_SELECTION`):
/// required, fixed at the top, so the pack screen cannot remove or reorder it.
pub fn downloaded(id: &str, compatible: bool) -> Self {
Self {
id: id.to_owned(),
required: true,
position: Position::Top,
fixed: true,
compatible,
}
}
}
/// Ids from `DownloadedPackSource` (`server/…`); they must not go into
/// `options.txt` `resourcePacks`.
pub fn is_downloaded_pack_id(id: &str) -> bool {
id.starts_with("server/")
}
/// The player's own selection: drop every downloaded server/world pack id.
pub fn player_pack_ids(ids: impl IntoIterator<Item = String>) -> Vec<String> {
ids.into_iter()
.filter(|id| !is_downloaded_pack_id(id))
.collect()
}
/// `Pack.Position.insert`: below or above the packs fixed at that end.
@@ -1125,55 +1093,16 @@ impl SelectionModel {
ok
}
/// `commit`: the repository's selection, bottom first, as options.txt
/// stores it (downloaded `server/…` packs stay out of that file).
/// stores it.
pub fn commit(&self) -> Vec<String> {
let ids: Vec<String> = self.selected.iter().rev().map(|p| p.id.clone()).collect();
player_pack_ids(self.rebuild(&ids).into_iter().map(|p| p.id))
self.rebuild(&ids).into_iter().map(|p| p.id).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `DownloadedPackSource.DOWNLOADED_PACK_SELECTION`: the pack is selected,
/// fixed at the top, cannot be removed, and stays out of options.txt.
#[test]
fn a_downloaded_server_pack_is_fixed_selected_and_not_committed() {
let server = "server/00000000000000000000000000000001/hash.zip";
let available = vec![
Selectable::optional("file/Mine", true),
Selectable::downloaded(server, true),
Selectable::vanilla(),
];
let model = SelectionModel::new(available, &["file/Mine".into()]);
assert_eq!(
model
.selected
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>(),
[server, "file/Mine", "vanilla"]
);
assert!(!model.can_unselect(true, 0));
// A neighbour cannot move up past the fixed server pack.
assert!(!model.can_move_up(true, 1));
assert_eq!(
model.commit(),
vec!["vanilla".to_owned(), "file/Mine".to_owned()]
);
assert!(is_downloaded_pack_id(server));
assert!(!is_downloaded_pack_id("file/Mine"));
}
#[test]
fn a_comment_after_the_metadata_does_not_hide_a_pack() {
let meta = br#"{"pack":{"description":"d","pack_format":97}}
// Signed by the pack's author"#;
assert!(parse_meta(meta).is_some());
assert!(parse_meta(b"not json").is_none());
}
#[test]
fn mcmeta_with_utf8_bom_is_read() {
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
@@ -1,7 +1,6 @@
//! Overlay reuse validated by source topology and filesystem stamps.
//! Published output is an owned cache artifact; hits do not traverse its tree.
use super::{Pack, Source, build_overlay_uncached};
use crate::zip::Zip;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Read;
@@ -78,24 +77,6 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
}
Ok(())
}
/// The largest zip hashed by its bytes: vanilla's limit on a server pack.
const WHOLE_ZIP: u64 = 250 << 20;
/// A zip up to `whole` by its bytes, which also catches an edit its checksums
/// don't show (a pack may leave them empty, nothing reads them). A larger one
/// would take minutes on every check, so by its stamp and its directory: what a
/// local pack changes when it is replaced, and the directory reads in a moment.
fn zip_content(hash: &mut Sha256, path: &Path, whole: u64) -> io::Result<()> {
let meta = fs::metadata(path)?;
match Zip::open(path) {
Ok(zip) if meta.len() > whole => {
hash.update(meta.len().to_le_bytes());
field(hash, format!("{:?}", meta.modified()?).as_bytes());
hash.update(zip.directory());
Ok(())
}
_ => content(hash, path),
}
}
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
let meta = fs::metadata(path)?;
@@ -269,8 +250,8 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
// By its bytes, not its stamp: a server pack found in the download cache is
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
// time rebuilt the whole overlay each time.
zip_content(&mut hash, path, WHOLE_ZIP)?;
// time rebuilt the whole overlay each time. One file reads in a moment.
content(&mut hash, path)?;
}
}
}
@@ -651,36 +632,6 @@ mod tests {
assert!(!marker.exists());
}
#[test]
fn a_large_zip_is_stamped_by_its_directory_and_time() {
let f = Fixture::new();
let path = f.0.join("large.zip");
let at = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1 << 30);
let write = |name: &str, first: u8, modified| {
let mut body = vec![0u8; 1 << 18];
body[0] = first;
fs::write(&path, archive(&[(name, body)], false)).unwrap();
let file = fs::File::options().write(true).open(&path).unwrap();
file.set_modified(modified).unwrap();
};
let signature = |whole| {
let mut hash = Sha256::new();
zip_content(&mut hash, &path, whole).unwrap();
finish(hash)
};
write("a", 0, at);
let (bytes, directory) = (signature(u64::MAX), signature(0));
// Above the limit the entries' bytes go unread; at or under it they count.
write("a", 1, at);
assert_eq!(signature(0), directory);
assert_ne!(signature(u64::MAX), bytes);
write("b", 0, at);
assert_ne!(signature(0), directory);
write("a", 0, at + std::time::Duration::from_secs(60));
assert_ne!(signature(0), directory);
assert_eq!(signature(u64::MAX), bytes);
}
#[test]
fn zip_content_and_failed_build_invalidate() {
let f = Fixture::new();
+5 -46
View File
@@ -1075,8 +1075,6 @@ pub struct ParticleEngine {
}
/// `ParticleGroup.MAX_PARTICLES` (per render group; one group here).
const MAX_PARTICLES: usize = 16384;
/// `ParticleLimit.SPORE_BLOSSOM`, the only limited particle.
const SPORE_BLOSSOM_LIMIT: usize = 1000;
impl ParticleEngine {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
let mut engine = Self {
@@ -1482,27 +1480,11 @@ impl ParticleEngine {
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
/// `ParticleEngine.createParticle`: always spawned.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
let seed = self.seeds.next_long();
if let Some(particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
@@ -4142,8 +4124,8 @@ impl ParticleEngine {
layer: if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
} | u32::from(hdr_tier(p.ty)) << 16,
u32::from(hdr_tier(p.ty)) << 16
},
color,
light,
@@ -4154,8 +4136,8 @@ impl ParticleEngine {
}
}
}
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames,
/// glowing drops and fireworks, not their smoke.
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames and
/// glowing drops, not their smoke. Only opaque-layer particles are marked.
fn hdr_tier(ty: ParticleType) -> u8 {
use ParticleType as P;
match ty {
@@ -4172,8 +4154,6 @@ fn hdr_tier(ty: ParticleType) -> u8 {
P::DrippingObsidianTear | P::FallingObsidianTear | P::LandingObsidianTear => {
crate::mesher::LOW
}
// FireworkParticles: the sparks and the flash of the burst.
P::Firework | P::Flash => crate::mesher::BRIGHT,
_ => 0,
}
}
@@ -4364,27 +4344,6 @@ mod tests {
assert!(tall.iter().all(|p| p[1] < 1.0));
}
#[test]
fn spore_blossom_air_stops_at_its_particle_limit() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let spore = ParticleOptions::simple(ParticleType::SporeBlossomAir);
for i in 0..1001 {
engine.create(&spore, [f64::from(i % 16), 70.0, 0.0], [0.0; 3]);
}
assert_eq!(engine.pending.len(), 1000);
assert!(
engine
.pending
.iter()
.all(|p| p.ty == ParticleType::SporeBlossomAir)
);
// A type without a `ParticleLimit` is not capped.
let smoke = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..10 {
engine.create(&smoke, [0.0; 3], [0.0; 3]);
}
assert_eq!(engine.pending.len(), 1010);
}
#[test]
fn particle_names_round_trip() {
for &ty in ParticleType::ALL {
assert_eq!(
Loaded 100 of 822 files, more files were not shown because too many files have changed in this diff. Show more