Merge remote-tracking branch 'origin/main' into pr-5321

This commit is contained in:
rdbtCVS committed 2026-10-31 21:00:30 +00:00
commit aa68b6653f
379 files changed
+39339 -6208

No files matched your search

+3 -1
View File
@@ -9,7 +9,9 @@ permissions:
actions: write
jobs:
cancel:
runs-on: ubuntu-latest
# 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' }}
steps:
- name: Cancel the pull request's unfinished runs
env:
+26 -10
View File
@@ -26,14 +26,21 @@ env:
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: ubuntu-latest
timeout-minutes: 30
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
- name: Does this pull request need Rust checks?
@@ -60,12 +67,16 @@ jobs:
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# 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'
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -92,8 +103,8 @@ jobs:
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ubuntu-latest
timeout-minutes: 30
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
@@ -107,12 +118,16 @@ 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: |
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
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false'
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
workspaces: launcher
shared-key: launcher
@@ -129,7 +144,8 @@ 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
runs-on: ubuntu-latest
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
+4 -2
View File
@@ -29,7 +29,8 @@ jobs:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
@@ -55,7 +56,8 @@ jobs:
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+3 -1
View File
@@ -19,7 +19,9 @@ jobs:
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+3 -1
View File
@@ -16,7 +16,9 @@ jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
runs-on: ubuntu-latest
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
+10
View File
@@ -68,6 +68,16 @@ unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Write tests with signal
- Test observable behavior, not words in docs, comments or source. Keep checks for machine-consumed contracts.
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
- Make slow tests faster without removing cases or weakening their coverage.
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
## Build and test fast
```sh
+4
View File
@@ -113,6 +113,10 @@ it (edited on every push, never repeated): talk to its author in the thread befo
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
- **Once we've pushed to your branch, merge main into it; don't rebase or force-push.** A rebase or force-push
replaces our commits with yours and erases the fixes we added (it happened on #5188 and #5203). Tell your agent
too: `git merge origin/main`, never `git rebase` or `git push --force` on a branch a maintainer has touched. If
our fixes get erased anyway, we finish the work in our own pull request that keeps your commits and credits you.
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
Generated
+517 -12
View File
@@ -34,6 +34,17 @@ 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"
@@ -163,6 +174,45 @@ 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"
@@ -181,6 +231,12 @@ 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"
@@ -195,6 +251,9 @@ name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
@@ -217,6 +276,15 @@ 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"
@@ -301,6 +369,15 @@ 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"
@@ -339,7 +416,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"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",
]
[[package]]
@@ -349,7 +437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
@@ -386,6 +474,18 @@ 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"
@@ -425,6 +525,12 @@ 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"
@@ -520,6 +626,15 @@ 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"
@@ -548,7 +663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"rand_core 0.6.4",
"typenum",
]
@@ -571,10 +686,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto",
"rand_core",
"rand_core 0.6.4",
"rustc_version",
"subtle",
]
@@ -596,6 +711,65 @@ 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"
@@ -745,6 +919,12 @@ 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"
@@ -897,6 +1077,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1035,6 +1216,12 @@ 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"
@@ -1101,6 +1288,15 @@ 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"
@@ -1115,6 +1311,7 @@ dependencies = [
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
@@ -1125,6 +1322,7 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1192,6 +1390,7 @@ dependencies = [
"horizon-core",
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1209,6 +1408,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"smallvec",
"symphonia",
"unicode-bidi",
"ureq",
@@ -1294,6 +1494,18 @@ 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"
@@ -1354,14 +1566,20 @@ 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",
@@ -1625,6 +1843,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -1916,6 +2135,12 @@ 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"
@@ -2027,6 +2252,16 @@ 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"
@@ -2036,6 +2271,22 @@ 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"
@@ -2047,6 +2298,15 @@ 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"
@@ -2423,6 +2683,15 @@ 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"
@@ -2484,6 +2753,29 @@ 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"
@@ -2507,6 +2799,25 @@ 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"
@@ -2550,6 +2861,36 @@ 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"
@@ -2601,7 +2942,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -2642,6 +2983,12 @@ 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"
@@ -2711,6 +3058,17 @@ 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"
@@ -2720,6 +3078,12 @@ 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"
@@ -2744,6 +3108,28 @@ 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"
@@ -2835,6 +3221,15 @@ 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"
@@ -2911,6 +3306,15 @@ 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"
@@ -2932,6 +3336,17 @@ 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"
@@ -3010,7 +3425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3021,7 +3436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3136,7 +3551,7 @@ checksum = "c5482afe85a0b6ce956c945401598dbc527593c77ba51d0a87a586938b1b893a"
dependencies = [
"curve25519-dalek",
"hkdf",
"rand_core",
"rand_core 0.6.4",
"sha2",
]
@@ -3149,6 +3564,16 @@ 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"
@@ -3266,6 +3691,17 @@ 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"
@@ -3326,6 +3762,36 @@ 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"
@@ -3445,7 +3911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"nom 8.0.0",
"petgraph",
]
@@ -4515,6 +4981,35 @@ 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"
@@ -4559,6 +5054,16 @@ 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"
@@ -4579,7 +5084,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -4620,7 +5125,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+2
View File
@@ -3,6 +3,7 @@ exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-re
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-narrator",
"crates/horizon-threads",
"crates/horizon-hdr",
"crates/horizon-world",
@@ -14,6 +15,7 @@ members = [
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
File diff suppressed because it is too large. Load diff
+13 -6
View File
@@ -33,9 +33,16 @@ pub enum Change {
Velocity([f64; 3]),
/// ClientboundExplodePacket's player knockback, added to the velocity.
Knockback([f64; 3]),
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
/// attributes and effects, as they are when the replay reaches it.
State,
/// ClientboundUpdateAttributesPacket for the player itself: the server's attributes as they
/// are when the replay reaches it, but for the movement speed's sprint modifier, which the
/// client's sprinting decides: `sprint` is whether the packet had it when it carried the
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
Attributes { sprint: Option<bool> },
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
/// are the server's: they reach the client as attributes.)
Effects,
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
/// tick played before the client had them may be off.
@@ -386,16 +393,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::State);
r.changed(5, Change::Effects);
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
assert_eq!(
all,
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::Effects)]
);
assert_eq!(r.changes_since(1).unwrap().count(), 1);
assert!(r.changes_since(3).is_none());
for i in 0..CHANGES_KEPT as u64 {
r.changed(6 + i, Change::State);
r.changed(6 + i, Change::Effects);
}
// The first two are gone.
assert!(r.changes_since(1).is_none());
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "horizon-chat-filter"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
[lints]
workspace = true
+63
View File
@@ -0,0 +1,63 @@
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
# words are written here only so the filter can recognise them, because players spammed them on the
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
# aren't slurs out of it (moderators handle those).
#
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
# doubled in the text: "niger", the river and the country, isn't "nigger".
# word matches inside a longer word too
# =word only as a whole word ("spic", not "suspicious")
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
~nigger
~nigga
nigguh
niggah
niqqa
niqqer
nlgger
nlgga
kneegrow
=nig
=nigs
=niglet
=niglets
=nibba
=nibbas
=nibber
=nibbers
jigaboo
porchmonkey
=coon
=coons
~faggot
=fag
=fags
=faggy
=fagot
=fagots
=tranny
=trannies
=kike
=kikes
=chink
=chinks
=gook
=gooks
=spic
=spics
wetback
=beaner
=beaners
raghead
towelhead
zipperhead
=paki
=pakis
!snigger*
!niggard*
+472
View File
@@ -0,0 +1,472 @@
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
//! player's text and carries out what it decides.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
mod normalise;
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
pub const DEFAULT_WORDS: &str = include_str!("../data/slurs.txt");
/// The mutes, beside server.properties.
pub const MUTES_FILE: &str = "chat-filter-mutes.json";
/// `server.toml`'s `[chat_filter]`: on by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub enabled: bool,
/// The hit, counted within `window_minutes`, that mutes the player; 0: never.
pub mute_after: u32,
/// The hit that bans the player for `tempban_hours`; 0: never.
pub tempban_after: u32,
/// How long a hit counts towards the next ones (the time spent muted doesn't count).
pub window_minutes: u32,
pub mute_minutes: u32,
pub tempban_hours: u32,
/// The slur list, relative to the server's folder; empty: the list built into the game.
pub words_file: String,
/// Every refused text with the player and what followed, relative to the server's folder.
pub log: String,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
mute_after: 2,
tempban_after: 3,
window_minutes: 10,
mute_minutes: 10,
tempban_hours: 24,
words_file: String::new(),
log: "logs/chat-filter.log".into(),
}
}
}
impl Config {
/// The `[chat_filter]` section of a whole `server.toml`; the default without it.
pub fn from_server_toml(text: &str) -> Result<Self, String> {
#[derive(Deserialize)]
struct File {
#[serde(default)]
chat_filter: Config,
}
// Other sections are the supervisor's to check: only this one is read here.
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let mut only = toml::Table::new();
if let Some(section) = table.remove("chat_filter") {
only.insert("chat_filter".into(), section);
}
let file: File = only
.try_into()
.map_err(|e: toml::de::Error| format!("[chat_filter]: {e}"))?;
file.chat_filter.check()?;
Ok(file.chat_filter)
}
pub fn check(&self) -> Result<(), String> {
if self.window_minutes == 0 {
return Err("chat_filter.window_minutes must be 1 or more".into());
}
Ok(())
}
}
/// How an entry of the list matches.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
/// Inside a longer word too.
Embedded,
/// Only as a whole word.
Whole,
/// Inside a word, or spread over several words from the start of one.
Spread,
}
#[derive(Debug)]
struct Entry {
runs: Vec<Run>,
kind: Kind,
}
/// The slur list, compiled: each entry's letters as runs, indexed by first letter.
#[derive(Debug)]
pub struct Filter {
entries: Vec<Entry>,
by_first: [Vec<u16>; 27],
/// Words never matched: exact (`false`) or as a prefix (`true`).
allowed: Vec<(Vec<Run>, bool)>,
}
impl Filter {
/// Compiles a list in `data/slurs.txt`'s format.
pub fn new(list: &str) -> Self {
let mut f = Filter {
entries: Vec::new(),
by_first: Default::default(),
allowed: Vec::new(),
};
for line in list.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(word) = line.strip_prefix('!') {
let (word, prefix) = match word.strip_suffix('*') {
Some(w) => (w, true),
None => (word, false),
};
f.allowed.push((normalise::word(word), prefix));
continue;
}
let (kind, word) = match line.as_bytes()[0] {
b'=' => (Kind::Whole, &line[1..]),
b'~' => (Kind::Spread, &line[1..]),
_ => (Kind::Embedded, line),
};
let runs = normalise::word(word);
let Some(first) = runs.first() else {
continue;
};
f.by_first[usize::from(first.letter)].push(f.entries.len() as u16);
f.entries.push(Entry { runs, kind });
}
f
}
/// The built-in list.
pub fn default_list() -> Self {
Self::new(DEFAULT_WORDS)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Whether `text` holds a slur of the list.
pub fn matches(&self, text: &str) -> bool {
let t = Text::new(text);
let runs = &t.runs;
for &(start, end) in t.words.iter().chain(&t.spaced) {
let word = &runs[start..end];
if self.is_allowed(word) {
continue;
}
for at in 0..word.len() {
for &e in &self.by_first[usize::from(word[at].letter)] {
let e = &self.entries[usize::from(e)];
let whole = e.kind == Kind::Whole;
if (!whole || at == 0) && fits(&e.runs, &word[at..], whole) {
return true;
}
}
}
}
// Spread entries over the whole text, from the start of a word, across at least two.
for (w, &at) in t.joined_at.iter().enumerate() {
for &e in &self.by_first[usize::from(t.joined[at].letter)] {
let e = &self.entries[usize::from(e)];
if e.kind == Kind::Spread
&& fits(&e.runs, &t.joined[at..], false)
&& t.spans_words(w, e.runs.len())
{
return true;
}
}
}
false
}
fn is_allowed(&self, word: &[Run]) -> bool {
self.allowed.iter().any(|(a, prefix)| {
if *prefix {
// A prefix: its last run may be part of a longer one.
word.len() >= a.len() && fits(a, word, false)
} else {
fits(a, word, true)
}
})
}
}
/// Whether `entry`'s runs start `runs` (are all of it with `whole`): the same letters, each at least
/// as many times as the entry has it.
fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
if runs.len() < entry.len() || (whole && runs.len() != entry.len()) {
return false;
}
entry
.iter()
.zip(runs)
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
}
/// What a hit leads to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// The text is refused, nothing more.
Refused,
/// Refused, and the player can't chat until then (epoch milliseconds).
Muted { until: i64 },
/// Refused, and the player is banned until then.
Banned { until: i64 },
}
impl Action {
pub fn name(self) -> &'static str {
match self {
Action::Refused => "refused",
Action::Muted { .. } => "muted",
Action::Banned { .. } => "banned",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
}
#[derive(Default, Serialize, Deserialize)]
struct MutesFile {
mutes: Vec<Mute>,
}
/// One player's hits: how many, and when the last one stops counting.
#[derive(Clone, Copy, Debug)]
struct Strikes {
count: u32,
counts_until: i64,
}
/// The filter, the players' hits and the mutes.
pub struct Guard {
pub config: Config,
pub filter: Filter,
strikes: HashMap<u128, Strikes>,
mutes: HashMap<u128, Mute>,
/// Where the mutes are kept; `None` keeps them in memory only (tests).
file: Option<PathBuf>,
}
const MINUTE: i64 = 60_000;
impl Guard {
pub fn new(config: Config, filter: Filter, file: Option<PathBuf>) -> Self {
let mut mutes = HashMap::new();
if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok())
&& let Ok(saved) = serde_json::from_str::<MutesFile>(&text)
{
for m in saved.mutes {
if let Some(uuid) = parse_uuid(&m.uuid) {
mutes.insert(uuid, m);
}
}
}
Self {
config,
filter,
strikes: HashMap::new(),
mutes,
file,
}
}
/// The server's guard: `words_file` or the built-in list, the mutes in `dir`. An unreadable
/// `words_file` falls back to the built-in list, with why.
pub fn load(config: Config, dir: &Path) -> (Self, Option<String>) {
let mut problem = None;
let filter = if config.words_file.is_empty() {
Filter::default_list()
} else {
match std::fs::read_to_string(dir.join(&config.words_file)) {
Ok(text) => Filter::new(&text),
Err(e) => {
problem = Some(format!(
"chat_filter.words_file {}: {e}; using the built-in list",
config.words_file
));
Filter::default_list()
}
}
};
(
Self::new(config, filter, Some(dir.join(MUTES_FILE))),
problem,
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
pub fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
let c = &self.config;
let window = i64::from(c.window_minutes) * MINUTE;
let s = self.strikes.entry(uuid).or_insert(Strikes {
count: 0,
counts_until: 0,
});
if now > s.counts_until {
s.count = 0;
}
s.count += 1;
s.counts_until = now + window;
let count = s.count;
let action = if c.tempban_after > 0 && count >= c.tempban_after {
self.strikes.remove(&uuid);
Action::Banned {
until: now + i64::from(c.tempban_hours) * 60 * MINUTE,
}
} else if c.mute_after > 0 && count >= c.mute_after {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
Action::Muted { until }
} else {
Action::Refused
};
(count, action)
}
/// Lifts a player's mute (and forgets their hits), by name; the name as muted, if they were.
pub fn unmute(&mut self, name: &str) -> Option<String> {
let uuid = *self
.mutes
.iter()
.find(|(_, m)| m.name.eq_ignore_ascii_case(name))?
.0;
let mute = self.mutes.remove(&uuid)?;
self.strikes.remove(&uuid);
self.save();
Some(mute.name)
}
fn save(&mut self) {
let Some(file) = &self.file else {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
let tmp = file.with_extension("json.tmp");
if std::fs::write(&tmp, text).is_ok() {
let _ = std::fs::rename(&tmp, file);
}
}
}
}
/// One line of `logs/chat-filter.log`, without its newline.
pub fn log_line(
now: i64,
name: &str,
uuid: u128,
place: &str,
count: u32,
action: Action,
text: &str,
) -> String {
let text: String = text
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.collect();
format!(
"{} {name} {} {place} hit {count} {}: {text}",
utc(now),
uuid_string(uuid),
action.name()
)
}
/// Appends a line to the log at `path` (its folder made if needed).
pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
use std::io::Write;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(f, "{line}")
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
rest / 3600,
rest / 60 % 60,
rest % 60
)
}
fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
fn parse_uuid(s: &str) -> Option<u128> {
u128::from_str_radix(&s.replace('-', ""), 16).ok()
}
#[cfg(test)]
mod tests;
+255
View File
@@ -0,0 +1,255 @@
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
const OTHER: u8 = 26;
/// One letter and how many times it repeats (`gg` is `g` twice).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Run {
/// 0..=25 for `a`..`z`, 26 for any other letter.
pub letter: u8,
pub count: u8,
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
pub weak: bool,
}
/// A whole message, normalised.
#[derive(Debug, Default)]
pub struct Text {
pub runs: Vec<Run>,
/// Each word's runs, `runs[start..end]`.
pub words: Vec<(usize, usize)>,
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
/// without the punctuation they end with (`fag!`).
pub spaced: Vec<(usize, usize)>,
/// Every word one after the other, a letter that ends one and starts the next merged.
pub joined: Vec<Run>,
/// Where each word starts in `joined`.
pub joined_at: Vec<usize>,
/// The last word each run of `joined` belongs to.
joined_last: Vec<usize>,
}
enum Fold {
Letter(u8, bool),
Separator,
Space,
Skip,
}
impl Text {
pub fn new(text: &str) -> Self {
let mut t = Text::default();
let mut start = 0;
for c in text.chars() {
match fold(c) {
Fold::Letter(letter, weak) => {
let in_word = t.runs.len() > start;
match t.runs.last_mut() {
Some(last) if in_word && last.letter == letter => {
last.count = last.count.saturating_add(1);
last.weak &= weak;
}
_ => t.runs.push(Run {
letter,
count: 1,
weak,
}),
}
}
Fold::Space => {
t.end_word(start);
start = t.runs.len();
}
Fold::Separator | Fold::Skip => {}
}
}
t.end_word(start);
t.add_spaced();
t.join();
t
}
fn end_word(&mut self, start: usize) {
if self.runs.len() > start {
self.words.push((start, self.runs.len()));
}
}
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
/// copied after the real words').
fn add_spaced(&mut self) {
let words = self.words.len();
let mut w = 0;
while w < words {
let mut end = w;
while end < words && self.words[end].1 - self.words[end].0 == 1 {
end += 1;
}
if end - w >= 2 {
let first = self.runs.len();
for k in w..end {
let run = self.runs[self.words[k].0];
let in_word = self.runs.len() > first;
match self.runs.last_mut() {
Some(last) if in_word && last.letter == run.letter => {
last.count = last.count.saturating_add(run.count);
}
_ => self.runs.push(run),
}
}
self.spaced.push((first, self.runs.len()));
w = end;
} else {
w += 1;
}
}
for k in 0..words {
let (start, mut end) = self.words[k];
while end > start + 1 && self.runs[end - 1].weak {
end -= 1;
}
if end < self.words[k].1 {
self.spaced.push((start, end));
}
}
}
fn join(&mut self) {
for (w, &(start, end)) in self.words.iter().enumerate() {
let mut first = true;
for run in &self.runs[start..end] {
match self.joined.last_mut() {
Some(last) if first && last.letter == run.letter => {
last.count = last.count.saturating_add(run.count);
self.joined_at.push(self.joined.len() - 1);
*self.joined_last.last_mut().expect("beside joined") = w;
}
_ => {
if first {
self.joined_at.push(self.joined.len());
}
self.joined.push(*run);
self.joined_last.push(w);
}
}
first = false;
}
}
}
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
pub fn spans_words(&self, w: usize, len: usize) -> bool {
self.joined_last
.get(self.joined_at[w] + len - 1)
.is_some_and(|&last| last > w)
}
}
/// An entry of the list, normalised the same way (one word, whitespace ignored).
pub fn word(text: &str) -> Vec<Run> {
let t = Text::new(&text.replace(char::is_whitespace, ""));
t.words
.first()
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
}
fn letter(c: char) -> Fold {
Fold::Letter(c as u8 - b'a', false)
}
fn fold(c: char) -> Fold {
if c.is_ascii_alphabetic() {
return letter(c.to_ascii_lowercase());
}
if c.is_ascii() {
return match c {
'0' => letter('o'),
'1' => letter('i'),
'3' => letter('e'),
'4' => letter('a'),
'5' => letter('s'),
'6' | '9' => letter('g'),
'7' => letter('t'),
'8' => letter('b'),
'!' | '|' => Fold::Letter(b'i' - b'a', true),
'@' => Fold::Letter(0, true),
'$' => Fold::Letter(b's' - b'a', true),
c if c.is_ascii_whitespace() => Fold::Space,
_ => Fold::Separator,
};
}
let u = c as u32;
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
let mapped = match u {
// Zero-width characters, soft hyphen, combining marks, variation selectors.
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
0x300..=0x36F
| 0x1AB0..=0x1AFF
| 0x1DC0..=0x1DFF
| 0x20D0..=0x20FF
| 0xFE00..=0xFE0F
| 0xFE20..=0xFE2F
| 0xE0100..=0xE01EF => return Fold::Skip,
// Full-width Latin and digits.
0xFF21..=0xFF3A => from(0xFF21, 26),
0xFF41..=0xFF5A => from(0xFF41, 26),
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
// Mathematical alphanumerics: 52 letters a style, then digits.
0x1D400..=0x1D6A3 => from(0x1D400, 26),
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
// Circled, parenthesised, squared and negative letters, regional indicators.
0x24B6..=0x24CF => from(0x24B6, 26),
0x24D0..=0x24E9 => from(0x24D0, 26),
0x249C..=0x24B5 => from(0x249C, 26),
0x1F130..=0x1F149 => from(0x1F130, 26),
0x1F150..=0x1F169 => from(0x1F150, 26),
0x1F170..=0x1F189 => from(0x1F170, 26),
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
_ => {
if c.is_whitespace() {
return Fold::Space;
}
let lower = c.to_lowercase().next().unwrap_or(c);
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
Some((base, _)) => *base,
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
None => return Fold::Separator,
}
}
};
letter(mapped)
}
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
const LOOK_ALIKES: &[(char, &str)] = &[
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
('b', "ƀɓḃḅḇвьъβʙᵇ"),
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
('f', "ƒḟꜰᶠ"),
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
('j', "ĵǰјᴊʲ"),
('k', "ķĸǩḱḳḵкκᴋᵏ"),
('l', "ĺļľŀłḷḹḻḽʟˡ"),
('m', "мᴍᵐ"),
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
('p', "ṕṗрρᴘᵖ"),
('q', "ԛ"),
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
('v', "νᴠᵛ"),
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
('x', "хχˣ"),
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
('z', "źżžƶẑẓẕᴢᶻ"),
];
+253
View File
@@ -0,0 +1,253 @@
// 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());
}
@@ -118,6 +118,12 @@ 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)]
@@ -365,6 +371,8 @@ 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,6 +28,12 @@ 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.
+3
View File
@@ -15,6 +15,8 @@ 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" }
@@ -40,6 +42,7 @@ 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,6 +235,9 @@ 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();
+2 -1
View File
@@ -13,7 +13,8 @@ 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, mod_effects, namespaced, potion, potion_color, register_mod_effects,
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
register_mod_effects,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
@@ -434,6 +434,10 @@ 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 {
@@ -450,6 +454,8 @@ 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],
}
}
}
@@ -505,6 +511,8 @@ 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() {
+50 -4
View File
@@ -878,6 +878,22 @@ 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 {
@@ -889,8 +905,17 @@ 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
@@ -1149,12 +1174,14 @@ 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`.
@@ -1378,11 +1405,12 @@ impl ChatScreen {
};
self.suggestions = None;
if self.allow_suggestions && self.auto_suggestions {
self.show_suggestions(m);
self.show_suggestions(m, false);
}
}
/// `CommandSuggestions.showSuggestions`.
fn show_suggestions(&mut self, m: &Measure) {
/// `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) {
let Some((start, list)) = self.pending.clone() else {
return;
};
@@ -1425,6 +1453,7 @@ 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);
@@ -1444,6 +1473,9 @@ 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);
@@ -1554,7 +1586,7 @@ impl ChatScreen {
self.suggestions = Some(l);
handled
} else if key == K::Tab {
self.show_suggestions(m);
self.show_suggestions(m, true);
true
} else {
false
@@ -1787,4 +1819,18 @@ 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",
})
}
}
@@ -579,6 +579,39 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_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
@@ -391,6 +391,9 @@ 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).
@@ -428,6 +431,7 @@ impl Chunk {
lit: false,
light_loaded: false,
fresh: false,
generated: false,
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
+3 -2
View File
@@ -23,7 +23,7 @@ enum Source {
}
impl DataPack {
/// Every file path under `prefix` (relative to the pack root).
fn list(&self, prefix: &str) -> Vec<String> {
pub fn list(&self, prefix: &str) -> Vec<String> {
match &self.source {
Source::Zip(z) => z
.names()
@@ -58,12 +58,13 @@ impl DataPack {
}
out
}
fn read(&self, path: &str) -> Option<Vec<u8>> {
pub 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 {
@@ -13,6 +13,37 @@ 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.
@@ -87,6 +118,17 @@ 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);
+39 -9
View File
@@ -235,10 +235,12 @@ 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) 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.
/// `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.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -247,7 +249,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) -> OmenTick,
omen: &mut dyn FnMut(&EffectInstance, i32) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
@@ -269,8 +271,9 @@ impl PlayerEffects {
actions.push(a);
}
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"bad_omen" => omen(e, t),
"raid_omen" if t == 1 => omen(e, t),
id if potions::is_mod_effect(id) => omen(e, t),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
@@ -508,7 +511,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| match e.id {
let mut convert = |e: &EffectInstance, _: i32| match e.id {
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
_ => OmenTick::Continue,
};
@@ -550,12 +553,39 @@ 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| {
let mut watch = |e: &EffectInstance, _: i32| {
seen.push(e.duration);
OmenTick::Remove
};
@@ -22,6 +22,10 @@ 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>,
}
@@ -90,6 +94,15 @@ 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;
@@ -103,6 +116,9 @@ 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;
+247 -16
View File
@@ -86,12 +86,14 @@ mod end_sky;
mod flat;
mod frame_gen;
mod hdr;
mod hdr_stats;
pub use hdr::Calibration as HdrCalibration;
pub mod lightning;
mod lod;
mod native_material;
mod native_pack;
mod native_post;
pub mod native_render;
mod native_sky;
mod occlusion;
mod oit;
@@ -236,6 +238,10 @@ struct Globals {
outline_params: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
colored_lights: [crate::dynamic_light::Colored; crate::dynamic_light::MAX],
colored_count: [u32; 4],
emissive_layers: [[u32; 4]; crate::native_emissive::MAX],
emissive_count: [u32; 4],
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
/// x: `Options.textureFiltering` (0 none, 1 RGSS, 2 anisotropic); y: the sprite arrays' width.
@@ -268,7 +274,22 @@ struct SectionGpu {
/// When it began to fade out, its column gone over far terrain that
/// stays behind it (`Renderer::leave_section`); released once it has.
leaving: Option<std::time::Instant>,
/// Animated sprite layers present in this section.
animated_layers: Box<[u16]>,
}
/// Extracts the animated sprite layers present in a section mesh. Meshing
/// records the distinct layers, so uploading does not rescan every vertex.
pub(crate) fn collect_animated_layers(
mesh: &SectionMesh,
is_animated: impl Fn(u16) -> bool,
) -> Box<[u16]> {
mesh.sprite_layers
.iter()
.copied()
.filter(|&layer| is_animated(layer))
.collect()
}
impl SectionGpu {
/// Its fade code this frame (`lod::fade_code`), 0 once whole. `retiring`:
/// its column has left the render distance, far terrain is coming in
@@ -297,6 +318,12 @@ pub struct Frame<'a> {
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
/// The camera entity's eye and view rotation (yaw, pitch): the camera's own except in a
/// third-person view.
pub eye: DVec3,
pub eye_rotation: [f32; 2],
/// The camera is the entity's eye: no third-person view.
pub first_person: bool,
/// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the
/// projection (`crate::camera::bob_matrix`).
pub bob: Mat4,
@@ -369,6 +396,8 @@ pub struct Frame<'a> {
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
pub dynamic_lights: &'a [[f32; 4]],
pub colored_lights: &'a [crate::dynamic_light::Colored],
pub emissive_layers: &'a [[u32; 4]],
/// Cloud mesh inputs; geometry and GPU storage are retained by the renderer.
pub clouds: Option<crate::clouds::Input<'a>>,
/// Retained lightning bolts, drawn before the other additive entities.
@@ -583,6 +612,7 @@ pub struct Renderer {
lightmap_view: wgpu::TextureView,
lightmap_sampler: wgpu::Sampler,
animations: animate::SpriteAnimator,
active_animated_layers: Vec<u64>,
uploads: upload::Uploads,
/// Section meshes are staged through their own belt into
/// `section_copies`, which the next frame records its commands after:
@@ -661,6 +691,9 @@ pub struct Renderer {
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
hdr: Option<hdr::Hdr>,
/// Which pixels of each block sprite may shine in HDR output (terrain.wgsl's
/// `emission_thresholds`), measured when HDR output turns on.
emission_stats: hdr_stats::EmissionStats,
/// The HDR output the window's surface can present.
hdr_output: Option<hdr::Output>,
/// The marker file of an HDR output that has not presented a frame yet
@@ -795,6 +828,7 @@ fn terrain_group(
sprite_sampler: &wgpu::Sampler,
lightmap: &wgpu::TextureView,
lightmap_sampler: &wgpu::Sampler,
emission_thresholds: &wgpu::TextureView,
) -> wgpu::BindGroup {
let view = |binding, view| wgpu::BindGroupEntry {
binding,
@@ -825,6 +859,7 @@ fn terrain_group(
view(9, &sprites[5]),
view(10, &sprites[6]),
view(11, &sprites[7]),
view(12, emission_thresholds),
],
})
}
@@ -1543,6 +1578,15 @@ impl Renderer {
entry(9, vf, sprite_array),
entry(10, vf, sprite_array),
entry(11, vf, sprite_array),
entry(
12,
vf,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
),
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -1823,6 +1867,7 @@ impl Renderer {
split,
animate::SpriteAnimator::compute(&adapter, &device),
);
let active_layer_words = animations.active_layer_words();
let sprite_unused = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Block sprites (unused)"),
@@ -1865,6 +1910,8 @@ impl Renderer {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let emission_stats =
hdr_stats::EmissionStats::new(&device, split, sprite_textures.len() as u32);
let group = terrain_group(
&device,
"Horizon terrain resources",
@@ -1874,6 +1921,7 @@ impl Renderer {
&sprite_sampler,
&lightmap_view,
&lightmap_sampler,
&emission_stats.view,
);
let crosshair_group = terrain_group(
&device,
@@ -1884,6 +1932,7 @@ impl Renderer {
&sprite_sampler,
&lightmap_view,
&lightmap_sampler,
&emission_stats.view,
);
let mirror_group = terrain_group(
&device,
@@ -1894,6 +1943,7 @@ impl Renderer {
&sprite_sampler,
&lightmap_view,
&lightmap_sampler,
&emission_stats.view,
);
let index_quads = 1 << 16;
let indices = Self::quad_indices(&device, index_quads);
@@ -3023,6 +3073,7 @@ impl Renderer {
lightmap_view,
lightmap_sampler,
animations,
active_animated_layers: vec![0; active_layer_words],
uploads,
section_uploads,
section_copies: None,
@@ -3069,6 +3120,7 @@ impl Renderer {
layout: crate::chunk::ColumnLayout::VANILLA,
world_post,
hdr: None,
emission_stats,
hdr_output,
hdr_marker: None,
pack_post: None,
@@ -3431,6 +3483,8 @@ impl Renderer {
{
self.queue.submit([copies]);
}
let animated_layers =
collect_animated_layers(mesh, |layer| self.animations.is_animated(layer));
Some(SectionGpu {
page,
start,
@@ -3453,6 +3507,7 @@ impl Renderer {
flat_id,
fading: None,
leaving: None,
animated_layers,
})
}
/// The section copies recorded since the last submission, closed.
@@ -3812,6 +3867,7 @@ impl Renderer {
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = ui;
@@ -3825,6 +3881,15 @@ impl Renderer {
self.send_hdr_metadata();
}
}
/// Measures which pixels of each block sprite may shine (`hdr_stats`), at the current
/// masks. Only with HDR output on: without it the frame's alpha is never read.
pub fn measure_emission(&mut self) {
if let Some(hdr) = &self.hdr {
let views = sprite_views(&self.sprite_textures, &self.sprite_unused, None);
self.emission_stats
.measure(&self.device, &self.queue, &views, hdr.masks);
}
}
/// Whether what the system reports for the display changed (another output, the
/// player recalibrating the desktop): the settings' Auto values follow it then, so
/// apply them again.
@@ -3897,6 +3962,7 @@ impl Renderer {
pack_post::PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
self.config.format.remove_srgb_suffix(),
world,
@@ -3952,8 +4018,24 @@ impl Renderer {
/// What a shader pack's gbuffers programs read from Horizon's geometry: the sky's, and
/// terrain's in the pack's block atlas (made here when missing; none when it does not fit).
pub fn pack_inputs(&mut self) -> horizon_shaders::GameInputs {
let terrain = self.pack_terrain_inputs();
// A geometry stage reads flat draws' vertices itself, through their index buffer.
let terrain_geometry = terrain
.clone()
.filter(|_| self.pack_terrain_flat)
.map(|mut inputs| {
inputs.declarations.push_str(
"layout(set = 5, binding = 0) readonly buffer HzIndices { uint hz_indices[]; };\n",
);
horizon_shaders::GeometryInputs {
inputs,
pull: pack_atlas::PULL.to_owned(),
index: "hz_indices[@]".to_owned(),
}
});
horizon_shaders::GameInputs {
terrain: self.pack_terrain_inputs(),
terrain,
terrain_geometry,
sky: Some(pack_sky::inputs()),
celestial: Some(pack_sky::celestial_inputs()),
entities: Some(pack_entities::inputs()),
@@ -4074,6 +4156,7 @@ impl Renderer {
&self.sprite_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
&self.emission_stats.view,
);
}
/// The Mipmap Levels option: block textures sample at most `levels`
@@ -4184,8 +4267,10 @@ impl Renderer {
self.lightmap_pixels = pixels;
self.lightmap_uploaded = true;
}
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) {
self.animations.animate(encoder, &mut self.uploads, tick);
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64, cull_inactive: bool) {
let active = cull_inactive.then_some(&self.active_animated_layers[..]);
self.animations
.animate(encoder, &mut self.uploads, tick, active);
if let Some(atlas) = &self.pack_atlas {
atlas.copy(
encoder,
@@ -4310,7 +4395,6 @@ impl Renderer {
})
});
if self.animated_textures {
self.animate(&mut encoder, frame.ticks);
self.hud
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
}
@@ -4426,6 +4510,32 @@ impl Renderer {
}
lights
},
colored_lights: {
let mut lights = [[[0.0; 4]; 2]; crate::dynamic_light::MAX];
for (to, from) in lights.iter_mut().zip(frame.colored_lights) {
*to = *from;
}
lights
},
colored_count: [
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
0,
0,
0,
],
emissive_layers: {
let mut layers = [[0; 4]; crate::native_emissive::MAX];
for (to, from) in layers.iter_mut().zip(frame.emissive_layers) {
*to = *from;
}
layers
},
emissive_count: [
frame.emissive_layers.len().min(crate::native_emissive::MAX) as u32,
0,
0,
0,
],
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
filtering: [
self.texture_filtering as f32,
@@ -4554,14 +4664,27 @@ impl Renderer {
planes: frustum(v.cull),
};
crate::spans::time("shadow visibility", || {
self.sections.visible(
camera,
radius,
&light,
|_| true,
|_, _| false,
&mut shadow_visible,
)
// Within the unculled distance every section, then those in the light's box.
if let Some(d) = v.unculled {
let all = Frustum {
planes: [glam::Vec4::W; 6],
};
let near = radius.min((d / 16.0).ceil() as i32);
self.sections.visible(
camera,
near,
&all,
|_| true,
|_, _| false,
&mut shadow_visible,
);
}
let mut boxed = Vec::new();
self.sections
.visible(camera, radius, &light, |_| true, |_, _| false, &mut boxed);
let seen: std::collections::HashSet<_> =
shadow_visible.iter().map(|s| s.key).collect();
shadow_visible.extend(boxed.into_iter().filter(|s| !seen.contains(&s.key)));
});
// Nearest the light first, so the depth test rejects what lies under it.
let light = v.light;
@@ -4570,6 +4693,43 @@ impl Renderer {
height(b).total_cmp(&height(a))
});
}
if self.animated_textures {
self.active_animated_layers.fill(0);
for v in &visible {
let s = self.sections.get(v.slot);
for &layer in &*s.animated_layers {
let layer = layer as usize;
self.active_animated_layers[layer / u64::BITS as usize] |=
1_u64 << (layer % u64::BITS as usize);
}
}
for v in &visible_lods {
if let Some(tile) = self.lods.get(&v.key) {
for &layer in &*tile.drawn().animated_layers {
let layer = layer as usize;
self.active_animated_layers[layer / u64::BITS as usize] |=
1_u64 << (layer % u64::BITS as usize);
}
for &layer in &*tile.mesh.animated_layers {
let layer = layer as usize;
self.active_animated_layers[layer / u64::BITS as usize] |=
1_u64 << (layer % u64::BITS as usize);
}
}
}
for v in &shadow_visible {
let s = self.sections.get(v.slot);
for &layer in &*s.animated_layers {
let layer = layer as usize;
self.active_animated_layers[layer / u64::BITS as usize] |=
1_u64 << (layer % u64::BITS as usize);
}
}
// Visibility is collected before terrain draw commands are recorded,
// so upload the current mask here and keep animation updates ahead
// of every draw in this command buffer.
self.animate(&mut encoder, frame.ticks, !frame.title);
}
self.drawn_sections = visible.len() + visible_lods.len();
let instances = self.drawn_sections + shadow_visible.len();
if instances as u64 > self.instance_capacity {
@@ -4874,7 +5034,13 @@ impl Renderer {
}
}
let before = self.draws.len();
facing_runs(&s.sides, Some(toward), v.origin, |first, quads| {
// Every face of a section within the unculled distance.
let unculled = view.unculled.is_some_and(|d| {
let o = glam::Vec3::from(v.origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled).then_some(toward);
facing_runs(&s.sides, toward, v.origin, |first, quads| {
let start = s.start as u32 + first * 4;
self.draws.push([quads * 6, 1, 0, start, instance]);
self.draw_pages.push((s.page, None));
@@ -5290,7 +5456,10 @@ impl Renderer {
}
let pack_reads_world = pack_post || native_post;
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
let keep_depth = pack_reads_world || (taa && self.world_post.taa) || oit_on;
let keep_depth = pack_reads_world
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth;
// The pack's program reads the vertices this frame's draws bind.
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
// The shadow camera's lists, drawn by the pack's shadow programs.
@@ -6203,7 +6372,8 @@ impl Renderer {
let mut terrain =
|rp: &mut wgpu::RenderPass<'_>,
list: pack_post::TerrainList,
page: &dyn Fn(&mut wgpu::RenderPass<'_>, u32)| {
page: &dyn Fn(&mut wgpu::RenderPass<'_>, u32),
geometry: Option<(u32, &wgpu::BindGroupLayout)>| {
use pack_post::TerrainList as L;
let shadow = matches!(list, L::ShadowSolid | L::ShadowTranslucent);
if !(if shadow {
@@ -6222,8 +6392,26 @@ impl Renderer {
L::ShadowSolid => &flat.calls[shadow..shadow_translucent],
L::ShadowTranslucent => &flat.calls[shadow_translucent..],
};
flat.draw_with(rp, calls, |p| self.arena.buffer(p), page);
match geometry {
Some((instances, layout)) => {
let indices =
self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack geometry indices"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: flat.indices.as_entire_binding(),
}],
});
rp.set_bind_group(5, &indices, &[]);
let vertices = |p| self.arena.buffer(p);
flat.draw_geometry(rp, calls, vertices, page, instances);
}
None => flat.draw_with(rp, calls, |p| self.arena.buffer(p), page),
}
}
// A geometry stage reads flat draws' indices; without them it draws nothing.
None if geometry.is_some() => {}
None => {
rp.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
rp.set_vertex_buffer(1, self.instances.slice(..));
@@ -6280,6 +6468,30 @@ impl Renderer {
self.world_post
.finish(&self.queue, &mut encoder, &view_target);
}
if !frame.title {
let (depth, depth_size) = match &scaled {
Some((_, depth)) => (depth, self.world_post.size().unwrap_or(size)),
None => (&self.depth, size),
};
native_render::native_world_drawn(&mut native_render::WorldDrawn {
device: &self.device,
queue: &self.queue,
encoder: &mut encoder,
color: &target_texture,
color_view: &view_target,
color_format: self.config.format.remove_srgb_suffix(),
size,
depth,
depth_size,
view_proj,
jittered,
camera: frame.camera,
entities: frame.entities,
spans: frame.entity_spans,
atlas: self.hud.atlas_texture.as_ref(),
quad_indices: &self.indices,
});
}
if let Some(hdr) = &self.hdr {
hdr.keep_world(&mut encoder);
}
@@ -7592,6 +7804,25 @@ mod sprite_upload_tests {
assert_eq!(chain.capacity(), chain.len());
}
}
#[test]
fn collect_animated_layers_masks_opaque_layer() {
use crate::mesher::{self, SectionMesh, TerrainVertex};
let opaque_vertex =
TerrainVertex::new([0.0, 0.0, 0.0], [0.0, 0.0], 7 | mesher::OPAQUE_LAYER, 0, 0);
let translucent_vertex = TerrainVertex::new([0.0, 0.0, 0.0], [0.0, 0.0], 12, 0, 0);
let mut mesh = SectionMesh {
solid: vec![opaque_vertex; 4],
translucent: vec![translucent_vertex; 4],
..Default::default()
};
mesh.note_sprite_layer(7 | mesher::OPAQUE_LAYER);
mesh.note_sprite_layer(12);
mesh.finish_sprite_layers();
let animated = super::collect_animated_layers(&mesh, |layer| layer == 7 || layer == 12);
assert_eq!(&*animated, &[7, 12]);
}
}
/// What the system says about the window's display, kept up to date: on Wayland, the
@@ -72,6 +72,8 @@ 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 {
@@ -147,6 +149,11 @@ 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()
@@ -168,6 +175,7 @@ impl SpriteAnimator {
},
size,
changed: Vec::new(),
is_animated,
};
};
let frames: Vec<_> = frames
@@ -247,9 +255,23 @@ 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).
@@ -269,14 +291,16 @@ impl SpriteAnimator {
}
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
/// frame or mix changed (`SpriteContents.AnimationState.tick`), skipping
/// sprites not present in `active` when provided.
pub fn animate(
&mut self,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut Uploads,
tick: u64,
active: Option<&[u64]>,
) {
if self.collect(tick) == 0 {
if self.collect(tick, active) == 0 {
return;
}
let (jobs, pipeline) = match &self.writer {
@@ -336,10 +360,18 @@ 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) -> usize {
fn collect(&mut self, tick: u64, active: Option<&[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;
@@ -827,7 +859,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);
animator.animate(&mut encoder, &mut uploads, tick, None);
writes.push(
animator
.sprites
@@ -864,4 +896,56 @@ 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]);
}
}
+43 -7
View File
@@ -129,7 +129,7 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
}
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11's Vulkan
/// 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")
@@ -137,15 +137,34 @@ pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|| is_broken_intel_vulkan(info)
}
/// 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.
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
/// be drawn with another section's origin and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
&& (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)))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -725,6 +744,23 @@ 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,
@@ -91,6 +91,19 @@ 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)]
@@ -133,6 +146,8 @@ 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>,
}
@@ -276,6 +291,7 @@ impl Hdr {
black: 0.0,
tiers: TIERS,
ui: 1.0,
masks: MASKS,
calibration: None,
}
}
@@ -586,6 +602,7 @@ 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();
@@ -0,0 +1,158 @@
//! HDR emission thresholds (`hdr_stats.wgsl`): for every block sprite, the brightness its
//! pixels must reach to shine in HDR output, measured on the GPU from the sprite's own
//! darkest and brightest opaque pixel when HDR output turns on. Until then (and with HDR
//! off) the texture holds zeros: every pixel of a light source may shine, and hdr.wgsl's
//! tier windows alone decide, as before.
use super::hdr::Masks;
pub struct EmissionStats {
/// One texel per sprite: x the layer in its array, y the array.
pub view: wgpu::TextureView,
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
params: wgpu::Buffer,
}
impl EmissionStats {
pub fn new(device: &wgpu::Device, split: u32, arrays: u32) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon HDR emission thresholds"),
size: wgpu::Extent3d {
width: split,
height: arrays,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR emission thresholds"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr_stats.wgsl").into()),
});
let mut entries: Vec<_> = (0..super::SPRITE_ARRAYS)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
})
.collect();
entries.push(wgpu::BindGroupLayoutEntry {
binding: super::SPRITE_ARRAYS,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR emission thresholds"),
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR emission thresholds"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR emission thresholds"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_stats"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_stats"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR emission threshold parameters"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view,
layout,
pipeline,
params,
}
}
/// Measures every sprite of `sprites` (terrain.wgsl's arrays) at `masks`.
pub fn measure(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
sprites: &[wgpu::TextureView],
masks: Masks,
) {
let mut params = [0u8; 16];
params[..4].copy_from_slice(&masks.parts.to_le_bytes());
params[4..8].copy_from_slice(&masks.whole.to_le_bytes());
queue.write_buffer(&self.params, 0, &params);
let mut entries: Vec<_> = sprites
.iter()
.enumerate()
.map(|(binding, view)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: wgpu::BindingResource::TextureView(view),
})
.collect();
entries.push(wgpu::BindGroupEntry {
binding: super::SPRITE_ARRAYS,
resource: self.params.as_entire_binding(),
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR emission thresholds"),
layout: &self.layout,
entries: &entries,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR emission thresholds"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
queue.submit([encoder.finish()]);
}
}
+5 -16
View File
@@ -96,21 +96,8 @@ struct Bounds {
impl Bounds {
fn mesh(mesh: &SectionMesh) -> Option<Self> {
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)
let [min, max] = mesh.bounds?;
Some(Self { min, max })
}
/// In view: the plan alone decides which tiles are in range (a circle),
@@ -419,7 +406,8 @@ impl Renderer {
let mesh = pending.meshes.remove(&key).expect("pending tile");
let [x, _, z] = key.origin();
let size = key.size();
let keep = around_new.contains(&key)
let keep = mesh.keep
|| around_new.contains(&key)
|| taken.iter().any(|&(cx, cz)| {
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
});
@@ -511,6 +499,7 @@ 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();
@@ -0,0 +1,74 @@
//! 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
}
@@ -113,6 +113,16 @@ 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 \
@@ -194,8 +204,15 @@ 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)",
@@ -11,6 +11,13 @@ pub(super) struct PackImage {
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],
}
@@ -35,13 +42,17 @@ pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
},
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.
fn format(f: Format) -> wgpu::TextureFormat {
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let pick = |r, rg, rgba| match f.channels {
1 => r,
@@ -106,43 +117,158 @@ pub(super) fn resize_images(
// 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 texture = 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
| wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
image.view = Some(texture.create_view(&Default::default()));
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
}
/// Clears the images the pack clears before each frame.
pub(super) fn clear(encoder: &mut wgpu::CommandEncoder, images: &[PackImage]) {
/// 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())
{
encoder.clear_texture(view.texture(), &Default::default());
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,
@@ -153,12 +279,17 @@ pub(super) struct Buffers {
}
impl Buffers {
pub(super) fn new(device: &wgpu::Device, objects: BTreeMap<u32, BufferObject>) -> Self {
/// 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: wgpu::ShaderStages::VERTEX_FRAGMENT | wgpu::ShaderStages::COMPUTE,
visibility: stages,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
@@ -236,3 +367,94 @@ impl Buffers {
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));
}
}
+338 -22
View File
@@ -186,17 +186,31 @@ impl Entities {
enum Image {
Color(usize),
Custom(usize),
/// The copy of a buffer or a pack image taken before a dispatch, which its loads read.
ColorBefore(usize),
CustomBefore(usize),
}
impl Image {
fn buffer(self) -> Option<usize> {
match self {
Image::Color(b) => Some(b),
Image::Custom(_) => None,
_ => None,
}
}
}
/// The buffers and the pack images (masks) `images` read as copies taken before a dispatch.
fn befores(images: &[(u32, Image)]) -> (u16, u64) {
images
.iter()
.fold((0, 0), |(colors, customs), i| match i.1 {
Image::ColorBefore(b) => (colors | 1 << b, customs),
Image::CustomBefore(k) => (colors, customs | 1 << k),
_ => (colors, customs),
})
}
/// The color buffers of `images`, as a mask.
fn image_buffers(images: &[(u32, Image)]) -> u16 {
images
@@ -205,6 +219,83 @@ fn image_buffers(images: &[(u32, Image)]) -> u16 {
.fold(0, |m, b| m | 1 << b)
}
/// The format of the sink of passes that write only images.
const SINK: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
/// What packs use of the device beyond drawing. OpenGL ES and older drivers lack some of it: a
/// program that needs what is missing is skipped with a note, and the game runs on without it.
#[derive(Clone, Copy)]
struct Gpu {
compute: bool,
/// Images and storage buffers written by fragment shaders.
fragment_storage: bool,
/// Storage read and written by vertex shaders (a geometry stage).
vertex_storage: bool,
/// `clear_texture`; without it images are cleared by copying zeros.
clear: bool,
adapter_formats: bool,
}
impl Gpu {
fn of(adapter: &wgpu::Adapter, features: wgpu::Features) -> Self {
use wgpu::DownlevelFlags as D;
let flags = adapter.get_downlevel_capabilities().flags;
let mut gpu = Gpu {
compute: flags.contains(D::COMPUTE_SHADERS),
fragment_storage: flags.contains(D::FRAGMENT_WRITABLE_STORAGE),
vertex_storage: flags.contains(D::VERTEX_STORAGE)
&& features.contains(wgpu::Features::VERTEX_WRITABLE_STORAGE),
clear: features.contains(wgpu::Features::CLEAR_TEXTURE),
adapter_formats: features
.contains(wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES),
};
// `HORIZON_SHADER_PACK_WITHOUT=compute,fragment-storage,vertex-storage,clear` tries the
// fallbacks on a device that has it all.
let without = std::env::var("HORIZON_SHADER_PACK_WITHOUT").unwrap_or_default();
for w in without.split(',') {
match w.trim() {
"compute" => gpu.compute = false,
"fragment-storage" => gpu.fragment_storage = false,
"vertex-storage" => gpu.vertex_storage = false,
"clear" => gpu.clear = false,
_ => {}
}
}
gpu
}
/// Whether textures of `format` can be bound as images.
fn storable(
self,
adapter: &wgpu::Adapter,
format: wgpu::TextureFormat,
features: wgpu::Features,
) -> bool {
let usages = if self.adapter_formats {
adapter.get_texture_format_features(format).allowed_usages
} else {
format.guaranteed_format_features(features).allowed_usages
};
(self.compute || self.fragment_storage)
&& usages.contains(wgpu::TextureUsages::STORAGE_BINDING)
}
/// The stages that may bind images and storage buffers.
fn storage_stages(self) -> wgpu::ShaderStages {
let mut stages = wgpu::ShaderStages::NONE;
if self.compute {
stages |= wgpu::ShaderStages::COMPUTE;
}
if self.fragment_storage {
stages |= wgpu::ShaderStages::FRAGMENT;
}
if self.vertex_storage {
stages |= wgpu::ShaderStages::VERTEX;
}
stages
}
}
/// A pass's pipeline: a draw, or a compute dispatch.
#[derive(Clone)]
enum Pipeline {
@@ -259,6 +350,15 @@ struct Pass {
copies: u16,
/// Compute programs dispatched before this pass.
compute: Vec<Pass>,
/// Writes no buffer, only images: draws into `Targets::sink`.
sink: bool,
/// Instances to draw for a geometry stage folded into the vertex stage; 0 without one.
geometry: u32,
/// The pack's images (bit n: `image.*` n) this pass samples and stores: sampled from a copy
/// taken just before it.
image_copies: u64,
/// The pack's images a compute pass's loads read as they were before it: copied first.
snapshots: u64,
}
impl Pass {
@@ -292,6 +392,8 @@ struct Targets {
scratch: [Option<wgpu::TextureView>; BUFFERS],
/// Each buffer's size: the render size, or its `size.buffer`.
sizes: [[u32; 2]; BUFFERS],
/// The attachment of passes that write only images.
sink: wgpu::TextureView,
}
/// What a pack's gbuffers programs read and draw from the game's world.
@@ -408,6 +510,13 @@ pub struct PackPost {
dummy_1d: wgpu::TextureView,
/// `image.*` and `bufferObject.*`.
images: Vec<pack_images::PackImage>,
/// Set 5 of programs with a geometry stage.
indices: Option<wgpu::BindGroupLayout>,
shadow_culling: horizon_shaders::ShadowCulling,
gpu: Gpu,
/// Zeros to clear images by copying, where the device has no `clear_texture`.
zeros: Option<wgpu::Buffer>,
dump: Option<pack_images::Dump>,
buffers: pack_images::Buffers,
textures: Vec<pack_textures::PackTexture>,
/// The stand-in shadow map and its comparison sampler; cleared on the first frame.
@@ -752,8 +861,14 @@ impl TerrainList {
/// Draws the game's terrain of one list into a pass; calls its third argument with each arena
/// page before drawing from it.
pub(super) type TerrainDraw<'a> =
dyn FnMut(&mut wgpu::RenderPass<'_>, TerrainList, &dyn Fn(&mut wgpu::RenderPass<'_>, u32)) + 'a;
/// Draws a list's terrain: indexed, or for a program with a geometry stage, without indices and
/// with that many instances (and its index buffer bound in set 5 by this layout).
pub(super) type TerrainDraw<'a> = dyn FnMut(
&mut wgpu::RenderPass<'_>,
TerrainList,
&dyn Fn(&mut wgpu::RenderPass<'_>, u32),
Option<(u32, &wgpu::BindGroupLayout)>,
) + 'a;
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
@@ -762,6 +877,9 @@ pub(super) struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
}
/// Set 3 of flat terrain, read by the vertex stage: `mc_Entity`'s table (-1 pairs when empty)
@@ -816,6 +934,11 @@ struct Resources<'a> {
/// The images this device can't make.
left_out: &'a [horizon_shaders::CustomImage],
buffers: &'a pack_images::Buffers,
/// Set 5 of a program with a geometry stage: the draw's indices.
indices: Option<&'a wgpu::BindGroupLayout>,
gpu: Gpu,
/// The buffers whose format can be bound as an image.
storable: u16,
}
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
@@ -1014,6 +1137,22 @@ fn bindings(
let (target, actual) = match (buffer, own) {
(_, Some(k)) => (Image::Custom(k), pack.images[k].format),
(Some(b), None) => (Image::Color(b), formats[b]),
(None, None) if i.before.is_some() => {
// Bound as the image it copies.
let of = i.before.as_deref().unwrap_or_default();
let buffer = of
.strip_prefix("colorimg")
.and_then(|n| n.parse::<usize>().ok())
.filter(|&n| n < BUFFERS && !group.shadow());
match (buffer, pack.images.iter().position(|c| c.def.name == of)) {
(_, Some(k)) => (Image::CustomBefore(k), pack.images[k].format),
(Some(b), None) => (Image::ColorBefore(b), formats[b]),
(None, None) => {
notes.push(format!("{name}: image `{of}` is not provided yet"));
return None;
}
}
}
(None, None) => {
notes.push(format!("{name}: image `{}` is not provided yet", i.name));
return None;
@@ -1041,9 +1180,18 @@ fn bindings(
} else {
wgpu::TextureViewDimension::D2
};
if let Image::Color(b) | Image::ColorBefore(b) = target
&& pack.storable & 1 << b == 0
{
notes.push(format!(
"{name}: `{}` can't be an image on this GPU; skipped",
i.name
));
return None;
}
entries.push(wgpu::BindGroupLayoutEntry {
binding: i.binding,
visibility: wgpu::ShaderStages::FRAGMENT | wgpu::ShaderStages::COMPUTE,
visibility: pack.gpu.storage_stages(),
ty: wgpu::BindingType::StorageTexture {
access,
format,
@@ -1133,6 +1281,21 @@ fn build_pass(
let program = p.program.take()?;
let interface = &program.interface;
let draw = interface.draw;
let storage = !interface.images.is_empty() || !interface.buffers.is_empty();
if storage && !pack.gpu.fragment_storage {
notes.push(format!(
"{}: this GPU can't write images from a fragment shader; skipped",
p.name
));
return None;
}
if p.geometry_triangles > 0 && (pack.indices.is_none() || !pack.gpu.vertex_storage) {
notes.push(format!(
"{}: this GPU can't run a geometry stage; skipped",
p.name
));
return None;
}
let (entries, samplers, slots, reads, shadow_reads, images) = bindings(
features, interface, &p.name, p.group, &p.buffers, geometry, formats, shadow, notes, pack,
)?;
@@ -1168,7 +1331,16 @@ fn build_pass(
while targets.last().is_some_and(Option::is_none) {
targets.pop();
}
if !final_pass && writes.is_empty() {
// A program that only writes images draws into a sink that keeps nothing; one drawing
// geometry has the depth buffer, and draws with no colour target at all.
let sink = !final_pass && writes.is_empty() && !program.interface.images.is_empty();
if sink && geometry.is_none() {
targets = vec![Some(wgpu::ColorTargetState {
format: SINK,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})];
} else if !final_pass && writes.is_empty() && !sink {
notes.push(format!("{}: writes no buffer; skipped", p.name));
return None;
}
@@ -1197,6 +1369,7 @@ fn build_pass(
draw.then_some(draws),
geometry.and_then(|g| g.ids),
(!interface.buffers.is_empty()).then_some(&pack.buffers.layout),
pack.indices.filter(|_| p.geometry_triangles > 0),
],
..Default::default()
});
@@ -1215,12 +1388,17 @@ fn build_pass(
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: geometry.map_or(&[], |g| g.buffers),
// A geometry stage reads its vertices itself.
buffers: geometry
.filter(|_| p.geometry_triangles == 0)
.map_or(&[], |g| g.buffers),
},
// Rows flipped to GL's turn front faces clockwise.
primitive: wgpu::PrimitiveState {
front_face: wgpu::FrontFace::Cw,
cull_mode: geometry.filter(|g| g.cull).map(|_| wgpu::Face::Back),
cull_mode: geometry
.filter(|g| g.cull && p.geometry_triangles == 0)
.map(|_| wgpu::Face::Back),
..Default::default()
},
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
@@ -1259,6 +1437,7 @@ fn build_pass(
if !missing.is_empty() {
notes.push(format!("{}: zero for now: {}", p.name, missing.join(" ")));
}
let image_copies = image_copies(&samplers, &images);
Some(Pass {
ubo: device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&p.name),
@@ -1291,9 +1470,24 @@ fn build_pass(
buffers: !interface.buffers.is_empty(),
images,
compute: Vec::new(),
sink,
geometry: p.geometry_triangles,
image_copies,
snapshots: 0,
})
}
/// The pack's images a pass both samples and stores.
fn image_copies(samplers: &[Binding], images: &[(u32, Image)]) -> u64 {
samplers
.iter()
.filter_map(|s| match s.source {
Source::Image(k) if images.iter().any(|i| i.1 == Image::Custom(k)) => Some(k),
_ => None,
})
.fold(0, |m, k| m | 1 << k)
}
/// The pass of compute program `c` of a `group` program: `None` (with a note) when wgpu refuses
/// it or it needs what the game can't give.
#[allow(clippy::too_many_arguments)]
@@ -1368,6 +1562,8 @@ fn build_compute(
}
};
let written = image_buffers(&images);
let image_copies = image_copies(&samplers, &images);
let (before_colors, snapshots) = befores(&images);
Some(Pass {
ubo: device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&name),
@@ -1393,7 +1589,7 @@ fn build_compute(
samplers,
writes: Vec::new(),
flip: 0,
copies: written & reads,
copies: written & reads | before_colors,
reads: reads | written,
shadow_reads,
written,
@@ -1405,6 +1601,10 @@ fn build_compute(
buffers: !interface.buffers.is_empty(),
images,
compute: Vec::new(),
sink: false,
geometry: 0,
image_copies,
snapshots,
})
}
@@ -1413,6 +1613,7 @@ impl PackPost {
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
mut post: Post,
screen: wgpu::TextureFormat,
world: World<'_>,
@@ -1463,29 +1664,78 @@ impl PackPost {
.filter(|_| i < 2)
.map_or(formats[i], |f| f[i])
});
let (mut storage, mut copied) = (0u16, 0u16);
// An image this device can't make: the programs using it are skipped.
let (mut storage, mut copied, mut image_copied, mut snapshotted) = (0u16, 0u16, 0u64, 0u64);
let gpu = Gpu::of(adapter, features);
if !gpu.compute {
notes.push("this GPU has no compute shaders: compute programs are skipped".into());
}
// An image this device can't make or store into: the programs using it are skipped.
let mut image_defs = std::mem::take(&mut post.images);
let mut left_out = Vec::new();
image_defs.retain(|def| match pack_images::makeable(device, def) {
Ok(()) => true,
Err(e) => {
image_defs.retain(|def| {
let made = pack_images::makeable(device, def).and_then(|()| {
if gpu.storable(adapter, pack_images::format(def.format), features) {
Ok(())
} else {
Err("this GPU can't store into its format".into())
}
});
if let Err(e) = &made {
notes.push(format!("image `{}`: {e}", def.name));
left_out.push(def.clone());
false
}
made.is_ok()
});
// A mask of a pass's images holds this many.
let kept = image_defs.len().min(pack_images::IMAGES);
for def in image_defs.drain(kept..) {
notes.push(format!(
"image `{}`: past the {} images a pack may have",
def.name,
pack_images::IMAGES
));
left_out.push(def);
}
let images = pack_images::images(image_defs);
let buffers = pack_images::Buffers::new(device, std::mem::take(&mut post.buffer_objects));
let mut objects = std::mem::take(&mut post.buffer_objects);
if gpu.storage_stages().is_empty() {
objects.clear();
}
let buffers = pack_images::Buffers::new(device, objects, gpu.storage_stages());
let storable = (0..BUFFERS)
.filter(|&i| gpu.storable(adapter, formats[i], features))
.fold(0u16, |m, i| m | 1 << i);
let indices = gpu.vertex_storage.then(|| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack geometry indices"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
})
});
let pack = Resources {
textures: &textures,
images: &images,
left_out: &left_out,
buffers: &buffers,
indices: indices.as_ref(),
gpu,
storable,
};
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
let in_shadow = p.group.shadow();
let compute = std::mem::take(&mut p.compute);
let mut compute = std::mem::take(&mut p.compute);
if !gpu.compute {
compute.clear();
p.program.as_ref()?;
}
let group = p.group;
let mut computes: Vec<Pass> = compute
.into_iter()
@@ -1547,6 +1797,8 @@ impl PackPost {
used |= p.reads | p.written | p.flip;
}
storage |= image_buffers(&p.images);
image_copied |= p.image_copies;
snapshotted |= p.snapshots;
copied |= p.copies;
}
Some(pass)
@@ -1986,8 +2238,20 @@ impl PackPost {
.cloned()
.unwrap_or_else(|| noise(device, queue)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
images,
images: {
let mut images = images;
for (k, image) in images.iter_mut().enumerate() {
image.copied = image_copied & 1 << k != 0;
image.snapshotted = snapshotted & 1 << k != 0;
}
images
},
buffers,
indices,
shadow_culling: post.shadow_culling,
gpu,
zeros: None,
dump: pack_images::Dump::new(),
dummy_3d: device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack 3D stand-in"),
@@ -2080,6 +2344,7 @@ impl PackPost {
pass: &mut Pass,
out: [u32; 2],
) -> wgpu::BindGroup {
pack_images::copy(encoder, &self.images, pass.image_copies);
if let Some(targets) = &self.targets {
for i in (0..BUFFERS).filter(|i| pass.copies & 1 << i != 0) {
if let (Some(t), Some(copy)) = (&targets.color[i], &targets.scratch[i]) {
@@ -2185,6 +2450,9 @@ impl PackPost {
&self.dummy_1d
}
Source::Dummy => &self.dummy,
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
}
Source::Image(k) => self.images[k].view.as_ref().unwrap_or(&self.dummy),
Source::Custom { index, .. } => match &self.textures[index].image {
pack_textures::Image::View(v) => v,
@@ -2224,6 +2492,8 @@ impl PackPost {
targets.color[i].as_ref().map_or(&self.dummy, |t| &t[side])
}
Image::Custom(k) => self.images[k].view.as_ref().unwrap_or(&self.dummy),
Image::ColorBefore(i) => targets.scratch[i].as_ref().unwrap_or(&self.dummy),
Image::CustomBefore(k) => self.images[k].before.as_ref().unwrap_or(&self.dummy),
};
entries.push(wgpu::BindGroupEntry {
binding,
@@ -2262,6 +2532,9 @@ impl PackPost {
[n(0), n(1), 1]
});
let (pipeline, groups) = (pipeline.clone(), groups);
for image in (0..self.images.len()).filter(|k| pass.snapshots & 1 << k != 0) {
pack_images::snapshot(encoder, &self.images[image]);
}
let group = self.bind(device, queue, encoder, pass, size);
let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(&pass.name),
@@ -2381,7 +2654,12 @@ impl PackPost {
rp.set_bind_group(3, group, &[]);
}
};
draw(&mut rp, list, &page);
let geometry = self
.indices
.as_ref()
.filter(|_| pass.geometry > 0)
.map(|indices| (pass.geometry, indices));
draw(&mut rp, list, &page, geometry);
}
if !pass.group.shadow() {
self.mips_valid &= !pass.written;
@@ -2822,6 +3100,11 @@ impl PackPost {
cull: pack_uniforms::shadow_culling(frame, shadow),
light: pack_uniforms::shadow_light(frame, shadow),
distance: (shadow.render_mul > 0.0).then_some(shadow.distance * shadow.render_mul),
unculled: match self.shadow_culling {
horizon_shaders::ShadowCulling::On => None,
horizon_shaders::ShadowCulling::Off => Some(f32::INFINITY),
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
},
})
}
@@ -2933,6 +3216,15 @@ impl PackPost {
let mut attachment = wgpu::TextureUsages::RENDER_ATTACHMENT;
if self.storage & 1 << i != 0 {
attachment |= wgpu::TextureUsages::STORAGE_BINDING;
// Atomics on a texture need it made for them (Metal ignores them otherwise).
if device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
&& matches!(
self.formats[i],
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
)
{
attachment |= wgpu::TextureUsages::STORAGE_ATOMIC;
}
}
if self.copied & 1 << i != 0 {
attachment |= wgpu::TextureUsages::COPY_SRC;
@@ -2986,18 +3278,31 @@ impl PackPost {
});
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
let image = if self.storage & 1 << i != 0 {
wgpu::TextureUsages::STORAGE_BINDING
} else {
wgpu::TextureUsages::empty()
};
texture_sized(
&format!("colortex{i} copy"),
self.formats[i],
wgpu::TextureUsages::COPY_DST,
wgpu::TextureUsages::COPY_DST | image,
1,
sizes[i],
)
.create_view(&Default::default())
})
});
let sink = texture(
"Shader pack sink",
SINK,
wgpu::TextureUsages::RENDER_ATTACHMENT,
)
.create_view(&Default::default());
self.targets = Some(Targets {
size,
sink,
sizes,
scratch,
color,
@@ -3147,9 +3452,18 @@ impl PackPost {
if self.targets.is_none() {
return;
}
// Images the shadow and shadowcomp programs write start the frame cleared, as every
// other program sees them.
pack_images::clear(encoder, &self.images);
// Images start the frame cleared, before the shadow pass writes them.
if let Some(dump) = &mut self.dump {
dump.read(device);
dump.copy(device, encoder, &self.images);
}
pack_images::clear(
device,
encoder,
&self.images,
self.gpu.clear,
&mut self.zeros,
);
self.run_shadow(device, queue, encoder, terrain);
let Some(targets) = &self.targets else { return };
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
@@ -3329,6 +3643,8 @@ impl PackPost {
if final_pass {
attachments.push(attach(screen, overwrite));
shown = true;
} else if pass.sink {
attachments.push(attach(&targets.sink, overwrite));
} else {
for &(location, buffer) in &pass.writes {
let l = location as usize;
@@ -162,6 +162,15 @@ pub const NAMES: &[&str] = &[
"shadowModelViewInverse",
"shadowProjection",
"shadowProjectionInverse",
"cameraPositionInt",
"cameraPositionFract",
"previousCameraPositionInt",
"previousCameraPositionFract",
"eyePosition",
"relativeEyePosition",
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
];
/// Values carried from frame to frame.
@@ -172,7 +181,7 @@ pub struct FrameUniforms {
start: std::time::Instant,
last: Option<std::time::Instant>,
frame: u32,
previous: Option<(Vec3, Mat4, Mat4)>,
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// The pack's shadow settings (7.1).
@@ -294,10 +303,23 @@ impl FrameUniforms {
frame.depth_far,
);
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
let (previous_exact, previous_view, previous_projection) =
self.previous
.unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO));
self.previous = Some((camera, view, projection));
.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(),
);
let to_view = |v: Vec3| view.transform_vector3(v);
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
@@ -342,6 +364,15 @@ 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),
@@ -453,6 +484,14 @@ fn components(name: &str) -> usize {
match name {
"cameraPosition"
| "previousCameraPosition"
| "cameraPositionInt"
| "cameraPositionFract"
| "previousCameraPositionInt"
| "previousCameraPositionFract"
| "eyePosition"
| "relativeEyePosition"
| "playerLookVector"
| "playerBodyVector"
| "upPosition"
| "sunPosition"
| "moonPosition"
@@ -147,6 +147,34 @@ 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,
+462 -6
View File
@@ -474,6 +474,19 @@ 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 {
@@ -777,6 +790,39 @@ 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.
@@ -795,8 +841,8 @@ pub enum Action {
Done,
}
#[derive(Clone, Copy)]
enum Target {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Item(Item),
Button(Action),
}
@@ -814,6 +860,8 @@ 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>,
}
@@ -822,6 +870,8 @@ 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 {
@@ -939,8 +989,10 @@ impl GraphicsUi {
let mut index = 0;
let mut tooltip = None;
for wd in &widgets {
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
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;
match wd.target {
Target::Item(item) => {
let (cost, text) = descriptions[index];
@@ -961,13 +1013,15 @@ impl GraphicsUi {
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
Some(value) => STYLE.slider_ext(
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),
),
@@ -1072,11 +1126,16 @@ 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) => vec![Action::ButtonClick, action],
Target::Button(action) => {
self.can_change_value = false;
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 => {
@@ -1095,10 +1154,12 @@ 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]
}
@@ -1118,6 +1179,246 @@ 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)]
@@ -1514,4 +1815,159 @@ 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}"
);
}
}
}
}
@@ -0,0 +1,73 @@
// 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);
}
+2
View File
@@ -54,6 +54,7 @@ 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;
@@ -73,6 +74,7 @@ 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;
File diff suppressed because it is too large. Load diff
+140 -88
View File
@@ -1,5 +1,5 @@
//! Exposed voxel-column faces, merged without changing their block-scale UVs.
use super::{Column, Span, TileKey};
use super::{Column, Span, Spans, TileKey};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex, fluid_face},
@@ -12,10 +12,10 @@ use std::collections::BTreeMap;
const CELLS: usize = 16;
pub(super) struct EdgeColumn {
pub(super) struct EdgeColumn<'a> {
pub start: i32,
pub end: i32,
pub column: Column,
pub column: &'a 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(
@@ -296,6 +296,7 @@ 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)
@@ -305,11 +306,19 @@ fn mesh_columns_with_neighbors(
.map(|(row, column)| EdgeColumn {
start: row as i32 * stride,
end: (row as i32 + 1) * stride,
column: column.clone(),
column,
})
.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.
@@ -325,7 +334,7 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
min: crate::chunk::MIN_Y as i16,
..*span
};
let mut spans = Vec::with_capacity(2);
let mut spans = Spans::with_capacity(2);
match below.last() {
Some(under) if fluid(top) => {
spans.push(ground(under));
@@ -341,33 +350,44 @@ 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);
let heightmap_columns;
let heightmap_neighbors;
// 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 (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::<Vec<_>>();
heightmap_neighbors = neighbors.each_ref().map(|side| {
.collect();
heightmap_edges = neighbors.each_ref().map(|side| {
side.iter()
.map(|edge| EdgeColumn {
.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 {
start: edge.start,
end: edge.end,
column: heightmap(&edge.column, &mut fluid),
column,
})
.collect::<Vec<_>>()
.collect()
});
(&heightmap_columns[..], &heightmap_neighbors)
(&heightmap_refs[..], &heightmap_neighbors)
} else {
(columns, neighbors)
};
@@ -417,18 +437,16 @@ fn mesh_columns_with_edges(
[ox, oz]
}
};
let mut materials = Vec::new();
let prepared: Vec<Vec<Segment<'_>>> = filled
.iter()
.map(|column| &**column)
.chain(neighbors.iter().flatten().map(|edge| &edge.column))
.map(|column| column.as_ref())
.chain(neighbors.iter().flatten().map(|edge| edge.column))
.enumerate()
.map(|(index, column)| {
let at = centre(index);
let materials: Vec<_> = column
.spans
.iter()
.map(|s| material(s, Direction::Up, at))
.collect();
materials.clear();
materials.extend(column.spans.iter().map(|s| material(s, Direction::Up, at)));
column
.spans
.iter()
@@ -562,7 +580,6 @@ 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
@@ -577,13 +594,20 @@ fn mesh_columns_with_edges(
u1,
segment,
&[],
side,
|| material(segment.span, direction, centre(z * CELLS + x)),
own_sky,
);
}
} else if !edge {
add_side(
&mut faces, direction, plane, u0, u1, segment, column, side,
&mut faces,
direction,
plane,
u0,
u1,
segment,
column,
|| material(segment.span, direction, centre(z * CELLS + x)),
own_sky,
);
} else if (0..CELLS as i32).contains(&nx)
@@ -597,7 +621,7 @@ fn mesh_columns_with_edges(
u1,
segment,
&prepared[nz as usize * CELLS + nx as usize],
side,
|| material(segment.span, direction, centre(z * CELLS + x)),
own_sky,
);
} else {
@@ -617,7 +641,7 @@ fn mesh_columns_with_edges(
u1,
segment,
&[],
side,
|| material(segment.span, direction, centre(z * CELLS + x)),
own_sky,
);
}
@@ -635,7 +659,13 @@ fn mesh_columns_with_edges(
end,
segment,
&prepared[edge_offsets[edge_side] + i],
side,
|| {
material(
segment.span,
direction,
centre(z * CELLS + x),
)
},
own_sky,
);
}
@@ -654,6 +684,7 @@ fn mesh_columns_with_edges(
emit(&mut mesh, key, rect)
}
}
mesh.finish_sprite_layers();
mesh
}
@@ -683,15 +714,17 @@ 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,
&std::array::from_fn(|_| Vec::new()),
&[Vec::new(), Vec::new(), Vec::new(), Vec::new()],
&[fine],
|_, _, _| Material {
faces: [
@@ -752,8 +785,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 [Column],
neighbors: &[Vec<EdgeColumn>; 4],
columns: &'a [&'a Column],
neighbors: &'a [Vec<EdgeColumn<'a>>; 4],
stride: i32,
whole: impl Fn(usize, usize) -> bool,
mut fluid: impl FnMut(&Span) -> bool,
@@ -779,19 +812,17 @@ 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: Vec<_> = (0..CELLS * CELLS)
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)
.map(|i| {
if whole(i % CELLS, i / CELLS) {
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])
gaps_of(columns[i])
} else {
Vec::new()
}
@@ -805,7 +836,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)
};
@@ -813,7 +844,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))
})
@@ -878,12 +909,13 @@ 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: Vec::with_capacity(column.spans.len()),
spans: Spans::with_capacity(column.spans.len()),
};
for (k, &span) in column.spans.iter().enumerate() {
let mut span = span;
@@ -918,13 +950,22 @@ fn add_side(
u1: f32,
segment: &Segment<'_>,
neighbor: &[Segment<'_>],
side: Material,
mut side: impl FnMut() -> 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);
@@ -1033,6 +1074,7 @@ 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 {
@@ -1096,7 +1138,7 @@ mod tests {
(0..CELLS * CELLS)
.map(|_| Column {
surface: None,
spans: Vec::new(),
spans: Spans::new(),
})
.collect()
}
@@ -1176,13 +1218,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)],
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)].into(),
};
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)],
spans: vec![span(0, 50, 1), span(50, 62, 2)].into(),
};
let flat = heightmap(&sea, fluid);
assert_eq!(flat.spans, [span(-64, 50, 1), span(50, 62, 2)]);
@@ -1197,7 +1239,8 @@ mod tests {
span(0, top - 4, 1),
span(top - 4, top - 1, 3),
span(top - 1, top, 4),
],
]
.into(),
}
})
.collect();
@@ -1257,7 +1300,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)]
column.spans = vec![span(0, 16, 1)].into()
}
let mesh = mesh(&columns);
assert_eq!(mesh.solid.len(), 6 * 4);
@@ -1292,7 +1335,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)];
columns[0].spans = vec![span(3, 5, 1), span(8, 9, 1)].into();
let mesh = mesh(&columns);
assert_eq!(mesh.solid.len(), 12 * 4);
assert!(
@@ -1311,8 +1354,8 @@ mod tests {
#[test]
fn neighbor_hides_only_its_occupied_side_intervals() {
let mut columns = columns();
columns[0].spans = vec![span(0, 10, 1)];
columns[1].spans = vec![span(0, 3, 1), span(7, 10, 1)];
columns[0].spans = vec![span(0, 10, 1)].into();
columns[1].spans = vec![span(0, 3, 1), span(7, 10, 1)].into();
let mesh = mesh(&columns);
let opening: Vec<_> = mesh
.solid
@@ -1346,9 +1389,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)];
columns[1].spans = vec![span(0, 4, 1)];
columns[CELLS].spans = vec![span(0, 2, 1), span(6, 12, 1)];
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();
let mesh = mesh(&columns);
let quads = mesh.solid.as_chunks::<4>().0;
let bounds = |q: &[TerrainVertex; 4]| {
@@ -1379,19 +1422,21 @@ mod tests {
fn air_under_a_canopy_stays_open_through_a_clearing() {
let column = |spans: Vec<Span>| Column {
surface: None,
spans,
spans: spans.into(),
};
// 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: column(vec![span(0, 20, 1)]),
column: &hill_column,
};
let neighbors = [vec![hill()], vec![hill()], vec![hill()], vec![hill()]];
let fill = |columns: &[Column]| -> Vec<Vec<Span>> {
fill_buried_gaps(columns, &neighbors, 1, |_, _| true, |_| false)
let columns: Vec<&Column> = columns.iter().collect();
fill_buried_gaps(&columns, &neighbors, 1, |_, _| true, |_| false)
.iter()
.map(|filled| filled.spans.clone())
.map(|filled| filled.spans.as_slice().to_vec())
.collect()
};
let mut forest = vec![column(vec![span(0, 5, 1), span(10, 14, 3)]); CELLS * CELLS];
@@ -1402,23 +1447,27 @@ 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, &column.spans);
assert_eq!(filled.as_slice(), column.spans.as_slice());
}
// 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 == &[span(0, 20, 1)]));
assert!(
fill(&rock)
.iter()
.all(|spans| spans.as_slice() == [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)];
column.spans = vec![span(0, 20, 1)].into();
}
let cave = 5 * CELLS + 5;
columns[cave].spans = vec![span(0, 8, 1), span(12, 20, 1)];
columns[cave].spans = vec![span(0, 8, 1), span(12, 20, 1)].into();
let at = |mesh: &SectionMesh, y: f32| {
mesh.solid
.as_chunks::<4>()
@@ -1437,14 +1486,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)];
columns[cave + 1].spans = vec![span(0, 10, 1)].into();
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)];
column.spans = vec![span(0, 5, 1), span(5, 10, 2)].into();
}
columns[cave].spans = vec![span(0, 5, 1), span(5, 7, 2), span(8, 10, 2)];
columns[cave].spans = vec![span(0, 5, 1), span(5, 7, 2), span(8, 10, 2)].into();
let water = mesh(&columns);
assert!(
water
@@ -1458,12 +1507,13 @@ mod tests {
#[test]
fn greedy_faces_preserve_material_and_light_boundaries() {
let mut columns = columns();
columns[0].spans = vec![span(0, 1, 1)];
columns[1].spans = vec![span(0, 1, 3)];
columns[0].spans = vec![span(0, 1, 1)].into();
columns[1].spans = vec![span(0, 1, 3)].into();
columns[2].spans = vec![Span {
light: 16,
..span(0, 1, 3)
}];
}]
.into();
let mesh = mesh(&columns);
let tops: Vec<_> = mesh
.solid
@@ -1482,7 +1532,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)];
columns[0].spans = vec![span(0, 3, 1), span(3, 6, 2)].into();
columns[1].spans = columns[0].spans.clone();
let mesh = mesh(&columns);
assert!(
@@ -1509,42 +1559,44 @@ 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)];
column.spans = vec![span(0, 16, 1)].into();
}
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].clone(),
column: &columns[0],
}]
});
// 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].clone(),
column: &columns[0],
},
EdgeColumn {
start: 1,
end: 2,
column: Column {
surface: None,
spans: vec![span(0, 8, 1)],
},
column: &low,
},
EdgeColumn {
start: 2,
end: 32,
column: columns[0].clone(),
column: &columns[0],
},
];
let columns: Vec<&Column> = columns.iter().collect();
let mesh = mesh_columns_with_edges(
key,
&columns,
@@ -1572,7 +1624,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)];
column.spans = vec![span(0, 16, 1)].into();
}
let mut neighbors = columns[..4 * CELLS].to_vec();
let key = TileKey {
@@ -1596,10 +1648,10 @@ mod tests {
);
for column in &mut neighbors[CELLS..2 * CELLS] {
column.spans = vec![span(0, 8, 1)];
column.spans = vec![span(0, 8, 1)].into();
}
for column in &mut neighbors[..CELLS] {
column.spans = vec![span(0, 3, 1), span(7, 16, 1)];
column.spans = vec![span(0, 3, 1), span(7, 16, 1)].into();
}
let mesh = mesh_columns_with_neighbors(key, &columns, &covered, None, &neighbors, material);
assert_eq!(mesh.solid.len(), 4 * 4);
@@ -1626,7 +1678,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)]
column.spans = vec![span(10, 12, 1)].into()
}
let key = TileKey {
x: -1,
+92 -23
View File
@@ -125,6 +125,8 @@ 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
@@ -132,14 +134,16 @@ mod vertex_tests {
}
#[test]
fn light_sources_shine_by_kind_not_light_level() {
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
use super::{BRIGHT, INCANDESCENT, LOW, WHOLE, 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);
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("respawn_anchor", 3), INCANDESCENT);
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
assert_eq!(hdr_tier("sea_pickle", 15), 0);
@@ -227,8 +231,14 @@ 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 {
@@ -238,11 +248,27 @@ 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;
@@ -252,14 +278,17 @@ 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`).
@@ -465,6 +494,7 @@ 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);
@@ -499,6 +529,11 @@ 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() {
@@ -621,8 +656,16 @@ 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 {
@@ -823,9 +866,10 @@ 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, 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, 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.
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.
@@ -834,9 +878,18 @@ pub const INCANDESCENT: u8 = 2;
pub const BRIGHT: u8 = 3;
/// The sun and lightning.
pub const VERY_BRIGHT: u8 = 4;
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
/// 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`).
pub fn emitting(color: u32, tier: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
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
@@ -853,26 +906,36 @@ pub fn fluid_face(color: u32, face: usize) -> u32 {
pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
_ if level == 0 => 0,
"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,
"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,
// Glowing at every charge, not only the brighter ones.
"respawn_anchor" => INCANDESCENT,
"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,
"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,
// 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" => 0,
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame"
| "ender_chest" => 0,
_ if level >= 10 => INCANDESCENT,
_ => LOW,
}
@@ -1136,6 +1199,7 @@ impl Mesher<'_> {
}
}
}
out.finish_sprite_layers();
out
}
/// `BlockColors` tint source `index`, or `None` for an untinted (-1) layer.
@@ -1169,7 +1233,9 @@ impl Mesher<'_> {
state: &Info,
) {
let q = &face.quad;
let tier = state.tier.max(hdr_tier("", q.light_emission));
// 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 shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = if self.shader_pack_lighting {
1.0
@@ -1191,6 +1257,9 @@ 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 {
@@ -240,7 +240,7 @@ pub(super) fn tesselate(
let emission = if water {
0
} else {
crate::mesher::INCANDESCENT
crate::mesher::hdr_tier("lava", 15)
};
let color = |by: f32| {
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
@@ -320,12 +320,16 @@ 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));
@@ -455,6 +459,7 @@ pub(super) fn tesselate(
d as usize,
);
}
out.sprite_layers.extend(sprite_layers);
}
#[cfg(test)]
@@ -0,0 +1,61 @@
//! 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,6 +829,15 @@ 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);
@@ -874,6 +883,11 @@ 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);
}
@@ -887,6 +901,38 @@ 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(
@@ -926,12 +972,7 @@ impl PackScreen {
let (cx, cy) = (left + 2, e.y + 2);
match &e.row {
Row::Header => {
let key = if l.selected_side {
"pack.selected.title"
} else {
"pack.available.title"
};
let t = tr(key).with_style(&Style {
let t = tr(header).with_style(&Style {
underlined: Some(true),
bold: Some(true),
..Style::default()
+18 -2
View File
@@ -448,8 +448,16 @@ 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()?,
};
// 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()
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()
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
@@ -1158,6 +1166,14 @@ mod tests {
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()));
@@ -422,6 +422,15 @@ pub fn button(
active: bool,
highlighted: bool,
) {
// Its narration: hovered is the mouse over it (`isHovered`), which the
// narrator tests.
crate::widgets::narratable(|| {
crate::widgets::Narratable::button(
label.plain(),
[x as f32, y as f32, w as f32, h as f32],
active,
)
});
let sprite_id = if !active {
"widget/button_disabled"
} else if highlighted {
@@ -469,6 +478,8 @@ pub struct TextField {
pub text_shadow: bool,
pub hint: Option<Text>,
pub suggestion: Option<String>,
/// `getMessage`: what the narrator calls the box.
pub message: String,
}
fn chars(s: &str) -> usize {
s.chars().count()
@@ -511,6 +522,7 @@ impl TextField {
text_shadow: true,
hint: None,
suggestion: None,
message: String::new(),
}
}
pub fn inner_width(&self) -> i32 {
@@ -717,15 +729,17 @@ impl TextField {
blink_ms: u64,
format: Option<&dyn Fn(&str, usize) -> Text>,
) {
crate::widgets::target_as(
[
self.x as f32,
self.y as f32,
self.width as f32,
self.height as f32,
],
crate::widgets::Kind::Field,
);
let rect = [
self.x as f32,
self.y as f32,
self.width as f32,
self.height as f32,
];
crate::widgets::target_as(rect, crate::widgets::Kind::Field);
crate::widgets::narratable(|| {
crate::widgets::Narratable::edit_box(self.message.clone(), self.value.clone(), rect)
.focused(self.focused)
});
if self.bordered {
let s = if self.focused {
"widget/text_field_highlighted"
@@ -202,6 +202,8 @@ pub struct Settings {
/// The packs in `shaderpacks/`, listed when the Shader Packs page opens; the pick is
/// `horizon.shader_pack`.
pub shader_packs: Vec<crate::shader_pack_list::Entry>,
/// The picked pack's options while its Shader Options page is open.
pub shader_options: Option<crate::shader_options::ShaderOptions>,
}
impl Default for Settings {
fn default() -> Self {
@@ -285,6 +287,7 @@ impl Default for Settings {
extras: Default::default(),
mods: Default::default(),
shader_packs: Vec::new(),
shader_options: None,
}
}
}
@@ -467,6 +470,7 @@ impl Settings {
extras: Default::default(),
mods: Default::default(),
shader_packs: Vec::new(),
shader_options: None,
auto_jump: f.bool("autoJump"),
language: f.string("lang"),
force_unicode: f.bool("forceUnicodeFont"),
@@ -661,6 +665,22 @@ impl Settings {
}
})
}
/// `Options.getFinalSoundSourceVolume`: a source's volume times master's.
pub fn final_volume(&self, source: &str) -> f32 {
if source == "master" {
self.master_volume
} else {
self.volume(source) * self.master_volume
}
}
/// `Options.narrator`: the `narrator` id in options.txt.
pub fn narrator(&self) -> horizon_narrator::Status {
horizon_narrator::Status::by_id(self.file.int("narrator"))
}
pub fn set_narrator(&mut self, status: horizon_narrator::Status) {
self.file
.set("narrator", crate::options_file::V::Int(status.id()));
}
/// `Options.getBackgroundColor(float)`: the text backdrop colour, `default`
/// opacity unless `backgroundForChatOnly` is off.
pub fn text_background(&self, default: f32) -> u32 {
@@ -809,6 +829,19 @@ pub enum Opt {
mod tests {
use super::*;
#[test]
fn the_narrator_reads_its_status_and_the_voice_under_master() {
let s = Settings::parse("narrator:2\nsoundCategory_master:0.5\nsoundCategory_voice:0.5\n");
assert_eq!(s.narrator(), horizon_narrator::Status::Chat);
assert_eq!(s.final_volume("voice"), 0.25);
assert_eq!(s.final_volume("master"), 0.5);
let mut s = s;
s.set_narrator(horizon_narrator::Status::Off);
assert_eq!(
Settings::parse(&s.serialize()).narrator(),
horizon_narrator::Status::Off
);
}
#[test]
fn an_option_set_keeps_horizon_files() {
let mut s = Settings::parse("fov:70\n");
s.mods.enabled.insert("zoom".into());
@@ -0,0 +1,391 @@
//! The Shader Options page of a `shaders/` pack: its options on the screens the pack lays out,
//! with the names its `lang` files give them, and the values the player picked.
use std::collections::BTreeMap;
use horizon_shaders::{Lang, PackOption, ScreenItem, Screens};
use crate::lang;
use crate::text::Text;
#[derive(Clone, Debug)]
pub struct ShaderOptions {
/// The pack's file name in `shaderpacks/`.
pub pack: String,
pub options: BTreeMap<String, PackOption>,
pub screens: Screens,
pub lang: Lang,
/// The values that differ from the defaults.
pub chosen: BTreeMap<String, String>,
/// The screens opened from the main one, innermost last.
pub path: Vec<String>,
/// Something changed since the page opened.
pub changed: bool,
}
impl ShaderOptions {
pub fn new(
pack: String,
options: BTreeMap<String, PackOption>,
screens: Screens,
lang: Lang,
chosen: BTreeMap<String, String>,
) -> Self {
let mut out = Self {
pack,
options,
screens,
lang,
chosen: BTreeMap::new(),
path: Vec::new(),
changed: false,
};
for (name, value) in chosen {
out.set(&name, value);
}
out.changed = false;
out
}
/// The screen shown: the innermost opened, else the main one.
pub fn screen(&self) -> &str {
self.path.last().map_or("", String::as_str)
}
pub fn items(&self) -> &[ScreenItem] {
self.screens
.screen(self.screen())
.map_or(&[], |(items, _)| items)
}
pub fn value(&self, name: &str) -> Option<&str> {
self.chosen
.get(name)
.or_else(|| self.options.get(name).map(|o| &o.default))
.map(String::as_str)
}
fn set(&mut self, name: &str, value: String) {
let Some(option) = self.options.get(name).filter(|o| o.values.contains(&value)) else {
return;
};
if value == option.default {
self.chosen.remove(name);
} else {
self.chosen.insert(name.to_owned(), value);
}
self.changed = true;
}
/// The option's next value (the previous one with `back`), wrapping around.
pub fn cycle(&mut self, name: &str, back: bool) {
let (Some(option), Some(value)) = (self.options.get(name), self.value(name)) else {
return;
};
let n = option.values.len();
let at = option.values.iter().position(|v| v == value);
let next = match (at, back) {
(Some(i), false) => (i + 1) % n,
(Some(i), true) => (i + n - 1) % n,
(None, _) => 0,
};
let value = option.values[next].clone();
self.set(name, value);
}
/// Where the slider of option `name` stands (its value's place in its list, 0 to 1), when
/// the pack shows it as one.
pub fn slider_position(&self, name: &str) -> Option<f32> {
let option = self.options.get(name)?;
if option.switch || option.values.len() < 2 || !self.screens.sliders.contains(name) {
return None;
}
let at = option
.values
.iter()
.position(|v| Some(v.as_str()) == self.value(name))?;
Some(at as f32 / (option.values.len() - 1) as f32)
}
/// Option `name` at the value nearest place `position` (0 to 1) of its list.
pub fn set_position(&mut self, name: &str, position: f32) {
let Some(option) = self.options.get(name) else {
return;
};
let last = option.values.len().saturating_sub(1);
let at = (position.clamp(0.0, 1.0) * last as f32).round() as usize;
if let Some(value) = option.values.get(at).cloned()
&& Some(value.as_str()) != self.value(name)
{
self.set(name, value);
}
}
/// Option `name` back at the pack's default.
pub fn reset_option(&mut self, name: &str) {
if self.chosen.remove(name).is_some() {
self.changed = true;
}
}
/// The profile after the current one (the first when none matches) with its values.
pub fn next_profile(&mut self) {
let profiles = &self.screens.profiles;
if profiles.is_empty() {
return;
}
let current = self.current_profile().map(str::to_owned);
let at = profiles
.iter()
.position(|(n, _)| Some(n) == current.as_ref());
let (_, values) = profiles[at.map_or(0, |i| (i + 1) % profiles.len())].clone();
for (name, value) in values {
self.set(&name, value);
}
}
pub fn current_profile(&self) -> Option<&str> {
self.screens
.current_profile(|n| self.value(n).map(str::to_owned))
}
pub fn reset(&mut self) {
if !self.chosen.is_empty() {
self.chosen.clear();
self.changed = true;
}
}
fn named(&self, key: &str, or: &str) -> Text {
Text::legacy(self.lang.get(key).unwrap_or(or))
}
/// The screen's title: its name in the pack's language, or its key.
pub fn title(&self) -> Text {
match self.screen() {
"" => Text::literal(self.pack.clone()),
screen => self.named(&format!("screen.{screen}"), screen),
}
}
pub fn screen_label(&self, screen: &str) -> Text {
self.named(&format!("screen.{screen}"), screen)
.append("...")
}
pub fn profile_label(&self) -> Text {
let value = match self.current_profile() {
Some(name) => self.named(&format!("profile.{name}"), name),
None => Text::literal(lang::get("generator.custom")),
};
Text::literal(format!("{}: ", lang::get("horizon.shader_options.profile"))).append(value)
}
/// What the pack says of an option, a screen or the profiles (`option.NAME.comment`...).
pub fn comment(&self, key: &str) -> Option<Text> {
self.lang
.get(&format!("{key}.comment"))
.map(|c| Text::legacy(&c.replace("\\n", "\n")))
}
/// `option_label` within `width` as `measure` sizes it: the name shortened (`Bloom Up...: 2`)
/// when the whole doesn't fit, and whether it was.
pub fn option_label_within(
&self,
name: &str,
width: f32,
measure: impl Fn(&Text) -> f32,
) -> (Text, bool) {
let full = self.option_label(name);
if measure(&full) <= width {
return (full, false);
}
// The pack's text, cut a character at a time with the `§` codes it holds kept.
let key = format!("option.{name}");
let caption = self.lang.get(&key).unwrap_or(name);
let value = self.value_text(name);
let mut chars: Vec<char> = caption.chars().collect();
loop {
while chars.len() >= 2 && chars[chars.len() - 2] == '§' {
chars.truncate(chars.len() - 2);
}
if chars.pop().is_none() {
return (full, true);
}
// Without the spaces and codes it ends with.
let mut end = chars.len();
loop {
if end >= 2 && chars[end - 2] == '§' {
end -= 2;
} else if end > 0 && chars[end - 1].is_whitespace() {
end -= 1;
} else {
break;
}
}
let cut: String = chars[..end].iter().collect();
let label = Text::legacy(&format!("{cut}..."))
.append(": ")
.append(value.clone());
if measure(&label) <= width {
return (label, true);
}
}
}
/// `Name: value`, in the pack's words, the value yellow when it is not the default.
pub fn option_label(&self, name: &str) -> Text {
self.named(&format!("option.{name}"), name)
.append(": ")
.append(self.value_text(name))
}
/// The value of option `name` as the pack names it, yellow when it is not the default.
fn value_text(&self, name: &str) -> Text {
let Some(option) = self.options.get(name) else {
return Text::literal(String::new());
};
let value = self.value(name).unwrap_or_default();
let shown = if option.switch {
Text::literal(lang::get(if value == "true" {
"options.on"
} else {
"options.off"
}))
} else {
let text = self
.lang
.get(&format!("value.{name}.{value}"))
.unwrap_or(value);
let prefix = self.lang.get(&format!("prefix.{name}")).unwrap_or("");
let suffix = self.lang.get(&format!("suffix.{name}")).unwrap_or("");
Text::legacy(&format!("{prefix}{text}{suffix}"))
};
if self.chosen.contains_key(name) {
shown.with_color(crate::text::YELLOW)
} else {
shown
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shaders::{Macros, Properties};
fn options() -> ShaderOptions {
let switch = |name: &str, default: &str| PackOption {
name: name.into(),
default: default.into(),
values: vec!["false".into(), "true".into()],
switch: true,
tested: true,
};
let options = BTreeMap::from([
("AO".to_owned(), switch("AO", "true")),
(
"LEVEL".to_owned(),
PackOption {
name: "LEVEL".into(),
default: "2".into(),
values: vec!["1".into(), "2".into(), "3".into()],
switch: false,
tested: true,
},
),
]);
let text = "screen = <profile> [more]\nscreen.more = AO LEVEL\nsliders = LEVEL AO\n\
profile.gone = OLD=1\n\
profile.low = !AO LEVEL=1\nprofile.high = AO LEVEL=3\n";
let screens = Screens::parse(&Properties::parse(text, &Macros::standard()), &options);
ShaderOptions::new(
"P.zip".into(),
options,
screens,
Lang::default(),
BTreeMap::from([
("LEVEL".to_owned(), "2".to_owned()),
("AO".to_owned(), "maybe".to_owned()),
]),
)
}
#[test]
fn values_cycle_and_defaults_are_not_kept() {
let mut o = options();
assert!(o.chosen.is_empty() && !o.changed);
o.cycle("LEVEL", false);
o.cycle("LEVEL", false);
assert_eq!(o.value("LEVEL"), Some("1"));
o.cycle("LEVEL", true);
o.cycle("AO", false);
assert_eq!(
o.chosen,
BTreeMap::from([
("AO".to_owned(), "false".to_owned()),
("LEVEL".to_owned(), "3".to_owned())
])
);
assert!(o.changed);
o.reset();
assert!(o.chosen.is_empty());
}
#[test]
fn sliders_take_the_nearest_value() {
let mut o = options();
assert_eq!(o.slider_position("LEVEL"), Some(0.5));
// A switch is a button even when `sliders` names it.
assert_eq!(o.slider_position("AO"), None);
o.set_position("LEVEL", 0.9);
assert_eq!(o.value("LEVEL"), Some("3"));
o.set_position("LEVEL", -1.0);
assert_eq!(o.value("LEVEL"), Some("1"));
o.set_position("LEVEL", 0.4);
assert_eq!(o.value("LEVEL"), Some("2"));
assert!(o.chosen.is_empty());
}
#[test]
fn single_resets_comments_and_fitted_labels() {
let mut o = options();
o.lang = Lang::parse(
"option.LEVEL=Level of detail\noption.LEVEL.comment=How much detail.\\nMore costs more.\n",
);
o.cycle("LEVEL", false);
assert_eq!(o.value("LEVEL"), Some("3"));
o.reset_option("LEVEL");
assert_eq!(o.value("LEVEL"), Some("2"));
assert_eq!(
o.comment("option.LEVEL").map(|c| c.plain()).as_deref(),
Some("How much detail.\nMore costs more.")
);
assert!(o.comment("option.AO").is_none());
// One unit a character.
let measure = |t: &Text| t.plain().chars().count() as f32;
assert!(!o.option_label_within("LEVEL", 40.0, measure).1);
let (fitted, cut) = o.option_label_within("LEVEL", 12.0, measure);
assert!(cut);
assert_eq!(fitted.plain(), "Level...: 2");
o.lang = Lang::parse("option.LEVEL=§aLevel §lof detail§r\n");
let (fitted, _) = o.option_label_within("LEVEL", 12.0, measure);
assert_eq!(fitted.plain(), "Level...: 2");
assert_eq!(fitted.spans[0].style, Text::legacy("§aL").spans[0].style);
let (fitted, _) = o.option_label_within("LEVEL", 14.0, measure);
assert_eq!(fitted.plain(), "Level of...: 2");
}
#[test]
fn profiles_follow_the_values() {
let mut o = options();
assert_eq!(o.current_profile(), None);
o.next_profile();
assert_eq!(o.current_profile(), Some("low"));
o.next_profile();
assert_eq!(o.current_profile(), Some("high"));
assert_eq!(o.value("LEVEL"), Some("3"));
o.next_profile();
assert_eq!(o.current_profile(), Some("low"));
}
}
+143 -18
View File
@@ -71,6 +71,33 @@ pub trait ChunkGenerator: Send + Sync + 'static {
}
/// Decorate `region[4]`; the region is row-major (dz, dx) around it.
fn decorate(&self, _worker: &mut Self::Worker, _region: &mut [Proto; 9]) {}
/// Whether a chunk next to final ones (loaded from the save, as the
/// chunks of a world an older version made) is decorated, its features
/// spilling into them (`proto_beside_final`, `final_writes`), as vanilla's
/// are; otherwise it is left undecorated.
fn decorates_beside_final(&self) -> bool {
false
}
/// The proto chunk of a final chunk, for a decoration region to hold: its
/// blocks are `snapshot`'s.
fn proto_beside_final(&self, _worker: &mut Self::Worker, _snapshot: &Chunk) -> Option<Proto> {
None
}
/// What decoration wrote into the proto chunk of a final chunk: the blocks
/// that differ from `snapshot`, as `([x, y, z], state)`.
fn final_writes(&self, _snapshot: &Chunk, _proto: &Proto) -> Vec<([i32; 3], u32)> {
Vec::new()
}
/// The block entities decoration made at the blocks `writes` put into a
/// final chunk (a chest of a structure piece, a spawner), with their
/// contents.
fn final_block_entities(
&self,
_proto: &Proto,
_writes: &[([i32; 3], u32)],
) -> Vec<((i32, i32, i32), crate::chunk::block_entity::BlockEntity)> {
Vec::new()
}
fn finish(&self, proto: Proto) -> Chunk;
/// Whether `proto` has been decorated (a saved one can come back decorated).
fn is_decorated(&self, _proto: &Proto) -> bool {
@@ -178,7 +205,10 @@ struct MeshSettings {
}
enum Job {
Generate(i32, i32),
Decorate(i32, i32, Box<[Proto; 9]>),
/// A region to decorate, with the final chunks in it (region index and
/// the chunk as it is): their slots hold nothing until the worker makes
/// their proto chunks.
Decorate(i32, i32, Box<[Proto; 9]>, Vec<(usize, Chunk)>),
Finish(Proto),
Light(i32, i32, Box<Region>, u64, bool),
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
@@ -189,11 +219,19 @@ enum Job {
/// is given back here, not in the frame.
Free(Vec<((i32, i32, i32), SectionMesh)>),
}
/// What decoration wrote into the final chunks of its region.
#[derive(Default)]
pub struct FinalWrites {
blocks: Vec<([i32; 3], u32)>,
block_entities: Vec<((i32, i32, i32), crate::chunk::block_entity::BlockEntity)>,
}
pub enum Done {
Generated(Chunk),
/// A proto chunk, and whether it was decorated (loaded so from the save).
Proto(i32, i32, Proto, bool),
Decorated(i32, i32, Box<[Proto; 9]>),
/// A decorated region, the region indices of its final chunks and what
/// decoration wrote into them.
Decorated(i32, i32, Box<[Proto; 9]>, Vec<usize>, FinalWrites),
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
Meshed(i32, i32, i32, SectionMesh, u64),
Saved(Vec<((i32, i32), Proto)>),
@@ -311,6 +349,9 @@ impl Mailbox {
}
pub struct Streaming {
shared: Arc<Shared>,
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
@@ -330,6 +371,10 @@ pub struct Streaming {
light_views: FxHashMap<(i32, i32), Arc<Chunk>>,
decorated: FxHashSet<(i32, i32)>,
decorating: usize,
/// The final chunks a running decoration writes into: another one that
/// reaches them waits, as vanilla's features run one after the other and
/// each sees what the one before placed.
decorating_finals: HashSet<(i32, i32)>,
/// Save jobs in flight.
saving: usize,
/// Vanilla's decoration order around the player, and the centre and
@@ -341,6 +386,8 @@ pub struct Streaming {
queue_basis: (((i32, i32), bool, Option<Band>), Vec<((i32, i32), i32)>),
finishing: FxHashSet<(i32, i32)>,
decorates: bool,
/// `ChunkGenerator::decorates_beside_final`.
saves_beside_final: bool,
lighting: FxHashMap<(i32, i32), u64>,
meshing: FxHashMap<(i32, i32, i32), u64>,
/// The sequence each section's latest accepted mesh was dispatched at
@@ -652,11 +699,34 @@ impl Streaming {
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region) => {
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) =
generator.proto_beside_final(&mut worker, snapshot)
{
region[*i] = proto;
}
}
generator.decorate(&mut worker, &mut region);
Done::Decorated(x, z, region)
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes.block_entities.extend(
generator.final_block_entities(&region[*i], &blocks),
);
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Finish(proto) => Done::Generated(generator.finish(proto)),
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
@@ -734,13 +804,16 @@ impl Streaming {
protos: Default::default(),
light_views: Default::default(),
decorated: Default::default(),
dropped_new: Default::default(),
decorating: 0,
decorating_finals: HashSet::new(),
saving: 0,
decoration_order: DecorationOrder::default(),
order_area: None,
queue_basis: Default::default(),
finishing: Default::default(),
decorates: generator.decorates(),
saves_beside_final: generator.decorates_beside_final(),
lighting: Default::default(),
meshing: Default::default(),
compile_started: Default::default(),
@@ -891,16 +964,43 @@ impl Streaming {
let mut queue = self.shared.queue.lock().unwrap();
rank_again(&mut queue, |x, z| self.distance(x, z));
}
fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) {
fn receive_decorated(
&mut self,
x: i32,
z: i32,
region: [Proto; 9],
finals: Vec<usize>,
writes: FinalWrites,
world: &mut World,
) {
self.generation_scan.exhausted = false;
self.decorating -= 1;
for i in &finals {
self.decorating_finals
.remove(&(x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1));
}
self.decorated.insert((x, z));
for (i, proto) in region.into_iter().enumerate() {
// A final chunk keeps its own chunk: only what decoration wrote
// into it goes there.
if finals.contains(&i) {
continue;
}
let key = (x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1);
self.light_views.remove(&key);
self.protos.insert(key, Some(proto));
self.written.insert(key);
}
for ([bx, by, bz], state) in writes.blocks {
let _ = world.set(bx, by, bz, horizon_world::registry::BlockStateId(state));
}
// After the blocks: the write of a block with a block entity made a
// default one, which the generated one replaces.
for (pos, entity) in writes.block_entities {
if world.chunks.contains_key(&(pos.0 >> 4, pos.2 >> 4)) {
world.block_entities.insert(pos, entity);
}
}
}
/// Saved proto chunks come back; those now out of range are dropped by
/// the next `update`.
@@ -943,6 +1043,7 @@ impl Streaming {
// not give it back.
if !self.ranges().chunk_kept(key.0, key.1, self.decorates) {
if chunk.fresh {
self.dropped_new.insert(key);
self.saves.save_finished(chunk);
}
return;
@@ -966,6 +1067,8 @@ impl Streaming {
// A chunk saved with its light needs no light job; its unlit
// neighbours still light themselves from it.
let loaded = chunk.light_loaded;
let new = self.dropped_new.remove(&key) || chunk.generated;
world.loaded_chunks.push((key.0, key.1, new));
world.chunks.insert(key, Arc::new(chunk));
if loaded {
self.lit.insert(key);
@@ -1156,7 +1259,9 @@ impl Streaming {
}
}
}
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Decorated(x, z, region, finals, writes) => {
self.receive_decorated(x, z, *region, finals, writes, world)
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::Lit(x, z, sections, seq) => {
@@ -1394,22 +1499,36 @@ impl Streaming {
}
let keys: Vec<(i32, i32)> =
(0..9).map(|i| (x + (i % 3) - 1, z + (i / 3) - 1)).collect();
// A final neighbour cannot receive writes: skip decoration. Only
// worlds saved before proto chunks were saved get here (their
// A final neighbour cannot be generated again: a generator that
// can decorates beside it (a chunk of an older world), writing
// into it as vanilla's features do; the others skip decoration
// (worlds saved before proto chunks were saved get here: their
// final chunks' neighbours are generated again).
if keys.iter().any(|k| world.chunks.contains_key(k)) {
if keys.iter().any(|k| self.decorating_finals.contains(k)) {
continue;
}
let finals: Vec<(usize, Chunk)> = keys
.iter()
.enumerate()
.filter_map(|(i, k)| Some((i, (**world.chunks.get(k)?).clone())))
.collect();
if !finals.is_empty() && !self.saves_beside_final {
self.decorated.insert((x, z));
continue;
}
if !keys
.iter()
.all(|k| matches!(self.protos.get(k), Some(Some(_))))
{
if !keys.iter().enumerate().all(|(i, k)| {
finals.iter().any(|(f, _)| *f == i)
|| matches!(self.protos.get(k), Some(Some(_)))
}) {
continue;
}
let region: Vec<Proto> = keys
.iter()
.map(|k| {
.enumerate()
.map(|(i, k)| {
if finals.iter().any(|(f, _)| *f == i) {
return Box::new(()) as Proto;
}
self.light_views.remove(k);
self.protos
.insert(*k, None)
@@ -1422,7 +1541,9 @@ impl Streaming {
Err(_) => continue,
};
self.decorating += 1;
self.submit(Some((x, z)), 1, Job::Decorate(x, z, region));
self.decorating_finals
.extend(finals.iter().map(|(i, _)| keys[*i]));
self.submit(Some((x, z)), 1, Job::Decorate(x, z, region, finals));
}
}
self.schedule_generation(world, workers);
@@ -1704,7 +1825,9 @@ impl Streaming {
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.shared.done.recv_timeout(left) {
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Some(Done::Decorated(x, z, region, finals, writes)) => {
self.receive_decorated(x, z, *region, finals, writes, world)
}
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Some(Done::Saved(protos)) => self.receive_saved(protos),
Some(other) => self.deferred.push(other),
@@ -1732,7 +1855,9 @@ impl Streaming {
let started = std::time::Instant::now();
while let Some(done) = self.shared.done.try_recv() {
match done {
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Decorated(x, z, region, finals, writes) => {
self.receive_decorated(x, z, *region, finals, writes, world)
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
other => self.deferred.push(other),
+44 -1
View File
@@ -200,6 +200,31 @@ pub fn sorted_players<'a>(players: &'a [TabPlayer], board: &Scoreboard) -> Vec<&
out
}
/// What `setVisible(true)` has the narrator say:
/// `multiplayer.player.list.narration` with the listed players' names
/// (`ComponentUtils.formatList` with ", ").
pub fn narration(players: &[TabPlayer], board: &Scoreboard) -> Text {
Text::translate(
"multiplayer.player.list.narration",
&[listed_names(players, board)],
)
}
/// The names the list shows, in its order, ", " between them.
fn listed_names(players: &[TabPlayer], board: &Scoreboard) -> Text {
sorted_players(players, board)
.into_iter()
.enumerate()
.fold(Text::empty(), |names, (i, p)| {
let names = if i == 0 {
names
} else {
names.append(Text::literal(", "))
};
names.append(name_for_display(p, board))
})
}
/// `MutableComponent.withStyle(ITALIC)` on a component's root.
fn italic_root(c: &Comp) -> Comp {
use serde_json::Value;
@@ -321,11 +346,15 @@ struct Entry {
}
impl TabList {
/// `setVisible`: showing or hiding forgets the heart states.
pub fn set_visible(&mut self, visible: bool) {
/// `setVisible`: true when the list has just come up, which vanilla
/// narrates ([`narration`]).
pub fn set_visible(&mut self, visible: bool) -> bool {
if self.visible != visible {
self.health.clear();
self.visible = visible;
return visible;
}
false
}
/// `reset`.
pub fn reset(&mut self) {
@@ -620,6 +649,20 @@ impl TabList {
mod tests {
use super::*;
#[test]
fn the_list_is_narrated_once_as_it_comes_up_with_its_names_in_order() {
let board = Scoreboard::default();
let mut alice = TabPlayer::new(2, "Alice");
alice.list_order = -1;
let players = [TabPlayer::new(1, "bob"), alice];
assert_eq!(listed_names(&players, &board).plain(), "bob, Alice");
let mut list = TabList::default();
assert!(list.set_visible(true));
assert!(!list.set_visible(true), "only as it comes up");
assert!(!list.set_visible(false));
assert!(list.set_visible(true));
}
#[test]
fn order_ping_and_skins() {
let board = Scoreboard::default();
+56 -6
View File
@@ -24,6 +24,10 @@ 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>,
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
@@ -44,6 +48,10 @@ struct Globals {
@group(0) @binding(9) var sprites_5: texture_2d_array<f32>;
@group(0) @binding(10) var sprites_6: texture_2d_array<f32>;
@group(0) @binding(11) var sprites_7: texture_2d_array<f32>;
// Per block sprite (x the layer in its array, y the array), the brightness its
// pixels must reach to shine in HDR output: r where the bright parts shine, g
// where all of it does (gfx/hdr_stats.rs, measured from the sprite itself).
@group(0) @binding(12) var emission_thresholds: texture_2d<f32>;
// How many sprite arrays hold layers (a pipeline constant): 1 for vanilla,
// which then samples exactly as before.
override SPRITE_ARRAYS: u32 = 1u;
@@ -144,7 +152,8 @@ struct TerrainOut {
@location(2) color: vec4<f32>,
@location(3) spherical: f32,
@location(4) cylindrical: f32,
// The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`).
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
@location(5) @interpolate(flat) emission: u32,
@location(6) @interpolate(flat) fade: u32,
// A distant tile's tile-relative position, for its fade (`faded`).
@@ -153,6 +162,7 @@ struct TerrainOut {
@location(8) visibility: f32,
// The camera-relative height, for the water mirror's cut at the plane.
@location(9) height: f32,
@location(10) @interpolate(flat) emissive_layer: u32,
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
@@ -223,6 +233,15 @@ 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 sample_lightmap(sky: f32, block: f32) -> vec4<f32> {
let uv = vec2(block, sky) * 16.0;
return textureSampleLevel(lightmap, lightmap_sampler,
@@ -284,8 +303,18 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.local = position;
out.uv = uv;
out.layer = layer;
out.emissive_layer = 0xffffffffu;
for (var i = 0u; i < min(globals.emissive_count.x, 32u); i++) {
if globals.emissive_layers[i].x == (layer & 0x7fffu) {
out.emissive_layer = globals.emissive_layers[i].y;
break;
}
}
let block = max(f32(light.y), dynamic_block_light(pos));
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
if globals.colored_count.x > 0u {
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
}
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
out.height = pos.y;
out.spherical = length(pos);
@@ -306,6 +335,12 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
linear_fog(cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// 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)));
}
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
return solid_color(in);
}
@@ -320,14 +355,29 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
let dy = dpdy(in.local);
if faded(in.fade, in.local, dx, dy) { discard; }
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
let texel = sprite(in.uv, in.layer & 0x7fffu);
var color = texel * in.color;
// terrain.fsh: the fade-in mixes toward the fog colour.
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel may
// shine, and at which tier (mesher::hdr_tier): 0.525 at the lowest, 0 at the
// brightest. Only pixels as bright as their sprite's threshold may: its
// bright parts, or all of it. Nothing else reads the frame's alpha.
var emissive = 1.0;
if in.emission > 0u {
let whole = in.emission > 4u;
let tier = select(in.emission, in.emission - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
if brightness(texel.rgb) >= select(t.r, t.g, whole) {
emissive = 0.7 * f32(4u - min(tier, 4u)) / 4.0;
}
}
if in.emissive_layer != 0xffffffffu {
let mask = sprite(in.uv, in.emissive_layer);
return apply_fog(vec4(mix(color.rgb, mask.rgb, mask.a), mix(emissive, 0.1, mask.a)), in.spherical, in.cylindrical);
}
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
// Water, glass and other see-through faces draw whole as they fade in, the
+74 -6
View File
@@ -353,8 +353,29 @@ pub fn button(
label: &Text,
active: bool,
hover: bool,
) {
button_as(batch, atlas, x, y, w, h, label, active, hover, |rect| {
crate::widgets::Narratable::button(label.plain(), rect, active)
});
}
/// A [`button`] that vanilla narrates as another widget (a `CycleButton`, a
/// `LockIconButton`): `narration` builds what it records from its rectangle,
/// and only while the narrator records.
#[allow(clippy::too_many_arguments)]
pub fn button_as(
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
w: f32,
h: f32,
label: &Text,
active: bool,
hover: bool,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
crate::widgets::record(&label.plain(), [x, y, w, h], active);
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
crate::widgets::widget_cursor(hover, active);
let sprite = if !active {
"gui/sprites/widget/button_disabled"
@@ -391,6 +412,27 @@ pub fn slider(
hover: bool,
active: bool,
dragging: bool,
) {
slider_ext(
batch, atlas, x, y, w, label, value, hover, false, false, active, dragging,
);
}
/// `AbstractSliderButton.extractWidgetRenderState`: track, 8px handle at `value` (0..1),
/// with support for keyboard focus and `canChangeValue`.
pub fn slider_ext(
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
w: f32,
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
let kind = crate::widgets::Kind::Slider {
value,
@@ -398,11 +440,14 @@ pub fn slider(
};
// The control socket's widget tree (`ui`): its label and value.
crate::widgets::record_as(&label.plain(), [x, y, w, 20.0], active, kind);
crate::widgets::narratable(|| {
crate::widgets::Narratable::slider(label.plain(), [x, y, w, 20.0], active).hovered(hover)
});
if active {
// A controller's D-pad can focus it (Horizon's controller support).
crate::widgets::target_as([x, y, w, 20.0], kind);
}
let (track, handle) = slider_sprites(hover, active, dragging);
let (track, handle) = slider_sprites_ext(hover, active, focused, can_change_value, dragging);
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
nine_slice(
batch,
@@ -421,15 +466,26 @@ pub fn slider(
};
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
}
/// Track and handle sprites: the track outline shows only while the slider is
/// dragged, hovering highlights just the handle.
#[cfg(test)]
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
let track = if dragging {
slider_sprites_ext(hover, active, false, false, dragging)
}
/// Track and handle sprites: the track outline shows while dragged or focused with
/// keyboard navigation (!can_change_value), hovering or editing highlights the handle.
fn slider_sprites_ext(
hover: bool,
active: bool,
focused: bool,
can_change_value: bool,
dragging: bool,
) -> (&'static str, &'static str) {
let track = if dragging || (active && focused && !can_change_value) {
"gui/sprites/widget/slider_highlighted"
} else {
"gui/sprites/widget/slider"
};
let handle = if hover && active {
let handle = if active && (hover || can_change_value || dragging) {
"gui/sprites/widget/slider_handle_highlighted"
} else {
"gui/sprites/widget/slider_handle"
@@ -648,6 +704,7 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
if lines.is_empty() {
return;
}
crate::widgets::frame_tooltip(lines);
let w = lines
.iter()
.map(|l| atlas.font.width_text(l))
@@ -665,7 +722,6 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
if y + h + 3.0 > batch.height {
y = batch.height - h - 3.0;
}
y = y.max(4.0);
nine_slice(
batch,
atlas,
@@ -714,6 +770,9 @@ pub struct EditBox {
/// Grey completion shown after the text (EditBox.setSuggestion).
pub suggestion: Option<String>,
pub text_color: u32,
/// `getMessage`: what the narrator calls the box ("<message> edit box:
/// <value>").
pub message: String,
}
/// The focused text field of a screen that keeps its values as `String`s
/// (the world list's search, world names, seeds, and the server screens): the `EditBox` state that
@@ -975,6 +1034,7 @@ impl EditBox {
focused_at: Instant::now(),
suggestion: None,
text_color: rgba(224, 224, 224, 255),
message: String::new(),
}
}
pub fn inner_width(&self) -> f32 {
@@ -1197,6 +1257,14 @@ impl EditBox {
[self.x, self.y, self.width, self.height],
crate::widgets::Kind::Field,
);
crate::widgets::narratable(|| {
crate::widgets::Narratable::edit_box(
self.message.clone(),
self.value.clone(),
[self.x, self.y, self.width, self.height],
)
.focused(self.focused)
});
if self.bordered {
let sprite = if self.focused {
"gui/sprites/widget/text_field_highlighted"
+714
View File
@@ -206,6 +206,9 @@ pub fn end_frame(frames: u64) {
*last = std::mem::take(current);
*generation += 1;
});
NARRATION.with_borrow_mut(|(current, last)| {
*last = std::mem::replace(current, Narration::EMPTY);
});
}
});
}
@@ -225,6 +228,717 @@ pub fn last_labelled() -> Vec<Widget> {
WIDGETS.with_borrow(|(_, last, _)| last.clone())
}
// Screen narration: what a frame's screen and widgets would narrate
// (`Screen.updateNarrationState` and each `NarratableEntry`), recorded by the
// drawers as they draw, for horizon-client's `screen_narrator` to read once the
// frame is done. Nothing is recorded (nor built: the record calls take
// closures) unless the narrator asked for it with `set_narrating`.
pub use horizon_narrator::screen::ElementType;
/// The screen a frame drew, as `Screen` narrates it.
#[derive(Clone, Debug, PartialEq)]
pub struct ScreenInfo {
/// Which screen this is: a new id is a new `Screen` (narrated as it
/// opens), the same id a screen still open.
pub id: String,
/// `getNarrationMessage` (TITLE at depth 0): the title, or its
/// override (`joinForNarration(title, message)` for confirm screens).
/// Empty for `CommonComponents.EMPTY`, which is narrated all the same.
pub title: String,
/// `shouldNarrateNavigation`: "Use mouse cursor or Tab button to select
/// element" when no widget is narrated.
pub navigation: bool,
/// `updateNarratedWidget` runs (screens that override
/// `updateNarrationState`, the chat, don't).
pub widgets: bool,
/// The screen's own elements beyond its title, at their depth (an
/// element of the title's kind and depth replaces it, as
/// `LevelLoadingScreen`'s progress does).
pub extra: Vec<(ElementType, u32, Vec<String>)>,
/// `getUsageNarration`, when the screen overrides it.
pub usage: Option<String>,
/// `triggerImmediateNarration(true)` every this many milliseconds
/// (`LevelLoadingScreen`'s progress).
pub repeat_ms: Option<u32>,
}
impl ScreenInfo {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
navigation: true,
widgets: true,
extra: Vec::new(),
usage: None,
repeat_ms: None,
}
}
/// One more element of the screen's own.
pub fn element(self, kind: ElementType, depth: u32, text: impl Into<String>) -> Self {
self.lines(kind, depth, vec![text.into()])
}
/// One more element of several lines, read as sentences.
pub fn lines(mut self, kind: ElementType, depth: u32, lines: Vec<String>) -> Self {
self.extra.push((kind, depth, lines));
self
}
pub fn without_widgets(mut self) -> Self {
self.widgets = false;
self
}
pub fn without_navigation(mut self) -> Self {
self.navigation = false;
self
}
pub fn usage(mut self, usage: impl Into<String>) -> Self {
self.usage = Some(usage.into());
self
}
pub fn repeat_every(mut self, ms: u32) -> Self {
self.repeat_ms = Some(ms);
self
}
}
/// What a list narrates its rows as.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ListKind {
/// `ObjectSelectionList`: "Selected list row i out of n", the row's
/// narration, and the selection's usage when focused.
Object,
/// `TabNavigationBar`: "Selected tab i out of n", "<title> tab", and
/// the tab navigation's usage when focused.
Tabs,
}
/// A row of an `ObjectSelectionList` (its `getNarration`) or a tab (its
/// title and `getTabExtraNarration`).
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
pub index: usize,
pub narration: String,
pub hint: Option<String>,
}
impl Row {
pub fn new(index: usize, narration: impl Into<String>) -> Self {
Self {
index,
narration: narration.into(),
hint: None,
}
}
}
/// A narratable widget's kind and what its narration reads.
#[derive(Clone, Debug, PartialEq)]
pub enum What {
/// `Button`: "<message> button".
Button { message: String, active: bool },
/// A button whose `createNarrationMessage` is its own (TITLE as is), with
/// a button's usage.
Named { title: String, active: bool },
/// `AbstractSliderButton`: "<message> slider".
Slider { message: String, active: bool },
/// `CycleButton`: "<message> button" and the value a click moves to.
Cycle {
message: String,
next: String,
active: bool,
},
/// A `CycleButton` with its own narration (`withCustomNarration`):
/// `title` as is, and the value a click moves to.
NamedCycle {
title: String,
next: String,
active: bool,
},
/// `Checkbox`: "<message> button" and whether a click checks it.
Checkbox {
message: String,
selected: bool,
active: bool,
},
/// `EditBox`: "<message> edit box: <value>"; an inactive one (a box
/// that can't be edited now) isn't narrated.
EditBox {
message: String,
value: String,
active: bool,
},
/// A text widget that narrates its message and nothing else
/// (`FittingMultiLineTextWidget`, a list's `FocusableTextWidget` header).
Text { message: String },
/// An `ObjectSelectionList` or a `TabNavigationBar`: `total` rows, the one
/// under the mouse and the selected one.
List {
kind: ListKind,
total: usize,
hovered: Option<Row>,
selected: Option<Row>,
},
/// A `ContainerObjectSelectionList` of `rows` rows (options, key binds):
/// its rows' widgets are the narratables recorded with it as their
/// parent. `hovered_row` is the row under the mouse.
ContainerList {
rows: usize,
hovered_row: Option<usize>,
},
/// `RecipeButton`: "Recipe for <item>".
Recipe { item: String, more: bool },
/// `RecipeBookComponent`: one narratable, HOVERED whenever `visible`
/// (whatever the mouse and the focus), that narrates the widget
/// `findNarratableWidget` picks among its own one level in, with no
/// position of its own. Its widgets are the narratables recorded with it
/// as their parent (row 0).
Group { visible: bool },
}
/// A `NarratableEntry` as drawn this frame.
#[derive(Clone, Debug, PartialEq)]
pub struct Narratable {
/// The widget's identity across frames (`lastNarratable`); 0 derives
/// one from its kind and place. A list's children need stable keys.
pub key: u64,
/// GUI pixels.
pub rect: [f32; 4],
/// `isHovered`, as the drawer saw it; `None` tests the mouse against
/// `rect`.
pub hovered: Option<bool>,
/// `isFocused` (a text field with the caret, a focused list).
pub focused: bool,
pub what: What,
/// Its tooltip's narration (HINT); empty for none. Without one, the
/// tooltip drawn this frame is the hovered widget's.
pub hint: Option<String>,
/// A row widget of a `ContainerList`: that list's key and the row.
pub parent: Option<(u64, usize)>,
/// `getTabOrderGroup`: narratables are numbered in this order, then in
/// the order they were drawn.
pub order: i32,
}
impl Narratable {
pub fn new(what: What, rect: [f32; 4]) -> Self {
Self {
key: 0,
rect,
hovered: None,
focused: false,
what,
hint: None,
parent: None,
order: 0,
}
}
pub fn button(message: impl Into<String>, rect: [f32; 4], active: bool) -> Self {
Self::new(
What::Button {
message: message.into(),
active,
},
rect,
)
}
pub fn slider(message: impl Into<String>, rect: [f32; 4], active: bool) -> Self {
Self::new(
What::Slider {
message: message.into(),
active,
},
rect,
)
}
pub fn edit_box(message: impl Into<String>, value: impl Into<String>, rect: [f32; 4]) -> Self {
Self::new(
What::EditBox {
message: message.into(),
value: value.into(),
active: true,
},
rect,
)
}
/// A button narrated by its own `createNarrationMessage`.
pub fn named(title: impl Into<String>, rect: [f32; 4], active: bool) -> Self {
Self::new(
What::Named {
title: title.into(),
active,
},
rect,
)
}
/// A `CycleButton` showing `message`; a click moves it to `next`.
pub fn cycle(
message: impl Into<String>,
next: impl Into<String>,
rect: [f32; 4],
active: bool,
) -> Self {
Self::new(
What::Cycle {
message: message.into(),
next: next.into(),
active,
},
rect,
)
}
pub fn checkbox(
message: impl Into<String>,
selected: bool,
rect: [f32; 4],
active: bool,
) -> Self {
Self::new(
What::Checkbox {
message: message.into(),
selected,
active,
},
rect,
)
}
pub fn text(message: impl Into<String>, rect: [f32; 4]) -> Self {
Self::new(
What::Text {
message: message.into(),
},
rect,
)
}
/// An `ObjectSelectionList` (or tabs) of `total` rows over `rect`.
pub fn list(
kind: ListKind,
total: usize,
hovered: Option<Row>,
selected: Option<Row>,
rect: [f32; 4],
) -> Self {
Self::new(
What::List {
kind,
total,
hovered,
selected,
},
rect,
)
}
/// A `ContainerObjectSelectionList` of `rows` rows over `rect`.
pub fn container_list(rows: usize, hovered_row: Option<usize>, rect: [f32; 4]) -> Self {
Self::new(What::ContainerList { rows, hovered_row }, rect)
}
/// A `RecipeBookComponent`-like group over `rect`, narrated while
/// `visible`; its widgets are recorded with [`Self::in_group`].
pub fn group(visible: bool, rect: [f32; 4]) -> Self {
Self::new(What::Group { visible }, rect)
}
/// `RecipeButton`: the recipe's result `item`, and whether it has more
/// recipes than one.
pub fn recipe(item: impl Into<String>, more: bool, rect: [f32; 4]) -> Self {
Self::new(
What::Recipe {
item: item.into(),
more,
},
rect,
)
}
pub fn key(mut self, key: u64) -> Self {
self.key = key;
self
}
pub fn hovered(mut self, hovered: bool) -> Self {
self.hovered = Some(hovered);
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// No HINT, whatever tooltip the frame draws: the tooltip isn't the
/// widget's (a list's row tooltip, drawn by the list).
pub fn without_tooltip(self) -> Self {
self.hint("")
}
/// A widget of row `row` of the `ContainerList` keyed `list`.
pub fn child_of(mut self, list: u64, row: usize) -> Self {
self.parent = Some((list, row));
self
}
/// A widget of the [`What::Group`] keyed `group`.
pub fn in_group(self, group: u64) -> Self {
self.child_of(group, 0)
}
pub fn order(mut self, order: i32) -> Self {
self.order = order;
self
}
/// `isActive`: visible and active. Inactive buttons and sliders are left
/// out of a screen's narratables.
pub fn is_active(&self) -> bool {
match &self.what {
What::Button { active, .. }
| What::Named { active, .. }
| What::Slider { active, .. }
| What::Cycle { active, .. }
| What::NamedCycle { active, .. }
| What::Checkbox { active, .. }
| What::EditBox { active, .. } => *active,
What::Text { .. }
| What::List { .. }
| What::ContainerList { .. }
| What::Recipe { .. }
| What::Group { .. } => true,
}
}
/// `key`, or one from the widget's kind and place (never 0).
pub fn identity(&self) -> u64 {
if self.key != 0 {
return self.key;
}
let place = self.rect.map(|v| v.round() as i32);
key_of(&(std::mem::discriminant(&self.what), place))
}
}
/// A stable key for a widget from anything that names it (a list's name, an
/// option's id), never 0.
pub fn key_of(name: &impl std::hash::Hash) -> u64 {
use std::hash::Hasher;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
name.hash(&mut hasher);
hasher.finish() | 1
}
/// `CommonComponents.joinForNarration`: the parts, ". " between them.
pub fn join_for_narration(parts: &[&str]) -> String {
parts.join(". ")
}
/// A frame's narration records.
#[derive(Debug, Default)]
pub struct Narration {
pub screen: Option<ScreenInfo>,
pub items: Vec<Narratable>,
/// The tooltip drawn this frame, its lines joined.
pub tooltip: Option<String>,
}
impl Narration {
const EMPTY: Narration = Narration {
screen: None,
items: Vec::new(),
tooltip: None,
};
}
thread_local! {
/// The narrator wants the frames' narration (`Screen.shouldRunNarration`).
static NARRATING: Cell<bool> = const { Cell::new(false) };
/// Narration recorded since the last `end_frame`, and the last frame's.
static NARRATION: RefCell<(Narration, Narration)> =
const { RefCell::new((Narration::EMPTY, Narration::EMPTY)) };
/// A screen asked for `triggerImmediateNarration(true)`.
static NARRATE_NOW: Cell<bool> = const { Cell::new(false) };
/// Where the narrator's focused widget was last drawn.
static FOCUS: Cell<Option<[f32; 4]>> = const { Cell::new(None) };
}
/// The narrator tells the drawers where its focused widget is (a click's
/// or the controller's), so what only a hovered or focused widget narrates
/// (a cycle button's next value) is worked out for it alone.
pub fn set_narration_focus(rect: Option<[f32; 4]>) {
FOCUS.set(rect);
}
/// Whether the widget at `rect` has the narrator's focus.
pub fn narration_focused(rect: [f32; 4]) -> bool {
FOCUS
.get()
.is_some_and(|f| f.iter().zip(rect).all(|(a, b)| (a - b).abs() < 0.5))
}
/// Whether the frames record their narration; the narrator switches it.
pub fn set_narrating(on: bool) {
NARRATING.set(on);
if !on {
NARRATION.with_borrow_mut(|(current, _)| *current = Narration::EMPTY);
}
}
pub fn narrating() -> bool {
NARRATING.get()
}
/// The screen being drawn: what it narrates and which one it is. The
/// narratables recorded before it in the frame are dropped: the last screen
/// drawn (an overlay over the screen under it) is the one narrated.
pub fn begin_screen(info: impl FnOnce() -> ScreenInfo) {
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
current.screen = Some(info());
current.items.clear();
current.tooltip = None;
});
}
}
/// Changes the screen begun this frame, if one was.
pub fn screen_mut(f: impl FnOnce(&mut ScreenInfo)) {
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
if let Some(screen) = &mut current.screen {
f(screen);
}
});
}
}
/// A narratable widget of the screen begun this frame (nothing without one).
pub fn narratable(narratable: impl FnOnce() -> Narratable) {
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
if current.screen.is_some() && current.items.len() < 512 {
current.items.push(narratable());
}
});
}
}
/// Changes the narratable recorded last this frame (one a shared drawer
/// recorded: an edit box's message, a button's key or row).
pub fn last_narratable(f: impl FnOnce(&mut Narratable)) {
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
if let Some(last) = current.items.last_mut() {
f(last);
}
});
}
}
/// Changes the narratable recorded last this frame and moves it to `index`
/// among the screen's: a widget the screen adds before others it draws first.
pub fn last_narratable_at(index: usize, f: impl FnOnce(&mut Narratable)) {
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
if let Some(mut last) = current.items.pop() {
f(&mut last);
let index = index.min(current.items.len());
current.items.insert(index, last);
}
});
}
}
/// How many narratables the frame has so far, for [`move_narratables`].
pub fn narratables_mark() -> usize {
if !NARRATING.get() {
return 0;
}
NARRATION.with_borrow(|(current, _)| current.items.len())
}
/// Moves the narratables recorded since `mark` to `index`, in their order:
/// widgets a screen adds before others it draws first.
pub fn move_narratables(mark: usize, index: usize) {
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
let items = &mut current.items;
let mark = mark.min(items.len());
let index = index.min(mark);
let moved = items.len() - mark;
items[index..].rotate_right(moved);
});
}
}
/// Names the edit box recorded last, which a shared drawer recorded without
/// its `getMessage`.
pub fn name_last_edit_box(name: impl FnOnce() -> String) {
last_narratable(|n| {
if let What::EditBox { message, .. } = &mut n.what {
*message = name();
}
});
}
/// The tooltip drawn this frame: the hovered widget's narration hint.
pub fn frame_tooltip(lines: &[crate::text::Text]) {
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
let text: Vec<String> = lines.iter().map(|l| l.plain()).collect();
current.tooltip = Some(text.join(" "));
});
}
}
/// `triggerImmediateNarration(true)`: narrate what changed at once.
pub fn narrate_now() {
if NARRATING.get() {
NARRATE_NOW.set(true);
}
}
/// Whether a screen asked to narrate at once since the last call.
pub fn take_narrate_now() -> bool {
NARRATE_NOW.replace(false)
}
/// The last drawn frame's narration, taken (the narrator is its only reader).
pub fn take_last_narration() -> Narration {
NARRATION.with_borrow_mut(|(_, last)| std::mem::replace(last, Narration::EMPTY))
}
#[cfg(test)]
mod narration_tests {
use super::*;
fn frame(n: u64) -> Narration {
end_frame(n);
take_last_narration()
}
#[test]
fn nothing_is_recorded_or_built_unless_narrating() {
set_narrating(false);
let _ = frame(1000);
begin_screen(|| unreachable!("not built while the narrator is off"));
narratable(|| unreachable!());
narrate_now();
let last = frame(1001);
assert!(last.screen.is_none() && last.items.is_empty());
assert!(!take_narrate_now());
}
#[test]
fn a_frame_keeps_the_last_screen_begun_and_its_widgets() {
set_narrating(true);
let _ = frame(2000);
// Without a screen, widgets aren't kept.
narratable(|| Narratable::button("Stray", [0.0; 4], true));
begin_screen(|| ScreenInfo::new("pause", "Game Menu"));
narratable(|| Narratable::button("Back to Game", [0.0, 0.0, 200.0, 20.0], true));
frame_tooltip(&[crate::text::Text::literal("Under")]);
// An overlay over the pause menu: its own screen and widgets.
begin_screen(|| ScreenInfo::new("confirm", "Sure?"));
narratable(|| Narratable::button("Yes", [0.0, 30.0, 100.0, 20.0], true));
screen_mut(|s| s.title.push('!'));
frame_tooltip(&[
crate::text::Text::literal("Two"),
crate::text::Text::literal("lines"),
]);
let last = frame(2001);
assert_eq!(last.screen.unwrap().title, "Sure?!");
assert_eq!(last.items.len(), 1);
assert_eq!(
last.items[0].what,
What::Button {
message: "Yes".into(),
active: true
}
);
assert_eq!(last.tooltip.as_deref(), Some("Two lines"));
// Taken: read once.
assert!(take_last_narration().screen.is_none());
// A frame not drawn doesn't rotate.
begin_screen(|| ScreenInfo::new("title", ""));
assert!(frame(2001).screen.is_none());
assert_eq!(frame(2002).screen.unwrap().id, "title");
// Switching off drops what was recorded.
begin_screen(|| ScreenInfo::new("title", ""));
set_narrating(false);
assert!(frame(2003).screen.is_none());
}
#[test]
fn the_last_widget_recorded_can_be_named_after_the_fact() {
set_narrating(true);
let _ = frame(3000);
begin_screen(|| ScreenInfo::new("select_world", "Select World"));
// A shared drawer records the box without its message.
narratable(|| Narratable::edit_box("", "abc", [0.0; 4]));
last_narratable(|n| {
if let What::EditBox { message, .. } = &mut n.what {
*message = "Search for worlds".into();
}
n.order = -1;
});
let last = frame(3001);
assert_eq!(
last.items[0].what,
What::EditBox {
message: "Search for worlds".into(),
value: "abc".into(),
active: true,
}
);
assert_eq!(last.items[0].order, -1);
set_narrating(false);
last_narratable(|_| unreachable!("nothing recorded while off"));
}
#[test]
fn widgets_drawn_late_can_be_moved_to_where_they_were_added() {
set_narrating(true);
let _ = frame(4000);
begin_screen(|| ScreenInfo::new("command_block", ""));
narratable(|| Narratable::button("Done", [0.0; 4], true));
narratable(|| Narratable::button("Cancel", [0.0; 4], true));
let mark = narratables_mark();
narratable(|| Narratable::edit_box("Command", "", [0.0; 4]));
narratable(|| Narratable::button("Late", [0.0; 4], true));
move_narratables(mark, 0);
let order: Vec<String> = frame(4001)
.items
.iter()
.map(|n| match &n.what {
What::Button { message, .. } | What::EditBox { message, .. } => message.clone(),
_ => String::new(),
})
.collect();
assert_eq!(order, ["Command", "Late", "Done", "Cancel"]);
set_narrating(false);
assert_eq!(narratables_mark(), 0);
}
#[test]
fn widgets_know_who_they_are_and_whether_they_are_narrated() {
let a = Narratable::button("A", [10.0, 20.0, 200.0, 20.0], true);
let moved = Narratable::button("A", [10.0, 44.0, 200.0, 20.0], true);
let slider = Narratable::slider("A", [10.0, 20.0, 200.0, 20.0], true);
assert_eq!(
a.identity(),
Narratable::button("B", a.rect, false).identity()
);
assert_ne!(a.identity(), moved.identity());
assert_ne!(a.identity(), slider.identity());
assert_eq!(a.clone().key(7).identity(), 7);
assert_ne!(key_of(&"options"), 0);
assert!(a.is_active());
assert!(!Narratable::button("B", a.rect, false).is_active());
assert!(Narratable::edit_box("Search", "", a.rect).is_active());
let mut port = Narratable::edit_box("Port", "", a.rect);
if let What::EditBox { active, .. } = &mut port.what {
*active = false;
}
assert!(!port.is_active());
assert!(Narratable::group(false, a.rect).is_active());
let recipe = Narratable::recipe("Torch", true, a.rect).in_group(9);
assert_eq!(recipe.parent, Some((9, 0)));
assert_eq!(join_for_narration(&["Title", "Message"]), "Title. Message");
}
}
impl Kind {
/// The kind's name in the control socket's `ui` reply.
pub fn name(self) -> &'static str {
@@ -70,6 +70,9 @@ pub struct World {
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks that just came into this level from a worker, and whether each
/// was generated rather than read from the save: the game tells native mods.
pub loaded_chunks: Vec<(i32, i32, bool)>,
/// Chunks changed since the last save.
pub modified: FxHashSet<(i32, i32)>,
/// Block entities, and the order they tick in.
@@ -192,6 +195,7 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: FxHashMap::default(),
+4
View File
@@ -24,11 +24,15 @@ horizon-protocol = { path = "../horizon-protocol" }
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
@@ -28,6 +28,7 @@
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.shader_options": "Shader Options",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
@@ -61,6 +62,9 @@
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Shader Options...",
"horizon.shader_options.profile": "Profile",
"horizon.shader_options.loading": "Reading the shader pack's options...",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
@@ -0,0 +1,80 @@
//! Makes a world that holds chunks "an older version generated": copies the
//! overworld chunks of a rectangle from one world into another, each with a
//! `blending_data` compound as the 1.18 data fixer leaves on a pre-1.18
//! chunk, and drops every other chunk of the destination, so that the chunks
//! generated around them blend with them (`Blender.of`).
//!
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
use horizon_core::position::ChunkPos;
use horizon_world::{
nbt::{Limits, Tag},
region::{ChunkRecord, RegionFile},
};
use std::{fs, path::Path};
type Error = Box<dyn std::error::Error + Send + Sync>;
fn main() -> Result<(), Error> {
let mut args = std::env::args().skip(1);
let source = args.next().ok_or("source world")?;
let destination = args.next().ok_or("destination world")?;
let rect: Vec<i32> = args
.next()
.ok_or("x0,z0,x1,z1")?
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()?;
let [x0, z0, x1, z1]: [i32; 4] = rect.try_into().map_err(|_| "x0,z0,x1,z1")?;
let overworld = |world: &str| {
Path::new(world)
.join("dimensions")
.join("minecraft")
.join("overworld")
.join("region")
};
let (source, destination) = (overworld(&source), overworld(&destination));
fs::create_dir_all(&destination)?;
for entry in fs::read_dir(&destination)?.flatten() {
if entry.path().extension().is_some_and(|e| e == "mca") {
fs::remove_file(entry.path())?;
}
}
let mut copied = 0;
for z in z0..=z1 {
for x in x0..=x1 {
let pos = ChunkPos::new(x, z);
let path = source.join(format!("r.{}.{}.mca", x >> 5, z >> 5));
let Ok(mut region) = RegionFile::open(&path) else {
continue;
};
let Some(record) = region.read(pos)? else {
continue;
};
let mut root = record.nbt(Limits::saved())?;
if !matches!(root.tag.get("Status"), Some(Tag::String(s)) if s.to_string_lossy().ends_with("full"))
{
continue;
}
if let Tag::Compound(chunk) = &mut root.tag {
chunk.insert(
"blending_data".into(),
Tag::compound([("min_section", Tag::Int(-4)), ("max_section", Tag::Int(20))]),
);
}
let out = destination.join(format!("r.{}.{}.mca", x >> 5, z >> 5));
let mut target = RegionFile::open_writable(&out, true)?;
target.write(
pos,
&ChunkRecord::from_nbt(
&root,
record.compression,
record.timestamp,
Limits::saved(),
)?,
)?;
copied += 1;
}
}
println!("{copied} chunks copied");
Ok(())
}
@@ -1194,6 +1194,7 @@ pub fn main() -> horizon_client::Result<()> {
sea_level: -63,
items: Some(&items),
lid_openers: &lid_openers,
mod_effects: None,
}
};
}
@@ -268,6 +268,9 @@ pub const COMMANDS: &[Cmd] = &[
"N defaults to 20; chat is newest first.", ["horizon-ctl messages 5"], &[opt("n", Ty::Int, "how many chat lines (default 20)")], mcp: true, local: false),
cmd!("menu", Observe, "menu", "the open menu's slots and carried stack, with station details", "",
["horizon-ctl menu"], &[], mcp: false, local: false),
cmd!("narration", Observe, "narration", "what the narrator was last asked to say, oldest first, and its status",
"recent holds the last 32 messages the narrator's status let through, said aloud or not; status is the Narrator option (off, all, chat or system) and active whether text-to-speech is there (with HORIZON_NO_AUDIO it is, silently).",
["horizon-ctl narration"], &[], mcp: true, local: false),
// Move
cmd!("move", Move, "move forward|back|left|right SECONDS [--sprint] [--sneak] [--jump]", "walk in a direction relative to the view for a time",
"Replies when done with the start and end positions.",
+115 -4
View File
@@ -13,6 +13,7 @@ pub struct Confirm {
pub lines: Vec<(Text, f32, f32)>,
/// The yes and no buttons' corners.
pub buttons: [(f32, f32); 2],
pub focus: Option<usize>,
}
impl Confirm {
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
@@ -20,6 +21,28 @@ impl Confirm {
Self {
lines,
buttons: [buttons[0], buttons[1]],
focus: None,
}
}
/// Tab cycles focus between yes (0) and no (1).
pub fn tab(&mut self, reverse: bool) {
self.focus = match self.focus {
Some(0) => Some(1),
Some(1) => Some(0),
None => Some(if reverse { 1 } else { 0 }),
_ => Some(0),
};
}
/// Left or right arrow key focuses yes or no.
pub fn arrow(&mut self, left: bool) {
self.focus = Some(if left { 0 } else { 1 });
}
/// The answer of the currently focused button.
pub fn answer(&self) -> Option<bool> {
match self.focus {
Some(0) => Some(true),
Some(1) => Some(false),
_ => None,
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
@@ -56,8 +79,9 @@ impl Confirm {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
for (i, ((bx, by), label)) in self.buttons.into_iter().zip(labels).enumerate() {
let is_focused = self.focus == Some(i);
let hover = active && (ui::hovered(cursor, bx, by, 150.0, 20.0) || is_focused);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
}
}
@@ -71,6 +95,7 @@ pub struct Choice {
/// The buttons' corners, left to right.
pub buttons: Vec<(f32, f32)>,
pub width: f32,
pub focus: Option<usize>,
}
impl Choice {
pub fn new(
@@ -111,8 +136,47 @@ impl Choice {
.map(|i| (bx + (width + 4.0) * i as f32, by))
.collect(),
width,
focus: None,
}
}
/// Tab cycles focus across the buttons.
pub fn tab(&mut self, reverse: bool) {
let count = self.buttons.len();
if count == 0 {
return;
}
self.focus = match self.focus {
Some(cur) => {
if reverse {
Some(if cur == 0 { count - 1 } else { cur - 1 })
} else {
Some((cur + 1) % count)
}
}
None => Some(if reverse { count - 1 } else { 0 }),
};
}
/// Left or right arrow key moves focus.
pub fn arrow(&mut self, left: bool) {
let count = self.buttons.len();
if count == 0 {
return;
}
self.focus = match self.focus {
Some(cur) => {
if left {
Some(cur.saturating_sub(1))
} else {
Some((cur + 1).min(count - 1))
}
}
None => Some(if left { 0 } else { count - 1 }),
};
}
/// The index of the currently focused button.
pub fn pressed(&self) -> Option<usize> {
self.focus
}
/// The button a click at `cursor` hits, counted from the left.
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option<usize> {
self.buttons
@@ -130,8 +194,9 @@ impl Choice {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for (&(bx, by), label) in self.buttons.iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
for (i, (&(bx, by), label)) in self.buttons.iter().zip(labels).enumerate() {
let is_focused = self.focus == Some(i);
let hover = ui::hovered(cursor, bx, by, self.width, 20.0) || is_focused;
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
}
}
@@ -177,4 +242,50 @@ mod tests {
assert_eq!(two.buttons[0].0, 88.0);
assert_eq!(two.buttons[1].0, 242.0);
}
#[test]
fn confirm_and_choice_keyboard_navigation() {
let (title, message) = (Text::literal("Title"), Text::literal("Message"));
let mut confirm = Confirm::new(&font(), (480.0, 270.0), &title, &message);
assert_eq!(confirm.focus, None);
assert_eq!(confirm.answer(), None);
confirm.tab(false);
assert_eq!(confirm.focus, Some(0));
assert_eq!(confirm.answer(), Some(true));
confirm.tab(false);
assert_eq!(confirm.focus, Some(1));
assert_eq!(confirm.answer(), Some(false));
confirm.tab(true);
assert_eq!(confirm.focus, Some(0));
confirm.arrow(false);
assert_eq!(confirm.focus, Some(1));
confirm.arrow(true);
assert_eq!(confirm.focus, Some(0));
let mut choice = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
assert_eq!(choice.focus, None);
assert_eq!(choice.pressed(), None);
choice.tab(false);
assert_eq!(choice.focus, Some(0));
assert_eq!(choice.pressed(), Some(0));
choice.tab(false);
assert_eq!(choice.focus, Some(1));
choice.tab(false);
assert_eq!(choice.focus, Some(2));
choice.tab(false);
assert_eq!(choice.focus, Some(0));
choice.arrow(false);
assert_eq!(choice.focus, Some(1));
choice.arrow(true);
assert_eq!(choice.focus, Some(0));
}
}
+28 -2
View File
@@ -117,8 +117,8 @@ impl Clicks {
self.quick_crafting
}
/// A press Horizon handled itself (a double click's `PICKUP_ALL`): its
/// release clicks nothing more.
/// A press another screen control handled (such as a book button or
/// double click's `PICKUP_ALL`): its release clicks nothing more.
pub fn skip_release(&mut self) {
self.quick_crafting = None;
self.skip_next_release = true;
@@ -269,6 +269,32 @@ mod tests {
);
}
#[test]
fn a_recipe_book_click_does_not_throw_a_carried_stack_after_the_layout_shifts() {
let mut clicks = Clicks::default();
let button = Target::<usize>::Nothing;
// The book button consumes the press while the player carries a stack.
assert_eq!(
clicks.press(&button, Button::Left, false, false, false),
None
);
clicks.skip_release();
// Closing the book shifts the inventory before mouseReleased tests the
// pointer again; it may now look like an outside click.
assert_eq!(
clicks.release(
&Target::<usize>::Outside,
Button::Left,
false,
false,
false,
false,
),
Release::Nothing
);
}
#[test]
fn a_creative_page_release_does_not_throw_a_carried_stack() {
let mut clicks = Clicks::default();
@@ -71,6 +71,12 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
}
}
}
let components: Vec<_> = horizon_protocol::item::custom::entries()
.map(|(id, name)| (id as u64, name.to_owned()))
.collect();
if !components.is_empty() {
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
}
identities.insert(root.to_owned(), hash);
Ok(hash)
}
+104 -9
View File
@@ -10,8 +10,14 @@ use crate::{
items::ItemStack,
world::World,
};
use horizon_core::geometry::{Aabb, Direction, Vec3};
use horizon_world::block_collision::BlockCollisionRegistry;
use horizon_core::{
geometry::{Aabb, Direction, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::{
block_collision::BlockCollisionRegistry,
level::portal_travel::{BorderBounds, find_free_position},
};
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
const ITEM_GRAVITY: f64 = 0.04;
@@ -549,17 +555,55 @@ impl ExperienceOrb {
self.health = (self.health as f32 - damage) as i32;
self.health <= 0
}
pub fn bounds(&self) -> Aabb {
/// The box of an orb whose `position` (the bottom of the box) is `at`.
pub fn box_at(at: Vec3) -> Aabb {
let h = 0.25;
Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 0.5,
self.position.z + h,
),
Vec3::new(at.x - h, at.y, at.z - h),
Vec3::new(at.x + h, at.y + 0.5, at.z + h),
)
}
pub fn bounds(&self) -> Aabb {
Self::box_at(self.position)
}
/// `Entity.unstuckIfPossible`, which `ExperienceOrb`'s constructor runs as
/// `if (!level.noCollision(box)) unstuckIfPossible(box.getSize())`: the free
/// spot nearest the box's centre, searched in a cube of the box's size
/// around it. `None` when the box is already free (the orb then keeps its
/// exact position) or when nothing free is that close. `shape(p)` is the
/// block's collision shape at `p`, in world coordinates.
pub fn unstuck(
at: Vec3,
border: &BorderBounds,
mut shape: impl FnMut(BlockPos) -> Option<VoxelShape>,
) -> Option<Vec3> {
let bounds = Self::box_at(at);
let size = bounds.average_size();
let lo = bounds.min_position();
let cells = |v: f64| (v - 1.0e-7).floor() as i32 - 1..=(v + 1.0e-7).floor() as i32 + 1;
// noCollision(box): a block shape reaching into it leaves the orb stuck.
let mut stuck = false;
'cells: for x in cells(lo.x) {
for y in cells(lo.y) {
for z in cells(lo.z) {
if shape(BlockPos::new(x, y, z))
.is_some_and(|s| s.boxes().iter().any(|b| b.intersects(bounds)))
{
stuck = true;
break 'cells;
}
}
}
}
if !stuck {
return None;
}
// `AABB.ofSize(center, size, size, size)`: where the box's centre may
// go, a cube of the box's mean size around its centre.
let allowed = Aabb::of_size(bounds.center(), Vec3::new(size, size, size));
find_free_position(allowed, bounds.center(), bounds.size(), border, shape)
.map(|p| p.subtract(Vec3::new(0.0, bounds.size().y / 2.0, 0.0)))
}
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
/// the same value and ids a multiple of 40 apart.
pub fn can_merge(&self, id: u32, value: u32) -> bool {
@@ -1751,3 +1795,54 @@ mod slime_bounce_tests {
assert!(!falling.needs_sync);
}
}
#[cfg(test)]
mod unstuck_tests {
use super::*;
const BORDER: BorderBounds = BorderBounds {
min_x: -1.0e6,
min_z: -1.0e6,
max_x: 1.0e6,
max_z: 1.0e6,
};
/// A level of full blocks at `solid`; every other cell is air.
fn shapes(solid: &[(i32, i32, i32)]) -> impl FnMut(BlockPos) -> Option<VoxelShape> + '_ {
move |p| {
if solid.contains(&(p.x, p.y, p.z)) {
VoxelShape::from_box(Aabb::block(p)).ok()
} else {
None
}
}
}
/// `level.noCollision(box)` over the same cells the search reads.
fn free(solid: &[(i32, i32, i32)], b: Aabb) -> bool {
!solid
.iter()
.any(|&(x, y, z)| Aabb::block(BlockPos::new(x, y, z)).intersects(b))
}
#[test]
fn an_orb_spawned_in_a_block_moves_to_a_free_box() {
// One block at y 0..1 and an orb standing in its upper half, as a mob
// killed inside one leaves it: its box overlaps the block by 0.1.
let solid = [(0, 0, 0)];
let at = Vec3::new(0.5, 0.9, 0.5);
let moved = ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)).expect("the orb moves");
// The nearest free box sits on the block's top face.
assert_eq!(moved, Vec3::new(0.5, 1.0, 0.5));
assert!(!ExperienceOrb::box_at(moved).intersects(Aabb::block(BlockPos::new(0, 0, 0))));
}
#[test]
fn an_orb_in_open_air_keeps_its_position() {
let solid = [(0, 0, 0)];
let at = Vec3::new(0.5, 2.0, 0.5);
assert!(free(&solid, ExperienceOrb::box_at(at)));
// Nothing to move for: the spawn sites keep the position as given.
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
}
}
+123 -6
View File
@@ -383,11 +383,26 @@ pub fn mob_to_nbt(
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
}
}
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears.
if m.species() == crate::mobs::Species::Mannequin
&& let Some(profile) = &m.profile
{
put(&mut tag, "profile", profile_nbt(profile));
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears, whether it
// is `immovable`, and its description (the default is not written; none is `hide_description`).
if m.species() == crate::mobs::Species::Mannequin {
if let Some(profile) = &m.profile {
put(&mut tag, "profile", profile_nbt(profile));
}
put(
&mut tag,
"immovable",
Tag::Byte(i8::from(m.extra.immovable)),
);
match &m.extra.description {
crate::mobs::MannequinDescription::Default => {}
crate::mobs::MannequinDescription::Hidden => {
put(&mut tag, "hide_description", Tag::Byte(1));
}
crate::mobs::MannequinDescription::Custom(component) => {
put(&mut tag, "description", component.clone());
}
}
}
// `Entity.saveWithoutId` writes the flag only when set.
if m.name_visible {
@@ -578,6 +593,10 @@ pub fn mob_to_nbt(
map.insert("powered".into(), Tag::Byte(i8::from(m.extra.charged)));
map.insert("ignited".into(), Tag::Byte(i8::from(m.extra.flag)));
}
// `Ocelot.addAdditionalSaveData`.
"minecraft:ocelot" => {
map.insert("Trusting".into(), Tag::Byte(i8::from(m.extra.trusting)));
}
"minecraft:cat" => {
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
@@ -1408,9 +1427,21 @@ pub fn mob_from_nbt(
m.extra.angry_at = Some(crate::mobs::Who::Player);
}
}
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin.
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin, then
// `immovable`, `hide_description` and `description` (`updateDescription`).
if m.species() == crate::mobs::Species::Mannequin {
m.profile = tag.get("profile").and_then(profile_from_nbt);
m.extra.immovable = flag(tag, "immovable");
m.extra.description = if flag(tag, "hide_description") {
crate::mobs::MannequinDescription::Hidden
} else {
crate::mobs::MannequinDescription::from_component(Some(
&tag.get("description")
.cloned()
.or_else(|| crate::mobs::MannequinDescription::Default.component())
.unwrap_or(Tag::End),
))
};
}
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
@@ -1602,6 +1633,10 @@ pub fn mob_from_nbt(
}
_ => {}
}
// `Ocelot.readAdditionalSaveData`.
if m.species() == crate::mobs::Species::Ocelot {
m.extra.trusting = flag(tag, "Trusting");
}
if m.species() == crate::mobs::Species::Cat {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
@@ -3484,6 +3519,88 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
/// `Ocelot.addAdditionalSaveData` and `readAdditionalSaveData`: `Trusting`.
#[test]
fn an_ocelot_keeps_its_trust() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
Arc::new(data.types["minecraft:ocelot"].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("Trusting")), Some(0));
m.extra.trusting = true;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("Trusting")), Some(1));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert!(back.extra.trusting);
}
/// `Mannequin.addAdditionalSaveData` and `readAdditionalSaveData`: `immovable`, and the
/// description (the default is not written, `hide_description` hides it, a component stays).
#[test]
fn a_mannequin_keeps_its_immovable_flag_and_description() {
use crate::mobs::MannequinDescription as D;
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
Arc::new(data.types["minecraft:mannequin"].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let save = |m: &Mob| mob_to_nbt(m, [1, 2, 3, 4], None, None);
// The default description is not written; it comes back.
let tag = save(&m);
assert!(tag.get("description").is_none() && tag.get("hide_description").is_none());
assert_eq!(number(tag.get("immovable")), Some(0));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(
(back.extra.immovable, back.extra.description),
(false, D::Default)
);
// Hidden, and immovable.
m.extra.immovable = true;
m.extra.description = D::Hidden;
let tag = save(&m);
assert_eq!(number(tag.get("hide_description")), Some(1));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(
(back.extra.immovable, back.extra.description),
(true, D::Hidden)
);
// A component of the world's own.
let own = Tag::compound([("text", string("Guard")), ("color", string("red"))]);
m.extra.description = D::Custom(own.clone());
let tag = save(&m);
assert_eq!(tag.get("description"), Some(&own));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.description, D::Custom(own));
}
/// The only lib test that registers mod effects (once per process).
#[test]
fn a_mob_keeps_a_mod_effect() {
File diff suppressed because it is too large. Load diff
@@ -1780,7 +1780,8 @@ impl Game<'_> {
}
}
}
/// The slots held by shown advancement, recipe and pack toasts, as bits.
/// The slots held by shown advancement, recipe, pack and narrator toasts,
/// as bits.
pub(super) fn toast_slots_used(&self) -> u32 {
let packs = self.server_packs.toast_slots();
self.adv
@@ -1788,6 +1789,7 @@ impl Game<'_> {
.iter()
.filter_map(|t| t.slot)
.chain(packs)
.chain(self.narrator_toast.as_ref().and_then(|t| t.slot))
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
}
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
@@ -317,6 +317,12 @@ impl Game<'_> {
(title, desc, longest + 3.0 + 5.0)
}
pub(super) fn draw_advancements(&mut self, batch: &mut HudBatch) {
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new(
"advancements",
super::screen_info::tr("gui.advancements", &[]),
)
});
let (w, h) = (batch.width, batch.height);
let view = self.adv_view();
let (left, top) = window(w, h);
+9 -10
View File
@@ -3,6 +3,7 @@
//! like a player (walk, look, break, place, use, click buttons). Actions that
//! take time run as a task advanced every frame and reply when they finish or
//! time out, so `horizon-ctl break 10 64 3` returns what the block dropped.
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::{
Result,
@@ -23,10 +24,8 @@ use std::{
time::{Duration, Instant},
};
use winit::{
application::ApplicationHandler,
dpi::PhysicalPosition,
event::{DeviceId, ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
event_loop::ActiveEventLoop,
keyboard::KeyCode,
};
@@ -957,7 +956,7 @@ impl Game<'_> {
/// Synthesize the pointer moving to GUI pixel (x, y) and a click there.
pub(super) fn synth_click(
&mut self,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
x: f32,
y: f32,
button: MouseButton,
@@ -968,7 +967,7 @@ impl Game<'_> {
/// the others and a release at the last (one point is a click).
fn synth_drag(
&mut self,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
points: &[(f32, f32)],
button: MouseButton,
) -> Result<()> {
@@ -981,7 +980,7 @@ impl Game<'_> {
let device_id = DeviceId::dummy();
for (i, &(x, y)) in points.iter().enumerate() {
let position = PhysicalPosition::new(f64::from(x * scale), f64::from(y * scale));
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::CursorMoved {
@@ -990,7 +989,7 @@ impl Game<'_> {
},
);
if i == 0 {
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::MouseInput {
@@ -1001,7 +1000,7 @@ impl Game<'_> {
);
}
}
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::MouseInput {
@@ -1035,7 +1034,7 @@ impl Game<'_> {
words: &[&str],
line: &str,
reply: &Sender<String>,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
) -> Result<Handled> {
let name = words[0];
let a = parse_args(&words[1..]);
@@ -1561,7 +1560,7 @@ impl Game<'_> {
.ok_or("no renderer yet")?;
let device_id = DeviceId::dummy();
let position = PhysicalPosition::new(f64::from(x * scale), f64::from(y * scale));
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::CursorMoved {
@@ -1570,7 +1569,7 @@ impl Game<'_> {
},
);
for _ in 0..steps.abs() as u32 {
self.window_event(
self.handle_window_event(
event_loop,
id,
WindowEvent::MouseWheel {
+23 -5
View File
@@ -2,7 +2,7 @@
//! falling, `interactAt` equipment swaps by clicked height, the two-hit break
//! of `hurtServer` (creative breaks at once), and `ArmorStandRenderer` with
//! the default pose, the hit wobble and humanoid armor layers.
use super::Game;
use super::{Game, InteractTarget};
use horizon_client::{
entities::ArmorStand,
items::ItemStack,
@@ -485,12 +485,30 @@ impl Game<'_> {
_ => 0,
}
}
/// `ArmorStand.interactAt`.
pub(super) fn interact_armor_stand(&mut self) -> bool {
let Some((i, d, point)) = self.armor_stand_hit() else {
/// `ArmorStand.interactAt` for the stand under the crosshair, or for the
/// stand a guest's interact packet named (its client's click point).
pub(super) fn interact_armor_stand(&mut self, target: Option<InteractTarget>) -> bool {
let Some((i, d, point)) = (match target {
// interactAt gets the packet's location, measured from the stand's
// position, so getClickedSlot reads the height the client clicked.
Some(t) => Self::named_entity(&self.armor_stands, t.id, |a| a.id).map(|i| {
let p = self.armor_stands[i].position;
(
i,
0.0,
[
p.x + t.location[0],
p.y + t.location[1],
p.z + t.location[2],
],
)
}),
None => self.armor_stand_hit(),
}) else {
return false;
};
if !self.before_block(d) {
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none() && !self.before_block(d) {
return false;
}
let held = self.held().cloned();
+7
View File
@@ -454,6 +454,13 @@ impl Game<'_> {
let levels = self.beacon_levels();
let sel = self.station.as_ref().map_or([None; 2], |s| s.beacon);
let paid = self.station.as_ref().is_some_and(|s| s.inputs[0].is_some());
// BeaconScreen draws no title, but has the menu's.
super::screen_info::name_container(|| {
self.remote_menu_title().map_or_else(
|| self.translate("container.beacon", "Beacon"),
|t| t.plain(),
)
});
let atlas = &self.gui.atlas;
batch.fill(
atlas,
+10 -5
View File
@@ -2,7 +2,7 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
use super::{Game, Keys, entity_culling::at, view_vector};
use super::{Game, InteractTarget, Keys, entity_culling::at, view_vector};
use horizon_client::{
blocks::Fluid,
collisions::{EntityCollisions, SceneCollisions},
@@ -938,13 +938,18 @@ impl Game<'_> {
}
best
}
/// `AbstractBoat.interact`: board the boat under the crosshair.
pub(super) fn interact_boat(&mut self) -> bool {
let Some((i, d)) = self.boat_hit() else {
/// `AbstractBoat.interact`: board the boat under the crosshair, or the one
/// a guest's interact packet named.
pub(super) fn interact_boat(&mut self, target: Option<InteractTarget>) -> bool {
let Some((i, d)) = (match target {
Some(t) => Self::named_entity(&self.boats, t.id, |b| b.id).map(|i| (i, 0.0)),
None => self.boat_hit(),
}) else {
return false;
};
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
if !self.before_block(d) {
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none() && !self.before_block(d) {
return false;
}
// AbstractBoat.interact starts with Entity.interact: the lead.
+65 -2
View File
@@ -3,7 +3,7 @@
//! pixels, 1024 characters, 100 pages), signing it into a written book,
//! reading written books, and lecterns (placing a book, turning its pages
//! with a redstone pulse, the comparator output and taking the book).
use super::{Game, lan::LanHost};
use super::{Game, lan::LanHost, screen_info};
use horizon_client::{
containers::{BlockEntity, Lectern},
first_person::Hand,
@@ -13,6 +13,7 @@ use horizon_client::{
menu::{Kind, Menu},
text::Text,
ui,
widgets::{self, Narratable, ScreenInfo},
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
@@ -323,6 +324,16 @@ impl Game<'_> {
if !((0..9).contains(&p.slot) || p.slot == super::shield::OFFHAND_SLOT as i32) {
return;
}
if let Some(title) = &p.title
&& !self.chat_filter_text(
host,
conn,
&[title],
horizon_client::lan::hub::chat_filter::Place::Book,
)
{
return;
}
self.as_guest(host, conn, |g| {
g.write_book(p.slot as usize, p.pages, p.title)
});
@@ -542,7 +553,8 @@ impl Game<'_> {
let (start, end) = rows[next];
let chars: Vec<char> = text.chars().collect();
let slice: String = chars[start..end].iter().collect();
let pos = start + self.gui.atlas.font.fit(&slice, x);
// MultilineTextField.LINE_SEEK_PIXEL_BIAS: vanilla biases this seek, not the drawn caret.
let pos = start + self.gui.atlas.font.fit(&slice, x + 2.0);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
@@ -1158,6 +1170,27 @@ impl Game<'_> {
let tw = HudBatch::text_width(atlas, &text);
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
};
// PageButton: a button named for where it turns, there while it can;
// the view adds the forward one first, the editor the back one.
let page_narratables = |back: bool, forward: bool, view: bool| {
let back = (back, 43.0, "book.page_button.previous");
let forward = (forward, 116.0, "book.page_button.next");
let order = if view {
[forward, back]
} else {
[back, forward]
};
for (show, x, key) in order {
if show {
widgets::narratable(|| {
let rect = [left + x, top + 157.0, 23.0, 13.0];
Narratable::button(screen_info::tr(key, &[]), rect, true)
.hovered(hover(rect[0], rect[1], rect[2], rect[3]))
.order(i32::from(view))
});
}
}
};
let button = |batch: &mut HudBatch, x: f32, bw: f32, label: &str, active: bool| {
ui::button(
batch,
@@ -1178,6 +1211,19 @@ impl Game<'_> {
title_edit,
..
} => {
// BookSignScreen: its title, and the title box focused.
widgets::begin_screen(|| {
ScreenInfo::new("book/sign", screen_info::tr("book.sign.title", &[]))
});
widgets::narratable(|| {
let rect = [((w - 114.0) / 2.0).floor() - 3.0, 50.0, 114.0, 20.0];
Narratable::edit_box(
screen_info::tr("book.sign.titlebox", &[]),
title_edit.value.clone(),
rect,
)
.focused(title_edit.focused)
});
let edit = tr("book.editTitle", "Enter Book Title:");
let tw = HudBatch::text_width(atlas, &edit);
batch.text(
@@ -1275,6 +1321,16 @@ impl Game<'_> {
page_edit,
..
} => {
widgets::begin_screen(|| {
screen_info::book_edit(*page, pages.len(), &screen_info::tr)
});
// The page's MultiLineEditBox (no message), focused, then the
// page buttons.
widgets::narratable(|| {
let rect = [((w - 114.0) / 2.0).floor() - 8.0, 28.0, 122.0, 134.0];
Narratable::edit_box("", pages[*page].clone(), rect).focused(page_edit.focused)
});
page_narratables(*page > 0, true, false);
indicator(batch, *page, pages.len());
let text = &pages[*page];
let lines = self.book_lines(text);
@@ -1341,6 +1397,13 @@ impl Game<'_> {
page,
lectern,
} => {
widgets::begin_screen(|| {
let text = pages.get(*page).map(Text::plain).unwrap_or_default();
screen_info::book_view(*page, pages.len(), &text, &screen_info::tr)
});
// createMenuControls comes before createPageControlButtons
// (forward, then back).
page_narratables(*page > 0, *page + 1 < pages.len(), true);
indicator(batch, *page, pages.len());
if let Some(text) = pages.get(*page) {
for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() {
+22 -1
View File
@@ -1022,6 +1022,15 @@ impl Game<'_> {
let Some(url) = &self.link_prompt else {
return;
};
// ConfirmLinkScreen: the question and the link (its warning is a
// widget of its own, not narrated).
horizon_client::widgets::begin_screen(|| {
super::screen_info::confirm(
"chat/confirm_link",
&super::screen_info::tr("chat.link.confirm", &[]),
url,
)
});
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -1123,6 +1132,17 @@ impl Game<'_> {
let Some(mut screen) = self.ui.chat_screen.take() else {
return;
};
// ChatScreen.updateNarrationState: the restricted prompt is
// `displayMode.showRestrictedPrompt`, the abilities' restrictions.
let in_bed = self.ui.in_bed_chat && self.sleeping.is_some();
horizon_client::widgets::begin_screen(|| {
super::screen_info::chat(
in_bed,
screen.value(),
screen.abilities.any_restriction(),
&super::screen_info::tr,
)
});
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote),
@@ -1143,7 +1163,7 @@ impl Game<'_> {
let draws = screen.draw(&m, mouse, blink, background);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
if self.ui.in_bed_chat && self.sleeping.is_some() {
if in_bed {
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
@@ -1263,6 +1283,7 @@ impl Game<'_> {
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
use horizon_world::food::Difficulty;
self.world.health_rules.difficulty = match self.ui.difficulty {
0 => Difficulty::Peaceful,
@@ -0,0 +1,193 @@
//! The dedicated server's chat filter, game side (docs/chat-filter.md): what the hub refused is
//! written to `logs/chat-filter.log` and told to horizon-server's event stream as `chat_filtered`
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators lift mutes with
//! `/unmute <player>` (or `unmute` on the console).
use super::{Game, dedicated, lan::LanHost};
use horizon_chat_filter::Action;
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_protocol::text::{literal, translatable};
use horizon_server_files::user_lists::{BanEntry, NameAndId};
/// The operator level `/unmute` needs, as `/ban`'s.
const UNMUTE_LEVEL: u8 = 3;
/// The reason a temporary ban gives at login.
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
impl Game<'_> {
/// The hub's events from a filtered text: `ChatFiltered` carried out here, the others logged.
pub(super) fn chat_filter_events(&mut self, host: &mut LanHost, events: Vec<HostEvent>) {
for event in events {
match event {
HostEvent::ChatFiltered {
sender,
name,
place,
text,
count,
action,
} => self.chat_filtered(host, sender, &name, place, &text, count, action),
event => self.lan_log_event(event),
}
}
}
/// One refused text: the log, the event, and a temporary ban when it came to that.
#[allow(clippy::too_many_arguments)]
pub(super) fn chat_filtered(
&mut self,
host: &mut LanHost,
uuid: u128,
name: &str,
place: Place,
text: &str,
count: u32,
action: Action,
) {
let now = horizon_chat_filter::now_millis();
let summary = format!(
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
place.name(),
action.name()
);
if self.options.dedicated.is_none() {
log::info!("{summary}");
return;
}
// The text only goes to the filter's own log, for operators: never to the console or the stream.
dedicated::log("INFO", &summary);
if let Some(path) = host.hub.chat_filter_log() {
let line =
horizon_chat_filter::log_line(now, name, uuid, place.name(), count, action, text);
if let Err(e) = horizon_chat_filter::append_log(std::path::Path::new(path), &line) {
dedicated::log("WARN", &format!("[chat-filter] {path}: {e}"));
}
}
dedicated::events::record(
"chat_filtered",
name,
uuid,
serde_json::json!({ "place": place.name(), "hit": count, "action": action.name() }),
);
let Action::Banned { until } = action else {
return;
};
if let Some(lists) = &self.server_lists {
lists.lock().unwrap().bans.add(BanEntry::new(
NameAndId {
id: uuid,
name: name.to_owned(),
},
Some("Server".into()),
Some(until),
Some(BAN_REASON.into()),
));
}
let hours = (until - now + 3_599_999) / 3_600_000;
dedicated::log(
"INFO",
&format!("[chat-filter] Banned {name} for {hours} hours: {BAN_REASON}"),
);
let reason = literal(&format!(
"You're banned for {hours} hours: slurs aren't allowed here."
));
let conns: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| g.uuid == uuid)
.map(|g| g.conn)
.collect();
for conn in conns {
host.hub.kick(conn, reason.clone());
dedicated::events::record(
"kick",
name,
uuid,
serde_json::json!({ "reason": "chat filter: temporary ban" }),
);
}
}
/// The message arguments of a guest's command (`/msg`, `/me`, `/say`, `/teammsg`...) through the
/// filter: false when it refused them.
pub(super) fn chat_filter_command(
&mut self,
host: &mut LanHost,
i: usize,
command: &str,
) -> bool {
if !host.hub.chat_filter_on() {
return true;
}
let permission = host.hub.guests[i].permission;
let arguments = self.signable_arguments(permission, command);
if arguments.is_empty() {
return true;
}
let texts: Vec<&str> = arguments.iter().map(|(_, t)| t.as_str()).collect();
let mut events = Vec::new();
let allowed = host
.hub
.chat_allowed(i, &texts, Place::Command, &mut events);
self.chat_filter_events(host, events);
allowed
}
/// A guest's text for a sign, an anvil or a book through the filter (`conn`'s guest).
pub(super) fn chat_filter_text(
&mut self,
host: &mut LanHost,
conn: u32,
texts: &[&str],
place: Place,
) -> bool {
if !host.hub.chat_filter_on() {
return true;
}
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return true;
};
let mut events = Vec::new();
let allowed = host.hub.chat_allowed(i, texts, place, &mut events);
self.chat_filter_events(host, events);
allowed
}
/// `/unmute <player>` from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command
/// for the others.
pub(super) fn guest_unmute(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let (permission, by) = (g.permission, g.name.clone());
let reply = if permission < UNMUTE_LEVEL {
translatable("command.unknown.command", &[])
} else if words.len() != 2 {
literal("/unmute <player>")
} else {
literal(&self.unmute(host, words[1], &by))
};
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: reply,
overlay: false,
}),
);
}
/// Lifts `name`'s mute, logged; what to answer.
pub(super) fn unmute(&mut self, host: &mut LanHost, name: &str, by: &str) -> String {
match host.hub.unmute(name) {
Some(name) => {
if self.options.dedicated.is_some() {
dedicated::log("INFO", &format!("[chat-filter] {by} unmuted {name}"));
}
format!("Unmuted {name}")
}
None => format!("{name} isn't muted"),
}
}
}
+12 -7
View File
@@ -14,19 +14,24 @@ impl Game<'_> {
}
let m = self.modifiers;
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.contains(k));
// InputQuirks.EDIT_SHORTCUT_KEY_MODIFIER: Cmd on macOS, Ctrl elsewhere.
let edit = if cfg!(target_os = "macos") {
m.super_key() || held([KeyCode::SuperLeft, KeyCode::SuperRight])
} else {
m.control_key() || held([KeyCode::ControlLeft, KeyCode::ControlRight])
};
clipboard::edit_shortcut(
clipboard::shortcut_letter(&event.logical_key, event.physical_key),
edit,
self.has_control_down_with_quirk(),
m.shift_key() || held([KeyCode::ShiftLeft, KeyCode::ShiftRight]),
m.alt_key() || held([KeyCode::AltLeft, KeyCode::AltRight]),
)
}
/// `InputWithModifiers.hasControlDownWithQuirk`: the edit shortcuts'
/// modifier (`InputQuirks.EDIT_SHORTCUT_KEY_MODIFIER`: Cmd on macOS, Ctrl
/// elsewhere), held on the keyboard or by an agent.
pub(super) fn has_control_down_with_quirk(&self) -> bool {
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.contains(k));
if cfg!(target_os = "macos") {
self.modifiers.super_key() || held([KeyCode::SuperLeft, KeyCode::SuperRight])
} else {
self.modifiers.control_key() || held([KeyCode::ControlLeft, KeyCode::ControlRight])
}
}
/// Whether the held keys let a key's text through to the focused field
/// (`clipboard::types_text`): not with the edit modifier, unless it is
/// part of AltGr.
@@ -169,6 +169,15 @@ impl Game<'_> {
let Some(prompt) = &self.code_of_conduct else {
return;
};
// CodeOfConductScreen: WarningScreen's narration, the title and the
// text on the next line.
horizon_client::widgets::begin_screen(|| {
let title = super::screen_info::tr("multiplayer.codeOfConduct.title", &[]);
horizon_client::widgets::ScreenInfo::new(
"code_of_conduct",
format!("{title}\n{}", prompt.text),
)
});
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
if self.player.is_none() {
@@ -209,6 +218,17 @@ impl Game<'_> {
let [x, y, cw, ch] = layout.check;
let hover = ui::hovered(cursor, x, y, cw, ch);
horizon_client::widgets::narratable(|| {
horizon_client::widgets::Narratable::new(
horizon_client::widgets::What::Checkbox {
message: layout.label.plain(),
selected: prompt.stop_showing,
active: true,
},
[x, y, cw, ch],
)
.hovered(hover)
});
let sprite = match (prompt.stop_showing, hover) {
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
(true, false) => "gui/sprites/widget/checkbox_selected",
+4
View File
@@ -118,6 +118,10 @@ pub(super) struct CombatTracker {
/// blast leaves it out (`getEntities(source, box)`). A source guessed
/// from the mobs near the centre is still hurt: it may be a bystander.
pub(super) explosion_source_named: bool,
/// The blast's direct source is in water (`Explosion.getDirectSourceEntity`
/// `.isInWater`): paintings, item frames and cushions ignore it
/// (`BlockAttachedEntity.ignoreExplosion`).
pub(super) explosion_in_water: bool,
/// The blast now going off comes from a primed TNT, creeper, wither
/// skull, wither or TNT minecart, the sources that empty a hive they
/// destroy (`BeehiveBlock.getDrops`).
@@ -94,6 +94,18 @@ impl CommandBlockScreen {
]);
out
}
/// What button `b` narrates: the switches are `CycleButton`s that show
/// only their value (`displayOnlyValue`), "<name>: <value> button" and
/// the value a click moves to.
fn narration(
&self,
b: Button,
rect: [f32; 4],
tr: super::screen_info::Tr<'_>,
) -> horizon_client::widgets::Narratable {
let state = (self.track_output, self.mode, self.conditional, self.auto);
button_narration(b, state, rect, tr)
}
fn label(&self, b: Button) -> Text {
let tr = |key: &str| Text::translate(key, &[]);
match b {
@@ -127,6 +139,48 @@ impl CommandBlockScreen {
}
}
/// [`CommandBlockScreen::narration`] of the switches' state (track output,
/// mode, conditional, automatic).
fn button_narration(
b: Button,
(track_output, mode, conditional_on, auto_on): (bool, Mode, bool, bool),
rect: [f32; 4],
tr: super::screen_info::Tr<'_>,
) -> horizon_client::widgets::Narratable {
use horizon_client::widgets::Narratable;
let full_tr = tr;
let tr = |key: &str| full_tr(key, &[]);
let o_x = |on: bool| if on { "O" } else { "X" }.to_owned();
let conditional = |on: bool| {
tr(if on {
"advMode.mode.conditional"
} else {
"advMode.mode.unconditional"
})
};
let auto = |on: bool| {
tr(if on {
"advMode.mode.autoexec.bat"
} else {
"advMode.mode.redstoneTriggered"
})
};
let (name, value, next) = match b {
Button::Output => ("advMode.trackOutput", o_x(track_output), o_x(!track_output)),
Button::Mode => ("advMode.mode", tr(mode.label()), tr(mode.next().label())),
Button::Conditional => (
"advMode.type",
conditional(conditional_on),
conditional(!conditional_on),
),
Button::Auto => ("advMode.triggering", auto(auto_on), auto(!auto_on)),
Button::Done => return Narratable::button(tr("gui.done"), rect, true),
Button::Cancel => return Narratable::button(tr("gui.cancel"), rect, true),
};
let full = full_tr("options.generic_value", &[&tr(name), &value]);
Narratable::cycle(full, next, rect, true)
}
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
registry.state(state)
}
@@ -542,7 +596,9 @@ impl Game<'_> {
};
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
command.auto_suggestions = self.settings.auto_suggestions;
command.input.message = super::screen_info::tr("advMode.command", &[]);
let mut previous = EditBox::new(0.0, previous_y(cart.is_some()), 276.0, 20.0);
previous.message = super::screen_info::tr("advMode.previousOutput", &[]);
previous.max_length = 32500;
previous.text_color = UNEDITABLE;
previous.focused = false;
@@ -576,6 +632,16 @@ impl Game<'_> {
self.close_command_block();
return;
}
// AbstractCommandBlockEditScreen: `GameNarrator.NO_TITLE`; its usage
// is the suggestions' while they show (`getUsageNarration`).
let usage = s.command.suggestions_usage();
horizon_client::widgets::begin_screen(|| {
let info = horizon_client::widgets::ScreenInfo::new("command_block", "");
match usage {
Some(key) => info.usage(super::screen_info::tr(key, &[])),
None => info,
}
});
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
let mid = (w / 2.0).floor();
@@ -610,7 +676,18 @@ impl Game<'_> {
}
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h, s.cart.is_some()) {
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
ui::button_as(
batch,
atlas,
x,
y,
bw,
20.0,
&s.label(b),
true,
hover,
|rect| s.narration(b, rect, &super::screen_info::tr),
);
}
// The command box, then its suggestions over the widgets.
let m = Measure(&atlas.font);
@@ -621,7 +698,10 @@ impl Game<'_> {
s.command.resize(&m, &c, w as i32, h as i32);
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
let blink = s.opened.elapsed().as_millis() as u64;
// The command box is added first.
let mark = horizon_client::widgets::narratables_mark();
let draws = s.command.draw(&m, mouse, blink, 0);
horizon_client::widgets::move_narratables(mark, 0);
horizon_client::screen_draw::paint(batch, atlas, &draws);
self.command_block_screen = Some(s);
}
@@ -958,3 +1038,60 @@ mod tests {
assert_eq!(cart[1].2, block[4].2);
}
}
#[cfg(test)]
mod narration_tests {
use super::*;
use horizon_client::widgets::What;
fn keys(key: &str, args: &[&str]) -> String {
if args.is_empty() {
key.to_owned()
} else {
format!("{key}({})", args.join(", "))
}
}
#[test]
fn the_switches_narrate_as_cycle_buttons_showing_their_value() {
let state = (true, Mode::Redstone, false, true);
let cycle = |b| button_narration(b, state, [0.0; 4], &keys).what;
let expect = |message: &str, next: &str| What::Cycle {
message: message.into(),
next: next.into(),
active: true,
};
assert_eq!(
cycle(Button::Output),
expect("options.generic_value(advMode.trackOutput, O)", "X")
);
assert_eq!(
cycle(Button::Mode),
expect(
"options.generic_value(advMode.mode, advMode.mode.redstone)",
"advMode.mode.sequence"
)
);
assert_eq!(
cycle(Button::Conditional),
expect(
"options.generic_value(advMode.type, advMode.mode.unconditional)",
"advMode.mode.conditional"
)
);
assert_eq!(
cycle(Button::Auto),
expect(
"options.generic_value(advMode.triggering, advMode.mode.autoexec.bat)",
"advMode.mode.redstoneTriggered"
)
);
assert_eq!(
cycle(Button::Done),
What::Button {
message: "gui.done".into(),
active: true
}
);
}
}
+5 -2
View File
@@ -2056,11 +2056,14 @@ impl Game<'_> {
return Err(err("commands.summon.failed", &[]));
}
"experience_orb" => {
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck
// like any other orb spawn.
let at = self.unstuck_orb(pos);
self.next_entity += 1;
self.orbs.push(horizon_client::entities::ExperienceOrb {
id: self.next_entity,
position: Vec3::new(pos[0], pos[1], pos[2]),
previous: Vec3::new(pos[0], pos[1], pos[2]),
position: Vec3::new(at[0], at[1], at[2]),
previous: Vec3::new(at[0], at[1], at[2]),
velocity: Vec3::new(0.0, 0.0, 0.0),
value: 0,
age: 0,
@@ -73,8 +73,10 @@ impl Game<'_> {
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
let name = namespaced(effect.id);
let new = !m.has_effect(name);
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
m.add_effect_with(name, duration, amplifier, false, particles);
self.mod_effect_added(name, Some(id as i32), amplifier, new);
true
}
None => false,
+21 -5
View File
@@ -1,4 +1,5 @@
//! Commands from the control socket (`horizon-ctl`), run on the game thread.
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::{Result, items::ItemStack};
use horizon_core::geometry::Vec3;
@@ -7,7 +8,7 @@ use std::{
sync::mpsc::Sender,
time::{Duration, Instant},
};
use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode};
use winit::{event::MouseButton, keyboard::KeyCode};
const HELP: &[&str] = &[
"status game, player, streaming and renderer state",
@@ -36,6 +37,7 @@ const HELP: &[&str] = &[
"chat-log [N] the newest N chat messages (default 10)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
"stats [reset] frame-time summary since the last reset",
"narration the narrator's status and the last messages it was asked to say",
"bench SECONDS reset stats, wait, then reply with the summary",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
"open WORLD [SEED] [creative] open or create a world from the title screen",
@@ -107,7 +109,7 @@ impl Game<'_> {
let _ = flight.reply.send(summary.to_string());
}
}
pub(super) fn poll_control(&mut self, event_loop: &ActiveEventLoop) {
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
if let Some((deadline, reply)) = self.pending_bench.take() {
if Instant::now() >= deadline {
let _ = reply.send(self.frame_stats.summary().to_string());
@@ -116,6 +118,7 @@ impl Game<'_> {
}
}
horizon_client::widgets::end_frame(self.frames);
self.narrate_screen();
self.advance_agent();
for request in self.ui_wait.ready() {
self.answer(request, event_loop);
@@ -137,7 +140,7 @@ impl Game<'_> {
}
}
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &dyn EventLoopCtl) {
let reply = match self.control_command(&request.line, &request.reply, event_loop) {
Ok(Some(value)) => value,
// Deferred: the command keeps its own reply channel.
@@ -151,7 +154,7 @@ impl Game<'_> {
&mut self,
line: &str,
reply: &Sender<String>,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
) -> Result<Option<Value>> {
// A tap's or an up's release must run even when a dialog, the sign
// editor or a screen consumes the key, there is no world, or a
@@ -168,7 +171,7 @@ impl Game<'_> {
&mut self,
line: &str,
reply: &Sender<String>,
event_loop: &ActiveEventLoop,
event_loop: &dyn EventLoopCtl,
) -> Result<Option<Value>> {
let words: Vec<&str> = line.split_whitespace().collect();
if self.pending_save.is_some() && words.first() != Some(&"quit") {
@@ -317,6 +320,8 @@ impl Game<'_> {
return ok();
}
self.require_world()?;
// KeyboardHandler.keyPress: the open screen's narration hears it.
super::narrator::narrate_key(&mut self.narration, key, arg(2)? != "up");
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
if self.sign_edit.is_some() {
if arg(2)? != "up" {
@@ -667,6 +672,8 @@ impl Game<'_> {
return ok();
}
self.require_world()?;
// Typed keys: the open screen's narration hears them.
self.narration.keyboard_action();
if self.book.is_some() {
self.book_type(text);
return ok();
@@ -756,6 +763,15 @@ impl Game<'_> {
.unwrap_or_else(|| self.ui.chat.recent_lines(n));
Ok(Some(json!({ "chat": lines })))
}
"narration" => {
// NarratorStatus's options.txt names: off, all, chat, system.
let status = horizon_narrator::status().key().rsplit('.').next();
Ok(Some(json!({
"status": status,
"active": horizon_narrator::is_active(),
"recent": horizon_narrator::recent(),
})))
}
"stats" => {
let summary = self.frame_stats.summary();
if words.get(1) == Some(&"reset") {
@@ -91,6 +91,8 @@ impl Game<'_> {
key(&format!("key.hotbar.{}", index + 1)),
],
);
// handleHotbarLoadOrSave: the overlay, said at once.
self.say_system_now(&message);
self.titles.set_overlay(message, false);
}
}
+212 -16
View File
@@ -2,7 +2,7 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{Dim, Game, dispenser, view_vector};
use super::{Dim, Game, InteractTarget, dispenser, view_vector};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result,
@@ -227,6 +227,20 @@ fn flame_geometry(
pub(super) fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
m.seen(player)
}
/// Whether a mob a packet named may take the click: `mob_hit` skips a mob in
/// its death animation, the one the player rides (it shares the player's root
/// vehicle) and the camera entity the pick starts from, so a packet that names
/// one of those does nothing rather than reaching the feed, shear, leash or
/// name paths the pick path refuses.
pub(super) fn named_mob_pickable(
alive: bool,
id: u32,
riding: Option<u32>,
camera: Option<u32>,
) -> bool {
alive && Some(id) != riding && Some(id) != camera
}
impl Game<'_> {
pub(super) fn player_view(&self) -> Option<PlayerView> {
let p = self.player.as_ref()?;
@@ -459,6 +473,107 @@ impl Game<'_> {
self.lan_entity_event(id as i32, if body { 65 } else { 49 }, at);
}
}
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
/// `MobEvent::ArmorHurt` reported: every worn piece that wears when its
/// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking
/// applied, and at its maximum the slot empties and the equipment-break
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52
/// for the armour, 65 for the body). A fire hit leaves a fire-resistant
/// piece alone (`ItemStack.canBeHurtBy`).
fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) {
use horizon_client::mobs::drops;
use horizon_world::player_inventory::EquipmentSlot;
if damage <= 0.0 {
return;
}
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let body_armour = drops::head_is_body(m.species());
let amount = ((damage / 4.0) as u32).max(1);
let name = damage_type(kind, false);
let fire = self
.motion
.health
.damage
.id(name)
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
// A piece's maximum, or none when it may not lose durability at all.
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
let info = me.res.items().get(&stack.item)?;
if !info.equip_damage_on_hurt
|| info.max_damage == 0
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|| (fire && me.res.items().resists_fire(stack, name))
{
return None;
}
Some(info.max_damage)
}
let mut broke: Vec<i8> = Vec::new();
// The four armour slots, then the body slot (`extra.head`).
let event_of = horizon_client::mobs::item_break::equipment_break_event;
for (index, slot) in [
(0usize, EquipmentSlot::Head),
(1, EquipmentSlot::Chest),
(2, EquipmentSlot::Legs),
(3, EquipmentSlot::Feet),
] {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let Some(stack) = m.extra.armor[index].clone() else {
continue;
};
let Some(max) = wearable(self, &stack, fire, name) else {
continue;
};
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
continue;
}
let lost = stack.damage + loss;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.armor[index] = None;
broke.push(event_of(slot));
} else if let Some(worn) = m.extra.armor[index].as_mut() {
worn.damage = lost;
}
}
if body_armour {
let body = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.item_by_slot(EquipmentSlot::Body));
if let Some(body) = body
&& let Some(max) = wearable(self, &body, fire, name)
{
let loss = self.durability_loss(&body, amount);
if loss > 0 {
let lost = body.damage + loss;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.head = None;
m.extra.body_damage = 0;
broke.push(event_of(EquipmentSlot::Body));
} else {
m.extra.body_damage = lost as i32;
}
}
}
}
for event in broke {
// LivingEntity.onEquippedItemBroken through the level's broadcast.
self.lan_entity_event(id as i32, event, at);
}
}
/// What a converted frog gets once its tadpole has grown up: the spawn
/// finalization of a conversion, then persistence.
fn finalize_converted(&mut self, id: u32) {
@@ -661,6 +776,11 @@ impl Game<'_> {
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.items()),
lid_openers: &self.lid_openers,
mod_effects: if self.remote.is_none() {
Some(self)
} else {
None
},
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
self.level_players()
@@ -1496,6 +1616,7 @@ impl Game<'_> {
self.drown_particles(at, v);
}
MobEvent::SunProtectionWear { mob } => self.wear_sun_protection(mob),
MobEvent::ArmorHurt { mob, amount, kind } => self.hurt_mob_armor(mob, amount, kind),
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
@@ -1634,8 +1755,14 @@ impl Game<'_> {
owner,
} => self.add_fangs(at, yaw, delay, owner),
MobEvent::Shoot(..) => {}
MobEvent::ExplosionSource { mob, charged, name } => {
MobEvent::ExplosionSource {
mob,
charged,
name,
in_water,
} => {
self.combat.explosion_source = Some(mob);
self.combat.explosion_in_water = in_water;
// The creeper causes the blast's damage, but it is out of
// the mob list already: its name comes with the event.
self.combat.exploded.push((mob, name));
@@ -1688,6 +1815,7 @@ impl Game<'_> {
};
self.combat.explosion_source = None;
self.combat.explosion_source_named = false;
self.combat.explosion_in_water = false;
self.combat.explosion_cause = None;
result?
}
@@ -4156,12 +4284,14 @@ impl Game<'_> {
}
}
self.combat.explosion_owner = owner;
self.combat.explosion_in_water = p.touching_water;
// The blast's source names the owner, so a dragon it kills
// remembers the player who shot it.
self.set_blast_by_player(p.owner == Some(Who::Player));
let result = self.explode(atp, *power as f32);
self.set_blast_by_player(false);
self.combat.explosion_owner = None;
self.combat.explosion_in_water = false;
result?;
}
(
@@ -4398,12 +4528,28 @@ impl Game<'_> {
};
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// Right click on a mob (Mob.interact / mobInteract). Returns true when consumed.
pub(super) fn interact_mob(&mut self) -> Result<bool> {
let Some((index, distance)) = self.mob_hit() else {
/// Right click on a mob (Mob.interact / mobInteract), on the mob under the
/// crosshair or on the one a guest's interact packet named: returns true
/// when consumed.
pub(super) fn interact_mob(&mut self, target: Option<InteractTarget>) -> Result<bool> {
let Some((index, distance)) = (match target {
// The id the packet named, under the filters the crosshair pick
// applies (`mob_hit`): a mob in its death animation is not clicked
// by either path, so naming one in a packet must not reach the
// feed, shear, leash or name paths either.
Some(t) => Self::named_entity(&self.mobs, t.id, |m| m.id)
.filter(|&i| {
let m = &self.mobs[i];
named_mob_pickable(m.alive(), m.id, self.riding, self.remote_camera_id())
})
.map(|i| (i, 0.0)),
None => self.mob_hit(),
}) else {
return Ok(false);
};
if let Some(hit) = self.pick_unfiltered()
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none()
&& let Some(hit) = self.pick_unfiltered()
&& let Some(eye) = self.eye_position()
{
let d = ((hit.point[0] - eye[0]).powi(2)
@@ -4568,6 +4714,10 @@ impl Game<'_> {
.get("minecraft:parrot_poisonous_food")
.is_some_and(|t| t.contains(&*item));
let player_at = self.eye_position();
let player_feet = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
let acting = self.acting_player_uuid();
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
@@ -4589,6 +4739,9 @@ impl Game<'_> {
let feet = [m.position.x, m.position.y, m.position.z];
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
// `Sheep.shear`'s drops add a spread on top of the ItemEntity's own
// motion; `MushroomCow.shear`'s do not.
let mut wool = false;
let is_food = m.kind.food.contains(&item);
let baby_turtle = species == Species::Turtle && m.is_baby();
// MushroomCow.getEffectsFromItemStack: the item's block supplies them.
@@ -4601,6 +4754,8 @@ impl Game<'_> {
// `Some(true)` for the four spell particles, `Some(false)` for the two
// smoke puffs of a cow that already holds effects.
let mut stew_particles: Option<bool> = None;
// `Ocelot.mobInteract`: whether the fish won its trust.
let mut ocelot_trust: Option<bool> = None;
match species {
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
@@ -4680,6 +4835,7 @@ impl Game<'_> {
&mut self.world.random,
);
drops.extend(stacks.into_iter().map(|s| (s.item.to_string(), s.count)));
wool = true;
sound = Some("entity.sheep.shear");
self.damage_held_tool();
}
@@ -5026,6 +5182,30 @@ impl Game<'_> {
}
consume = true;
}
// Ocelot.mobInteract: raw fish from a hand within 3 blocks, while it comes for the
// food (`temptGoal.isRunning`), is used up; one time in three it trusts the player
// (entity event 41, hearts), otherwise smoke (event 40). Anything else is
// `Animal.mobInteract`.
Species::Ocelot
if is_food
&& !m.extra.trusting
&& m.ai.goals.is_running("OcelotTemptGoal")
&& player_feet.is_some_and(|p| {
let d = [
p[0] - m.position.x,
p[1] - m.position.y,
p[2] - m.position.z,
];
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < 9.0
}) =>
{
consume = true;
let trusts = m.random.next_int_bound(3).unwrap_or(1) == 0;
if trusts {
m.extra.trusting = true;
}
ocelot_trust = Some(trusts);
}
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
_ if is_food
&& m.kind.flags.animal
@@ -5061,6 +5241,10 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
if let Some(trusts) = ocelot_trust {
// `spawnTrustingParticles` on the clients: entity event 41 (hearts) or 40 (smoke).
self.mob_event_particles(self.mobs[index].id, if trusts { 41 } else { 40 });
}
if let Some(fresh) = stew_particles {
// `SpellParticleOption.create(EFFECT, -1, 1)` is the plain white
// effect particle; the smoke is the already-fed answer.
@@ -5147,11 +5331,21 @@ impl Game<'_> {
}
for (item, count) in drops {
for _ in 0..count {
let v = [
(self.world.random.next_float() - self.world.random.next_float()) as f64 * 0.1,
self.world.random.next_float() as f64 * 0.05,
(self.world.random.next_float() - self.world.random.next_float()) as f64 * 0.1,
];
// `spawnAtLocation(stack, 1.0)`: an ItemEntity a block above
// the feet that starts with the constructor's own motion, and
// `setDefaultPickUpDelay`. Sheep.shear adds its spread on top;
// MushroomCow.shear builds the entity by hand instead, so its
// mushrooms keep the constructor's motion and no pickup delay.
let mut v = dispenser::new_item_motion();
if wool {
v[0] += (self.world.random.next_float() - self.world.random.next_float())
as f64
* 0.1;
v[1] += self.world.random.next_float() as f64 * 0.05;
v[2] += (self.world.random.next_float() - self.world.random.next_float())
as f64
* 0.1;
}
self.spawn_item(
ItemStack {
item: item.as_str().into(),
@@ -5159,9 +5353,9 @@ impl Game<'_> {
damage: 0,
extra: None,
},
[center[0], center[1] + 0.5, center[2]],
[feet[0], feet[1] + 1.0, feet[2]],
v,
10,
if wool { 10 } else { 0 },
);
}
}
@@ -5617,9 +5811,9 @@ impl Game<'_> {
}
}
if is_player {
// PlayerRenderer's CapeLayer, WingsLayer and SpinAttackEffectLayer.
// Only the cape hides with the body; WingsLayer and
// SpinAttackEffectLayer have no `isInvisible` check.
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
@@ -5632,6 +5826,7 @@ impl Game<'_> {
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
@@ -6369,6 +6564,7 @@ mod pearl_tests {
sea_level: 63,
items: Some(&items),
lid_openers: &lid_openers,
mod_effects: None,
};
let pw = ProjWorld {
w: &w,
+12 -5
View File
@@ -6,7 +6,7 @@
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
//! only an entity whose `Pos` is the click point's height. Placement uses
//! outline bounds, collision shapes and suffocation predicates.
use super::{Game, Hit};
use super::{Game, Hit, InteractTarget};
use horizon_client::entities::{Cushion, dye_index};
use horizon_client::items::ItemStack;
use horizon_client::mobs::anim::Poses;
@@ -307,12 +307,19 @@ impl Game<'_> {
true
}
/// `Cushion.interact`: sit down unless sneaking or already occupied.
pub(super) fn interact_cushion(&mut self) -> bool {
let Some((i, d, _)) = self.cushion_hit() else {
/// `Cushion.interact`: sit down unless sneaking or already occupied. The
/// cushion under the crosshair, or the one a guest's interact packet named.
pub(super) fn interact_cushion(&mut self, target: Option<InteractTarget>) -> bool {
let Some((i, d, _)) = (match target {
Some(t) => {
Self::named_entity(&self.cushions, t.id, |c| c.id).map(|i| (i, 0.0, [0.0; 3]))
}
None => self.cushion_hit(),
}) else {
return false;
};
if !self.before_block(d) {
// Only a client's own pick is stopped by a block: the range check passed.
if target.is_none() && !self.before_block(d) {
return false;
}
// isSecondaryUseActive.
+8 -2
View File
@@ -40,6 +40,7 @@ impl DebugHost for Host<'_, '_> {
let g = &mut *self.game;
if without_menu && g.screen == Screen::Playing {
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
g.pause_focus = None;
g.screen = Screen::Paused;
g.capture(false);
g.mining = None;
@@ -515,13 +516,18 @@ impl Game<'_> {
let prefix = Text::translate("debug.prefix", &[])
.with_color(color)
.bold();
self.system_message(
self.show_debug_chat(
Text::empty()
.append(prefix)
.append(Text::literal(" "))
.append(message),
);
}
/// `Minecraft.showDebugChat`: a client system message the narrator says.
fn show_debug_chat(&mut self, message: Text) {
self.say_system_queued(&message);
self.system_message(message);
}
fn debug_feedback(&mut self, message: Text) {
self.debug_decorated(YELLOW, message);
}
@@ -662,7 +668,7 @@ impl Game<'_> {
DebugAction::DumpVersion => {
self.debug_translated("debug.version.header");
for line in self.version_lines() {
self.system_message(line);
self.show_debug_chat(line);
}
true
}
+56 -10
View File
@@ -35,6 +35,7 @@ use std::{
};
pub(super) mod events;
mod management;
mod report;
mod status;
@@ -195,6 +196,37 @@ fn load_anticheat() -> horizon_anticheat::Config {
}
}
/// `server.toml`'s `[chat_filter]` (docs/chat-filter.md), like `[anticheat]`: on by default, off (with an
/// error logged) when the section is wrong. The mutes are kept beside server.properties.
fn load_chat_filter() -> Option<horizon_chat_filter::Guard> {
let text = std::fs::read_to_string("server.toml").unwrap_or_default();
let config = match horizon_chat_filter::Config::from_server_toml(&text) {
Ok(config) => config,
Err(e) => {
log(
"ERROR",
&format!("server.toml: {e}; the chat filter stays off"),
);
return None;
}
};
if !config.enabled {
return None;
}
let (guard, problem) = horizon_chat_filter::Guard::load(config, std::path::Path::new("."));
if let Some(problem) = problem {
log("WARN", &problem);
}
log(
"INFO",
&format!(
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
guard.filter.len()
),
);
Some(guard)
}
/// The secret for Velocity's player information forwarding, from `forwarding.secret` beside server.properties.
/// No file means no forwarding. A file that is empty or can't be read stops the start, because an empty secret
/// would let anyone sign a login, and a typo must not leave a server open that was meant to be behind a proxy.
@@ -470,6 +502,8 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
/// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it).
const FAR_PROPERTY: &str = "horizon-max-view-distance";
/// server.properties' line for `server.toml`'s `admin_feedback_to_ops` (horizon-server writes it).
const ADMIN_FEEDBACK_PROPERTY: &str = "horizon-admin-feedback-to-ops";
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
fn parse_identifier(text: &str) -> Option<String> {
@@ -610,6 +644,7 @@ impl Game<'_> {
pub(super) fn run_dedicated(&mut self) -> Result<()> {
let result = self.run_dedicated_inner();
if let Err(error) = &result {
self.native_players_leaving();
self.native_lifecycle_world_failed(&error.to_string());
self.native_lifecycle_close_failed(&error.to_string());
}
@@ -889,6 +924,7 @@ impl Game<'_> {
}
self.sync_ops(&lists);
let anticheat = load_anticheat();
let chat_filter = load_chat_filter();
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
let far = props
.raw
@@ -906,9 +942,15 @@ impl Game<'_> {
);
}
host.hub.set_anticheat(anticheat);
host.hub.set_chat_filter(chat_filter);
host.hub.spawn_protection = props.spawn_protection;
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
host.hub.command_spam_threshold = props.command_spam_threshold_seconds.wrapping_mul(20);
// Off: an operator's commands reach no other player, only the log.
host.hub.admin_feedback_to_ops = props
.raw
.get(ADMIN_FEEDBACK_PROPERTY)
.is_none_or(|v| v.trim() != "false");
}
log(
"INFO",
@@ -1336,6 +1378,16 @@ impl Game<'_> {
}
}
"whitelist" => self.console_whitelist(state, &rest),
// The chat filter's (game/chat_filter.rs), on servers that filter.
"unmute"
if rest.len() == 1 && self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
{
if let Some(mut host) = self.lan.take() {
let reply = self.unmute(&mut host, rest[0], "Server");
self.lan = Some(host);
self.console_reply(lit(&reply));
}
}
"ban" if !rest.is_empty() => {
let Some(user) = self.resolve(rest[0]) else {
return;
@@ -1716,21 +1768,15 @@ impl Game<'_> {
// MinecraftServer.stopServer's start: everyone is still here.
self.native_server_stopping(true);
log("INFO", "Stopping server");
self.native_players_leaving();
log("INFO", "Saving players");
if let Some(mut host) = self.lan.take() {
self.flush_native_net(&mut host);
for g in &host.hub.guests {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(g.vehicle);
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
}
// PlayerList.removeAll: each player's file, with the vehicle it
// alone rides, then its records.
self.save_leaving_guests(&mut host);
self.lan = Some(host);
}
if let Some(h) = self.lan.as_ref() {
for g in &h.hub.guests {
self.save_guest_records(h, g);
}
}
if let Some(h) = self.lan.as_mut() {
let conns: Vec<u32> = h.hub.guests.iter().map(|g| g.conn).collect();
for conn in conns {
@@ -0,0 +1,360 @@
//! The management protocol's list edits, which `horizon-server` forwards on the control endpoint as
//! `management <connection> {"method", "entries"}` (its management_methods.rs): vanilla 26.3's
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
use super::{ServerState, log};
use crate::game::Game;
use horizon_client::{
server::{
profiles,
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
},
text::Text,
};
use horizon_protocol::text::translatable;
use serde_json::{Value, json};
/// A `UserBan`: the player, the reason, the source, the end.
type Ban = BanEntry<NameAndId>;
/// An `IpBan`: the address, the reason, the source, the end.
type IpBan = BanEntry<String>;
/// `OperatorService.Op`: the player and the level and bypass asked for, which a stored operator always has.
#[derive(PartialEq)]
struct Op {
user: NameAndId,
level: Option<i32>,
bypass: Option<bool>,
}
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
fn shown(user: &NameAndId) -> String {
format!(
"NameAndId[id={}, name={}]",
horizon_protocol::crypto::uuid_string(user.id),
user.name
)
}
impl Game<'_> {
pub(super) fn management(&mut self, state: &mut ServerState, request: &str) -> Value {
let (connection, body) = request.split_once(' ').unwrap_or((request, "{}"));
let rpc =
|message: String| log("INFO", &format!("RPC Connection #{connection}: {message}"));
let Ok(body) = serde_json::from_str::<Value>(body) else {
return json!({ "error": "a management request is a connection number and a JSON object" });
};
let entries = body["entries"].as_array().cloned().unwrap_or_default();
let lists = state.lists.clone();
match body["method"].as_str().unwrap_or_default() {
"allowlist/add" => {
for user in entries.iter().filter_map(|e| self.management_user(e)) {
rpc(format!("Add player '{}' to allowlist", shown(&user)));
lists.lock().unwrap().white.add(WhiteEntry { user });
}
}
"allowlist/remove" => {
for user in entries.iter().filter_map(|e| self.management_user(e)) {
rpc(format!("Remove player '{}' from allowlist", shown(&user)));
lists.lock().unwrap().white.remove(&user);
}
rpc("Kick unlisted players".into());
self.kick_unlisted_players(&lists);
}
"allowlist/set" => {
let wanted: Vec<NameAndId> = entries
.iter()
.filter_map(|e| self.management_user(e))
.collect();
let current: Vec<NameAndId> = lists
.lock()
.unwrap()
.white
.entries()
.iter()
.map(|e| e.user.clone())
.collect();
for user in current.iter().filter(|u| !wanted.contains(u)) {
rpc(format!("Remove player '{}' from allowlist", shown(user)));
lists.lock().unwrap().white.remove(user);
}
for user in wanted.iter().filter(|u| !current.contains(u)) {
rpc(format!("Add player '{}' to allowlist", shown(user)));
lists
.lock()
.unwrap()
.white
.add(WhiteEntry { user: user.clone() });
}
rpc("Kick unlisted players".into());
self.kick_unlisted_players(&lists);
}
"allowlist/clear" => {
rpc("Clear allowlist".into());
lists.lock().unwrap().white.clear();
}
"bans/add" => {
let bans: Vec<Ban> = entries
.iter()
.filter_map(|e| self.management_ban(e))
.collect();
for ban in bans {
self.management_add_ban(&rpc, state, ban);
}
}
"bans/remove" => {
for user in entries.iter().filter_map(|e| self.management_user(e)) {
rpc(format!("Remove player '{}' from banlist", shown(&user)));
lists.lock().unwrap().bans.remove(&user);
}
}
"bans/set" => {
let wanted: Vec<Ban> = entries
.iter()
.filter_map(|e| self.management_ban(e))
.collect();
let current: Vec<Ban> = lists
.lock()
.unwrap()
.bans
.entries()
.into_iter()
.cloned()
.collect();
for ban in current.iter().filter(|b| !wanted.contains(b)) {
rpc(format!("Remove player '{}' from banlist", shown(&ban.user)));
lists.lock().unwrap().bans.remove(&ban.user);
}
for ban in wanted.into_iter().filter(|b| !current.contains(b)) {
self.management_add_ban(&rpc, state, ban);
}
}
// MinecraftBanListServiceImpl.clearUserBans logs nothing.
"bans/clear" => lists.lock().unwrap().bans.clear(),
"ip_bans/add" => {
for entry in &entries {
// An address, else the address of the player named, if online.
let ip = entry["ip"]
.as_str()
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok());
let ip = ip.map(str::to_owned).or_else(|| {
let player = entry.get("player")?;
self.management_online(player).map(|(_, ip)| ip)
});
if let Some(ip) = ip {
self.management_ip_ban(&rpc, state, management_ip_ban(entry, ip));
}
}
}
"ip_bans/remove" => {
for ip in entries.iter().filter_map(|e| e["ip"].as_str()) {
rpc(format!("Remove ip '{ip}' from ban list"));
lists.lock().unwrap().ip_bans.remove(&ip.to_owned());
}
}
"ip_bans/set" => {
let wanted: Vec<IpBan> = entries
.iter()
.filter_map(|e| {
let ip = e["ip"]
.as_str()
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok())?;
Some(management_ip_ban(e, ip.to_owned()))
})
.collect();
let current: Vec<IpBan> = lists
.lock()
.unwrap()
.ip_bans
.entries()
.into_iter()
.cloned()
.collect();
for ban in current.iter().filter(|b| !wanted.contains(b)) {
rpc(format!("Remove ip '{}' from ban list", ban.user));
lists.lock().unwrap().ip_bans.remove(&ban.user);
}
for ban in wanted.into_iter().filter(|b| !current.contains(b)) {
self.management_ip_ban(&rpc, state, ban);
}
}
"ip_bans/clear" => {
rpc("Clear ip ban list".into());
lists.lock().unwrap().ip_bans.clear();
}
"operators/add" => {
let ops: Vec<Op> = entries
.iter()
.filter_map(|e| self.management_op(e))
.collect();
for op in ops {
self.management_add_op(&rpc, state, op);
}
}
"operators/remove" => {
for user in entries.iter().filter_map(|e| self.management_user(e)) {
rpc(format!("Deop '{}'", shown(&user)));
lists.lock().unwrap().ops.remove(&user);
}
self.sync_ops(&lists);
}
"operators/set" => {
let wanted: Vec<Op> = entries
.iter()
.filter_map(|e| self.management_op(e))
.collect();
let current: Vec<Op> = lists
.lock()
.unwrap()
.ops
.entries()
.iter()
.map(|e| Op {
user: e.user.clone(),
level: Some(e.level),
bypass: Some(e.bypasses_player_limit),
})
.collect();
for op in current.iter().filter(|o| !wanted.contains(o)) {
rpc(format!("Deop '{}'", shown(&op.user)));
lists.lock().unwrap().ops.remove(&op.user);
}
for op in wanted.into_iter().filter(|o| !current.contains(o)) {
self.management_add_op(&rpc, state, op);
}
self.sync_ops(&lists);
}
"operators/clear" => {
rpc("Clear operator list".into());
lists.lock().unwrap().ops.clear();
self.sync_ops(&lists);
}
other => return json!({ "error": format!("unknown management method {other:?}") }),
}
json!({ "ok": true })
}
/// `addUserBan`, then the banned player, if online, is sent away.
fn management_add_ban(&mut self, rpc: &impl Fn(String), state: &ServerState, ban: Ban) {
let reason = ban.reason.clone().unwrap_or_else(|| {
Text::translate("multiplayer.disconnect.banned.reason.default", &[]).plain()
});
rpc(format!(
"Add player '{}' to banlist. Reason: '{reason}'",
ban.user.name
));
let id = ban.user.id;
state.lists.lock().unwrap().bans.add(ban);
self.kick_uuid(id, translatable("multiplayer.disconnect.banned", &[]));
}
/// `addIpBan`, then whoever is online from that address is sent away.
fn management_ip_ban(&mut self, rpc: &impl Fn(String), state: &ServerState, ban: IpBan) {
rpc(format!("Add ip '{}' to ban list", ban.user));
let ip = ban.user.clone();
state.lists.lock().unwrap().ip_bans.add(ban);
let banned: Vec<u128> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter(|g| g.address.ip().to_string() == ip)
.map(|g| g.uuid)
.collect()
});
for uuid in banned {
self.kick_uuid(uuid, translatable("multiplayer.disconnect.ip_banned", &[]));
}
}
/// `PlayerList.op`: the level asked for, else `op-permission-level`; the bypass asked for, else the one
/// the player had. The commands an online player may run follow (`sync_ops`, after the edit).
fn management_add_op(&mut self, rpc: &impl Fn(String), state: &ServerState, op: Op) {
rpc(format!("Op '{}'", shown(&op.user)));
let mut lists = state.lists.lock().unwrap();
let had = lists
.ops
.get(&op.user)
.is_some_and(|e| e.bypasses_player_limit);
let entry = OpEntry {
level: op.level.unwrap_or(lists.op_level),
bypasses_player_limit: op.bypass.unwrap_or(had),
user: op.user,
};
lists.ops.add(entry);
drop(lists);
self.sync_ops(&state.lists);
}
/// An `OperatorDto`: the player found, and the level and bypass when given.
fn management_op(&self, entry: &Value) -> Option<Op> {
Some(Op {
user: self.management_user(&entry["player"])?,
level: entry["permissionLevel"].as_i64().map(|l| l as i32),
bypass: entry["bypassesPlayerLimit"].as_bool(),
})
}
/// `PlayerListService.getPlayer(id, name)`: an online player, by id first; their address.
fn management_online(&self, player: &Value) -> Option<(u128, String)> {
let id = player["id"]
.as_str()
.and_then(|s| u128::from_str_radix(s, 16).ok());
let name = player["name"].as_str();
let guest = self
.lan
.as_ref()?
.hub
.guests
.iter()
.find(|g| match (id, name) {
(Some(id), _) => g.uuid == id,
(None, Some(name)) => g.name.eq_ignore_ascii_case(name),
(None, None) => false,
})?;
Some((guest.uuid, guest.address.ip().to_string()))
}
/// `UserBanDto.toUserBan`: the player found, the source "Management server" unless one is given; the ban
/// starts now.
fn management_ban(&self, entry: &Value) -> Option<Ban> {
let user = self.management_user(&entry["player"])?;
let source = entry["source"].as_str().unwrap_or("Management server");
let reason = entry["reason"].as_str().map(str::to_owned);
Some(BanEntry::new(
user,
Some(source.to_owned()),
entry["expires"].as_i64(),
reason,
))
}
/// `MinecraftPlayerListService.getUser`: by id, the user cache, else the session service; by name, the
/// user cache, else the profile service (an offline server's offline player).
fn management_user(&self, player: &Value) -> Option<NameAndId> {
if let Some(id) = player["id"]
.as_str()
.and_then(|s| u128::from_str_radix(s, 16).ok())
{
let cached = super::USER_CACHE
.lock()
.unwrap()
.as_mut()
.and_then(|c| c.get_by_id(id));
return cached.or_else(|| profiles::find_by_id(&profiles::discovery_url(), id));
}
self.resolve_name(player["name"].as_str()?)
}
}
/// `IpBan`: the reason, the source "Management server" unless one is given, the end; it starts now.
fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
let source = entry["source"].as_str().unwrap_or("Management server");
let reason = entry["reason"].as_str().map(str::to_owned);
BanEntry::new(
ip,
Some(source.to_owned()),
entry["expires"].as_i64(),
reason,
)
}
@@ -1,7 +1,8 @@
//! What `horizon-server` asks the dedicated server on its control endpoint (`--control-socket`, docs/server.md
//! section 2): `server-status` answers with the tick rate, the tick times `/tick query` reports, the players
//! and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
//! `server-events <since>` answers the typed events after that number (events.rs).
//! `server-events <since>` answers the typed events after that number (events.rs); `management <connection>
//! <json>` edits the lists as vanilla's management protocol does (management.rs).
use super::{ServerState, capture_log};
use crate::game::Game;
use serde_json::{Value, json};
@@ -45,6 +46,7 @@ impl Game<'_> {
Some(("server-events", since)) => {
super::events::since(since.trim().parse().unwrap_or(0))
}
Some(("management", rest)) => self.management(state, rest),
Some(("console", line)) => {
// Command feedback is logged as system chat; the reply carries the text alone.
let output: Vec<String> = capture_log(|| self.console_command(state, line))
+71 -9
View File
@@ -45,23 +45,41 @@ pub(super) fn aim_deflect(
p.acceleration = 0.1;
}
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge
/// 0.3125, from its feet up) grown by the pick radius.
/// `Entity.getPickRadius`: only `AbstractHurtingProjectile` has one (1); a shulker
/// bullet's is 0.
fn pick_radius(p: &Projectile) -> f64 {
if matches!(p.kind, ProjectileKind::ShulkerBullet { .. }) {
0.0
} else {
PICK_RADIUS
}
}
/// A projectile a player's blow reaches: a redirectable one, or a shulker bullet
/// (`isPickable` and `isAttackable`), which `ShulkerBullet.hurtServer` pops.
pub(super) fn attackable(p: &Projectile) -> bool {
redirectable(p) || (!p.stuck && matches!(p.kind, ProjectileKind::ShulkerBullet { .. }))
}
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge or a
/// shulker bullet 0.3125, from its feet up) grown by the pick radius, which only
/// `AbstractHurtingProjectile` has (`getPickRadius`: 1; a shulker bullet's is 0).
fn pick_box(p: &Projectile) -> Aabb {
let size = match p.kind {
ProjectileKind::LargeFireball { .. } => 1.0,
_ => 0.3125,
};
let half = size / 2.0 + PICK_RADIUS;
let radius = pick_radius(p);
let half = size / 2.0 + radius;
Aabb::new(
Vec3::new(
p.position.x - half,
p.position.y - PICK_RADIUS,
p.position.y - radius,
p.position.z - half,
),
Vec3::new(
p.position.x + half,
p.position.y + size + PICK_RADIUS,
p.position.y + size + radius,
p.position.z + half,
),
)
@@ -77,7 +95,7 @@ impl Game<'_> {
.projectiles
.iter()
.enumerate()
.filter(|(_, p)| redirectable(p))
.filter(|(_, p)| attackable(p))
.filter_map(|(i, p)| ray_box(eye, dir, reach, pick_box(p)).map(|t| (i, t)))
.min_by(|a, b| a.1.total_cmp(&b.1))
}
@@ -103,10 +121,37 @@ impl Game<'_> {
return false;
}
}
self.deflect(i);
if matches!(
self.creatures.projectiles[i].kind,
ProjectileKind::ShulkerBullet { .. }
) {
self.hit_shulker_bullet(i);
} else {
self.deflect(i);
}
true
}
/// `ShulkerBullet.hurtServer` from a player's blow: the hurt sound, fifteen crit
/// particles (`sendParticles`, spread 0.2) and `destroy`. The blow is
/// `Player.attack` on a target that is not a mob: its sounds, then the swing.
pub(super) fn hit_shulker_bullet(&mut self, i: usize) {
let p = self.creatures.projectiles.remove(i);
let at = [p.position.x, p.position.y, p.position.z];
self.audio
.play("minecraft:entity.shulker_bullet.hurt", Some(at), 1.0, 1.0);
if let Some(ty) = horizon_client::particles::ParticleType::from_name("crit") {
let options = horizon_client::particles::ParticleOptions::simple(ty);
self.particles
.send_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15);
self.lan_level_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
}
let (_, _, _, magic) = self.melee_damage("minecraft:shulker_bullet", false);
self.vehicle_attack_sounds(p.aabb(), magic.then_some(p.id as i32));
self.reset_attack_strength();
self.swing_arm();
}
/// `Player.canInteractWithEntity(box, 3.0)` as the server checks an
/// attack packet: the projectile's box within the reach plus 3 of the eyes.
pub(super) fn within_interaction_range(&self, i: usize) -> bool {
@@ -115,9 +160,10 @@ impl Game<'_> {
};
let b = pick_box(p);
let (lo, hi) = (b.min_position(), b.max_position());
let r = pick_radius(&self.creatures.projectiles[i]);
let (lo, hi) = (
[lo.x + PICK_RADIUS, lo.y + PICK_RADIUS, lo.z + PICK_RADIUS],
[hi.x - PICK_RADIUS, hi.y - PICK_RADIUS, hi.z - PICK_RADIUS],
[lo.x + r, lo.y + r, lo.z + r],
[hi.x - r, hi.y - r, hi.z - r],
);
let d2: f64 = (0..3)
.map(|a| (eye[a] - eye[a].clamp(lo[a], hi[a])).powi(2))
@@ -192,6 +238,22 @@ mod tests {
let small = Projectile::fireball(ProjectileKind::SmallFireball, at, None, [1.0, 0.0, 0.0]);
assert!(redirectable(&large));
assert!(!redirectable(&small));
// A shulker bullet takes a blow, with no pick radius round its box.
let bullet = Projectile::new(
ProjectileKind::ShulkerBullet {
target: Who::Player,
dir: None,
steps: 0,
delta: [0.0; 3],
seed: 0,
},
at,
None,
);
assert!(attackable(&bullet) && !redirectable(&bullet));
assert!(!attackable(&small));
let b = pick_box(&bullet);
assert_eq!(b.max_position().y, 64.3125);
// The pick box: a block round the fireball's 1 x 1 box.
let b = pick_box(&large);
assert_eq!(b.min_position().x, -1.5);
+127 -10
View File
@@ -15,6 +15,7 @@ use horizon_client::{
multiline_edit::MultiLineEdit,
text::{ClickEvent, Text},
ui,
widgets::{self, ScreenInfo},
};
use horizon_world::nbt::Tag;
use std::time::Instant;
@@ -111,6 +112,18 @@ impl Game<'_> {
let d = &self.dialogs;
d.open.is_some() || d.waiting.is_some() || d.warning
}
/// Whether the dialog's focus is one of its text inputs, which takes the
/// keys.
pub(super) fn dialog_typing(&self) -> bool {
let d = &self.dialogs;
!d.warning
&& d.waiting.is_none()
&& d.open.as_ref().is_some_and(|o| {
o.focus
.and_then(|f| o.fields.get(f))
.is_some_and(Option::is_some)
})
}
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
/// under the warning screen, it is what Back returns to.
@@ -144,6 +157,11 @@ impl Game<'_> {
});
dialog.set_list(&entries.unwrap_or_default());
}
self.open_dialog(dialog);
}
/// The parsed dialog's screen, its inputs at their initial values.
fn open_dialog(&mut self, dialog: Dialog) {
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
let font = &self.gui.atlas.font;
let fields = dialog
@@ -200,6 +218,7 @@ impl Game<'_> {
});
self.dialogs.waiting = None;
self.keys.clear();
self.physical_keys.clear();
self.left = false;
self.right = false;
self.capture(false);
@@ -368,6 +387,14 @@ impl Game<'_> {
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
if self.dialogs.warning {
let (title, message, [disconnect, back]) = warning_texts();
// DialogScreen.WarningScreen, a ConfirmScreen: its title and its
// message.
widgets::begin_screen(|| {
ScreenInfo::new(
"dialog/warning",
widgets::join_for_narration(&[&title.plain(), &message.plain()]),
)
});
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
batch,
atlas,
@@ -381,6 +408,12 @@ impl Game<'_> {
let ticks = (since.elapsed().as_millis() / 50) as u32;
let (label, visible, active) = waiting_button(ticks);
let title = Text::translate("gui.waitingForResponse.title", &[]);
widgets::begin_screen(|| ScreenInfo::new("dialog/waiting", title.plain()));
// WaitingForResponseScreen.tick: the close button turning active
// after five seconds is narrated at once.
if active && ticks < 102 {
widgets::narrate_now();
}
let top = ((h - 53.0) / 2.0).trunc();
let tw = atlas.font.width_text(&title);
batch.text_styled(
@@ -408,6 +441,11 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let [ax, ay, aw, ah] = layout.area;
open.scroll.clamp(ay, ay + ah, layout.content);
// DialogScreen: titled by its dialog; a new dialog is a new screen.
widgets::begin_screen(|| {
let title = open.dialog.title.plain();
ScreenInfo::new(format!("dialog:{title}"), title)
});
batch.text_styled(
atlas,
&open.dialog.title,
@@ -419,6 +457,19 @@ impl Game<'_> {
let [wx, wy, ww, wh] = layout.warning;
let warn_hover = ui::hovered(cursor, wx, wy, ww, wh);
horizon_client::widgets::record("warning", layout.warning, true);
// The warning ImageButton: its narration message and tooltip, first
// in tab order (group -10).
widgets::narratable(|| {
let t = |key: &str| Text::translate(key, &[]).plain();
widgets::Narratable::button(
t("menu.custom_screen_info.button_narration"),
layout.warning,
true,
)
.hovered(warn_hover)
.hint(t("menu.custom_screen_info.tooltip"))
.order(-10)
});
batch.sprite_scaled(
atlas,
WARNING_SPRITES[if warn_hover { 2 } else { 0 }],
@@ -445,16 +496,29 @@ impl Game<'_> {
}
Part::Field { input, rect } => {
let focused = open.focus == Some(input);
// The edit box is named after its input's label.
let label = match &open.dialog.inputs[input].control {
Control::Text { label, .. } if widgets::narrating() => label.plain(),
_ => String::new(),
};
match open.fields.get_mut(input) {
Some(Some(Field::Line(edit))) => {
[edit.x, edit.y, edit.width, edit.height] = rect;
edit.focused = focused;
if widgets::narrating() {
edit.message = label;
}
edit.draw(batch, atlas, None);
}
Some(Some(Field::Multi(edit))) => {
[edit.x, edit.y, edit.width, edit.height] = rect;
edit.set_focused(focused);
edit.draw(batch, atlas);
// MultiLineEditBox.updateWidgetNarration: as an EditBox.
widgets::narratable(|| {
widgets::Narratable::edit_box(label, edit.value(), rect)
.focused(focused)
});
}
_ => {}
}
@@ -473,6 +537,9 @@ impl Game<'_> {
continue;
};
horizon_client::widgets::record(&label.plain(), rect, true);
widgets::narratable(|| {
widgets::Narratable::checkbox(label.plain(), on, rect, true).hovered(hover)
});
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
batch.text_styled(
atlas,
@@ -494,6 +561,25 @@ impl Game<'_> {
continue;
};
ui::button(batch, atlas, x, y, cw, ch, &label, true, hover);
// A CycleButton: the option a click moves to.
widgets::last_narratable(|n| {
let control = &open.dialog.inputs[input].control;
let next = match (control, open.values.get(input)) {
(
Control::SingleOption { options, .. },
Some(InputValue::Option(i)),
) if !options.is_empty() => {
let (id, display, _) = &options[(i + 1) % options.len()];
display.clone().unwrap_or_else(|| Text::literal(id)).plain()
}
_ => String::new(),
};
n.what = widgets::What::Cycle {
message: label.plain(),
next,
active: true,
};
});
}
Part::Slider { input, rect } => {
let [x, y, sw, sh] = rect;
@@ -548,6 +634,9 @@ impl Game<'_> {
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
let b = buttons[index];
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
if let Some(t) = &b.tooltip {
widgets::last_narratable(|n| n.hint = Some(t.plain()));
}
if hover && let Some(t) = &b.tooltip {
tooltip = Some(t.clone());
}
@@ -698,6 +787,7 @@ impl Game<'_> {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
open.focus = Some(input);
open.dragging = Some(input);
if let Some(v) = open.values.get_mut(input) {
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
@@ -770,16 +860,17 @@ impl Game<'_> {
if let Some(open) = self.dialogs.open.as_mut() {
let [_, ay, _, ah] = layout.area;
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
let hovered = layout
.placed(open.scroll.amount)
.find_map(|(part, _)| match part {
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => Some(input),
_ => None,
});
if let Some(input) = hovered {
let hovered_cycle =
layout
.placed(open.scroll.amount)
.find_map(|(part, _)| match part {
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => Some(input),
_ => None,
});
if let Some(input) = hovered_cycle {
// CycleButton.mouseScrolled: up goes back.
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
return;
@@ -914,6 +1005,32 @@ impl Game<'_> {
}
return Ok(());
}
if let Some(focus) = open.focus
&& let Some(input) = open.dialog.inputs.get(focus)
&& let Control::NumberRange {
start,
end,
step,
width,
..
} = &input.control
&& let Some(InputValue::Slider(val)) = open.values.get_mut(focus)
&& matches!(key, KeyCode::ArrowLeft | KeyCode::ArrowRight)
{
let delta = if let Some(step) = step {
let range = (end - start).abs();
if range > 0.0 { *step / range } else { 0.01 }
} else {
1.0 / (*width as f32 - 8.0).max(1.0)
};
let step_sign = if key == KeyCode::ArrowRight {
delta
} else {
-delta
};
*val = (*val + step_sign).clamp(0.0, 1.0);
return Ok(());
}
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
return Ok(());
};
+195 -31
View File
@@ -8,6 +8,7 @@
//! exit portal, the egg and the next gateway; and its rendering and boss bar.
use super::{Dim, Game};
use horizon_client::{
health::HurtSource,
hud::HudBatch,
hud_layout, hud_overlays,
mesher::Vertex,
@@ -468,7 +469,7 @@ impl Dragon {
&mut self,
part: usize,
damage: f32,
counts: bool,
counts: Option<HurtSource<'_>>,
kind: DamageKind,
direct: Option<&ProjectileKind>,
) -> Hurt {
@@ -484,11 +485,12 @@ impl Dragon {
self.hurt(part, damage, counts, arrow)
}
/// `EnderDragon.hurt(part, source, damage)`: whether the hit landed
/// (an arrow is spent, else it turns back). `counts` is
/// `source.getEntity() instanceof Player` or a damage type that always
/// hurts dragons (explosions); `arrow` a direct arrow or wind charge,
/// which a perched dragon shrugs off (`AbstractDragonSittingPhase.onHurt`).
fn hurt(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt {
/// (an arrow is spent, else it turns back). `counts` is the source
/// `reallyHurt` takes when `source.getEntity() instanceof Player` or its
/// type always hurts dragons (explosions), and none otherwise; `arrow` a
/// direct arrow or wind charge, which a perched dragon shrugs off
/// (`AbstractDragonSittingPhase.onHurt`).
fn hurt(&mut self, part: usize, damage: f32, counts: Option<HurtSource>, arrow: bool) -> Hurt {
if matches!(self.phase, Phase::Dying { .. }) || !self.alive() {
return Hurt::Refused;
}
@@ -502,10 +504,22 @@ impl Dragon {
if damage < 0.01 {
return Hurt::Refused;
}
if !counts {
let Some(source) = counts else {
return Hurt::Landed { full: false };
};
let before = self.health;
// reallyHurt: LivingEntity.hurtServer, past its guards: a native mod's
// hook. A canceled hit still returns true.
let hurting = horizon_client::health::Hurting::Dragon(self.id as i32, source);
if horizon_client::health::hurt_server_canceled(hurting, &mut damage) {
return Hurt::Landed { full: false };
}
let before = self.health;
if damage < 0.0 {
damage = 0.0;
}
if !damage.is_finite() {
damage = f32::MAX;
}
// LivingEntity.hurtServer: within the cooldown only what exceeds the last hit.
let full = if self.invulnerable > 10 {
if damage <= self.last_hurt {
@@ -603,6 +617,23 @@ impl Dragon {
self.phase = phase;
}
}
/// The source `reallyHurt` takes from a hit `by` the player of that entity
/// id: `DamageSources.explosion(crystal, player)` is `player_explosion` with
/// a player behind it, who is the causing entity, and the direct one of a
/// melee hit (a projectile's direct entity isn't known here).
fn hurt_source(
kind: DamageKind,
by: Option<i32>,
direct: Option<&ProjectileKind>,
) -> HurtSource<'static> {
let causing = by.unwrap_or(0);
let melee = direct.is_none() && super::creatures::melee(kind);
HurtSource {
damage_type: super::creatures::damage_type(kind, by.is_some()),
causing,
direct: if melee { causing } else { 0 },
}
}
fn wrap(d: f32) -> f32 {
let r = d.rem_euclid(360.0);
if r >= 180.0 { r - 360.0 } else { r }
@@ -749,6 +780,21 @@ impl Game<'_> {
fn arena_loaded(&self) -> bool {
(-2..=1).all(|cz| (-2..=1).all(|cx| self.streaming.is_lit(cx, cz)))
}
/// A player's entity id: the acting player's body has it, another
/// player's comes from the LAN host (0 when it is out on a guest's turn,
/// and for an in-frame host with none).
fn fight_entity(&self, who: FightWho) -> i32 {
if who == self.acting_player() {
return self.player.as_ref().map_or(0, |p| p.entity_id);
}
let host = self.lan.as_ref();
match who {
FightWho::Host => host.map_or(0, |h| h.host_entity),
FightWho::Guest(conn) => host
.and_then(|h| h.hub.guest(conn))
.map_or(0, |g| g.entity_id),
}
}
/// The player acting now: the guest whose turn it is, else the host.
fn acting_player(&self) -> FightWho {
self.lan_acting.map_or(FightWho::Host, FightWho::Guest)
@@ -962,7 +1008,14 @@ impl Game<'_> {
self.fight.blast_by = by;
// EndCrystal.hurtServer: ExplosionInteraction.BLOCK.
let interaction = self.destroy_type("block_explosion_drop_decay");
// EndCrystal is 2 by 2 blocks: its box decides `isInWater`.
let (x, y, z) = (c.position.x, c.position.y, c.position.z);
self.combat.explosion_in_water = self.bounds_in_water(Aabb::new(
Vec3::new(x - 1.0, y, z - 1.0),
Vec3::new(x + 1.0, y + 2.0, z + 1.0),
));
let _ = self.server_explode(at, 6.0, interaction, false);
self.combat.explosion_in_water = false;
self.fight.blast_by = None;
}
if respawn_crystal {
@@ -1090,12 +1143,14 @@ impl Game<'_> {
kind: DamageKind,
direct: Option<&ProjectileKind>,
) -> Hurt {
let by_id = by.map(|who| self.fight_entity(who));
let source = counts.then(|| hurt_source(kind, by_id, direct));
let Some(d) = self.dragon.as_mut() else {
return Hurt::Refused;
};
let was_down = d.down();
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
let hurt = d.hurt_part(part, damage, counts, kind, direct);
let hurt = d.hurt_part(part, damage, source, kind, direct);
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
// (100 ticks); a hit the damage cooldown absorbs returns before that.
if counts
@@ -3884,6 +3939,12 @@ mod tests {
}
}
/// The damage type of the hits below, which count.
const HIT: Option<HurtSource> = Some(HurtSource {
damage_type: "minecraft:player_attack",
causing: 0,
direct: 0,
});
fn dragon(phase: Phase) -> Dragon {
let mut d = Dragon::new(Vec3::new(0.0, 70.0, 0.0), 0.0, 1);
d.phase = phase;
@@ -3901,13 +3962,13 @@ mod tests {
fn a_perched_dragon_shrugs_arrows_off() {
// AbstractDragonSittingPhase.onHurt: an arrow does nothing, melee does.
let mut d = dragon(Phase::Scanning { time: 0 });
assert_eq!(d.hurt(0, 9.0, true, true), Hurt::Shrugged);
assert_eq!(d.hurt(0, 9.0, HIT, true), Hurt::Shrugged);
assert_eq!(d.health, MAX_HEALTH);
assert_eq!(d.hurt(0, 9.0, true, false), Hurt::Landed { full: true });
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH - 9.0);
// In flight an arrow lands.
let mut d = dragon(flying());
assert_eq!(d.hurt(0, 9.0, true, true), Hurt::Landed { full: true });
assert_eq!(d.hurt(0, 9.0, HIT, true), Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH - 9.0);
}
@@ -3918,13 +3979,13 @@ mod tests {
let large = ProjectileKind::LargeFireball { power: 1 };
for part in [0, 2] {
let mut d = dragon(flying());
let hurt = d.hurt_part(part, 6.0, true, DamageKind::Fireball, Some(&large));
let hurt = d.hurt_part(part, 6.0, HIT, DamageKind::Fireball, Some(&large));
assert_eq!(hurt, Hurt::Refused);
assert_eq!(d.health, MAX_HEALTH);
}
let small = ProjectileKind::SmallFireball;
let mut d = dragon(flying());
let hurt = d.hurt_part(0, 5.0, true, DamageKind::Fireball, Some(&small));
let hurt = d.hurt_part(0, 5.0, HIT, DamageKind::Fireball, Some(&small));
assert_eq!(hurt, Hurt::Refused);
assert_eq!(d.health, MAX_HEALTH);
}
@@ -3935,16 +3996,16 @@ mod tests {
// nothing, a breeze's BreezeWindCharge is not one.
let charge = ProjectileKind::WindCharge;
let mut d = dragon(Phase::Scanning { time: 0 });
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&charge));
let hurt = d.hurt_part(0, 1.0, HIT, DamageKind::WindCharge, Some(&charge));
assert_eq!(hurt, Hurt::Shrugged);
assert_eq!(d.health, MAX_HEALTH);
let breeze = ProjectileKind::BreezeWindCharge;
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&breeze));
let hurt = d.hurt_part(0, 1.0, HIT, DamageKind::WindCharge, Some(&breeze));
assert_eq!(hurt, Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH - 1.0);
// In flight the player's charge lands too.
let mut d = dragon(flying());
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&charge));
let hurt = d.hurt_part(0, 1.0, HIT, DamageKind::WindCharge, Some(&charge));
assert_eq!(hurt, Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH - 1.0);
}
@@ -3957,17 +4018,17 @@ mod tests {
let wither = super::super::wither::skull_source(Some(Who::Mob(7)), [0.0; 3]);
assert_eq!((wither.amount, wither.kind), (8.0, DamageKind::WitherSkull));
let mut d = dragon(Phase::Scanning { time: 0 });
let hurt = d.hurt_part(2, wither.amount, false, wither.kind, Some(&skull));
let hurt = d.hurt_part(2, wither.amount, None, wither.kind, Some(&skull));
assert_eq!(hurt, Hurt::Landed { full: false });
assert_eq!(d.health, MAX_HEALTH);
// A player's skull: 8 on the head, 8 / 4 + 1 on the body.
let player = super::super::wither::skull_source(Some(Who::Player), [0.0; 3]);
let mut d = dragon(Phase::Scanning { time: 0 });
let hurt = d.hurt_part(0, player.amount, true, player.kind, Some(&skull));
let hurt = d.hurt_part(0, player.amount, HIT, player.kind, Some(&skull));
assert_eq!(hurt, Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH - 8.0);
let mut d = dragon(flying());
d.hurt_part(2, player.amount, true, player.kind, Some(&skull));
d.hurt_part(2, player.amount, HIT, player.kind, Some(&skull));
assert_eq!(d.health, MAX_HEALTH - 3.0);
}
@@ -3975,14 +4036,14 @@ mod tests {
fn the_cooldown_lets_only_the_excess_through() {
// LivingEntity.hurtServer: damageCooldownTime > 10 keeps lastHurt.
let mut d = dragon(flying());
assert_eq!(d.hurt(0, 6.0, true, false), Hurt::Landed { full: true });
assert_eq!(d.hurt(0, 4.0, true, false), Hurt::Landed { full: false });
assert_eq!(d.hurt(0, 6.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(d.hurt(0, 4.0, HIT, false), Hurt::Landed { full: false });
assert_eq!(d.health, MAX_HEALTH - 6.0);
assert_eq!(d.hurt(0, 10.0, true, false), Hurt::Landed { full: false });
assert_eq!(d.hurt(0, 10.0, HIT, false), Hurt::Landed { full: false });
assert_eq!(d.health, MAX_HEALTH - 10.0);
// Ten ticks on, the window is over: a full hit again.
d.invulnerable = 10;
assert_eq!(d.hurt(0, 3.0, true, false), Hurt::Landed { full: true });
assert_eq!(d.hurt(0, 3.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH - 13.0);
}
@@ -3990,28 +4051,131 @@ mod tests {
fn parts_and_sources() {
// Neither head nor neck: a quarter plus one.
let mut d = dragon(flying());
assert_eq!(d.hurt(2, 20.0, true, false), Hurt::Landed { full: true });
assert_eq!(d.hurt(2, 20.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH - 6.0);
// Not a player's and not an explosion: true, but no damage.
let mut d = dragon(flying());
assert_eq!(d.hurt(0, 20.0, false, false), Hurt::Landed { full: false });
assert_eq!(d.hurt(0, 20.0, None, false), Hurt::Landed { full: false });
assert_eq!(d.health, MAX_HEALTH);
// Too little after the part's cut, and nothing while dying.
assert_eq!(d.hurt(3, 0.001, true, false), Hurt::Refused);
assert_eq!(d.hurt(3, 0.001, HIT, false), Hurt::Refused);
d.phase = Phase::Dying {
target: None,
time: 0,
};
assert_eq!(d.hurt(0, 20.0, true, false), Hurt::Refused);
assert_eq!(d.hurt(0, 20.0, HIT, false), Hurt::Refused);
}
/// The dragon hits the hook of these tests saw: entity, damage type,
/// causing and direct entities, amount.
type Seen = (String, i32, i32, f32);
static SEEN: std::sync::Mutex<Vec<(i32, Seen)>> = std::sync::Mutex::new(Vec::new());
fn seen(entity: i32) -> Vec<Seen> {
let seen = SEEN.lock().unwrap();
seen.iter()
.filter(|s| s.0 == entity)
.map(|s| s.1.clone())
.collect()
}
/// Sets the native damage hook of these tests: it records the dragon
/// hits of entities 9001 to 9005, cancels 9001's, doubles 9002's, makes
/// 9003's -5 and 9004's NaN. `HURT_SERVER` is global, so it stays set for
/// every test of the process; the other tests' dragons are entity 0.
fn set_hook() {
use horizon_client::health::{HURT_SERVER, Hurting};
fn hook(hurting: Hurting<'_>, amount: &mut f32) -> bool {
let Hurting::Dragon(entity @ 9001..=9005, s) = hurting else {
return false;
};
let hit = (s.damage_type.to_owned(), s.causing, s.direct, *amount);
SEEN.lock().unwrap().push((entity, hit));
match entity {
9002 => *amount *= 2.0,
9003 => *amount = -5.0,
9004 => *amount = f32::NAN,
_ => {}
}
entity == 9001
}
let installed = *HURT_SERVER.get_or_init(|| hook);
let ours: fn(Hurting<'_>, &mut f32) -> bool = hook;
assert!(
std::ptr::fn_addr_eq(installed, ours),
"another native damage hook is set"
);
}
fn dragon_as(entity: u32) -> Dragon {
let mut d = dragon(flying());
d.id = entity;
d
}
#[test]
fn the_native_damage_hook_changes_or_cancels_a_dragons_hit() {
set_hook();
// EnderDragon.hurt returns true whatever reallyHurt did. A canceled
// hit takes no health, starts no cooldown and isn't full: what
// hurt_dragon_part reads to credit the player and play the hurt sound.
let mut d = dragon_as(9001);
let arrow = HurtSource {
damage_type: "minecraft:arrow",
causing: 12,
direct: 0,
};
let hurt = d.hurt(0, 9.0, Some(arrow), false);
assert_eq!(hurt, Hurt::Landed { full: false });
assert_eq!(seen(9001), [("minecraft:arrow".to_owned(), 12, 0, 9.0)]);
assert_eq!(d.health, MAX_HEALTH);
assert_eq!((d.invulnerable, d.last_hurt, d.hurt_time), (0, 0.0, 0));
// The hook sees the amount after the part's cut (20 / 4 + 1).
let mut d = dragon_as(9002);
assert_eq!(d.hurt(2, 20.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(
seen(9002),
[("minecraft:player_attack".to_owned(), 0, 0, 6.0)]
);
assert_eq!(d.health, MAX_HEALTH - 12.0);
// Neither a hit that doesn't count nor one cut below 0.01 reaches
// reallyHurt.
let mut d = dragon_as(9005);
assert_eq!(d.hurt(0, 9.0, None, false), Hurt::Landed { full: false });
assert_eq!(d.hurt(3, 0.001, HIT, false), Hurt::Refused);
assert!(seen(9005).is_empty());
// LivingEntity.hurtServer: a negative amount hurts by 0, a NaN by
// Float.MAX_VALUE (the killing blow: the dying flight).
let mut d = dragon_as(9003);
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(d.health, MAX_HEALTH);
let mut d = dragon_as(9004);
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
assert_eq!(d.health, 1.0);
assert!(matches!(d.phase, Phase::Dying { .. }));
}
#[test]
fn a_hit_names_the_player_behind_it() {
let source = |kind, by, direct| {
let s = super::hurt_source(kind, by, direct);
(s.damage_type, s.causing, s.direct)
};
let melee = source(DamageKind::PlayerAttack, Some(12), None);
assert_eq!(melee, ("minecraft:player_attack", 12, 12));
let arrow = ProjectileKind::Trident;
let shot = source(DamageKind::Arrow, Some(12), Some(&arrow));
assert_eq!(shot, ("minecraft:arrow", 12, 0));
let blast = source(DamageKind::Explosion, Some(12), None);
assert_eq!(blast, ("minecraft:player_explosion", 12, 0));
let blast = source(DamageKind::Explosion, None, None);
assert_eq!(blast, ("minecraft:explosion", 0, 0));
}
#[test]
fn a_quarter_of_its_health_while_perched_makes_it_take_off() {
let mut d = dragon(Phase::Flaming { ticks: 0 });
d.hurt(0, 30.0, true, false);
d.hurt(0, 30.0, HIT, false);
assert!(d.phase.sitting());
d.invulnerable = 0;
d.hurt(0, 21.0, true, false);
d.hurt(0, 21.0, HIT, false);
assert!(matches!(d.phase, Phase::Takeoff { .. }));
assert_eq!(d.sitting_damage, 0.0);
}
@@ -4035,7 +4199,7 @@ mod tests {
assert!(covered.iter().all(|h| h.1 == 1.0));
// Through the cooldown the strongest hit after the part's cut wins.
for (part, amount) in open {
d.hurt(part, amount, true, false);
d.hurt(part, amount, HIT, false);
}
assert_eq!(d.health, MAX_HEALTH - 85.0);
}
@@ -364,7 +364,9 @@ impl Game<'_> {
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
let (new, entity) = (!m.has_effect(id), m.id as i32);
m.add_effect(id, ticks, amplifier);
self.mod_effect_added(id, Some(entity), amplifier, new);
}
}
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
@@ -488,14 +490,7 @@ impl Game<'_> {
.is_some_and(|i| s.damage >= i.max_damage)
{
self.armor[slot] = None;
if let Some(eye) = self.eye_position() {
self.audio.play(
"minecraft:entity.item.break",
Some(eye),
0.8,
0.8 + self.world.random.next_float() * 0.4,
);
}
self.player_equipment_broke(super::armor_slot(slot), &stack);
self.apply_equipment();
}
}
@@ -760,16 +755,11 @@ impl Game<'_> {
boots.damage += loss;
if boots.damage >= max {
self.armor[3] = None;
if let Some(eye) = self.eye_position() {
self.audio.play(
"minecraft:entity.item.break",
Some(eye),
0.8,
0.8 + self.world.random.next_float() * 0.4,
);
}
// LivingEntity.breakItem: the broken boots' five crumbs.
self.eat_particles(&stack, 5);
// LivingEntity.onEquippedItemBroken: the broken boots.
self.player_equipment_broke(
horizon_world::player_inventory::EquipmentSlot::Feet,
&stack,
);
self.apply_equipment();
}
}
+69 -129
View File
@@ -6,7 +6,7 @@ use super::{Dim, Game};
use horizon_client::nether_portal::ENTITY_DELAY;
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
containers::BlockEntity,
environment::Environment,
items::ItemStack,
mesher::Vertex,
@@ -39,8 +39,6 @@ pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
const GATEWAY_RADIUS: f64 = 96.0;
const GATEWAY_Y: i32 = 75;
/// File (in the End region folder) keeping End gateway exits.
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
/// A gateway teleport waiting for its destination chunks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -74,17 +72,21 @@ enum Arriving {
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
gateway: [i32; 3],
entity: Option<&BlockEntity>,
legacy: Option<([i32; 3], bool)>,
) -> Self {
/// The source of a new exit must stay loaded until its block entity is updated.
pub(super) fn source_chunk(self) -> Option<(i32, i32)> {
match self {
Self::Find { gateway, .. } => Some((gateway[0] >> 4, gateway[2] >> 4)),
Self::Known { .. } => None,
}
}
/// TheEndGatewayBlockEntity.getPortalPosition, using the gateway block entity.
fn for_gateway(gateway: [i32; 3], entity: Option<&BlockEntity>) -> Self {
let configured = match entity {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
match configured.or(legacy) {
match configured {
Some((exit, exact)) => Self::Known { exit, exact },
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
@@ -105,17 +107,6 @@ fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
}
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
/// entity has its own; true when it took the saved one.
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
if entity.exit_portal.is_some() {
return false;
}
entity.exit_portal = Some(exit);
entity.exact_teleport = exact;
true
}
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
@@ -124,8 +115,6 @@ fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32;
#[derive(Default)]
pub(super) struct EndState {
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
exits: Option<HashMap<[i32; 3], ([i32; 3], bool)>>,
job: Option<GatewayJob>,
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
gateway_cooldown: u32,
@@ -932,74 +921,39 @@ impl Game<'_> {
Ok(())
}
fn gateway_exits(&mut self) -> &mut HashMap<[i32; 3], ([i32; 3], bool)> {
if self.end.exits.is_none() {
let path = horizon_client::save::dimension_dir(&self.world_root, "minecraft:the_end")
.join(GATEWAY_FILE);
let parsed: Option<Vec<([i32; 3], [i32; 3], bool)>> = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
self.end.exits = Some(
parsed
.unwrap_or_default()
.into_iter()
.map(|(g, e, x)| (g, (e, x)))
.collect(),
);
}
self.end.exits.as_mut().expect("loaded exits")
}
fn save_gateway_exits(&mut self) -> Result<()> {
let list: Vec<_> = self
.gateway_exits()
.iter()
.map(|(g, (e, x))| (*g, *e, *x))
.collect();
let dir = horizon_client::save::dimension_dir(&self.world_root, "minecraft:the_end");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join(GATEWAY_FILE), serde_json::to_vec(&list)?)?;
Ok(())
}
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
if self.gateway_exits().remove(&gateway).is_some() {
self.save_gateway_exits()?;
}
Ok(())
}
/// `TheEndGatewayBlockEntity.setExitPosition`: the block entity alone owns the exit.
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
// exit then waits in the side file until the gateway is used again (`gateway_job`).
if self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
if !self.world.chunks.contains_key(&chunk) {
return Err("gateway source chunk is not loaded".into());
}
// A gateway removed while an entity waits for the destination stays removed.
if &*self.block_name(gateway) != "minecraft:end_gateway" {
return Ok(());
}
if let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with((gateway[0], gateway[1], gateway[2]), || {
BlockEntity::EndGateway(Default::default())
})
{
g.exit_portal = Some(exit);
g.exact_teleport = exact;
self.world.modified.insert(chunk);
return self.forget_gateway_exit(gateway);
}
self.gateway_exits().insert(gateway, (exit, exact));
self.save_gateway_exits()
Ok(())
}
/// The exit a gateway already leads to: its block entity, or the side file
/// that kept it for a gateway whose chunk is not loaded.
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
let key = (gateway[0], gateway[1], gateway[2]);
let saved = match self.world.block_entities.get(&key) {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
fn known_gateway_exit(&self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
match self
.world
.block_entities
.get(&(gateway[0], gateway[1], gateway[2]))
{
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|e| (e, g.exact_teleport)),
_ => None,
};
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
}
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
@@ -1021,32 +975,18 @@ impl Game<'_> {
/// its exit, or the search for one, and the spot to load before either
/// is resolved ([`Game::resolve_gateway`]).
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
let mut known = self.gateway_exits().get(&gateway).copied();
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
// side file kept for it moves there, and the entry is dropped, so removing or replacing
// the exit (`/data remove block`) is not undone by an old entry.
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
if let Some(saved) = known
&& self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
{
if adopt_saved_exit(g, saved) {
self.world.modified.insert(chunk);
}
known = None;
let _ = self.forget_gateway_exit(gateway);
}
let job = GatewayJob::for_gateway(
gateway,
self.world
.block_entities
.get(&(gateway[0], gateway[1], gateway[2])),
known,
);
if let Some(c) = job.source_chunk()
&& !self.end.areas.contains(&c)
{
self.end.areas.push(c);
self.streaming.extra.push((c, 1));
}
let destination = match job {
GatewayJob::Known { exit, .. } => Vec3::new(
f64::from(exit[0]) + 0.5,
@@ -1108,6 +1048,13 @@ impl Game<'_> {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
if !self
.world
.chunks
.contains_key(&(gateway[0] >> 4, gateway[2] >> 4))
{
return Err("gateway source chunk is not loaded".into());
}
// The player and the entities may resolve the same gateway: the
// first one to arrive finds the exit, the others use it.
let known = self.known_gateway_exit(gateway);
@@ -1670,7 +1617,12 @@ impl Game<'_> {
/// on from there. The traveller comes back while it still waits.
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
if !self.world.chunks.contains_key(&chunk(arrival.target)) {
if !self.world.chunks.contains_key(&chunk(arrival.target))
|| arrival
.job
.source_chunk()
.is_some_and(|c| !self.world.chunks.contains_key(&c))
{
return Some(arrival);
}
let at = match self.resolve_gateway(arrival.job, arrival.target) {
@@ -1738,7 +1690,16 @@ impl Game<'_> {
)
});
let tickets = self.end.tickets.iter().filter(|_| in_end);
for c in arrivals.chain(tickets.map(|&(c, _)| c)) {
let sources = self
.end
.arrivals
.iter()
.filter_map(|a| a.job.source_chunk());
for c in arrivals
.chain(tickets.map(|&(c, _)| c))
.chain(sources)
.chain(self.gateway_source_chunks())
{
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
@@ -1787,23 +1748,6 @@ mod tests {
use super::*;
use horizon_client::containers::EndGateway;
#[test]
fn a_saved_exit_moves_into_a_gateway_without_one() {
let saved = ([8, 100, 0], true);
let mut bare = EndGateway::default();
assert!(adopt_saved_exit(&mut bare, saved));
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
assert!(bare.exact_teleport);
// The block entity's own exit wins over a stale entry.
let mut own = EndGateway {
exit_portal: Some([1, 2, 3]),
..Default::default()
};
assert!(!adopt_saved_exit(&mut own, saved));
assert_eq!(own.exit_portal, Some([1, 2, 3]));
assert!(!own.exact_teleport);
}
#[test]
fn a_new_exit_keeps_the_gateways_exact_teleport() {
let exact = BlockEntity::EndGateway(EndGateway {
@@ -1825,7 +1769,7 @@ mod tests {
}
#[test]
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
fn end_gateway_job_reads_the_block_entity_and_keeps_new_sources_loaded() {
let gateway = [702, 100, 0];
let exit = [8, 100, 0];
for exact in [true, false] {
@@ -1835,18 +1779,14 @@ mod tests {
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
GatewayJob::for_gateway(gateway, Some(&entity)),
GatewayJob::Known { exit, exact },
);
}
let empty = BlockEntity::EndGateway(EndGateway::default());
for entity in [None, Some(&empty)] {
assert_eq!(
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
GatewayJob::Known { exit, exact: false }
);
assert_eq!(
GatewayJob::for_gateway(gateway, entity, None),
GatewayJob::for_gateway(gateway, entity),
GatewayJob::Known {
exit: END_SPAWN,
exact: true
@@ -1858,14 +1798,14 @@ mod tests {
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(nearby, Some(&exact), None),
GatewayJob::for_gateway(nearby, Some(&exact)),
GatewayJob::Find {
gateway: nearby,
exact: true
}
);
assert_eq!(
GatewayJob::for_gateway(nearby, entity, None),
GatewayJob::for_gateway(nearby, entity),
GatewayJob::Find {
gateway: nearby,
exact: false
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff. Show more