mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85ccb2bd2 |
No files matched your search
@@ -7,4 +7,3 @@ kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
|
||||
@@ -9,9 +9,7 @@ permissions:
|
||||
actions: write
|
||||
jobs:
|
||||
cancel:
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
|
||||
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel the pull request's unfinished runs
|
||||
env:
|
||||
|
||||
+70
-41
@@ -1,7 +1,7 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
|
||||
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
@@ -24,32 +24,19 @@ env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
|
||||
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
|
||||
#
|
||||
# Where: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
|
||||
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
|
||||
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
|
||||
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
|
||||
# Python and the Rust cache steps are GitHub's only.
|
||||
verify:
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
|
||||
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
|
||||
# verify runs always() and fails unless all three passed.
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
@@ -65,20 +52,30 @@ jobs:
|
||||
fi
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
env:
|
||||
HOSTED: ${{ runner.environment == 'github-hosted' }}
|
||||
run: |
|
||||
if [ "$HOSTED" = true ]; then
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
fi
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
shared-key: verify-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -87,7 +84,32 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# these 4-core runners it was no faster (#4978).
|
||||
@@ -99,12 +121,24 @@ jobs:
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
verify:
|
||||
if: always()
|
||||
needs: [python, clippy, tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Did python, clippy and tests pass?
|
||||
env:
|
||||
RESULTS: ${{ join(needs.*.result, ' ') }}
|
||||
run: |
|
||||
echo "python, clippy, tests: $RESULTS"
|
||||
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
launcher:
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -118,16 +152,12 @@ jobs:
|
||||
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust and the headers GPUI links against
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
env:
|
||||
HOSTED: ${{ runner.environment == 'github-hosted' }}
|
||||
run: |
|
||||
if [ "$HOSTED" = true ]; then
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
fi
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher
|
||||
@@ -144,8 +174,7 @@ jobs:
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
parity-scope:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run: ${{ steps.scope.outputs.run }}
|
||||
steps:
|
||||
|
||||
@@ -29,8 +29,7 @@ jobs:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number }}
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -56,8 +55,7 @@ jobs:
|
||||
run: python3 tools/ci/claim_command.py command
|
||||
release:
|
||||
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -8,9 +8,6 @@ name: launcher
|
||||
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
|
||||
# commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
|
||||
# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
|
||||
# itself instead (launcher/src/backend.rs).
|
||||
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
@@ -22,7 +19,7 @@ on:
|
||||
push:
|
||||
branches: [main, launcher, 'launcher-*']
|
||||
paths:
|
||||
# Everything the launcher is built and packaged from, as tools/release_train.py's LAUNCHER_PATHS.
|
||||
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'crates/horizon-friends/**'
|
||||
@@ -32,8 +29,6 @@ on:
|
||||
- 'crates/horizon-release-sign/**'
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- 'crates/horizon-theme/**'
|
||||
- 'packaging/linux/**'
|
||||
- 'packaging/icons/**'
|
||||
- '.github/workflows/launcher.yml'
|
||||
schedule:
|
||||
- cron: '7-59/10 * * * *'
|
||||
@@ -166,7 +161,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev patchelf
|
||||
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
|
||||
- name: Metal shaders compiled now, or at run time without the Metal toolchain
|
||||
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
|
||||
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
|
||||
@@ -259,13 +254,6 @@ jobs:
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
if [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
# The .tar.gz below is what the launcher updates itself from; the AppImage is what a
|
||||
# player downloads and double-clicks, and it replaces itself instead
|
||||
# (packaging/linux/appimage.sh, launcher/src/backend.rs).
|
||||
../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
|
||||
fi
|
||||
mkdir -p dist
|
||||
cp "$BIN/${{ matrix.binary }}" dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: native hook stamp
|
||||
# Opens a PR that numbers pending fragments after they merge. Only pushes to
|
||||
# main run main's own tool; fragments are read as data, not PR code.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tools/native-hook-fragments/*.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: native-hook-stamp
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
stamp:
|
||||
if: github.repository == 'horizon-rust/horizon'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- name: Open a stamp pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
branch=automation/native-hook-stamp
|
||||
if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
|
||||
echo "A stamp pull request is already open."
|
||||
exit 0
|
||||
fi
|
||||
python3 tools/stamp_native_hooks.py
|
||||
if git diff --quiet; then
|
||||
echo "No fragments to stamp."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git checkout -b "$branch"
|
||||
git add -u
|
||||
git commit -m 'Stamp native hook API fragments'
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
|
||||
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
git push --force-with-lease origin "$branch"
|
||||
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
|
||||
@@ -19,9 +19,7 @@ jobs:
|
||||
concurrency:
|
||||
group: overlap-note-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
|
||||
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.88"
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,63 +0,0 @@
|
||||
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
|
||||
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
|
||||
# words are written here only so the filter can recognise them, because players spammed them on the
|
||||
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
|
||||
# aren't slurs out of it (moderators handle those).
|
||||
#
|
||||
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
|
||||
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
|
||||
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
|
||||
# doubled in the text: "niger", the river and the country, isn't "nigger".
|
||||
# word matches inside a longer word too
|
||||
# =word only as a whole word ("spic", not "suspicious")
|
||||
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
|
||||
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
|
||||
|
||||
~nigger
|
||||
~nigga
|
||||
nigguh
|
||||
niggah
|
||||
niqqa
|
||||
niqqer
|
||||
nlgger
|
||||
nlgga
|
||||
kneegrow
|
||||
=nig
|
||||
=nigs
|
||||
=niglet
|
||||
=niglets
|
||||
=nibba
|
||||
=nibbas
|
||||
=nibber
|
||||
=nibbers
|
||||
jigaboo
|
||||
porchmonkey
|
||||
=coon
|
||||
=coons
|
||||
~faggot
|
||||
=fag
|
||||
=fags
|
||||
=faggy
|
||||
=fagot
|
||||
=fagots
|
||||
=tranny
|
||||
=trannies
|
||||
=kike
|
||||
=kikes
|
||||
=chink
|
||||
=chinks
|
||||
=gook
|
||||
=gooks
|
||||
=spic
|
||||
=spics
|
||||
wetback
|
||||
=beaner
|
||||
=beaners
|
||||
raghead
|
||||
towelhead
|
||||
zipperhead
|
||||
=paki
|
||||
=pakis
|
||||
|
||||
!snigger*
|
||||
!niggard*
|
||||
@@ -1,472 +0,0 @@
|
||||
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
|
||||
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
|
||||
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
|
||||
//! player's text and carries out what it decides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
pub const DEFAULT_WORDS: &str = include_str!("../data/slurs.txt");
|
||||
|
||||
/// The mutes, beside server.properties.
|
||||
pub const MUTES_FILE: &str = "chat-filter-mutes.json";
|
||||
|
||||
/// `server.toml`'s `[chat_filter]`: on by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// The hit, counted within `window_minutes`, that mutes the player; 0: never.
|
||||
pub mute_after: u32,
|
||||
/// The hit that bans the player for `tempban_hours`; 0: never.
|
||||
pub tempban_after: u32,
|
||||
/// How long a hit counts towards the next ones (the time spent muted doesn't count).
|
||||
pub window_minutes: u32,
|
||||
pub mute_minutes: u32,
|
||||
pub tempban_hours: u32,
|
||||
/// The slur list, relative to the server's folder; empty: the list built into the game.
|
||||
pub words_file: String,
|
||||
/// Every refused text with the player and what followed, relative to the server's folder.
|
||||
pub log: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
mute_after: 2,
|
||||
tempban_after: 3,
|
||||
window_minutes: 10,
|
||||
mute_minutes: 10,
|
||||
tempban_hours: 24,
|
||||
words_file: String::new(),
|
||||
log: "logs/chat-filter.log".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// The `[chat_filter]` section of a whole `server.toml`; the default without it.
|
||||
pub fn from_server_toml(text: &str) -> Result<Self, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
chat_filter: Config,
|
||||
}
|
||||
// Other sections are the supervisor's to check: only this one is read here.
|
||||
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut only = toml::Table::new();
|
||||
if let Some(section) = table.remove("chat_filter") {
|
||||
only.insert("chat_filter".into(), section);
|
||||
}
|
||||
let file: File = only
|
||||
.try_into()
|
||||
.map_err(|e: toml::de::Error| format!("[chat_filter]: {e}"))?;
|
||||
file.chat_filter.check()?;
|
||||
Ok(file.chat_filter)
|
||||
}
|
||||
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.window_minutes == 0 {
|
||||
return Err("chat_filter.window_minutes must be 1 or more".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// How an entry of the list matches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
/// Inside a longer word too.
|
||||
Embedded,
|
||||
/// Only as a whole word.
|
||||
Whole,
|
||||
/// Inside a word, or spread over several words from the start of one.
|
||||
Spread,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entry {
|
||||
runs: Vec<Run>,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
/// The slur list, compiled: each entry's letters as runs, indexed by first letter.
|
||||
#[derive(Debug)]
|
||||
pub struct Filter {
|
||||
entries: Vec<Entry>,
|
||||
by_first: [Vec<u16>; 27],
|
||||
/// Words never matched: exact (`false`) or as a prefix (`true`).
|
||||
allowed: Vec<(Vec<Run>, bool)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Compiles a list in `data/slurs.txt`'s format.
|
||||
pub fn new(list: &str) -> Self {
|
||||
let mut f = Filter {
|
||||
entries: Vec::new(),
|
||||
by_first: Default::default(),
|
||||
allowed: Vec::new(),
|
||||
};
|
||||
for line in list.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(word) = line.strip_prefix('!') {
|
||||
let (word, prefix) = match word.strip_suffix('*') {
|
||||
Some(w) => (w, true),
|
||||
None => (word, false),
|
||||
};
|
||||
f.allowed.push((normalise::word(word), prefix));
|
||||
continue;
|
||||
}
|
||||
let (kind, word) = match line.as_bytes()[0] {
|
||||
b'=' => (Kind::Whole, &line[1..]),
|
||||
b'~' => (Kind::Spread, &line[1..]),
|
||||
_ => (Kind::Embedded, line),
|
||||
};
|
||||
let runs = normalise::word(word);
|
||||
let Some(first) = runs.first() else {
|
||||
continue;
|
||||
};
|
||||
f.by_first[usize::from(first.letter)].push(f.entries.len() as u16);
|
||||
f.entries.push(Entry { runs, kind });
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The built-in list.
|
||||
pub fn default_list() -> Self {
|
||||
Self::new(DEFAULT_WORDS)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `text` holds a slur of the list.
|
||||
pub fn matches(&self, text: &str) -> bool {
|
||||
let t = Text::new(text);
|
||||
let runs = &t.runs;
|
||||
for &(start, end) in t.words.iter().chain(&t.spaced) {
|
||||
let word = &runs[start..end];
|
||||
if self.is_allowed(word) {
|
||||
continue;
|
||||
}
|
||||
for at in 0..word.len() {
|
||||
for &e in &self.by_first[usize::from(word[at].letter)] {
|
||||
let e = &self.entries[usize::from(e)];
|
||||
let whole = e.kind == Kind::Whole;
|
||||
if (!whole || at == 0) && fits(&e.runs, &word[at..], whole) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spread entries over the whole text, from the start of a word, across at least two.
|
||||
for (w, &at) in t.joined_at.iter().enumerate() {
|
||||
for &e in &self.by_first[usize::from(t.joined[at].letter)] {
|
||||
let e = &self.entries[usize::from(e)];
|
||||
if e.kind == Kind::Spread
|
||||
&& fits(&e.runs, &t.joined[at..], false)
|
||||
&& t.spans_words(w, e.runs.len())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_allowed(&self, word: &[Run]) -> bool {
|
||||
self.allowed.iter().any(|(a, prefix)| {
|
||||
if *prefix {
|
||||
// A prefix: its last run may be part of a longer one.
|
||||
word.len() >= a.len() && fits(a, word, false)
|
||||
} else {
|
||||
fits(a, word, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `entry`'s runs start `runs` (are all of it with `whole`): the same letters, each at least
|
||||
/// as many times as the entry has it.
|
||||
fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
|
||||
if runs.len() < entry.len() || (whole && runs.len() != entry.len()) {
|
||||
return false;
|
||||
}
|
||||
entry
|
||||
.iter()
|
||||
.zip(runs)
|
||||
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
|
||||
}
|
||||
|
||||
/// What a hit leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The text is refused, nothing more.
|
||||
Refused,
|
||||
/// Refused, and the player can't chat until then (epoch milliseconds).
|
||||
Muted { until: i64 },
|
||||
/// Refused, and the player is banned until then.
|
||||
Banned { until: i64 },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Refused => "refused",
|
||||
Action::Muted { .. } => "muted",
|
||||
Action::Banned { .. } => "banned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds.
|
||||
pub until: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct MutesFile {
|
||||
mutes: Vec<Mute>,
|
||||
}
|
||||
|
||||
/// One player's hits: how many, and when the last one stops counting.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Strikes {
|
||||
count: u32,
|
||||
counts_until: i64,
|
||||
}
|
||||
|
||||
/// The filter, the players' hits and the mutes.
|
||||
pub struct Guard {
|
||||
pub config: Config,
|
||||
pub filter: Filter,
|
||||
strikes: HashMap<u128, Strikes>,
|
||||
mutes: HashMap<u128, Mute>,
|
||||
/// Where the mutes are kept; `None` keeps them in memory only (tests).
|
||||
file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
const MINUTE: i64 = 60_000;
|
||||
|
||||
impl Guard {
|
||||
pub fn new(config: Config, filter: Filter, file: Option<PathBuf>) -> Self {
|
||||
let mut mutes = HashMap::new();
|
||||
if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok())
|
||||
&& let Ok(saved) = serde_json::from_str::<MutesFile>(&text)
|
||||
{
|
||||
for m in saved.mutes {
|
||||
if let Some(uuid) = parse_uuid(&m.uuid) {
|
||||
mutes.insert(uuid, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
config,
|
||||
filter,
|
||||
strikes: HashMap::new(),
|
||||
mutes,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's guard: `words_file` or the built-in list, the mutes in `dir`. An unreadable
|
||||
/// `words_file` falls back to the built-in list, with why.
|
||||
pub fn load(config: Config, dir: &Path) -> (Self, Option<String>) {
|
||||
let mut problem = None;
|
||||
let filter = if config.words_file.is_empty() {
|
||||
Filter::default_list()
|
||||
} else {
|
||||
match std::fs::read_to_string(dir.join(&config.words_file)) {
|
||||
Ok(text) => Filter::new(&text),
|
||||
Err(e) => {
|
||||
problem = Some(format!(
|
||||
"chat_filter.words_file {}: {e}; using the built-in list",
|
||||
config.words_file
|
||||
));
|
||||
Filter::default_list()
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
Self::new(config, filter, Some(dir.join(MUTES_FILE))),
|
||||
problem,
|
||||
)
|
||||
}
|
||||
|
||||
/// When the player's mute ends, if they are muted at `now`.
|
||||
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
self.mutes
|
||||
.get(&uuid)
|
||||
.map(|m| m.until)
|
||||
.filter(|&until| until > now)
|
||||
}
|
||||
|
||||
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
|
||||
pub fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
|
||||
let c = &self.config;
|
||||
let window = i64::from(c.window_minutes) * MINUTE;
|
||||
let s = self.strikes.entry(uuid).or_insert(Strikes {
|
||||
count: 0,
|
||||
counts_until: 0,
|
||||
});
|
||||
if now > s.counts_until {
|
||||
s.count = 0;
|
||||
}
|
||||
s.count += 1;
|
||||
s.counts_until = now + window;
|
||||
let count = s.count;
|
||||
let action = if c.tempban_after > 0 && count >= c.tempban_after {
|
||||
self.strikes.remove(&uuid);
|
||||
Action::Banned {
|
||||
until: now + i64::from(c.tempban_hours) * 60 * MINUTE,
|
||||
}
|
||||
} else if c.mute_after > 0 && count >= c.mute_after {
|
||||
let until = now + i64::from(c.mute_minutes) * MINUTE;
|
||||
// The window starts again when the mute ends.
|
||||
s.counts_until = until + window;
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
Action::Muted { until }
|
||||
} else {
|
||||
Action::Refused
|
||||
};
|
||||
(count, action)
|
||||
}
|
||||
|
||||
/// Lifts a player's mute (and forgets their hits), by name; the name as muted, if they were.
|
||||
pub fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
let uuid = *self
|
||||
.mutes
|
||||
.iter()
|
||||
.find(|(_, m)| m.name.eq_ignore_ascii_case(name))?
|
||||
.0;
|
||||
let mute = self.mutes.remove(&uuid)?;
|
||||
self.strikes.remove(&uuid);
|
||||
self.save();
|
||||
Some(mute.name)
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
let Some(file) = &self.file else {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.until > now);
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, text).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of `logs/chat-filter.log`, without its newline.
|
||||
pub fn log_line(
|
||||
now: i64,
|
||||
name: &str,
|
||||
uuid: u128,
|
||||
place: &str,
|
||||
count: u32,
|
||||
action: Action,
|
||||
text: &str,
|
||||
) -> String {
|
||||
let text: String = text
|
||||
.chars()
|
||||
.map(|c| if c == '\n' { ' ' } else { c })
|
||||
.collect();
|
||||
format!(
|
||||
"{} {name} {} {place} hit {count} {}: {text}",
|
||||
utc(now),
|
||||
uuid_string(uuid),
|
||||
action.name()
|
||||
)
|
||||
}
|
||||
|
||||
/// Appends a line to the log at `path` (its folder made if needed).
|
||||
pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
|
||||
rest / 3600,
|
||||
rest / 60 % 60,
|
||||
rest % 60
|
||||
)
|
||||
}
|
||||
|
||||
fn uuid_string(u: u128) -> String {
|
||||
let h = format!("{u:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..]
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_uuid(s: &str) -> Option<u128> {
|
||||
u128::from_str_radix(&s.replace('-', ""), 16).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,255 +0,0 @@
|
||||
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
|
||||
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
|
||||
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
|
||||
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
|
||||
|
||||
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
|
||||
const OTHER: u8 = 26;
|
||||
|
||||
/// One letter and how many times it repeats (`gg` is `g` twice).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Run {
|
||||
/// 0..=25 for `a`..`z`, 26 for any other letter.
|
||||
pub letter: u8,
|
||||
pub count: u8,
|
||||
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
|
||||
pub weak: bool,
|
||||
}
|
||||
|
||||
/// A whole message, normalised.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Text {
|
||||
pub runs: Vec<Run>,
|
||||
/// Each word's runs, `runs[start..end]`.
|
||||
pub words: Vec<(usize, usize)>,
|
||||
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
|
||||
/// without the punctuation they end with (`fag!`).
|
||||
pub spaced: Vec<(usize, usize)>,
|
||||
/// Every word one after the other, a letter that ends one and starts the next merged.
|
||||
pub joined: Vec<Run>,
|
||||
/// Where each word starts in `joined`.
|
||||
pub joined_at: Vec<usize>,
|
||||
/// The last word each run of `joined` belongs to.
|
||||
joined_last: Vec<usize>,
|
||||
}
|
||||
|
||||
enum Fold {
|
||||
Letter(u8, bool),
|
||||
Separator,
|
||||
Space,
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn new(text: &str) -> Self {
|
||||
let mut t = Text::default();
|
||||
let mut start = 0;
|
||||
for c in text.chars() {
|
||||
match fold(c) {
|
||||
Fold::Letter(letter, weak) => {
|
||||
let in_word = t.runs.len() > start;
|
||||
match t.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == letter => {
|
||||
last.count = last.count.saturating_add(1);
|
||||
last.weak &= weak;
|
||||
}
|
||||
_ => t.runs.push(Run {
|
||||
letter,
|
||||
count: 1,
|
||||
weak,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Fold::Space => {
|
||||
t.end_word(start);
|
||||
start = t.runs.len();
|
||||
}
|
||||
Fold::Separator | Fold::Skip => {}
|
||||
}
|
||||
}
|
||||
t.end_word(start);
|
||||
t.add_spaced();
|
||||
t.join();
|
||||
t
|
||||
}
|
||||
|
||||
fn end_word(&mut self, start: usize) {
|
||||
if self.runs.len() > start {
|
||||
self.words.push((start, self.runs.len()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
|
||||
/// copied after the real words').
|
||||
fn add_spaced(&mut self) {
|
||||
let words = self.words.len();
|
||||
let mut w = 0;
|
||||
while w < words {
|
||||
let mut end = w;
|
||||
while end < words && self.words[end].1 - self.words[end].0 == 1 {
|
||||
end += 1;
|
||||
}
|
||||
if end - w >= 2 {
|
||||
let first = self.runs.len();
|
||||
for k in w..end {
|
||||
let run = self.runs[self.words[k].0];
|
||||
let in_word = self.runs.len() > first;
|
||||
match self.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
}
|
||||
_ => self.runs.push(run),
|
||||
}
|
||||
}
|
||||
self.spaced.push((first, self.runs.len()));
|
||||
w = end;
|
||||
} else {
|
||||
w += 1;
|
||||
}
|
||||
}
|
||||
for k in 0..words {
|
||||
let (start, mut end) = self.words[k];
|
||||
while end > start + 1 && self.runs[end - 1].weak {
|
||||
end -= 1;
|
||||
}
|
||||
if end < self.words[k].1 {
|
||||
self.spaced.push((start, end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&mut self) {
|
||||
for (w, &(start, end)) in self.words.iter().enumerate() {
|
||||
let mut first = true;
|
||||
for run in &self.runs[start..end] {
|
||||
match self.joined.last_mut() {
|
||||
Some(last) if first && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
self.joined_at.push(self.joined.len() - 1);
|
||||
*self.joined_last.last_mut().expect("beside joined") = w;
|
||||
}
|
||||
_ => {
|
||||
if first {
|
||||
self.joined_at.push(self.joined.len());
|
||||
}
|
||||
self.joined.push(*run);
|
||||
self.joined_last.push(w);
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
|
||||
pub fn spans_words(&self, w: usize, len: usize) -> bool {
|
||||
self.joined_last
|
||||
.get(self.joined_at[w] + len - 1)
|
||||
.is_some_and(|&last| last > w)
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry of the list, normalised the same way (one word, whitespace ignored).
|
||||
pub fn word(text: &str) -> Vec<Run> {
|
||||
let t = Text::new(&text.replace(char::is_whitespace, ""));
|
||||
t.words
|
||||
.first()
|
||||
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
|
||||
}
|
||||
|
||||
fn letter(c: char) -> Fold {
|
||||
Fold::Letter(c as u8 - b'a', false)
|
||||
}
|
||||
|
||||
fn fold(c: char) -> Fold {
|
||||
if c.is_ascii_alphabetic() {
|
||||
return letter(c.to_ascii_lowercase());
|
||||
}
|
||||
if c.is_ascii() {
|
||||
return match c {
|
||||
'0' => letter('o'),
|
||||
'1' => letter('i'),
|
||||
'3' => letter('e'),
|
||||
'4' => letter('a'),
|
||||
'5' => letter('s'),
|
||||
'6' | '9' => letter('g'),
|
||||
'7' => letter('t'),
|
||||
'8' => letter('b'),
|
||||
'!' | '|' => Fold::Letter(b'i' - b'a', true),
|
||||
'@' => Fold::Letter(0, true),
|
||||
'$' => Fold::Letter(b's' - b'a', true),
|
||||
c if c.is_ascii_whitespace() => Fold::Space,
|
||||
_ => Fold::Separator,
|
||||
};
|
||||
}
|
||||
let u = c as u32;
|
||||
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
|
||||
let mapped = match u {
|
||||
// Zero-width characters, soft hyphen, combining marks, variation selectors.
|
||||
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
|
||||
0x300..=0x36F
|
||||
| 0x1AB0..=0x1AFF
|
||||
| 0x1DC0..=0x1DFF
|
||||
| 0x20D0..=0x20FF
|
||||
| 0xFE00..=0xFE0F
|
||||
| 0xFE20..=0xFE2F
|
||||
| 0xE0100..=0xE01EF => return Fold::Skip,
|
||||
// Full-width Latin and digits.
|
||||
0xFF21..=0xFF3A => from(0xFF21, 26),
|
||||
0xFF41..=0xFF5A => from(0xFF41, 26),
|
||||
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
|
||||
// Mathematical alphanumerics: 52 letters a style, then digits.
|
||||
0x1D400..=0x1D6A3 => from(0x1D400, 26),
|
||||
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
|
||||
// Circled, parenthesised, squared and negative letters, regional indicators.
|
||||
0x24B6..=0x24CF => from(0x24B6, 26),
|
||||
0x24D0..=0x24E9 => from(0x24D0, 26),
|
||||
0x249C..=0x24B5 => from(0x249C, 26),
|
||||
0x1F130..=0x1F149 => from(0x1F130, 26),
|
||||
0x1F150..=0x1F169 => from(0x1F150, 26),
|
||||
0x1F170..=0x1F189 => from(0x1F170, 26),
|
||||
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
|
||||
_ => {
|
||||
if c.is_whitespace() {
|
||||
return Fold::Space;
|
||||
}
|
||||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||||
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
|
||||
Some((base, _)) => *base,
|
||||
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
|
||||
None => return Fold::Separator,
|
||||
}
|
||||
}
|
||||
};
|
||||
letter(mapped)
|
||||
}
|
||||
|
||||
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
|
||||
const LOOK_ALIKES: &[(char, &str)] = &[
|
||||
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
|
||||
('b', "ƀɓḃḅḇвьъβʙᵇ"),
|
||||
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
|
||||
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
|
||||
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
|
||||
('f', "ƒḟꜰᶠ"),
|
||||
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
|
||||
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
|
||||
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
|
||||
('j', "ĵǰјᴊʲ"),
|
||||
('k', "ķĸǩḱḳḵкκᴋᵏ"),
|
||||
('l', "ĺļľŀłḷḹḻḽʟˡ"),
|
||||
('m', "мᴍᵐ"),
|
||||
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
|
||||
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
|
||||
('p', "ṕṗрρᴘᵖ"),
|
||||
('q', "ԛ"),
|
||||
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
|
||||
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
|
||||
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
|
||||
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
|
||||
('v', "νᴠᵛ"),
|
||||
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
|
||||
('x', "хχˣ"),
|
||||
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
|
||||
('z', "źżžƶẑẓẕᴢᶻ"),
|
||||
];
|
||||
@@ -1,253 +0,0 @@
|
||||
// The test texts spell the slurs the filter must refuse: that is what they test.
|
||||
use super::*;
|
||||
|
||||
fn filter() -> Filter {
|
||||
Filter::default_list()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_compiles() {
|
||||
let f = filter();
|
||||
assert!(f.len() > 30, "{} entries", f.len());
|
||||
assert_eq!(f.allowed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalising_catches_the_usual_disguises() {
|
||||
let f = filter();
|
||||
for text in [
|
||||
"nigger",
|
||||
"NIGGER",
|
||||
"shut up nigga",
|
||||
"Niggers",
|
||||
"n1gg3r",
|
||||
"ni99a",
|
||||
"N!GGA",
|
||||
"nigg@",
|
||||
"n.i.g.g.e.r",
|
||||
"n_i_g_g_a",
|
||||
"n i g g e r",
|
||||
"n i gg a",
|
||||
"nig ger",
|
||||
"nig gaaa",
|
||||
"niiiiggggggerrrrr",
|
||||
"ñíggér",
|
||||
"nigger",
|
||||
"Niggeг", // Cyrillic ghe for r
|
||||
"nіgger", // Cyrillic i
|
||||
"nigg\u{0435}r", // Cyrillic e
|
||||
"n\u{200B}igger", // a zero-width space
|
||||
"n\u{0301}igger", // a combining accent
|
||||
"𝐧𝐢𝐠𝐠𝐞𝐫",
|
||||
"ⓝⓘⓖⓖⓐ",
|
||||
"ɴɪɢɢᴀ",
|
||||
"hi nig",
|
||||
"f a g",
|
||||
"fag!",
|
||||
"you $pic",
|
||||
"FAGGOTINO-CHAN",
|
||||
"sandnigger",
|
||||
"lol kike",
|
||||
] {
|
||||
assert!(f.matches(text), "{text:?} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_words_pass() {
|
||||
let f = filter();
|
||||
for text in [
|
||||
"hello",
|
||||
"Niger",
|
||||
"I live in Niger",
|
||||
"Nigeria and the Niger river",
|
||||
"nigerian food",
|
||||
"proud2benigerian",
|
||||
"don't snigger",
|
||||
"sniggering at that",
|
||||
"a niggardly offer",
|
||||
"knight",
|
||||
"qKnights",
|
||||
"its still night",
|
||||
"anyone able to boot the latest nightly version?",
|
||||
"i hate nighbours",
|
||||
"can someune give mi night vision",
|
||||
"suspicious stew",
|
||||
"spicy food",
|
||||
"a raccoon in a cocoon",
|
||||
"Scunthorpe",
|
||||
"bigger trigger digger",
|
||||
"gg",
|
||||
"gg ez",
|
||||
"mini gg all",
|
||||
"kniga",
|
||||
"shenanigans",
|
||||
"dignity and signing",
|
||||
"Pakistan",
|
||||
"chinking glasses",
|
||||
"the trannys? no: transmission",
|
||||
"1nicerOli",
|
||||
"ask me evernig",
|
||||
"冰淇淋",
|
||||
"привет",
|
||||
"",
|
||||
"!!!",
|
||||
] {
|
||||
assert!(!f.matches(text), "{text:?} should pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_list_has_the_same_syntax() {
|
||||
let f = Filter::new("# comment\n=bad\n~worse\nmeh\n!mehish*\n");
|
||||
assert!(f.matches("so bad"));
|
||||
assert!(!f.matches("badly"));
|
||||
assert!(f.matches("wor se"));
|
||||
assert!(f.matches("xxmehxx"));
|
||||
assert!(!f.matches("mehishness"));
|
||||
assert!(!f.matches("me h"));
|
||||
}
|
||||
|
||||
fn guard(config: Config) -> Guard {
|
||||
Guard::new(config, filter(), None)
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_793_469_939_000;
|
||||
const MIN: i64 = 60_000;
|
||||
|
||||
#[test]
|
||||
fn the_second_hit_mutes_and_the_third_bans() {
|
||||
let mut g = guard(Config::default());
|
||||
let id = 7;
|
||||
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.muted_until(id, NOW), None);
|
||||
let until = NOW + 3 * MIN + 10 * MIN;
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", NOW + 3 * MIN),
|
||||
(2, Action::Muted { until })
|
||||
);
|
||||
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
|
||||
assert_eq!(g.muted_until(id, until), None);
|
||||
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", until + 8 * MIN),
|
||||
(
|
||||
3,
|
||||
Action::Banned {
|
||||
until: until + 8 * MIN + 24 * 60 * MIN
|
||||
}
|
||||
)
|
||||
);
|
||||
// Then it starts again.
|
||||
assert_eq!(g.strike(id, "Ann", until + 9 * MIN), (1, Action::Refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hits_far_apart_dont_add_up() {
|
||||
let mut g = guard(Config::default());
|
||||
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.strike(1, "Bo", NOW + 11 * MIN), (1, Action::Refused));
|
||||
// Players are counted apart.
|
||||
assert_eq!(g.strike(2, "Cy", NOW + 12 * MIN), (1, Action::Refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thresholds_are_configurable() {
|
||||
let mut g = guard(Config {
|
||||
mute_after: 0,
|
||||
tempban_after: 2,
|
||||
tempban_hours: 1,
|
||||
..Config::default()
|
||||
});
|
||||
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
|
||||
assert_eq!(
|
||||
g.strike(1, "Bo", NOW + MIN),
|
||||
(
|
||||
2,
|
||||
Action::Banned {
|
||||
until: NOW + MIN + 60 * MIN
|
||||
}
|
||||
)
|
||||
);
|
||||
let mut off = guard(Config {
|
||||
mute_after: 0,
|
||||
tempban_after: 0,
|
||||
..Config::default()
|
||||
});
|
||||
for k in 1..5 {
|
||||
assert_eq!(off.strike(1, "Bo", NOW + k), (k as u32, Action::Refused));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutes_survive_a_restart_and_unmute_lifts_them() {
|
||||
let dir = std::env::temp_dir().join(format!("chat-filter-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let now = now_millis();
|
||||
let (mut g, problem) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(problem, None);
|
||||
g.strike(5, "Dee", now);
|
||||
let (_, action) = g.strike(5, "Dee", now);
|
||||
assert!(matches!(action, Action::Muted { .. }));
|
||||
let (mut again, _) = Guard::load(Config::default(), &dir);
|
||||
assert!(again.muted_until(5, now).is_some());
|
||||
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
|
||||
assert_eq!(again.unmute("dee"), None);
|
||||
assert_eq!(again.muted_until(5, now), None);
|
||||
let (reloaded, _) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(reloaded.muted_until(5, now), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_words_file_falls_back_to_the_built_in_list() {
|
||||
let config = Config {
|
||||
words_file: "nope.txt".into(),
|
||||
..Config::default()
|
||||
};
|
||||
let (g, problem) = Guard::load(config, Path::new("/nonexistent-horizon"));
|
||||
assert!(problem.unwrap().contains("nope.txt"));
|
||||
assert!(g.filter.matches("nigger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_section_reads_from_server_toml() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(c.enabled);
|
||||
let c = Config::from_server_toml(
|
||||
"[chat_filter]\nenabled = true\nmute_after = 3\ntempban_after = 5\nwords_file = \"slurs.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((c.mute_after, c.tempban_after), (3, 5));
|
||||
assert_eq!(c.words_file, "slurs.txt");
|
||||
assert!(
|
||||
!Config::from_server_toml("[chat_filter]\nenabled = false\n")
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
assert!(Config::from_server_toml("[chat_filter]\nmute = 2\n").is_err());
|
||||
assert!(Config::from_server_toml("[chat_filter]\nwindow_minutes = 0\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_lines_have_the_time_the_player_and_the_text() {
|
||||
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(utc(951_782_400_000), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(
|
||||
log_line(NOW, "Ann", 1, "chat", 2, Action::Muted { until: 0 }, "a\nb"),
|
||||
"2026-10-31T18:05:39Z Ann 00000000-0000-0000-0000-000000000001 chat hit 2 muted: a b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_costs_little() {
|
||||
// A long message through the whole list stays far below a tick (debug builds included).
|
||||
let f = filter();
|
||||
let text = "the quick brown fox jumps over the lazy dog ".repeat(6);
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
assert!(!f.matches(&text));
|
||||
}
|
||||
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
|
||||
}
|
||||
@@ -69,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");
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -10,6 +10,5 @@ pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod window;
|
||||
@@ -1,232 +0,0 @@
|
||||
//! OS modifier reconciliation and modifiers synthesized by agent control.
|
||||
|
||||
use crate::{
|
||||
keybinds::{KeyBinds, key_code},
|
||||
keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use winit::keyboard::{KeyCode, ModifiersState};
|
||||
|
||||
const MODIFIERS: [(ModifiersState, [KeyCode; 2]); 4] = [
|
||||
(
|
||||
ModifiersState::SHIFT,
|
||||
[KeyCode::ShiftLeft, KeyCode::ShiftRight],
|
||||
),
|
||||
(
|
||||
ModifiersState::CONTROL,
|
||||
[KeyCode::ControlLeft, KeyCode::ControlRight],
|
||||
),
|
||||
(ModifiersState::ALT, [KeyCode::AltLeft, KeyCode::AltRight]),
|
||||
(
|
||||
ModifiersState::SUPER,
|
||||
[KeyCode::SuperLeft, KeyCode::SuperRight],
|
||||
),
|
||||
];
|
||||
|
||||
/// A synthesized key updates the same modifier word as window events.
|
||||
/// Releasing one side must preserve the other side's hold.
|
||||
pub fn set_key(
|
||||
modifiers: &mut ModifiersState,
|
||||
physical: &mut HashSet<KeyCode>,
|
||||
key: KeyCode,
|
||||
down: bool,
|
||||
) {
|
||||
let Some((bit, pair)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&key)) else {
|
||||
return;
|
||||
};
|
||||
if down {
|
||||
physical.insert(key);
|
||||
} else {
|
||||
physical.remove(&key);
|
||||
}
|
||||
modifiers.set(*bit, pair.iter().any(|key| physical.contains(key)));
|
||||
}
|
||||
|
||||
/// A modifier held for one synthesized click (the pad's Y, an agent's
|
||||
/// shift-click): what [`restore`] puts back once the click is done.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Held {
|
||||
key: KeyCode,
|
||||
was_down: bool,
|
||||
bit: bool,
|
||||
}
|
||||
|
||||
/// Holds `key` in both the physical keys and the modifier word, which is
|
||||
/// what screens read (`InputWithModifiers.hasShiftDown`).
|
||||
pub fn hold(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, key: KeyCode) -> Held {
|
||||
let bit = MODIFIERS
|
||||
.iter()
|
||||
.find(|(_, pair)| pair.contains(&key))
|
||||
.is_some_and(|(bit, _)| modifiers.contains(*bit));
|
||||
let held = Held {
|
||||
key,
|
||||
was_down: physical.contains(&key),
|
||||
bit,
|
||||
};
|
||||
set_key(modifiers, physical, key, true);
|
||||
held
|
||||
}
|
||||
|
||||
/// Undoes [`hold`]: a key or bit that was already down stays down.
|
||||
pub fn restore(modifiers: &mut ModifiersState, physical: &mut HashSet<KeyCode>, held: Held) {
|
||||
if !held.was_down {
|
||||
physical.remove(&held.key);
|
||||
}
|
||||
if let Some((bit, _)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&held.key)) {
|
||||
modifiers.set(*bit, held.bit);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover missing modifier releases without releasing a rebound key or a pad.
|
||||
/// ToggleKeyMapping itself preserves keyboard and controller toggles on release.
|
||||
pub fn reconcile(
|
||||
modifiers: ModifiersState,
|
||||
physical: &mut HashSet<KeyCode>,
|
||||
bindings: &KeyBinds,
|
||||
toggles: &mut ToggleKeys,
|
||||
options: [bool; 2],
|
||||
pad_held: [bool; 2],
|
||||
) {
|
||||
for (bit, pair) in MODIFIERS {
|
||||
if modifiers.contains(bit) {
|
||||
continue;
|
||||
}
|
||||
for key in pair {
|
||||
if !physical.remove(&key) {
|
||||
continue;
|
||||
}
|
||||
for (slot, name) in [(SNEAK, "key.sneak"), (SPRINT, "key.sprint")] {
|
||||
if key_code(bindings.get(name)) == Some(key) && !pad_held[slot] {
|
||||
toggles.key(slot, KeyAction::Release, false, options[slot]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modifier_taps_release_both_bits() {
|
||||
let mut modifiers = ModifiersState::empty();
|
||||
let mut physical = HashSet::new();
|
||||
for (bit, pair) in MODIFIERS {
|
||||
for key in pair {
|
||||
set_key(&mut modifiers, &mut physical, key, true);
|
||||
assert!(modifiers.contains(bit));
|
||||
set_key(&mut modifiers, &mut physical, key, false);
|
||||
assert!(modifiers.is_empty());
|
||||
assert!(physical.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_one_side_keeps_the_other_modifier_held() {
|
||||
let mut modifiers = ModifiersState::empty();
|
||||
let mut physical = HashSet::new();
|
||||
for (bit, [left, right]) in MODIFIERS {
|
||||
set_key(&mut modifiers, &mut physical, left, true);
|
||||
set_key(&mut modifiers, &mut physical, right, true);
|
||||
set_key(&mut modifiers, &mut physical, left, false);
|
||||
assert!(modifiers.contains(bit));
|
||||
set_key(&mut modifiers, &mut physical, right, false);
|
||||
assert!(modifiers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_click_restores_the_previous_state() {
|
||||
let mut modifiers = ModifiersState::empty();
|
||||
let mut physical = HashSet::new();
|
||||
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
|
||||
assert!(modifiers.shift_key());
|
||||
restore(&mut modifiers, &mut physical, held);
|
||||
assert!(modifiers.is_empty());
|
||||
assert!(physical.is_empty());
|
||||
|
||||
// Right Shift already held, or the OS word set with no physical key.
|
||||
for start in [HashSet::from([KeyCode::ShiftRight]), HashSet::new()] {
|
||||
let mut modifiers = ModifiersState::SHIFT;
|
||||
let mut physical = start.clone();
|
||||
let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
|
||||
restore(&mut modifiers, &mut physical, held);
|
||||
assert_eq!(modifiers, ModifiersState::SHIFT);
|
||||
assert_eq!(physical, start);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_releases_respect_rebindings_and_pad_holds() {
|
||||
for slot in [SNEAK, SPRINT] {
|
||||
for rebound in [false, true] {
|
||||
for pad in [false, true] {
|
||||
for toggle in [false, true] {
|
||||
let mut bindings = KeyBinds::default();
|
||||
let (name, modifier) = if slot == SNEAK {
|
||||
("key.sneak", KeyCode::ShiftLeft)
|
||||
} else {
|
||||
("key.sprint", KeyCode::ControlLeft)
|
||||
};
|
||||
if rebound {
|
||||
bindings.set(name, "key.keyboard.c");
|
||||
}
|
||||
let mut physical = HashSet::from([modifier]);
|
||||
if rebound {
|
||||
physical.insert(KeyCode::KeyC);
|
||||
}
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.key(slot, KeyAction::Press, false, toggle);
|
||||
let mut options = [false; 2];
|
||||
options[slot] = toggle;
|
||||
let mut pad_held = [false; 2];
|
||||
pad_held[slot] = pad;
|
||||
reconcile(
|
||||
ModifiersState::ALT,
|
||||
&mut physical,
|
||||
&bindings,
|
||||
&mut toggles,
|
||||
options,
|
||||
pad_held,
|
||||
);
|
||||
assert_eq!(toggles.is_down(slot), rebound || pad || toggle);
|
||||
assert!(!physical.contains(&modifier));
|
||||
assert_eq!(physical.contains(&KeyCode::KeyC), rebound);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_modifier_change_does_not_release_an_action() {
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.key(SNEAK, KeyAction::Press, false, false);
|
||||
reconcile(
|
||||
ModifiersState::ALT,
|
||||
&mut HashSet::new(),
|
||||
&KeyBinds::default(),
|
||||
&mut toggles,
|
||||
[false; 2],
|
||||
[false; 2],
|
||||
);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_modifier_release_preserves_a_controller_toggle() {
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.pad_toggle(SNEAK, false);
|
||||
reconcile(
|
||||
ModifiersState::empty(),
|
||||
&mut HashSet::from([KeyCode::ShiftLeft]),
|
||||
&KeyBinds::default(),
|
||||
&mut toggles,
|
||||
[false; 2],
|
||||
[false; 2],
|
||||
);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
}
|
||||
}
|
||||
@@ -473,37 +473,6 @@ pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<Video
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The video modes of the window's monitor, listed once per monitor. Listing them takes tens of
|
||||
/// milliseconds on Windows, and settings apply at every step of a dragged slider: listing them
|
||||
/// each time made the slider stutter.
|
||||
#[derive(Debug)]
|
||||
pub struct VideoModeList<M = winit::monitor::MonitorHandle> {
|
||||
listed: Option<M>,
|
||||
}
|
||||
|
||||
impl<M> Default for VideoModeList<M> {
|
||||
fn default() -> Self {
|
||||
Self { listed: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: PartialEq> VideoModeList<M> {
|
||||
/// The modes of `monitor` when it is not the monitor listed last, else `None` (the list
|
||||
/// stands). No monitor lists none.
|
||||
pub fn refresh(
|
||||
&mut self,
|
||||
monitor: Option<M>,
|
||||
list: impl FnOnce(&M) -> Vec<VideoMode>,
|
||||
) -> Option<Vec<VideoMode>> {
|
||||
if monitor.is_some() && monitor == self.listed {
|
||||
return None;
|
||||
}
|
||||
let modes = monitor.as_ref().map(list).unwrap_or_default();
|
||||
self.listed = monitor;
|
||||
Some(modes)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
|
||||
/// the monitor's own size with the highest refresh rate and depth.
|
||||
fn monitor_current_mode(
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -235,12 +235,10 @@ impl PlayerEffects {
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
|
||||
/// (on its last tick) and a content mod's effect (every tick, with the
|
||||
/// tick count `shouldApplyEffectTickThisTick` takes); `absorption_empty`
|
||||
/// is `AbsorptionMobEffect`'s answer: the effect ends once the
|
||||
/// absorption is used up. Returns the actions to apply, in order, and
|
||||
/// whether Raid Omen was newly added.
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
|
||||
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
|
||||
/// answer: the effect ends once the absorption is used up. Returns the
|
||||
/// actions to apply, in order, and whether Raid Omen was newly added.
|
||||
///
|
||||
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
|
||||
/// iterated: vanilla's iterator then throws a
|
||||
@@ -249,7 +247,7 @@ impl PlayerEffects {
|
||||
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
|
||||
pub fn tick_with(
|
||||
&mut self,
|
||||
omen: &mut dyn FnMut(&EffectInstance, i32) -> OmenTick,
|
||||
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
|
||||
absorption_empty: bool,
|
||||
) -> (Vec<EffectAction>, bool) {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
@@ -271,9 +269,8 @@ impl PlayerEffects {
|
||||
actions.push(a);
|
||||
}
|
||||
let answer = match e.id {
|
||||
"bad_omen" => omen(e, t),
|
||||
"raid_omen" if t == 1 => omen(e, t),
|
||||
id if potions::is_mod_effect(id) => omen(e, t),
|
||||
"bad_omen" => omen(e),
|
||||
"raid_omen" if t == 1 => omen(e),
|
||||
"absorption" if absorption_empty => OmenTick::Remove,
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
@@ -511,7 +508,7 @@ mod tests {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("bad_omen", 100, 1));
|
||||
fx.add(EffectInstance::new("speed", 50, 0));
|
||||
let mut convert = |e: &EffectInstance, _: i32| match e.id {
|
||||
let mut convert = |e: &EffectInstance| match e.id {
|
||||
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
@@ -553,39 +550,12 @@ mod tests {
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
/// A content mod's effect answers each tick before it counts down (an
|
||||
/// infinite one with the tick count), and goes at once on Remove.
|
||||
#[test]
|
||||
fn a_mod_effect_answers_before_it_counts_down() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("ruby:glow", 3, 1));
|
||||
fx.add(EffectInstance::new("ruby:bleed", -1, 0));
|
||||
let mut seen = Vec::new();
|
||||
let mut answer = |e: &EffectInstance, t: i32| {
|
||||
seen.push((e.id, t));
|
||||
match (e.id, t) {
|
||||
("ruby:glow", 2) => OmenTick::Remove,
|
||||
_ => OmenTick::Continue,
|
||||
}
|
||||
};
|
||||
fx.tick_with(&mut answer, false);
|
||||
fx.tick_with(&mut answer, false);
|
||||
let ticks = [
|
||||
("ruby:glow", 3),
|
||||
("ruby:bleed", 1),
|
||||
("ruby:glow", 2),
|
||||
("ruby:bleed", 2),
|
||||
];
|
||||
assert_eq!(seen, ticks);
|
||||
assert!(fx.get("ruby:glow").is_none());
|
||||
assert_eq!(fx.get("ruby:bleed").unwrap().duration, -1);
|
||||
}
|
||||
#[test]
|
||||
fn raid_omen_acts_on_its_last_tick() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("raid_omen", 2, 0));
|
||||
let mut seen = Vec::new();
|
||||
let mut watch = |e: &EffectInstance, _: i32| {
|
||||
let mut watch = |e: &EffectInstance| {
|
||||
seen.push(e.duration);
|
||||
OmenTick::Remove
|
||||
};
|
||||
|
||||
@@ -22,10 +22,6 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
colored_lights: array<array<vec4<f32>, 2>, 16>,
|
||||
colored_count: vec4<u32>,
|
||||
emissive_layers: array<vec4<u32>, 32>,
|
||||
emissive_count: vec4<u32>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
filtering: vec4<f32>,
|
||||
}
|
||||
@@ -94,15 +90,6 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
|
||||
}
|
||||
return level * 16.0;
|
||||
}
|
||||
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
|
||||
var rgb = vec3(0.0);
|
||||
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
|
||||
let at = globals.colored_lights[i][0];
|
||||
let color = globals.colored_lights[i][1];
|
||||
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
|
||||
}
|
||||
return min(rgb, vec3(1.0));
|
||||
}
|
||||
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
@@ -116,9 +103,6 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|
||||
|| overlay == 11u || overlay == 12u;
|
||||
out.light_color = select(lm, vec4(1.0), unlit);
|
||||
if globals.colored_count.x > 0u && !unlit {
|
||||
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
|
||||
}
|
||||
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
|
||||
// (cracks): the destroy stage sprite's origin.
|
||||
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
|
||||
@@ -157,9 +141,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
|
||||
@location(7) offset: vec3<f32>) -> Out {
|
||||
var out = entity_vertex(offset + position, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
return entity_vertex(offset + position, uv, overlay, color, light, normal);
|
||||
}
|
||||
struct CloudInfo {
|
||||
offset: vec4<f32>,
|
||||
@@ -312,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);
|
||||
}
|
||||
|
||||
|
||||
+147
-1029
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]);
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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(¶ms));
|
||||
}
|
||||
};
|
||||
level_params(&self.game_level_params, &r.game_pyramid.sizes);
|
||||
level_params(
|
||||
&self.inpainting_level_params,
|
||||
&self.inpainting_pyramid.sizes,
|
||||
);
|
||||
for b in [
|
||||
&r.rec_prev_depth,
|
||||
&r.rec_interp_depth,
|
||||
&r.game_x,
|
||||
&r.game_y,
|
||||
&r.of_x,
|
||||
&r.of_y,
|
||||
] {
|
||||
encoder.clear_buffer(b, 0, None);
|
||||
}
|
||||
if reset {
|
||||
encoder.clear_buffer(&self.counters, 0, None);
|
||||
}
|
||||
let p = &self.pipelines;
|
||||
let params = || whole(&self.params);
|
||||
let sampler = || wgpu::BindingResource::Sampler(&self.sampler);
|
||||
let groups =
|
||||
|pipeline, entries: &[(u32, wgpu::BindingResource)]| bind(device, pipeline, entries);
|
||||
let prepare = groups(
|
||||
&p.prepare,
|
||||
&[
|
||||
(0, params()),
|
||||
(2, view(inputs.depth)),
|
||||
(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, ¶ms);
|
||||
let mut entries: Vec<_> = sprites
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, view)| wgpu::BindGroupEntry {
|
||||
binding: binding as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
})
|
||||
.collect();
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: super::SPRITE_ARRAYS,
|
||||
resource: self.params.as_entire_binding(),
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,7 @@ fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
|
||||
normal: [0; 4],
|
||||
pos: c,
|
||||
uv,
|
||||
// HDR output: the bolt shines like the sun (entity.wgsl fs_hdr_mark).
|
||||
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
|
||||
layer: 2,
|
||||
color,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
|
||||
@@ -57,8 +57,7 @@ fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex
|
||||
normal: [0; 4],
|
||||
pos: (o + glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
// The HDR tier lightning.rs adds; the geometry is vanilla's.
|
||||
layer: 2 | u32::from(crate::mesher::VERY_BRIGHT) << 16,
|
||||
layer: 2,
|
||||
color,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+434
-1669
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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(®ion, 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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in new issue
Block a user