mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Compare commits
363
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42ee9ad644 | ||
|
|
a21f8a3947 | ||
|
|
f0811d3363 | ||
|
|
c543cb8689 | ||
|
|
4b65ec8a0d | ||
|
|
bb8a15db1e | ||
|
|
555b885429 | ||
|
|
722e63500a | ||
|
|
f1708966d5 | ||
|
|
7b45eb9614 | ||
|
|
911aa6ddc6 | ||
|
|
5816e84e56 | ||
|
|
1cd6c70899 | ||
|
|
eb084c5152 | ||
|
|
aa3483bf5a | ||
|
|
23678db4f7 | ||
|
|
a97725d63c | ||
|
|
0ce3cf8624 | ||
|
|
aa548b0a41 | ||
|
|
cd4e73e270 | ||
|
|
d946b838fe | ||
|
|
b2d30693b8 | ||
|
|
8b13f108ef | ||
|
|
a15a3ea50c | ||
|
|
5ddbaaab62 | ||
|
|
6c34352b0f | ||
|
|
52540d4ff2 | ||
|
|
f671c935c2 | ||
|
|
11e85c0411 | ||
|
|
2dabcba7dc | ||
|
|
c94cdb0c76 | ||
|
|
8182a33000 | ||
|
|
dd90805814 | ||
|
|
0c1a839a02 | ||
|
|
00f230d640 | ||
|
|
535211eb7e | ||
|
|
d4c59f8c7a | ||
|
|
82f042de20 | ||
|
|
c351efbab6 | ||
|
|
3054963dec | ||
|
|
25a8fab320 | ||
|
|
194c5c2c6c | ||
|
|
bab03f04db | ||
|
|
9a71098242 | ||
|
|
6d76e89de2 | ||
|
|
609981c352 | ||
|
|
3aeb206813 | ||
|
|
9bd670d3d3 | ||
|
|
63bd6c914d | ||
|
|
ea787a658c | ||
|
|
14e27946ca | ||
|
|
9639526d6b | ||
|
|
d9b9996a4d | ||
|
|
a71f9d3bd3 | ||
|
|
91e8f91fee | ||
|
|
c6544a2311 | ||
|
|
63ad77a1da | ||
|
|
440a7f0144 | ||
|
|
96bb424a7f | ||
|
|
5cdcbf9812 | ||
|
|
3cc15c8ba0 | ||
|
|
e983ba70a5 | ||
|
|
1a5bea076d | ||
|
|
672b1cae9a | ||
|
|
9a2135d7b2 | ||
|
|
beb77a48e0 | ||
|
|
2abe71ca92 | ||
|
|
d7a25b8aec | ||
|
|
c6683ed9a2 | ||
|
|
0a484606c9 | ||
|
|
98961ccd14 | ||
|
|
f787306fce | ||
|
|
d8bf54d1e6 | ||
|
|
f165e7fa29 | ||
|
|
9d73f6a26a | ||
|
|
c2bead90c4 | ||
|
|
e470dac100 | ||
|
|
4a60f12112 | ||
|
|
192be7194f | ||
|
|
5f3caf9be4 | ||
|
|
571ef142c5 | ||
|
|
21845f7949 | ||
|
|
27a4dc65c9 | ||
|
|
deaab60ba9 | ||
|
|
c2c3cea8e3 | ||
|
|
fcf7c70c83 | ||
|
|
be34bdfd3c | ||
|
|
09d748f19c | ||
|
|
e72b69caca | ||
|
|
166e09f55e | ||
|
|
61c43e5109 | ||
|
|
10721b288e | ||
|
|
be9a3c000f | ||
|
|
bb9d4c2467 | ||
|
|
d9d89df9d8 | ||
|
|
5a5d9f780c | ||
|
|
344bcdec9a | ||
|
|
18415b2fbd | ||
|
|
45f36d84e1 | ||
|
|
8368def6fa | ||
|
|
10004fcd77 | ||
|
|
f8e59c3e3b | ||
|
|
94077f9e66 | ||
|
|
1dcb48e1ec | ||
|
|
80570541d0 | ||
|
|
475ea2f6a7 | ||
|
|
b0918fb06c | ||
|
|
702b9e5a6e | ||
|
|
0bdbffccf7 | ||
|
|
e3d11137f6 | ||
|
|
783c053601 | ||
|
|
1416eb94b9 | ||
|
|
76a0ded489 | ||
|
|
44699b4c51 | ||
|
|
22b2093962 | ||
|
|
7a67cce201 | ||
|
|
11bda3ecc3 | ||
|
|
7a725b44e8 | ||
|
|
c9fbcbc9c0 | ||
|
|
e3d38ae5bd | ||
|
|
58cdf1b551 | ||
|
|
0c03fe844b | ||
|
|
ede16abf28 | ||
|
|
02b5729038 | ||
|
|
370854a657 | ||
|
|
2e23fd1dd1 | ||
|
|
90827741ae | ||
|
|
83703c28b2 | ||
|
|
1b016ba22e | ||
|
|
023cef202f | ||
|
|
fdc97df507 | ||
|
|
fd7c423476 | ||
|
|
731699144c | ||
|
|
1fc67d4d10 | ||
|
|
59f63a251a | ||
|
|
e745db772e | ||
|
|
7573ae6a3e | ||
|
|
014462436f | ||
|
|
7dffe8983b | ||
|
|
789be0131d | ||
|
|
54ea398de4 | ||
|
|
0c07b18c76 | ||
|
|
71d150b028 | ||
|
|
f8cf08849e | ||
|
|
d78f353156 | ||
|
|
6c58b77fed | ||
|
|
0bd65042f2 | ||
|
|
245d97a312 | ||
|
|
eb1fbb151d | ||
|
|
9aae4a6671 | ||
|
|
2497c21f48 | ||
|
|
5cbded2bd9 | ||
|
|
d07604cd8e | ||
|
|
195dceeb94 | ||
|
|
8dc176f4e6 | ||
|
|
13fe0905ba | ||
|
|
7d7fa98a44 | ||
|
|
fd2f881a32 | ||
|
|
c3325b01f3 | ||
|
|
59dbb4703d | ||
|
|
fe78b81e88 | ||
|
|
4e86da4a7a | ||
|
|
98df3549d2 | ||
|
|
7d256e1766 | ||
|
|
012f70508a | ||
|
|
40a5a99ca8 | ||
|
|
4a90452d73 | ||
|
|
b1fb8a4048 | ||
|
|
b9abf293aa | ||
|
|
54909bf033 | ||
|
|
f5a3990fbf | ||
|
|
68b16245aa | ||
|
|
32a43ad647 | ||
|
|
46179fd42e | ||
|
|
190aa00a63 | ||
|
|
b2258c35e7 | ||
|
|
90fed51ab1 | ||
|
|
5de4e1ee74 | ||
|
|
6b1f808b13 | ||
|
|
81336ce7a9 | ||
|
|
47e22facbb | ||
|
|
f2a97a7329 | ||
|
|
1d2b14b01d | ||
|
|
4c2306d61d | ||
|
|
ff08319e96 | ||
|
|
194ddaa041 | ||
|
|
289674508b | ||
|
|
f2ea985b8d | ||
|
|
1bcc5ec3f9 | ||
|
|
1c5f7c2d3c | ||
|
|
e7922700f0 | ||
|
|
7ad2c3b0bc | ||
|
|
983bd56fb5 | ||
|
|
b61cff5625 | ||
|
|
37f6d169d7 | ||
|
|
7b98f70c85 | ||
|
|
ac4204f395 | ||
|
|
7714b1d37a | ||
|
|
b045dddfa1 | ||
|
|
5f61926401 | ||
|
|
98b40e8e5e | ||
|
|
c93c23248a | ||
|
|
fbfa055eff | ||
|
|
b610bc7618 | ||
|
|
b082e400c6 | ||
|
|
9a267cf4bf | ||
|
|
09b8b5b94b | ||
|
|
dc064e4bb1 | ||
|
|
c741fd4d39 | ||
|
|
64fc971ebc | ||
|
|
c6e83d5f09 | ||
|
|
8c893c55f0 | ||
|
|
08e01dc3da | ||
|
|
c289d208e6 | ||
|
|
84d6e3d97a | ||
|
|
7f0f9337f3 | ||
|
|
7d011d64aa | ||
|
|
4ea481cb2f | ||
|
|
30e4e488b2 | ||
|
|
b2095cc1ca | ||
|
|
e2ee8ad7d9 | ||
|
|
c59a4d6ffd | ||
|
|
866057db34 | ||
|
|
fb05c1acfc | ||
|
|
a2cdad2a10 | ||
|
|
53e8715d83 | ||
|
|
306dca0a21 | ||
|
|
6352b7468c | ||
|
|
26d32147ea | ||
|
|
0b2ed50a68 | ||
|
|
386d0560a1 | ||
|
|
f90e164b4b | ||
|
|
49873c3371 | ||
|
|
359388eea7 | ||
|
|
4d6923514e | ||
|
|
15e02ce312 | ||
|
|
1c15680e9a | ||
|
|
7cdd21f24d | ||
|
|
61d8dea2a7 | ||
|
|
0afcabe0ec | ||
|
|
d13e35bb8f | ||
|
|
e24554895d | ||
|
|
7d37a291c2 | ||
|
|
d2bc41cd4e | ||
|
|
61ffa23382 | ||
|
|
c44aa01ebb | ||
|
|
935faca944 | ||
|
|
70ee24e9ef | ||
|
|
267c3838b6 | ||
|
|
dfbdf94740 | ||
|
|
afb34764ec | ||
|
|
479276f66d | ||
|
|
17cdf49055 | ||
|
|
b49f625199 | ||
|
|
c1fad2fb18 | ||
|
|
9b087a4560 | ||
|
|
4d3d579464 | ||
|
|
01731db683 | ||
|
|
7b9fca2a49 | ||
|
|
1258ec339a | ||
|
|
7cbe3d49b0 | ||
|
|
83122825e5 | ||
|
|
c3af4fadeb | ||
|
|
fbf4ddd296 | ||
|
|
a8c16eb5db | ||
|
|
ce66fd40bd | ||
|
|
22e92450e4 | ||
|
|
ee1dfabaa6 | ||
|
|
5e1eab0e05 | ||
|
|
7f8e095514 | ||
|
|
793565a33f | ||
|
|
4bed23dc4c | ||
|
|
b820331574 | ||
|
|
9f1fb2c77c | ||
|
|
46fe2c7f3d | ||
|
|
f23a03f6c7 | ||
|
|
fa7424ca31 | ||
|
|
4cea8c2ee6 | ||
|
|
0694db7dcb | ||
|
|
b21058f361 | ||
|
|
58f80819d9 | ||
|
|
f606efbf36 | ||
|
|
e8397cd8eb | ||
|
|
dd2d560901 | ||
|
|
a03902b64d | ||
|
|
30637c0820 | ||
|
|
b63d7491d2 | ||
|
|
e64e477126 | ||
|
|
dc94adc898 | ||
|
|
f914bcd4a9 | ||
|
|
0a6d0dd257 | ||
|
|
c719e4ac0e | ||
|
|
7049d0e811 | ||
|
|
78e094e614 | ||
|
|
f81f6de46b | ||
|
|
ff42a99d98 | ||
|
|
aced4bb6b9 | ||
|
|
97a323b499 | ||
|
|
227333eacd | ||
|
|
9367ed7710 | ||
|
|
e15589bbbb | ||
|
|
84fe5b4f12 | ||
|
|
7ee566f6b7 | ||
|
|
3774ca6edd | ||
|
|
c73321876c | ||
|
|
19d083fa26 | ||
|
|
8d35e7fc0a | ||
|
|
46f0546de6 | ||
|
|
7c173c30b1 | ||
|
|
715a969071 | ||
|
|
5097c53509 | ||
|
|
a89477a4b9 | ||
|
|
430b6dc4c5 | ||
|
|
e591b36a8d | ||
|
|
701eaa6f9c | ||
|
|
018a51c27f | ||
|
|
4db980e5de | ||
|
|
d40ab7df1a | ||
|
|
54578041bc | ||
|
|
bdec76846e | ||
|
|
93ebb80b92 | ||
|
|
345041401e | ||
|
|
18c03d23d8 | ||
|
|
dceee849cb | ||
|
|
10b318cd6b | ||
|
|
79cfecbdb9 | ||
|
|
298d3f12e9 | ||
|
|
73a745339e | ||
|
|
70287510ab | ||
|
|
3877ae52d8 | ||
|
|
8e46f3971d | ||
|
|
ebe1a5d52b | ||
|
|
61a0362859 | ||
|
|
166073d4bc | ||
|
|
92742bef1b | ||
|
|
bb29b70b32 | ||
|
|
3ad8a18a7b | ||
|
|
16237bb2b7 | ||
|
|
71e2401edc | ||
|
|
6c5f62af35 | ||
|
|
d029364bd4 | ||
|
|
35fc116bd3 | ||
|
|
cc100b4470 | ||
|
|
2a95475e8f | ||
|
|
47894e831f | ||
|
|
5e735bd030 | ||
|
|
da4591bc88 | ||
|
|
dbcbe4fe87 | ||
|
|
8423eabc57 | ||
|
|
fd46aa03c5 | ||
|
|
87f2c1314e | ||
|
|
4c2a2c42d2 | ||
|
|
4c246e24f2 | ||
|
|
3ff75a8ecb | ||
|
|
6ca76d9e9c | ||
|
|
c58a3eef6f | ||
|
|
ca57097ae8 | ||
|
|
b98cb1a611 | ||
|
|
b7eb967776 | ||
|
|
c74851a64b | ||
|
|
6b6a9577c7 | ||
|
|
316f9680d5 | ||
|
|
5a60e2252b |
No files matched your search
+32
-18
@@ -1,5 +1,5 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# verify: every pull request, every merge queue entry and every push to main (runners: see the job). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
@@ -7,11 +7,15 @@ name: checks
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
|
||||
# never start this workflow. See docs/release-flow.md.
|
||||
# merge_group: the merge queue's commit (main's tip, the pull requests ahead in the queue and this one) runs verify
|
||||
# before it lands, on GitHub's runners; parity and launcher wait for main's push (docs/release-flow.md#merge-queue).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,7 +23,8 @@ concurrency:
|
||||
# A new push to a pull request replaces its running checks. On main, the running check finishes and only the
|
||||
# newest push waits behind it: GitHub cancels a waiting run when a newer one joins the group. A burst of merges
|
||||
# queued ~100 main runs on 2026-11-09; the release follows the newest finished main run (docs/release-flow.md).
|
||||
group: checks-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.sha }}
|
||||
# Each merge queue entry has a group of its own (its temporary branch), and nothing cancels it: the queue does.
|
||||
group: checks-${{ github.event.pull_request.number || github.event.merge_group.head_ref || (github.event_name == 'push' && 'main') || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
@@ -34,11 +39,14 @@ jobs:
|
||||
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
|
||||
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
|
||||
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
|
||||
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
|
||||
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
|
||||
# could move it off a machine that is off: it always runs on GitHub's. So does a merge queue's run, whatever
|
||||
# SELF_HOSTED_CI says: its commit can hold a fork's code, which job-started.sh refuses on ours. On ours the build
|
||||
# folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are
|
||||
# GitHub's only.
|
||||
verify:
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
|
||||
(vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
|
||||
@@ -52,22 +60,24 @@ jobs:
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# A merge queue's run reads its commit against main's tip (merge_group's base and head).
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
# A pull request (or a merge queue entry) adds only its own commits to main, so it reads those; main's pushes
|
||||
# read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
if [ "$EVENT" = pull_request ] || [ "$EVENT" = merge_group ]; then
|
||||
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
python tools/check_public_tree.py --history
|
||||
@@ -84,21 +94,22 @@ jobs:
|
||||
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
|
||||
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
|
||||
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
|
||||
# changes since then would count as the pull request's (#13874).
|
||||
# changes since then would count as the pull request's (#13874). A merge queue's entry compares with main's tip
|
||||
# (merge_group.base_sha), whatever the queue's merge method makes of HEAD^1.
|
||||
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
|
||||
- name: Crates follow the layers
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
|
||||
- name: Built-in addresses stay in their home
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
|
||||
- name: Big files don't grow
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
@@ -139,10 +150,13 @@ jobs:
|
||||
cargo +stable test --locked --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26). Not in the merge queue: no check of
|
||||
# its is required, so the queue wouldn't wait for it, and main's push runs it anyway.
|
||||
launcher:
|
||||
if: github.event_name != 'merge_group'
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
|
||||
(vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
|
||||
|
||||
Generated
+2
-1
@@ -1486,6 +1486,7 @@ dependencies = [
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1849,8 +1850,8 @@ dependencies = [
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -1764,14 +1764,13 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
|
||||
|
||||
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
|
||||
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
|
||||
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
|
||||
let found = if haystack.len() < 16 {
|
||||
haystack
|
||||
let found = if entries.len() < 16 {
|
||||
entries
|
||||
.iter()
|
||||
.position(|h| *h > needle)
|
||||
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
|
||||
.position(|e| e.0 > needle)
|
||||
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
|
||||
} else {
|
||||
match binary_search(&haystack, needle) {
|
||||
match binary_search(entries, needle) {
|
||||
Ok(index) => index as i64,
|
||||
Err(insertion) => insertion as i64 - 1,
|
||||
}
|
||||
@@ -1780,8 +1779,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
|
||||
}
|
||||
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
|
||||
/// values (-0.0 below 0.0, NaN above everything).
|
||||
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
|
||||
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
|
||||
let bits = |v: f32| -> i32 {
|
||||
if v.is_nan() {
|
||||
0x7fc0_0000
|
||||
@@ -1791,7 +1790,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
};
|
||||
while low <= high {
|
||||
let mid = (low + high) >> 1;
|
||||
let value = a[mid as usize];
|
||||
let value = entries[mid as usize].0;
|
||||
if value < key {
|
||||
low = mid + 1;
|
||||
} else if value > key {
|
||||
|
||||
@@ -158,3 +158,61 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_index_less_or_equal_linear_and_binary() {
|
||||
use super::last_index_less_or_equal;
|
||||
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
|
||||
thresholds
|
||||
.iter()
|
||||
.map(|&t| (t, ItemModelNode::Empty))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Empty
|
||||
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
|
||||
|
||||
// Short (< 16): linear search
|
||||
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
|
||||
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
|
||||
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
|
||||
|
||||
// Long (>= 16): binary search
|
||||
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
|
||||
let long = make(&thresholds);
|
||||
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
|
||||
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
|
||||
|
||||
// Short lists use upper-bound tie behavior, including signed zero.
|
||||
let duplicates = make(&[1.0, 1.0, 2.0]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
|
||||
|
||||
let zeroes = make(&[-0.0, 0.0]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
|
||||
|
||||
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
|
||||
// Expected indices were checked on the JVM.
|
||||
let duplicates = make(&[
|
||||
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
|
||||
|
||||
let zeroes = make(&[
|
||||
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -68,12 +69,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -109,84 +110,16 @@ fn probes() -> Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -199,22 +132,6 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -228,39 +145,11 @@ fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -274,9 +163,6 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
pub use hardware::{power_source, Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
@@ -36,7 +36,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -292,7 +292,7 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
|
||||
var mark = hdr_entity_mark(in);
|
||||
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
|
||||
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
|
||||
@@ -451,7 +451,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
return frame_stored(color);
|
||||
}
|
||||
@@ -472,7 +472,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// or it would come out darker than the sky around it.
|
||||
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
// Premultiplied after the decode: the light the texel adds, at its coverage.
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
|
||||
|
||||
@@ -192,6 +192,7 @@ fn packed_wanted() -> bool {
|
||||
pub(crate) mod animate;
|
||||
mod arena;
|
||||
pub mod capture;
|
||||
mod census;
|
||||
pub mod compositor;
|
||||
pub(crate) mod core_lines;
|
||||
pub(crate) mod core_sky;
|
||||
@@ -427,10 +428,10 @@ struct Globals {
|
||||
/// full (`hdr::Hdr::windows`); zero without HDR.
|
||||
hdr_windows: [[f32; 4]; 2],
|
||||
hdr_light: [f32; 4],
|
||||
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
|
||||
/// The scene's peak, HDR lighting's gain, world/look and debug; then the tiers' gains
|
||||
/// (`Hdr::draw_params`).
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
@@ -2547,160 +2548,6 @@ enum FirstFrame {
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "on" } else { "off" }
|
||||
}
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
impl Renderer {
|
||||
pub async fn new(
|
||||
window: Arc<Window>,
|
||||
@@ -8291,14 +8138,14 @@ impl Renderer {
|
||||
self.shader_pack_kind().is_some(),
|
||||
!frame.title,
|
||||
);
|
||||
let (hdr_settings, hdr_shares) = self
|
||||
let (hdr_settings, hdr_gains) = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
|
||||
self.world_post.baked_light(
|
||||
hdr_gain,
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
hdr_gains,
|
||||
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
|
||||
);
|
||||
let jitter = self.world_post.jitter(
|
||||
@@ -8458,7 +8305,7 @@ impl Renderer {
|
||||
f32::from(u8::from(hdr_gain)),
|
||||
],
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
hdr_gains,
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
|
||||
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
|
||||
//! GPU resources and of its per-section bookkeeping.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
|
||||
};
|
||||
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ struct Uniform {
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ impl CustomSky {
|
||||
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
|
||||
let mark =
|
||||
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
|
||||
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
|
||||
if globals.hdr_settings[2] > 0.5 {
|
||||
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
|
||||
} else {
|
||||
1.0
|
||||
@@ -428,7 +428,7 @@ impl CustomSky {
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
@@ -702,7 +702,7 @@ mod tests {
|
||||
mode: mode_index as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
|
||||
hdr_shares: [0.2, 0.5, 0.6, 1.0],
|
||||
hdr_gains: [1.6, 2.4, 2.8, 4.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
|
||||
@@ -10,7 +10,7 @@ struct Layer {
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@@ -54,7 +54,7 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
var color = t.rgb;
|
||||
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
|
||||
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
|
||||
}
|
||||
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
|
||||
switch layer.mode {
|
||||
@@ -84,5 +84,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
|
||||
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = custom_sky_color(in);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
//! or scRGB swapchain at the player's paper white, expanding the world's
|
||||
//! highlights towards their peak brightness. The world is copied aside before
|
||||
//! the hand and HUD so only it is expanded.
|
||||
//!
|
||||
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
|
||||
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
|
||||
//! peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -99,38 +103,53 @@ struct Layered {
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
|
||||
/// texel's own brightness before lighting.
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
|
||||
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
|
||||
/// `full` (all of it), the texel's own brightness before lighting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub share: f32,
|
||||
pub gain: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright.
|
||||
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
|
||||
/// glowstone and campfires, the sun.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
share: 0.2,
|
||||
gain: 1.6,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
share: 0.5,
|
||||
gain: 2.4,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 0.6,
|
||||
gain: 2.8,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 1.0,
|
||||
gain: 4.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// The scene's peak, in multiples of paper white: just above the brightest light the world
|
||||
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
|
||||
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
|
||||
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
|
||||
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
|
||||
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
|
||||
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
|
||||
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
|
||||
pub const SUNLIT_GAIN: f32 = 3.0;
|
||||
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
|
||||
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
|
||||
/// changes once, when the float frame replaces it.
|
||||
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
|
||||
|
||||
/// 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
|
||||
@@ -331,7 +350,7 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
@@ -944,16 +963,19 @@ impl Hdr {
|
||||
})
|
||||
}
|
||||
|
||||
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
|
||||
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
|
||||
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
|
||||
let p = self.params(world, false);
|
||||
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
|
||||
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
|
||||
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 16];
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -968,12 +990,17 @@ impl Hdr {
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
params[8..12].copy_from_slice(&COMPAT_SHARES);
|
||||
params[12] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[13] = c.map_or(0.0, |c| c.nits);
|
||||
params[14] = c.map_or(0.0, |c| c.band);
|
||||
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
|
||||
// Lighting off, a pack's own light, the title) the curve's source follows the display.
|
||||
params[16] = if world && self.lighting {
|
||||
SCENE_PEAK
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
params
|
||||
}
|
||||
|
||||
@@ -1050,8 +1077,9 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
|
||||
/// surface by, 4, which looked right across biomes; less only where there is little headroom
|
||||
/// to ease into. The float frame's is `SUNLIT_GAIN`.
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
@@ -1069,7 +1097,8 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
|
||||
// writers take the gain that a share gives.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ r#"
|
||||
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
|
||||
@@ -1088,19 +1117,20 @@ var<private> params: ReferenceParams;
|
||||
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
|
||||
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
|
||||
let expected = reference_lit(color, mark, shows, eased);
|
||||
let gains = 1.0 + (params.peak - 1.0) * params.share;
|
||||
let actual = hdr_mark_light(color, mark, shows, eased,
|
||||
vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
|
||||
// Gain stays above the tone knee until the separate tone curve runs.
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
|
||||
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
|
||||
let boundary = (f32(i % 255u) + 0.5) / 255.0;
|
||||
for (var step = -1; step <= 1; step++) {
|
||||
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
|
||||
let quantized = round(saturate(stored) * 255.0) / 255.0;
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
|
||||
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
|
||||
}
|
||||
}
|
||||
@@ -1319,10 +1349,12 @@ var<private> params: ReferenceParams;
|
||||
|
||||
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
|
||||
// Float targets in these tests only receive the resulting colour and coverage.
|
||||
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
|
||||
// of the headroom up to that peak (`writers_lift`, `display_lift`).
|
||||
pub(super) fn legacy_light(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
hdr: &Hdr,
|
||||
lift: [[f32; 4]; 2],
|
||||
source: &wgpu::Texture,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
@@ -1345,7 +1377,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let c = textureLoad(source, vec2(0), 0);
|
||||
let mark = round(saturate(c.a) * 255.0) / 255.0;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
|
||||
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
"#)
|
||||
@@ -1373,10 +1405,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&[settings, shares]),
|
||||
contents: bytemuck::cast_slice(&lift),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = source.create_view(&Default::default());
|
||||
@@ -1404,6 +1435,19 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
);
|
||||
}
|
||||
|
||||
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
|
||||
/// of the headroom up to the scene's peak.
|
||||
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
|
||||
}
|
||||
|
||||
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
|
||||
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let p = hdr.params(true, false);
|
||||
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
@@ -1416,8 +1460,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD.
|
||||
// The frozen oracle supplies the writer's light from a non-float attachment.
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD:
|
||||
// the frozen oracle turns the marks into the light that display gives them, and the
|
||||
// layered display, whose curve is the identity at 1000 nits, must show it the same.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr = Hdr::with_debug(
|
||||
&device,
|
||||
@@ -1441,7 +1486,14 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
if world[3] == 1.0 {
|
||||
fill(&mut encoder, &frame, world);
|
||||
} else {
|
||||
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
|
||||
// The light the compatibility frame shows: its own lift, not the writers'.
|
||||
legacy_light(
|
||||
&device,
|
||||
&mut encoder,
|
||||
display_lift(&legacy),
|
||||
&marked,
|
||||
&frame,
|
||||
);
|
||||
}
|
||||
hdr.begin(&queue, true);
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
@@ -1480,78 +1532,206 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
assert!(above[0] > white[0] && above[1] > white[1]);
|
||||
}
|
||||
|
||||
/// The world's writers read nothing of the display: the same gains and the same scene peak
|
||||
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
#[test]
|
||||
fn the_tone_curve_eases_light_into_the_peak() {
|
||||
fn the_writers_light_is_the_same_on_every_display() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let headroom = 1000.0 / 203.0;
|
||||
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
|
||||
let after =
|
||||
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
passes(hdr, &mut encoder);
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
|
||||
let written = hdr.draw_params(true);
|
||||
assert_eq!(
|
||||
written,
|
||||
(
|
||||
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
|
||||
[1.6, 2.4, 2.8, 4.0]
|
||||
)
|
||||
);
|
||||
for (paper, peak) in [
|
||||
(203.0, 300.0),
|
||||
(203.0, 600.0),
|
||||
(203.0, 1500.0),
|
||||
(400.0, 600.0),
|
||||
(480.0, 200.0),
|
||||
(80.0, 10000.0),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
|
||||
assert_eq!(
|
||||
hdr.params(true, false)[16],
|
||||
super::SCENE_PEAK,
|
||||
"{paper}/{peak}"
|
||||
);
|
||||
}
|
||||
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
|
||||
// nothing says where their light ends, so the curve's source follows the display.
|
||||
assert_eq!(hdr.params(false, false)[16], 0.0);
|
||||
hdr.lighting = false;
|
||||
assert_eq!(hdr.draw_params(true).0[1], 1.0);
|
||||
assert_eq!(hdr.params(true, false)[16], 0.0);
|
||||
}
|
||||
|
||||
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
|
||||
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
|
||||
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
|
||||
#[test]
|
||||
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let scene_peak = super::SCENE_PEAK;
|
||||
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
|
||||
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
if curve {
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
}
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
|
||||
// light just above S that the curve holds at S, and whether that comes back to the same
|
||||
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
|
||||
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
|
||||
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
|
||||
(0..4).all(|c| {
|
||||
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
|
||||
})
|
||||
};
|
||||
// Linear light in and out, in units of paper white.
|
||||
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let mapped = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
|
||||
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
|
||||
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
|
||||
assert_eq!(a, 1.0);
|
||||
[r, g, b].map(|c| c.powf(2.2))
|
||||
};
|
||||
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
|
||||
// Up to the knee (60% of the peak) the light stays exactly as stored.
|
||||
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
}
|
||||
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
|
||||
// nits (13.7 times paper white), worked out apart from the shader.
|
||||
for (light, want) in [
|
||||
(3.5, 3.43791),
|
||||
(4.0, 3.78351),
|
||||
(headroom, 4.23556),
|
||||
(8.0, 4.82043),
|
||||
(12.0, 4.92462),
|
||||
(50.0, headroom),
|
||||
// A display at or above the mastering one (T >= S): every stored value up to S comes
|
||||
// out exactly as it went in, grey or coloured, and light above S is held at S with its
|
||||
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
|
||||
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
|
||||
for (paper, peak) in [
|
||||
(203.0, 1000.0),
|
||||
(203.0, 1500.0),
|
||||
(203.0, 10000.0),
|
||||
(480.0, 10000.0),
|
||||
(10.0, 200.0),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for i in 0..=64 {
|
||||
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
|
||||
let exact = i < 64;
|
||||
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
|
||||
"{peak}: {i}"
|
||||
);
|
||||
}
|
||||
for light in [8.0f32, 50.0] {
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
|
||||
"{peak}: {light}: {r} {g} {b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others: the
|
||||
// light keeps its hue.
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 2.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
|
||||
last = r;
|
||||
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
|
||||
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
|
||||
// where the gates' curve, whose source followed the display, kept it at 203; the
|
||||
// highlights above it keep more of their steps.
|
||||
for (paper, peak, knee, checkpoints) in [
|
||||
(
|
||||
203.0,
|
||||
600.0,
|
||||
2.0,
|
||||
&[
|
||||
(2.5, 2.47516),
|
||||
(3.0, 2.75699),
|
||||
(4.0, 2.9404),
|
||||
(scene_peak, 600.0 / 203.0),
|
||||
(8.0, 600.0 / 203.0),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
203.0,
|
||||
300.0,
|
||||
0.75,
|
||||
&[
|
||||
(0.9, 0.89289),
|
||||
(1.0, 0.97365),
|
||||
(1.2, 1.10406),
|
||||
(1.4, 1.20134),
|
||||
(2.0, 1.36833),
|
||||
(4.0, 1.47641),
|
||||
(8.0, 300.0 / 203.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
400.0,
|
||||
600.0,
|
||||
0.8,
|
||||
&[
|
||||
(1.0, 0.97973),
|
||||
(1.2, 1.11501),
|
||||
(1.5, 1.25607),
|
||||
(2.0, 1.38818),
|
||||
(scene_peak, 1.5),
|
||||
],
|
||||
),
|
||||
// No headroom (a peak under paper white): the scene is compressed into white.
|
||||
(
|
||||
203.0,
|
||||
150.0,
|
||||
0.4,
|
||||
&[
|
||||
(1.0, 0.78346),
|
||||
(1.5, 0.90244),
|
||||
(2.0, 0.95589),
|
||||
(4.0, 0.99944),
|
||||
(scene_peak, 1.0),
|
||||
],
|
||||
),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for light in [0.0f32, 0.3, knee] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
|
||||
"{paper}/{peak}: {light}"
|
||||
);
|
||||
}
|
||||
for &(light, want) in checkpoints {
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others; a
|
||||
// half float's step in gamma is 0.1% of the light near white.
|
||||
let headroom = (peak / paper).max(1.0);
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 0.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
r >= last * 0.998 && r <= headroom * 1.002,
|
||||
"{peak}: {light}: {r}"
|
||||
);
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
|
||||
last = r;
|
||||
}
|
||||
}
|
||||
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
|
||||
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
|
||||
hdr.peak = 300.0;
|
||||
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
|
||||
// the curve's source follows the display, the knee at 60% of its peak and at least
|
||||
// white, as before one peak. At 300 nits the source's peak is 645 nits.
|
||||
hdr.lighting = false;
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
|
||||
let headroom = 300.0 / 203.0;
|
||||
for light in [0.5f32, 0.9, 1.0] {
|
||||
for light in [0.0f32, 0.5, 0.9, 1.0] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
|
||||
}
|
||||
for (light, want) in [
|
||||
(1.2, 1.16839),
|
||||
@@ -1560,12 +1740,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(2.0, 1.44024),
|
||||
(4.0, headroom),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
}
|
||||
// No headroom (a peak below paper white): a clip at white, channel by channel.
|
||||
// With no headroom there, a clip at white, channel by channel.
|
||||
hdr.peak = 150.0;
|
||||
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
}
|
||||
|
||||
@@ -1582,7 +1762,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
|
||||
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
|
||||
hdr.masks.parts = 0.4;
|
||||
hdr.tiers[1].share = 0.7;
|
||||
hdr.tiers[1].gain = 3.0;
|
||||
hdr.debug = true;
|
||||
hdr.calibration = Some(super::Calibration {
|
||||
step: 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{gpu, legacy_light, texel},
|
||||
tests::{gpu, legacy_light, texel, writers_lift},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
use wgpu::util::DeviceExt;
|
||||
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
|
||||
}
|
||||
if !written && !self.raw {
|
||||
let light = hdr.frame(self.device, [1, 1]);
|
||||
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
|
||||
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
|
||||
return texel(self.device, self.queue, encoder, &light);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let mut cases = 0;
|
||||
for hdr_on in [false, true] {
|
||||
let globals = super::super::Globals {
|
||||
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ include_str!("stars.wgsl")
|
||||
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
@fragment fn reference() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
|
||||
}
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
let mut data = [0.0_f32; 32];
|
||||
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
|
||||
data[20..24].copy_from_slice(&settings);
|
||||
data[24..28].copy_from_slice(&shares);
|
||||
data[24..28].copy_from_slice(&gains);
|
||||
data[31] = gain;
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let mut count = 0;
|
||||
for fog in [0.0, 0.5, 1.0] {
|
||||
for visibility in [1.0, 0.4] {
|
||||
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
|
||||
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 255);
|
||||
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 16);
|
||||
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
}))
|
||||
.unwrap();
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for &alpha in alphas {
|
||||
|
||||
@@ -2770,6 +2770,8 @@ file = \"b.wgsl\"
|
||||
true,
|
||||
);
|
||||
(display.paper_white, display.peak) = (paper, peak);
|
||||
// As the renderer has it under a pack that draws: Horizon lights none of it.
|
||||
display.lighting = false;
|
||||
if switched {
|
||||
use super::super::hdr::Output::{Pq, ScRgb};
|
||||
display = display.retargeted(&device, &queue, ScRgb);
|
||||
|
||||
@@ -47,7 +47,7 @@ struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
color: [f32; 4],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ impl Stars {
|
||||
clip_from_sky: clip_from_sky.to_cols_array_2d(),
|
||||
color: [b; 4],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
|
||||
@@ -5,7 +5,7 @@ struct Stars {
|
||||
// starBrightness in every channel (RenderSystem.setShaderColor).
|
||||
color: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> stars: Stars;
|
||||
@@ -20,7 +20,7 @@ struct Stars {
|
||||
@fragment fn fs_stars() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
|
||||
}
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
|
||||
@@ -360,13 +360,13 @@ impl Post {
|
||||
&mut self,
|
||||
gain: bool,
|
||||
settings: [f32; 4],
|
||||
shares: [f32; 4],
|
||||
gains: [f32; 4],
|
||||
windows: [[f32; 4]; 2],
|
||||
) {
|
||||
// These uniforms do not change baked colour without gain. Keep compatibility
|
||||
// history stable when only its final display settings change.
|
||||
let key = Some(if gain {
|
||||
(true, settings, shares, windows)
|
||||
(true, settings, gains, windows)
|
||||
} else {
|
||||
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
|
||||
});
|
||||
@@ -887,7 +887,7 @@ mod tests {
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
eprintln!(
|
||||
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
|
||||
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
|
||||
}
|
||||
return frame_stored(finished);
|
||||
}
|
||||
@@ -20,15 +20,20 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
|
||||
// The compatibility frame's lift (`lit`; the float frame's writers have theirs from
|
||||
// Hdr::draw_params). HDR lighting: what the strongest light multiplies a surface by, 1 for
|
||||
// none.
|
||||
scene: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// Per tier, low to very bright (hdr.rs `COMPAT_SHARES`): the share of the headroom
|
||||
// its fully lifted pixels get.
|
||||
share: vec4<f32>,
|
||||
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
|
||||
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
|
||||
// a share of the height.
|
||||
calibration: vec4<f32>,
|
||||
// The scene's peak in units of paper white where Horizon lifts the world (hdr.rs
|
||||
// `SCENE_PEAK`), else 0: what the tone curve maps onto the display's peak (`tone_scale`).
|
||||
scene_peak: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@@ -155,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
|
||||
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
|
||||
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
}
|
||||
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
@@ -251,33 +256,52 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
|
||||
return display(light, pos);
|
||||
}
|
||||
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
|
||||
// Where the scene's peak is unknown (`tone_scale`), the curve's knee: up to it light stays as it
|
||||
// is. Where HDR lighting eased into the peak before the curve (`lit`), 60% of it, so bright
|
||||
// textures keep their detail; at least paper white.
|
||||
fn tone_knee(headroom: f32) -> f32 {
|
||||
return max(0.6 * headroom, 1.0);
|
||||
}
|
||||
|
||||
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
|
||||
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
|
||||
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
|
||||
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
|
||||
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
|
||||
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
|
||||
// and no channel passes the peak.
|
||||
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
if top <= tone_knee(headroom) {
|
||||
return 1.0;
|
||||
}
|
||||
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper` nits:
|
||||
// what to scale the light by, so it ends at most `headroom` (T). ITU-R BT.2390's EETF in PQ,
|
||||
// without the black level lift (`display` does that): the same up to its knee, then a Hermite
|
||||
// spline reaching T at the source's peak, past which light shows at T. The brightest channel sets
|
||||
// the scale for all three, so light keeps its hue (lava stays orange) and no channel passes T.
|
||||
//
|
||||
// The source is the scene's peak S where Horizon lifts the world (`scene_peak`, hdr.rs
|
||||
// `SCENE_PEAK`): the world's light is the same on every display, light past S is held at S, and
|
||||
// where T reaches S that is all, so a display above S shows the world as one at S. Where the
|
||||
// scene's range is unknown (`scene_peak` 0: a 0.2 pack's own light, HDR Lighting off), the
|
||||
// source follows the display, as the 0.2 contract has it: the peak that puts the EETF's knee at
|
||||
// `tone_knee`, PQ's 10 000 nits at most.
|
||||
fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
|
||||
let peak = pq(vec3(headroom * paper)).x;
|
||||
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
|
||||
let e = pq(vec3(top * paper)).x / source;
|
||||
var source: f32;
|
||||
var e: f32;
|
||||
var held = 1.0;
|
||||
if scene_peak > 0.0 {
|
||||
held = min(1.0, scene_peak / max(top, 1e-6));
|
||||
if scene_peak <= headroom {
|
||||
return held;
|
||||
}
|
||||
source = pq(vec3(scene_peak * paper)).x;
|
||||
e = pq(vec3(min(top, scene_peak) * paper)).x / source;
|
||||
} else {
|
||||
if top <= tone_knee(headroom) {
|
||||
return 1.0;
|
||||
}
|
||||
source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
|
||||
e = pq(vec3(top * paper)).x / source;
|
||||
}
|
||||
let max_lum = peak / source;
|
||||
let ks = 1.5 * max_lum - 0.5;
|
||||
// At least 0: past 3 to 1 in PQ the knee would leave the source's range.
|
||||
let ks = max(1.5 * max_lum - 0.5, 0.0);
|
||||
if e >= 1.0 {
|
||||
return headroom / top;
|
||||
}
|
||||
if e <= ks {
|
||||
return 1.0;
|
||||
return held;
|
||||
}
|
||||
let t = (e - ks) / (1.0 - ks);
|
||||
let t2 = t * t;
|
||||
@@ -287,16 +311,16 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
}
|
||||
|
||||
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
|
||||
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
|
||||
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
|
||||
// knee stays exactly as stored. With no headroom, a clip at white.
|
||||
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) fitted to the display, in
|
||||
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light the curve
|
||||
// leaves alone stays exactly as stored. With no headroom and no scene peak, a clip at white.
|
||||
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
|
||||
if params.peak <= 1.0 {
|
||||
if params.scene_peak == 0.0 && params.peak <= 1.0 {
|
||||
return vec4(min(g, vec3(1.0)), 1.0);
|
||||
}
|
||||
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
|
||||
let s = tone_scale(top, params.peak, params.paper * 80.0);
|
||||
let s = tone_scale(top, params.scene_peak, params.peak, params.paper * 80.0);
|
||||
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
|
||||
}
|
||||
|
||||
@@ -331,7 +355,7 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
}
|
||||
|
||||
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
|
||||
// paper white in linear light. Unlike the display curve (whose knee is at least white),
|
||||
// paper white in linear light. Unlike the display curve (which ends at the display's peak),
|
||||
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
|
||||
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
|
||||
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@@ -27,7 +27,8 @@ struct Lit {
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
|
||||
// `lifts`, per tier: how much its fully lifted pixels gain over their own light (their gain - 1).
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, lifts: vec4<f32>) -> Lit {
|
||||
var lifted = false;
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
@@ -60,7 +61,7 @@ fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4
|
||||
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
|
||||
var gain = 1.0 + lifts[tier] * lift;
|
||||
if eased {
|
||||
gain = min(gain, settings.x / max(top, 1e-4));
|
||||
}
|
||||
@@ -88,7 +89,8 @@ fn hdr_quantized_mark(mark: f32) -> f32 {
|
||||
return round(saturate(mark) * 255.0) / 255.0;
|
||||
}
|
||||
|
||||
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
|
||||
let shows = settings.z > 0.5 && settings.x > 1.0;
|
||||
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
|
||||
// The world's writers' light, from the tiers' `gains` (Hdr::draw_params): the same on every
|
||||
// display; the tone curve fits it to the display afterwards (fs_tonemap).
|
||||
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, gains: vec4<f32>) -> vec3<f32> {
|
||||
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), settings.z > 0.5, false, settings, gains - vec4(1.0)).light, vec3(1.0 / 2.2));
|
||||
}
|
||||
@@ -24,7 +24,7 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
|
||||
var color = oit_cloud_color(in, front, true);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
|
||||
globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
var color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
globals.hdr_light.y, f * globals.fog_color.a));
|
||||
// Convert the surface before weighting; its mark never belongs to the light behind it.
|
||||
let fogged = mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a);
|
||||
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_shares) * weighted.a, weighted.a);
|
||||
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_gains) * weighted.a, weighted.a);
|
||||
}
|
||||
return vec4(rgb, weighted.a);
|
||||
}
|
||||
|
||||
@@ -1100,6 +1100,9 @@ impl PackScreen {
|
||||
0,
|
||||
);
|
||||
self.search.draw(&mut out, m, blink_ms, None);
|
||||
if self.browser.is_none() && self.search.contains(f64::from(mouse.0), f64::from(mouse.1)) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
for side in 0..2 {
|
||||
self.draw_list(&mut out, m, side, mouse);
|
||||
}
|
||||
@@ -1302,6 +1305,9 @@ impl PackScreen {
|
||||
field.x = qx;
|
||||
field.y = qy;
|
||||
field.draw(out, m, 0, None);
|
||||
if field.contains(f64::from(mouse.0), f64::from(mouse.1)) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
let (sbx, sby) = self.browser_search_button();
|
||||
d::button(
|
||||
out,
|
||||
|
||||
@@ -310,6 +310,9 @@ pub(crate) fn search_field(
|
||||
let frame = [x, y, clear[0] - x, h];
|
||||
search.frame = Some(frame);
|
||||
search.draw(batch, atlas, None);
|
||||
if ui::hovered(cursor, x, y, frame[2], h) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
|
||||
@@ -43,7 +43,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -788,7 +788,7 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_gains), 1.0);
|
||||
}
|
||||
return frame_stored(vec4(finished.rgb, 1.0));
|
||||
}
|
||||
@@ -870,7 +870,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
|
||||
}
|
||||
return frame_stored(finished);
|
||||
}
|
||||
@@ -1076,7 +1076,7 @@ fn sky_finished(color: vec3<f32>) -> vec4<f32> {
|
||||
let mark = 1.0 - 0.24 * globals.hdr_light.y;
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_gains), 1.0);
|
||||
}
|
||||
return frame_stored(vec4(color, 1.0));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub enum Cursor {
|
||||
#[default]
|
||||
Arrow,
|
||||
PointingHand,
|
||||
/// `CursorTypes.IBEAM`: over a text field that takes input.
|
||||
Ibeam,
|
||||
NotAllowed,
|
||||
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
|
||||
ResizeNs,
|
||||
@@ -55,6 +57,18 @@ pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EditBox.handleCursor`: a hovered text field shows the I-beam, or the
|
||||
/// forbidden sign when it takes no input.
|
||||
pub fn text_cursor(hovered: bool, editable: bool) {
|
||||
if hovered {
|
||||
request_cursor(if editable {
|
||||
Cursor::Ibeam
|
||||
} else {
|
||||
Cursor::NotAllowed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor the frame asked for, and back to the arrow for the next one
|
||||
/// (the window shows the arrow unless a widget asks).
|
||||
pub fn take_cursor() -> Cursor {
|
||||
@@ -429,6 +443,16 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
|
||||
mod cursor_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_hovered_text_field_asks_for_the_i_beam() {
|
||||
let _ = take_cursor();
|
||||
text_cursor(false, true);
|
||||
assert_eq!(take_cursor(), Cursor::Arrow);
|
||||
text_cursor(true, true);
|
||||
assert_eq!(take_cursor(), Cursor::Ibeam);
|
||||
text_cursor(true, false);
|
||||
assert_eq!(take_cursor(), Cursor::NotAllowed);
|
||||
}
|
||||
#[test]
|
||||
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
|
||||
let _ = take_cursor();
|
||||
|
||||
@@ -73,6 +73,8 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
# The machine and process probes (the dedicated server's performance report).
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
|
||||
@@ -40,6 +40,17 @@ pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
|
||||
.is_some_and(|data| data.get("RecordItem").is_some())
|
||||
}
|
||||
|
||||
/// `LecternBlock.getStateForPlacement`: the item's `minecraft:block_entity_data`
|
||||
/// carries a `Book` (`TypedEntityData.contains` checks the key only), so the
|
||||
/// placed lectern's `has_book` state says so. Vanilla reads the item's data,
|
||||
/// not the block entity the placement just filled.
|
||||
pub fn carries_book(stack: &crate::items::ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
|
||||
.is_some_and(|data| data.get("Book").is_some())
|
||||
}
|
||||
|
||||
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
|
||||
/// the item carries data of an op-only type.
|
||||
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
|
||||
@@ -139,6 +139,8 @@ pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod studio;
|
||||
pub mod telemetry;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_fixtures;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
|
||||
@@ -84,6 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
pub mod nameplate;
|
||||
mod obstacles;
|
||||
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
|
||||
pub use obstacles::ObstacleGrid;
|
||||
@@ -1289,8 +1290,41 @@ pub struct ExperienceOrb {
|
||||
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
|
||||
/// until they are gone or further than 8 blocks. Never saved.
|
||||
pub following: Option<u32>,
|
||||
/// `Entity.fallDistance` (`fall_distance` in its save).
|
||||
pub fall_distance: f64,
|
||||
}
|
||||
/// An orb's `Entity.move` this tick, for the fall bookkeeping its caller
|
||||
/// finishes (the long-move cast, `checkFallDamage`'s HIT_GROUND).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct OrbMove {
|
||||
pub from: Vec3,
|
||||
pub moved: Vec3,
|
||||
/// `Entity.isInWater` from this tick's fluid step.
|
||||
pub in_water: bool,
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// A fresh orb (`new ExperienceOrb(level, pos, motion, value)`): full
|
||||
/// health, no age, fall or followed player, not yet ticked.
|
||||
pub fn new(id: u32, position: Vec3, velocity: Vec3, value: i32) -> Self {
|
||||
Self {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id,
|
||||
position,
|
||||
previous: position,
|
||||
velocity,
|
||||
value,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
air: 300,
|
||||
no_gravity: false,
|
||||
following: None,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
|
||||
/// `int`; an orb at 0 or below is discarded. True when it is gone.
|
||||
pub fn hurt(&mut self, damage: f32) -> bool {
|
||||
@@ -1414,7 +1448,7 @@ impl ExperienceOrb {
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
) -> bool {
|
||||
self.tick_among(
|
||||
let (stays, _) = self.tick_among(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
@@ -1425,10 +1459,12 @@ impl ExperienceOrb {
|
||||
fluids,
|
||||
on_sound,
|
||||
&[],
|
||||
)
|
||||
);
|
||||
stays
|
||||
}
|
||||
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
|
||||
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
|
||||
/// Also gives the move it made, whose fall bookkeeping the caller finishes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tick_among(
|
||||
&mut self,
|
||||
@@ -1442,12 +1478,12 @@ impl ExperienceOrb {
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
entities: &[Aabb],
|
||||
) -> bool {
|
||||
) -> (bool, OrbMove) {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
|
||||
// water branch below reads: the orb's eye, not its feet, decides.
|
||||
let eye_in_water = self.update_fluids(world, fluids);
|
||||
let (eye_in_water, in_water) = self.update_fluids(world, fluids);
|
||||
let block = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
self.position.y.floor() as i32,
|
||||
@@ -1514,6 +1550,7 @@ impl ExperienceOrb {
|
||||
let moved = scene
|
||||
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
let from = self.position;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
@@ -1561,7 +1598,12 @@ impl ExperienceOrb {
|
||||
self.velocity.y = -wanted.y * 0.4;
|
||||
}
|
||||
self.age += 1;
|
||||
!discarded && self.age < 6000
|
||||
let step = OrbMove {
|
||||
from,
|
||||
moved,
|
||||
in_water,
|
||||
};
|
||||
(!discarded && self.age < 6000, step)
|
||||
}
|
||||
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
|
||||
/// server, through the orb's existing hurt/discard policy, playing the generic
|
||||
@@ -2020,6 +2062,8 @@ pub struct ArmorStand {
|
||||
/// (`getAttribute`) on the stand's first server tick, and a save that lists
|
||||
/// it loads it back, so a stand summoned this tick has none yet.
|
||||
pub speed_instance: bool,
|
||||
/// `NAME_TAG_DISTANCE` and `BELOW_NAME_DISTANCE` from server attribute updates.
|
||||
pub nameplate: crate::entities::nameplate::NameplateDistances,
|
||||
}
|
||||
/// The fluid a stand travels in (`LivingEntity.travelInFluid`: water first,
|
||||
/// then lava), from the heights `Entity::push_by_fluid` measured.
|
||||
@@ -2103,20 +2147,6 @@ impl ArmorStand {
|
||||
}
|
||||
}
|
||||
impl ArmorStand {
|
||||
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
|
||||
/// only a living target changes. Stack consumption belongs to the caller.
|
||||
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:name_tag" {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
if self.health > 0.0 {
|
||||
self.custom_name = Some(crate::component::name_text(name));
|
||||
}
|
||||
true
|
||||
}
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
/// The `Pose` compound's keys.
|
||||
pub const POSE_KEYS: [&'static str; 6] =
|
||||
@@ -2273,6 +2303,7 @@ impl ArmorStand {
|
||||
enchantment_equipment: Default::default(),
|
||||
support: Default::default(),
|
||||
speed_instance: false,
|
||||
nameplate: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
|
||||
@@ -3346,6 +3377,7 @@ mod world_border_tests {
|
||||
None,
|
||||
);
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4757,6 +4789,7 @@ mod orb_underwater_tests {
|
||||
.iter()
|
||||
.map(|(v, _)| {
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4792,6 +4825,7 @@ mod render_light_tests {
|
||||
|
||||
fn orb(y: f64) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4994,6 +5028,7 @@ mod orb_lava_tests {
|
||||
|
||||
fn sample_orb(at: Vec3, health: i32) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -159,19 +159,28 @@ impl ItemEntity {
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
|
||||
/// orb's box and pushes its velocity by any water or lava current.
|
||||
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
|
||||
/// `0.425` above the orb's feet.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
|
||||
update_entity_fluids(
|
||||
/// orb's box and pushes its velocity by any water or lava current; water
|
||||
/// resets its fall, and `Entity.baseTick` halves it in lava (`isInLava`
|
||||
/// is false on the orb's first tick, `Entity.firstTick`). Answers
|
||||
/// `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads, `0.425`
|
||||
/// above the orb's feet, and `isInWater`.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (bool, bool) {
|
||||
let (water, lava, eye) = update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
self.position.y + f64::from(0.425f32),
|
||||
&mut self.velocity,
|
||||
world,
|
||||
fluids,
|
||||
)
|
||||
.2
|
||||
);
|
||||
if water > 0.0 {
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
// `tick_among` counts this tick first: `tickCount` is 1 on the first.
|
||||
if lava > 0.0 && self.ticks > 1 {
|
||||
self.fall_distance *= 0.5;
|
||||
}
|
||||
(eye, water > 0.0)
|
||||
}
|
||||
}
|
||||
impl ArmorStand {
|
||||
@@ -471,6 +480,7 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -562,6 +572,7 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.6, 20.5);
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! The nameplate attributes carried by armor stands and mobs.
|
||||
use super::ArmorStand;
|
||||
use crate::items::ItemStack;
|
||||
use crate::mobs::Mob;
|
||||
|
||||
/// The two synced attributes that control name tags and below-name lines.
|
||||
/// `None` means the server has not sent a sync for that attribute yet.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct NameplateDistances {
|
||||
/// `minecraft:name_tag_distance` from the server's attribute updates.
|
||||
pub name_tag: Option<f64>,
|
||||
/// `minecraft:below_name_distance` from the server's attribute updates.
|
||||
pub below_name: Option<f64>,
|
||||
}
|
||||
|
||||
impl NameplateDistances {
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`: the
|
||||
/// default is 64 and vanilla's maximum is 512.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
name_tag_distance_of(self.name_tag)
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`: the
|
||||
/// default is 10 and vanilla's maximum is 512.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
below_name_distance_of(self.below_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mob {
|
||||
/// Stores both distances, from a model's own live attributes.
|
||||
pub fn set_nameplate(&mut self, distances: NameplateDistances) {
|
||||
let attributes = self.extra.attributes.get_or_insert_with(Default::default);
|
||||
attributes.name_tag_distance = distances.name_tag;
|
||||
attributes.below_name_distance = distances.below_name;
|
||||
}
|
||||
|
||||
/// Stores `NAME_TAG_DISTANCE` from a server attribute update.
|
||||
pub fn set_name_tag_distance(&mut self, value: f64) {
|
||||
self.extra
|
||||
.attributes
|
||||
.get_or_insert_with(Default::default)
|
||||
.name_tag_distance = Some(value);
|
||||
}
|
||||
|
||||
/// Stores `BELOW_NAME_DISTANCE` from a server attribute update.
|
||||
pub fn set_below_name_distance(&mut self, value: f64) {
|
||||
self.extra
|
||||
.attributes
|
||||
.get_or_insert_with(Default::default)
|
||||
.below_name_distance = Some(value);
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
name_tag_distance_of(
|
||||
self.extra
|
||||
.attributes
|
||||
.as_ref()
|
||||
.and_then(|attributes| attributes.name_tag_distance),
|
||||
)
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
below_name_distance_of(
|
||||
self.extra
|
||||
.attributes
|
||||
.as_ref()
|
||||
.and_then(|attributes| attributes.below_name_distance),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArmorStand {
|
||||
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
|
||||
/// only a living target changes. Stack consumption belongs to the caller.
|
||||
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:name_tag" {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
if self.health > 0.0 {
|
||||
self.custom_name = Some(crate::component::name_text(name));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
self.nameplate.name_tag_distance()
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
self.nameplate.below_name_distance()
|
||||
}
|
||||
}
|
||||
|
||||
/// The vanilla default (64) and maximum (512) for `NAME_TAG_DISTANCE`.
|
||||
pub fn name_tag_distance_of(value: Option<f64>) -> f64 {
|
||||
value
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(64.0)
|
||||
.clamp(0.0, 512.0)
|
||||
}
|
||||
|
||||
/// The vanilla default (10) and maximum (512) for `BELOW_NAME_DISTANCE`.
|
||||
pub fn below_name_distance_of(value: Option<f64>) -> f64 {
|
||||
value
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(10.0)
|
||||
.clamp(0.0, 512.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
#[test]
|
||||
fn nameplate_distances_use_vanilla_defaults_and_clamp() {
|
||||
assert_eq!(name_tag_distance_of(None), 64.0);
|
||||
assert_eq!(name_tag_distance_of(Some(5.0)), 5.0);
|
||||
assert_eq!(name_tag_distance_of(Some(600.0)), 512.0);
|
||||
assert_eq!(name_tag_distance_of(Some(-4.0)), 0.0);
|
||||
assert_eq!(below_name_distance_of(None), 10.0);
|
||||
assert_eq!(below_name_distance_of(Some(2.0)), 2.0);
|
||||
assert_eq!(below_name_distance_of(Some(-1.0)), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stand_nameplate_values_override_defaults_and_clamp() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
assert_eq!(stand.name_tag_distance(), 64.0);
|
||||
assert_eq!(stand.below_name_distance(), 10.0);
|
||||
stand.nameplate = NameplateDistances {
|
||||
name_tag: Some(5.0),
|
||||
below_name: Some(2.0),
|
||||
};
|
||||
assert_eq!(
|
||||
(stand.name_tag_distance(), stand.below_name_distance()),
|
||||
(5.0, 2.0)
|
||||
);
|
||||
stand.nameplate.name_tag = Some(600.0);
|
||||
assert_eq!(stand.name_tag_distance(), 512.0);
|
||||
}
|
||||
}
|
||||
@@ -916,15 +916,7 @@ pub fn mob_to_nbt(
|
||||
Tag::Int(m.extra.more_carrot_ticks),
|
||||
);
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
"IsScreamingGoat".into(),
|
||||
Tag::Byte(i8::from(m.extra.screaming)),
|
||||
);
|
||||
let [left, right] = m.extra.lost_horns;
|
||||
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
|
||||
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
|
||||
}
|
||||
"minecraft:goat" => crate::mobs::goat::write_nbt(m, map),
|
||||
"minecraft:creeper" => {
|
||||
map.insert("Fuse".into(), Tag::Short(m.max_swell as i16));
|
||||
map.insert(
|
||||
@@ -2353,11 +2345,7 @@ pub fn mob_from_nbt_with_random(
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
crate::mobs::Species::Goat => {
|
||||
m.extra.screaming = flag(tag, "IsScreamingGoat");
|
||||
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
|
||||
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
|
||||
}
|
||||
crate::mobs::Species::Goat => crate::mobs::goat::read_nbt(&mut m, tag),
|
||||
_ => {}
|
||||
}
|
||||
// `Rabbit.readAdditionalSaveData`.
|
||||
@@ -2372,8 +2360,8 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
crate::mobs::tame::restore_sitting(&mut m, flag(tag, "Sitting"));
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
|
||||
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Panda {
|
||||
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
|
||||
@@ -2398,8 +2386,8 @@ pub fn mob_from_nbt_with_random(
|
||||
.and_then(crate::save::item_from_nbt);
|
||||
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
|
||||
m.extra.body_extra = armor.and_then(|s| s.extra);
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
|
||||
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -2652,7 +2640,7 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) as i8 % 6).unsigned_abs();
|
||||
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
|
||||
m.anim.v[0] = m.extra.counter as f32 * 0.01;
|
||||
@@ -2913,7 +2901,7 @@ pub fn entity_base_tag(
|
||||
motion,
|
||||
rotation,
|
||||
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
|
||||
fall_distance: 0.0,
|
||||
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
|
||||
}
|
||||
.tag(raw)
|
||||
}
|
||||
@@ -3049,6 +3037,7 @@ pub fn arrow_to_nbt(
|
||||
}
|
||||
for (key, value) in [
|
||||
("Fire", Tag::Short(a.fire_ticks as i16)),
|
||||
("Air", Tag::Short(p.air_supply as i16)),
|
||||
("life", Tag::Short(a.life as i16)),
|
||||
("shake", Tag::Byte(a.shake as i8)),
|
||||
("inGround", Tag::Byte(i8::from(a.in_ground))),
|
||||
@@ -3127,6 +3116,7 @@ pub fn arrow_from_nbt(
|
||||
p.owner_uuid = owner;
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
p.on_ground = flag(tag, "OnGround");
|
||||
projectile_fields::load_arrow_entity_fields(&mut p, tag);
|
||||
let a = p.arrow.as_mut()?;
|
||||
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
|
||||
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
|
||||
@@ -3146,8 +3136,6 @@ pub fn arrow_from_nbt(
|
||||
_ => false,
|
||||
})
|
||||
.cloned();
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
a.spectral = spectral;
|
||||
// `SpectralArrow.readAdditionalSaveData`: absent Duration defaults to 200.
|
||||
if spectral {
|
||||
@@ -4174,6 +4162,30 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// `Shulker` reads `AttachFace` as a byte before
|
||||
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
|
||||
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
|
||||
#[test]
|
||||
fn a_shulkers_attach_face_is_narrowed_to_a_byte_first() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = "minecraft:shulker".into();
|
||||
ty.species = crate::mobs::Species::Shulker;
|
||||
let types = [("minecraft:shulker".into(), kind)].into();
|
||||
let face = |saved: Tag| {
|
||||
let raw = Tag::compound([
|
||||
("id", string("minecraft:shulker")),
|
||||
("Pos", doubles(&[4.5, 220.0, 4.5])),
|
||||
("AttachFace", saved),
|
||||
]);
|
||||
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant3
|
||||
};
|
||||
assert_eq!(face(Tag::Byte(3)), 3);
|
||||
assert_eq!(face(Tag::Byte(-2)), 2);
|
||||
assert_eq!(face(Tag::Int(258)), 2);
|
||||
assert_eq!(face(Tag::Int(250)), 0);
|
||||
}
|
||||
use super::*;
|
||||
use crate::items::ItemStack;
|
||||
|
||||
@@ -4221,6 +4233,33 @@ mod tests {
|
||||
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
|
||||
}
|
||||
|
||||
/// An entity that never moves (an `AreaEffectCloud`) saves the
|
||||
/// `fall_distance` `Entity.load` read, and 0 when it was made in play.
|
||||
#[test]
|
||||
fn a_base_tag_keeps_the_loaded_fall_distance() {
|
||||
let raw = horizon_world::nbt::parse_snbt(
|
||||
"{id:\"minecraft:area_effect_cloud\",Pos:[1d,200d,1d],fall_distance:5.0d}",
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let fall = |raw: Option<&super::Tag>| {
|
||||
let tag = super::entity_base_tag(
|
||||
"minecraft:area_effect_cloud",
|
||||
super::Vec3::new(1.0, 200.0, 1.0),
|
||||
super::Vec3::ZERO,
|
||||
[0.0, 0.0],
|
||||
[1; 4],
|
||||
raw,
|
||||
);
|
||||
match tag.get("fall_distance") {
|
||||
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
|
||||
other => panic!("no fall_distance: {other:?}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(fall(Some(&raw)), 5.0);
|
||||
assert_eq!(fall(None), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
|
||||
let base = Base {
|
||||
@@ -4551,6 +4590,35 @@ mod tests {
|
||||
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
|
||||
}
|
||||
|
||||
/// `Wolf` and `Cat` read `CollarColor` with `DyeColor.LEGACY_ID_CODEC`: a
|
||||
/// byte through `DyeColor.byId`, whose out-of-range answer is the first
|
||||
/// colour, white (`OutOfBoundsStrategy.ZERO`). Nothing wraps.
|
||||
#[test]
|
||||
fn a_collar_colour_outside_the_sixteen_reads_white() {
|
||||
for (species, id) in [
|
||||
(crate::mobs::Species::Wolf, "minecraft:wolf"),
|
||||
(crate::mobs::Species::Cat, "minecraft:cat"),
|
||||
] {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = id.into();
|
||||
ty.species = species;
|
||||
let types = [(id.into(), kind)].into();
|
||||
let colour = |saved: Option<Tag>| {
|
||||
let mut fields = vec![("id", string(id)), ("Pos", doubles(&[4.5, 220.0, 4.5]))];
|
||||
fields.extend(saved.map(|c| ("CollarColor", c)));
|
||||
let m = mob_from_nbt(&Tag::compound(fields), &types, 1, None).unwrap();
|
||||
m.extra.color
|
||||
};
|
||||
assert_eq!(colour(Some(Tag::Byte(11))), 11, "{id}");
|
||||
assert_eq!(colour(Some(Tag::Byte(17))), 0, "{id}: not 17 & 15");
|
||||
assert_eq!(colour(Some(Tag::Byte(-1))), 0, "{id}: not 15");
|
||||
// Codec.BYTE narrows an int first: 259 is byte 3.
|
||||
assert_eq!(colour(Some(Tag::Int(259))), 3, "{id}");
|
||||
assert_eq!(colour(None), crate::mobs::tame::RED, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
fn fused_creeper(fuse: Option<i16>) -> (Mob, Tag) {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
@@ -4794,6 +4862,7 @@ mod tests {
|
||||
no_gravity: true,
|
||||
on_ground: true,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: TRIDENT_HIT_GROUND,
|
||||
item: &trident,
|
||||
},
|
||||
@@ -5799,6 +5868,7 @@ mod tests {
|
||||
no_gravity: false,
|
||||
on_ground: false,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: TRIDENT_HIT_GROUND,
|
||||
item: &trident,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Experience orbs: `ExperienceOrb` save data.
|
||||
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
|
||||
use super::{
|
||||
Base, ExperienceOrb, Tag, flag, float, int_value, motion, number, position, put, rotation,
|
||||
};
|
||||
|
||||
/// `ExperienceOrb`: Health, Age, Value, Count.
|
||||
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
@@ -10,7 +12,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
motion: o.velocity,
|
||||
rotation: o.rotation,
|
||||
on_ground: o.on_ground,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: o.fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
@@ -35,6 +37,7 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
|
||||
let orb = ExperienceOrb {
|
||||
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
|
||||
rotation: rotation(tag),
|
||||
join: Default::default(),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
|
||||
@@ -45,3 +45,17 @@ pub(super) fn load_projectile_flags_and_entity_fields(p: &mut Projectile, tag: &
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
load_projectile_entity_fields(p, tag);
|
||||
}
|
||||
|
||||
/// `Entity.load` for an arrow: fire and fall distance live on its arrow state,
|
||||
/// air on the projectile and defaults to the 300 of `getMaxAirSupply`.
|
||||
pub(super) fn load_arrow_entity_fields(p: &mut Projectile, tag: &Tag) {
|
||||
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(300);
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
a.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "projectile_fields_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::entity_nbt::{arrow_from_nbt, arrow_to_nbt};
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
|
||||
/// `Entity.load` reads `Air` and `saveWithoutId` writes it back.
|
||||
#[test]
|
||||
fn arrow_keeps_its_saved_air() {
|
||||
let parse = |text: &str| parse_snbt(text).unwrap().0;
|
||||
let raw = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d],Air:32767s,Fire:7s}");
|
||||
let (arrow, _) = arrow_from_nbt(&raw, None, |_| None).unwrap();
|
||||
assert_eq!(arrow.air_supply, 32767);
|
||||
let saved = arrow_to_nbt(&arrow, [197, 0, 0, 1], None, None, Some(&raw)).unwrap();
|
||||
assert_eq!(saved.get("Air"), Some(&Tag::Short(32767)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(7)));
|
||||
let absent = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d]}");
|
||||
let (fresh, _) = arrow_from_nbt(&absent, None, |_| None).unwrap();
|
||||
assert_eq!(fresh.air_supply, 300);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Thrown tridents: `AbstractArrow`, `Projectile` and `ThrownTrident` save data.
|
||||
use super::{
|
||||
Base, Pickup, Tag, Vec3, flag, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
|
||||
Base, Pickup, Tag, Vec3, flag, float, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
|
||||
position, put, rotation, string, text,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct TridentSave<'a> {
|
||||
/// `Entity.onGround`: a trident moves by `setPos`, so it keeps the loaded one.
|
||||
pub on_ground: bool,
|
||||
pub fire_ticks: i32,
|
||||
/// `Entity.fallDistance`.
|
||||
pub fall_distance: f64,
|
||||
/// `AbstractArrow.soundEvent`: the sound of its next block hit.
|
||||
pub sound_event: &'a str,
|
||||
pub item: &'a crate::items::ItemStack,
|
||||
@@ -38,7 +40,7 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
|
||||
motion: t.motion,
|
||||
rotation: [t.yaw, t.pitch],
|
||||
on_ground: t.on_ground,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: t.fall_distance,
|
||||
}
|
||||
.tag(raw);
|
||||
// Projectile.addAdditionalSaveData omits LeftOwner until it is true,
|
||||
@@ -95,6 +97,8 @@ pub struct TridentNbt {
|
||||
pub no_gravity: bool,
|
||||
pub on_ground: bool,
|
||||
pub fire_ticks: i32,
|
||||
/// `Entity.load`: `getDoubleOr("fall_distance", 0.0)`.
|
||||
pub fall_distance: f64,
|
||||
/// `SoundEvent`, or the trident's default when missing or no identifier.
|
||||
pub sound_event: String,
|
||||
pub item: crate::items::ItemStack,
|
||||
@@ -120,6 +124,7 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i16 as i32,
|
||||
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
|
||||
sound_event: text(tag.get("SoundEvent"))
|
||||
.and_then(|s| horizon_client_core::identifier::normalize_id(&s))
|
||||
.unwrap_or_else(|| TRIDENT_HIT_GROUND.to_owned()),
|
||||
|
||||
@@ -1499,8 +1499,8 @@ impl LevelAccess for GameLevel<'_> {
|
||||
signal
|
||||
};
|
||||
}
|
||||
if let Some(signal) = books::lectern_signal(self.world, pos) {
|
||||
return signal * i32::from(books::has_book_state(self.registry, state));
|
||||
if let Some(signal) = books::lectern_signal(self.world, self.registry, state, pos) {
|
||||
return signal;
|
||||
}
|
||||
if let Some(signal) =
|
||||
machines::shelf_signal(self.world, self.registry, state, pos, direction)
|
||||
@@ -3357,11 +3357,10 @@ impl Game<'_> {
|
||||
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
|
||||
// Minecraft.pick: from the camera entity's eyes along its view
|
||||
// (LivingEntity.getViewYRot: the head's yaw).
|
||||
if let Some(m) = self.camera_mob() {
|
||||
return Some((
|
||||
[m.position.x, m.eye_y(), m.position.z],
|
||||
pick_view_vector(m.head_yaw, m.head_pitch),
|
||||
));
|
||||
if let Some(e) = self.camera_entity() {
|
||||
let [x, y, z] = e.position;
|
||||
let eye = [x, y + f64::from(e.eye_height), z];
|
||||
return Some((eye, pick_view_vector(e.y_rot, e.x_rot)));
|
||||
}
|
||||
Some((self.eye_position()?, pick_view_vector(self.yaw, self.pitch)))
|
||||
}
|
||||
@@ -3404,7 +3403,7 @@ impl Game<'_> {
|
||||
Some(b)
|
||||
}
|
||||
fn outline_context_kind(&self) -> horizon_world::collision_context::EntityContextKind {
|
||||
if self.camera_mob().is_some() {
|
||||
if self.camera_entity().is_some() {
|
||||
horizon_world::collision_context::EntityContextKind::Living
|
||||
} else {
|
||||
horizon_world::collision_context::EntityContextKind::Player
|
||||
@@ -8321,22 +8320,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.tracked_special_box(id as i32)
|
||||
}
|
||||
/// The renderer for the window, with the current block sprites, GUI
|
||||
/// atlas and world border texture.
|
||||
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
|
||||
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
|
||||
pub(super) fn forget_atlas_slots(&mut self) {
|
||||
self.skin_slots.forget();
|
||||
self.maps.slots.forget();
|
||||
self.trim_slots.forget();
|
||||
self.map_patches.clear();
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
}
|
||||
|
||||
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
|
||||
self.renderer_for(window, &self.pack, &self.gui)
|
||||
}
|
||||
/// Rebuild GPU-owned state after the backend reports device loss.
|
||||
fn recover_device(&mut self) -> Result<()> {
|
||||
if !self.renderer.as_ref().is_some_and(Renderer::device_lost) {
|
||||
@@ -8399,34 +8382,6 @@ impl Game<'_> {
|
||||
log::info!("Horizon: graphics device recovered; rebuilding world meshes");
|
||||
Ok(())
|
||||
}
|
||||
/// A renderer made again from the resources loaded takes over: what named the old one's
|
||||
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
|
||||
/// settings are applied again.
|
||||
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
|
||||
// A new renderer starts with the vanilla column and without the world border's wall.
|
||||
renderer.clear_sections(self.world.layout());
|
||||
self.border_renderer.invalidate();
|
||||
self.renderer = Some(renderer);
|
||||
self.forget_atlas_slots();
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
|
||||
&mut self.native_shaders.picked,
|
||||
));
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
|
||||
/// discarded, then every loaded section is rebuilt.
|
||||
fn all_sections_changed(&mut self) {
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.clear_sections(self.world.layout());
|
||||
}
|
||||
self.streaming.reload_sections(&mut self.world);
|
||||
}
|
||||
/// A translated message for the player the game acts for: `overlay` is
|
||||
/// `sendOverlayMessage` (the action bar), else `sendSystemMessage` (chat).
|
||||
/// A guest gets a packet, so its own client translates it.
|
||||
@@ -9110,7 +9065,7 @@ impl Game<'_> {
|
||||
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
|
||||
// A spectated entity's view is not the local player's yaw and pitch
|
||||
// (`Camera.setup` reads the camera entity), so it is kept too.
|
||||
let spectating = self.camera_mob().is_some();
|
||||
let spectating = self.camera_entity().is_some();
|
||||
self.posed_rotation = view
|
||||
.filter(|posed| spectating || Some(*posed) != vanilla)
|
||||
.map(|(_, yaw, pitch)| (yaw, pitch));
|
||||
|
||||
@@ -533,7 +533,8 @@ impl Game<'_> {
|
||||
let reach: f64 = self.entity_reach;
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, a) in self.armor_stands.iter().enumerate() {
|
||||
if a.marker {
|
||||
// Minecraft.pick never hits the camera entity it looks from.
|
||||
if a.marker || self.remote_camera_id() == Some(a.id) {
|
||||
continue;
|
||||
}
|
||||
let b = a.bounds();
|
||||
@@ -1437,7 +1438,11 @@ impl Game<'_> {
|
||||
) {
|
||||
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
|
||||
let skinning = self.skinning();
|
||||
for a in &self.armor_stands {
|
||||
for a in self
|
||||
.armor_stands
|
||||
.iter()
|
||||
.filter(|a| !self.hides_as_camera(a.id))
|
||||
{
|
||||
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
|
||||
// edge (4 for a marker's empty box), whatever the entity distance.
|
||||
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
|
||||
use horizon_client::{
|
||||
block_entity_item,
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
@@ -18,8 +19,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{level::flags as level_flags, nbt::Tag, registry::BlockStateId};
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -914,6 +914,32 @@ impl Game<'_> {
|
||||
self.set_lectern_powered(key, false);
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getStateForPlacement`: a lectern placed from an item
|
||||
/// whose `block_entity_data` holds a `Book`, by a placer who may use
|
||||
/// game-master blocks, starts with `has_book` on. It runs before the
|
||||
/// item's `block_state` component, which vanilla applies afterwards and
|
||||
/// which may set `has_book` back. Returns the state to go on with.
|
||||
pub(super) fn lectern_placed(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
stack: &ItemStack,
|
||||
game_master: bool,
|
||||
) -> horizon_client::Result<BlockStateId> {
|
||||
if !game_master
|
||||
|| !block_entity_item::carries_book(stack)
|
||||
|| &*self.res.facts().get(state).name != "minecraft:lectern"
|
||||
{
|
||||
return Ok(state);
|
||||
}
|
||||
match self.with_props(state, &[("has_book", "true")]) {
|
||||
Some(new) if new != state => {
|
||||
self.level_set(pos, new)?;
|
||||
Ok(new)
|
||||
}
|
||||
_ => Ok(state),
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.useItemOn`: a book goes onto an empty lectern.
|
||||
pub(super) fn use_lectern(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
@@ -1686,20 +1712,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
|
||||
pub(super) fn has_book_state(
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> bool {
|
||||
registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`.
|
||||
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
|
||||
/// `LecternBlock.getAnalogOutputSignal` and
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: the state's `has_book` gates the
|
||||
/// answer, and an entity with no book reads 14 (a page count of 0 is a page
|
||||
/// progress of 1.0 with no book to add).
|
||||
pub(super) fn lectern_signal(
|
||||
world: &horizon_client::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
) -> Option<i32> {
|
||||
let s = registry.state(state)?;
|
||||
if s.name != "minecraft:lectern" {
|
||||
return None;
|
||||
}
|
||||
if s.properties.get("has_book").is_none_or(|v| v != "true") {
|
||||
return Some(0);
|
||||
}
|
||||
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(0, |b| {
|
||||
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(14, |b| {
|
||||
let data = b.data();
|
||||
let count = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.len()))
|
||||
@@ -1809,6 +1840,10 @@ mod lectern_books_tag_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lectern_tests.rs"]
|
||||
mod lectern_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lectern_signal_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
@@ -1822,19 +1857,26 @@ mod lectern_signal_tests {
|
||||
use horizon_world::level::LevelAccess;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
|
||||
fn signal(
|
||||
game: &mut super::Game<'_>,
|
||||
has_book: bool,
|
||||
book: Option<ItemStack>,
|
||||
page: i32,
|
||||
) -> i32 {
|
||||
let key = (8, 64, 8);
|
||||
let state: BlockStateId = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:lectern")
|
||||
.unwrap();
|
||||
let registry = game.pack.assets.registry;
|
||||
let default = registry.default_state("minecraft:lectern").unwrap();
|
||||
let mut state = registry.state(default).unwrap().clone();
|
||||
state
|
||||
.properties
|
||||
.insert("has_book".into(), has_book.to_string());
|
||||
let state: BlockStateId = registry.id(&state).unwrap();
|
||||
game.world.set(key.0, key.1, key.2, state).unwrap();
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Lectern(Lectern { book, page }));
|
||||
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
|
||||
let pos = BlockPos::new(key.0, key.1, key.2);
|
||||
lectern_signal(&game.world, registry, state, pos).unwrap()
|
||||
}
|
||||
|
||||
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
|
||||
@@ -1886,30 +1928,19 @@ mod lectern_signal_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
|
||||
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
|
||||
/// component, so a stack that only sits in the lectern (a non-book item a
|
||||
/// command left in `Book`) reads 14, not the book's 15.
|
||||
/// `LecternBlock.getAnalogOutputSignal` asks for the state's `HAS_BOOK`
|
||||
/// first, so a lectern without it reads 0 whatever its block entity holds;
|
||||
/// with it, `LecternBlockEntity.getRedstoneSignal` is `floor(page /
|
||||
/// (pageCount - 1) * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a
|
||||
/// book-content component, so a stack that only sits in the lectern (a
|
||||
/// non-book item a command left in `Book`) reads 14, not the book's 15.
|
||||
/// An entity with no book at all reads 14 too: a page count of 0 is a page
|
||||
/// progress of 1.0 with no book to add.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_lecterns_signal_adds_one_only_for_a_book() {
|
||||
let mut game = server_game();
|
||||
assert_eq!(signal(&mut game, None, 0), 0, "no book");
|
||||
assert_eq!(
|
||||
signal(&mut game, Some(ItemStack::new("minecraft:stone", 1)), 0),
|
||||
14,
|
||||
"a non-book stack has no book content"
|
||||
);
|
||||
// `writable_book_content` is a writable book's default component.
|
||||
assert_eq!(
|
||||
signal(
|
||||
&mut game,
|
||||
Some(ItemStack::new("minecraft:writable_book", 1)),
|
||||
0
|
||||
),
|
||||
15,
|
||||
"a writable book is a book"
|
||||
);
|
||||
let writable = Some(ItemStack::new("minecraft:writable_book", 1));
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.extra = Some(Box::new(StackData {
|
||||
written: Some(WrittenBook {
|
||||
@@ -1920,8 +1951,25 @@ mod lectern_signal_tests {
|
||||
}),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(signal(&mut game, Some(written.clone()), 0), 1, "page 0");
|
||||
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
|
||||
// `has_book=false`: no signal, whatever the block entity holds.
|
||||
assert_eq!(signal(&mut game, false, None, 0), 0);
|
||||
assert_eq!(
|
||||
signal(&mut game, false, writable.clone(), 0),
|
||||
0,
|
||||
"state denies"
|
||||
);
|
||||
// `has_book=true`, an entity with no book: page progress 1.0.
|
||||
assert_eq!(signal(&mut game, true, None, 0), 14, "no book");
|
||||
let stone = Some(ItemStack::new("minecraft:stone", 1));
|
||||
assert_eq!(signal(&mut game, true, stone, 0), 14, "no book content");
|
||||
// `writable_book_content` is a writable book's default component.
|
||||
assert_eq!(signal(&mut game, true, writable, 0), 15, "a book");
|
||||
assert_eq!(
|
||||
signal(&mut game, true, Some(written.clone()), 0),
|
||||
1,
|
||||
"page 0"
|
||||
);
|
||||
assert_eq!(signal(&mut game, true, Some(written), 1), 15, "last page");
|
||||
}
|
||||
}
|
||||
impl BookScreen {
|
||||
|
||||
@@ -143,6 +143,9 @@ pub(super) struct CombatTracker {
|
||||
/// A firework rocket's burst is hurting the player now: the rocket is
|
||||
/// the direct entity (`rocket_hurt_player`).
|
||||
pub(super) rocket_hit: bool,
|
||||
/// The arrow hurting the player now pierces (`AbstractArrow.getPierceLevel` above 0):
|
||||
/// `LivingEntity.applyItemBlocking` lets it past a raised shield (`arrow_hits_player`).
|
||||
pub(super) piercing_hit: bool,
|
||||
/// The player whose melee hit is landing on the player now: where they
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
|
||||
@@ -2613,23 +2613,14 @@ impl Game<'_> {
|
||||
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck like any orb spawn.
|
||||
let at = self.unstuck_orb(pos);
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(horizon_client::entities::ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: self.next_entity,
|
||||
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,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
air: 300,
|
||||
following: None,
|
||||
});
|
||||
let at = Vec3::new(at[0], at[1], at[2]);
|
||||
let orb = horizon_client::entities::ExperienceOrb::new(
|
||||
self.next_entity,
|
||||
at,
|
||||
Vec3::ZERO,
|
||||
0,
|
||||
);
|
||||
self.orbs.push(orb);
|
||||
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
|
||||
if !self.native_joins_flush(false, self.next_entity) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
|
||||
@@ -646,6 +646,7 @@ impl Game<'_> {
|
||||
orb.count = new.count;
|
||||
orb.health = new.health;
|
||||
orb.no_gravity = new.no_gravity;
|
||||
orb.fall_distance = new.fall_distance;
|
||||
// Entity.load reads portalCooldown and leaves the portal
|
||||
// process as it is.
|
||||
if let Some(portal) = self.portals.entities.get_mut(&id) {
|
||||
|
||||
@@ -1039,14 +1039,7 @@ impl Game<'_> {
|
||||
.unwrap_or(Tag::Compound(Default::default())),
|
||||
v => horizon_client::scoreboard::json_to_tag(v),
|
||||
};
|
||||
edit_components(&mut stack, |c| {
|
||||
let mut data = match c.get(&"minecraft:custom_data".into()) {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
merge_compound(&mut data, &tag);
|
||||
c.insert("minecraft:custom_data".into(), data);
|
||||
});
|
||||
set_custom_data(&mut stack, &tag);
|
||||
}
|
||||
"minecraft:set_components" => {
|
||||
let patch = m["components"].clone();
|
||||
@@ -1577,6 +1570,22 @@ fn edit_components(stack: &mut ItemStack, f: impl FnOnce(&mut horizon_world::nbt
|
||||
stack.extra = extra;
|
||||
stack.tidy();
|
||||
}
|
||||
/// `SetCustomDataFunction.run`: `CustomData.update(stack, tag -> tag.merge(value))`.
|
||||
fn set_custom_data(stack: &mut ItemStack, value: &Tag) {
|
||||
edit_components(stack, |c| {
|
||||
let mut data = match c.get(&"minecraft:custom_data".into()) {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
merge_compound(&mut data, value);
|
||||
// Vanilla CustomData.update removes the component when the result is empty.
|
||||
if matches!(&data, Tag::Compound(m) if m.is_empty()) {
|
||||
c.remove(&"minecraft:custom_data".into());
|
||||
} else {
|
||||
c.insert("minecraft:custom_data".into(), data);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `CompoundTag.merge`: nested compounds merge, other values replace.
|
||||
fn merge_compound(into: &mut Tag, from: &Tag) {
|
||||
let (Tag::Compound(a), Tag::Compound(b)) = (into, from) else {
|
||||
@@ -2815,3 +2824,16 @@ mod registered_slot_source_preservation_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod set_custom_data_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merging_an_empty_tag_into_a_stack_without_custom_data_adds_no_component() {
|
||||
let mut stack = ItemStack::new("minecraft:stick", 1);
|
||||
set_custom_data(&mut stack, &Tag::Compound(Default::default()));
|
||||
let saved = horizon_client::item_data::encode_components(&stack);
|
||||
assert!(saved.is_none_or(|c| c.get("minecraft:custom_data").is_none()));
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,15 @@ impl Game<'_> {
|
||||
consumable: &ItemConsumable,
|
||||
stack: &ItemStack,
|
||||
) {
|
||||
// Consumable.onConsume runs on the stack's own component.
|
||||
let consumable = horizon_client::item_sync::stack_consumable(stack, consumable);
|
||||
let consumable = &*consumable;
|
||||
self.emit_consume(consumable, stack, 16);
|
||||
// ServerPlayer: the used statistic and the consume_item trigger.
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.trigger_item("minecraft:consume_item", stack);
|
||||
// FoodProperties.onConsume, the player's one ConsumableListener.
|
||||
if let Some((nutrition, saturation)) = info.food {
|
||||
if let Some((nutrition, saturation)) = horizon_client::item_sync::food(stack, info.food) {
|
||||
let at = self.feet();
|
||||
let pitch = self.world.random.triangle_float(1.0, 0.4);
|
||||
self.audio.play(&consumable.sound, at, 1.0, pitch);
|
||||
@@ -96,10 +99,11 @@ impl Game<'_> {
|
||||
// with infinite materials, the hand when emptied, else the inventory
|
||||
// (ServerPlayer.handleExtraItemsCreatedOnUse) or a drop.
|
||||
self.consume_held();
|
||||
if let Some(remainder) = &info.use_remainder
|
||||
if let Some(remainder) =
|
||||
horizon_client::item_sync::use_remainder(stack, info.use_remainder.clone())
|
||||
&& !self.creative
|
||||
{
|
||||
let remainder = ItemStack::new(remainder, 1);
|
||||
let remainder = ItemStack::new(&remainder, 1);
|
||||
if self.held().is_none() {
|
||||
*self.held_mut() = Some(remainder);
|
||||
} else if let Some(rest) = self.give(remainder) {
|
||||
|
||||
@@ -247,8 +247,16 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
|
||||
/// death and a natural heart drops experience. `onExplosionHit` does the
|
||||
/// same for the creaking (`by_player` false), without the experience.
|
||||
/// same for the creaking, and awards the experience only when its
|
||||
/// blast's indirect source is a player (`creaking_heart_broken_by`).
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
|
||||
let award = by_player && !self.creative && !self.spectator();
|
||||
self.creaking_heart_broken_by(pos, award);
|
||||
}
|
||||
/// `creaking_heart_broken` once the breaker is known: `award` is
|
||||
/// `tryAwardExperience`'s player check (a player who neither builds
|
||||
/// instantly, `preventsBlockDrops`, nor spectates).
|
||||
pub(super) fn creaking_heart_broken_by(&mut self, pos: [i32; 3], award: bool) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
@@ -271,16 +279,20 @@ impl Game<'_> {
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("natural").cloned())
|
||||
.is_some_and(|v| v == "true");
|
||||
if by_player && natural && !self.creative && !self.spectator() {
|
||||
if award && natural {
|
||||
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
|
||||
self.spawn_orbs(
|
||||
[
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
xp,
|
||||
);
|
||||
// Block.popExperience: the amount is drawn, then nothing pops
|
||||
// with `block_drops` off.
|
||||
if self.ui.rules.bool("block_drops") {
|
||||
self.spawn_orbs(
|
||||
[
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
xp,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod bred_draws;
|
||||
mod effect_instance;
|
||||
use effect_instance::mob_effect_instance;
|
||||
mod mob_geometry;
|
||||
mod mob_group;
|
||||
mod mob_islands;
|
||||
@@ -2310,10 +2312,9 @@ impl Game<'_> {
|
||||
self.post_game_event(event, at, ctx);
|
||||
}
|
||||
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
|
||||
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
|
||||
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
|
||||
// (ENTITY_PLACE), Enderman.teleport's TELEPORT,
|
||||
// LivingEntity.onEquipItem's EQUIP/UNEQUIP.
|
||||
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, a fox's
|
||||
// DRINK, the shulker's lid, an evoker's vexes (ENTITY_PLACE),
|
||||
// Enderman.teleport's TELEPORT, onEquipItem's EQUIP/UNEQUIP.
|
||||
MobEvent::GameEvent {
|
||||
event, at, source, ..
|
||||
} if matches!(
|
||||
@@ -2321,6 +2322,7 @@ impl Game<'_> {
|
||||
"minecraft:entity_action"
|
||||
| "minecraft:prime_fuse"
|
||||
| "minecraft:eat"
|
||||
| "minecraft:drink"
|
||||
| "minecraft:container_open"
|
||||
| "minecraft:container_close"
|
||||
| "minecraft:entity_place"
|
||||
@@ -2532,6 +2534,13 @@ impl Game<'_> {
|
||||
});
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
let portal_proof = species == Species::SulfurCube
|
||||
&& source_mob.is_some_and(|id| {
|
||||
self.portals
|
||||
.entities
|
||||
.get(&id)
|
||||
.is_some_and(|p| p.cooldown > 0)
|
||||
});
|
||||
self.combat.explosion_cause = match species {
|
||||
Species::Creeper => Some("minecraft:creeper"),
|
||||
Species::Wither => Some("minecraft:wither"),
|
||||
@@ -2561,7 +2570,11 @@ impl Game<'_> {
|
||||
Species::Creeper | Species::Wither => {
|
||||
self.with_hive_emptying_blast(|g| g.explode(at, radius, source))
|
||||
}
|
||||
Species::SulfurCube => self.explode_sulfur_cube(at, radius, source),
|
||||
Species::SulfurCube => self.explode_sulfur_cube(
|
||||
at,
|
||||
radius,
|
||||
source.with_portal_proof(portal_proof),
|
||||
),
|
||||
_ => self.explode(at, radius, source),
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
@@ -3970,6 +3983,7 @@ impl Game<'_> {
|
||||
) -> bool {
|
||||
let post_attack_if_alive = std::mem::take(&mut self.combat.post_attack_if_alive);
|
||||
let rocket = std::mem::take(&mut self.combat.rocket_hit);
|
||||
let pierce = std::mem::take(&mut self.combat.piercing_hit);
|
||||
let melee = self.combat.melee_mob.take();
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
@@ -3984,7 +3998,7 @@ impl Game<'_> {
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
let blocking = self.player_blocking(from, damage_type, false);
|
||||
let blocking = self.player_blocking(from, damage_type, pierce);
|
||||
let raised = blocking.is_some();
|
||||
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
|
||||
let attacker = match known {
|
||||
@@ -5307,11 +5321,7 @@ impl Game<'_> {
|
||||
}
|
||||
let hurt = match who {
|
||||
Who::Player => {
|
||||
let hurt = self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
);
|
||||
let hurt = self.arrow_hits_player(amount, p);
|
||||
// AbstractArrow.onHitEntity: a hit that hurt leaves a
|
||||
// non-piercing arrow stuck in the body.
|
||||
if hurt && p.arrow.as_ref().is_some_and(|a| a.pierce == 0) {
|
||||
@@ -5575,7 +5585,7 @@ impl Game<'_> {
|
||||
if let Some(id) = self.spawn_mob_raw("minecraft:chicken", atp, true)
|
||||
&& let Some(c) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
c.extra.age = -24000;
|
||||
(c.extra.age, c.extra.variant) = (-24000, p.egg_variant.unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7138,12 +7148,12 @@ impl Game<'_> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
|
||||
// last moved, not a velocity the server kept for a guest.
|
||||
// Projectile.shootFromRotation adds getKnownMovement: what the thrower last moved.
|
||||
let on_ground = p.ground.on_ground;
|
||||
let velocity = self.known_player_movement();
|
||||
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
|
||||
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
|
||||
proj.set_egg_item(stack);
|
||||
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
|
||||
self.add_projectile(proj);
|
||||
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
|
||||
@@ -7642,24 +7652,6 @@ fn knock_back(
|
||||
);
|
||||
}
|
||||
|
||||
/// The instance a mob effect gives the player; `None` for a creative or
|
||||
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
|
||||
fn mob_effect_instance(
|
||||
effect: &'static str,
|
||||
ticks: i32,
|
||||
amplifier: i32,
|
||||
ambient: bool,
|
||||
immune: bool,
|
||||
) -> Option<horizon_client::effects::EffectInstance> {
|
||||
if immune && !ambient {
|
||||
return None;
|
||||
}
|
||||
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
|
||||
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
|
||||
instance.ambient = ambient;
|
||||
Some(instance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_sound_event_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/// The instance a mob effect gives the player; `None` for a creative or
|
||||
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
|
||||
pub(super) fn mob_effect_instance(
|
||||
effect: &'static str,
|
||||
ticks: i32,
|
||||
amplifier: i32,
|
||||
ambient: bool,
|
||||
immune: bool,
|
||||
) -> Option<horizon_client::effects::EffectInstance> {
|
||||
if immune && !ambient {
|
||||
return None;
|
||||
}
|
||||
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
|
||||
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
|
||||
instance.ambient = ambient;
|
||||
Some(instance)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ impl Recording {
|
||||
Self {
|
||||
seconds,
|
||||
started: Instant::now(),
|
||||
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
|
||||
cpu_s: horizon_system::cpu_seconds(std::process::id()),
|
||||
spans_were_on,
|
||||
ticks: Vec::with_capacity(seconds as usize * 20),
|
||||
sampled_ticks: 0,
|
||||
@@ -363,7 +363,7 @@ impl Game<'_> {
|
||||
let costs = tick_profile::take();
|
||||
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
|
||||
let jobs = job_stats::take();
|
||||
let cpu = horizon_bench::cpu_seconds(std::process::id())
|
||||
let cpu = horizon_system::cpu_seconds(std::process::id())
|
||||
.zip(r.cpu_s)
|
||||
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
@@ -382,7 +382,7 @@ impl Game<'_> {
|
||||
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"process": process_limits(),
|
||||
"cpu_cores_used": cpu,
|
||||
"config": {
|
||||
@@ -395,9 +395,12 @@ impl Game<'_> {
|
||||
// The machine's description runs commands: off the tick.
|
||||
std::thread::spawn(move || {
|
||||
let mut report = report;
|
||||
let hw = horizon_bench::Hardware::detect();
|
||||
report["machine"] = json!({
|
||||
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"cpu": horizon_system::cpu(),
|
||||
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
});
|
||||
match write_report(Path::new("reports"), &report, &text) {
|
||||
Ok(path) => log::info!(
|
||||
|
||||
@@ -37,6 +37,10 @@ pub(super) fn aim_deflect(
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
guest: Option<u32>,
|
||||
) {
|
||||
// `WindCharge.deflect`: refused while its `noDeflectTicks` last.
|
||||
if !p.deflectable() {
|
||||
return;
|
||||
}
|
||||
if let Some(look) = look {
|
||||
p.velocity = look;
|
||||
p.needs_sync = true;
|
||||
@@ -183,6 +187,27 @@ impl Game<'_> {
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
|
||||
let p = &mut self.creatures.projectiles[i];
|
||||
// `WindCharge.deflect` refuses while its `noDeflectTicks` last: the
|
||||
// blow goes on as an attack that hurts nothing.
|
||||
if p.deflectable() {
|
||||
Self::turn_by_player(p, dir, guest, owner_uuid);
|
||||
}
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
}
|
||||
/// `AIM_DEFLECT` by a player's blow: along their view, theirs now.
|
||||
fn turn_by_player(
|
||||
p: &mut Projectile,
|
||||
dir: [f64; 3],
|
||||
guest: Option<u32>,
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
) {
|
||||
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
|
||||
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
|
||||
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
|
||||
@@ -196,14 +221,6 @@ impl Game<'_> {
|
||||
// at once, not at the fireball's next update ten ticks later.
|
||||
p.acceleration = 0.1;
|
||||
p.needs_sync = true;
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +262,7 @@ mod tests {
|
||||
assert_eq!(b.max_position().y, 66.0);
|
||||
}
|
||||
|
||||
/// A wind charge past its first five ticks (`noDeflectTicks` run out).
|
||||
fn wind_charge() -> Projectile {
|
||||
let mut p = Projectile::new(
|
||||
ProjectileKind::WindCharge,
|
||||
@@ -252,9 +270,26 @@ mod tests {
|
||||
Some(Who::Mob(7)),
|
||||
);
|
||||
p.velocity = Vec3::new(0.0, 1.5, 0.0);
|
||||
p.no_deflect_ticks = 0;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_wind_charge_is_not_turned() {
|
||||
let mut p = wind_charge();
|
||||
p.no_deflect_ticks = 5;
|
||||
aim_deflect(
|
||||
&mut p,
|
||||
Some(Vec3::new(0.0, -1.0, 0.0)),
|
||||
Some(Who::Player),
|
||||
Some([4; 4]),
|
||||
Some(3),
|
||||
);
|
||||
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
|
||||
assert_eq!(p.owner, Some(Who::Mob(7)));
|
||||
assert!(!p.needs_sync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
|
||||
let mut p = wind_charge();
|
||||
|
||||
@@ -319,7 +319,9 @@ impl Game<'_> {
|
||||
);
|
||||
let state = self.world.block(origin.x, origin.y, origin.z);
|
||||
let mut used = Vec::new();
|
||||
if let Some(op) = case.get("special_attack") {
|
||||
if let Some(op) = case.get("shield_hit") {
|
||||
used = self.fixture_shield_hit(op)?;
|
||||
} else if let Some(op) = case.get("special_attack") {
|
||||
used = self.fixture_special_attack(op)?;
|
||||
} else if let Some(op) = case.get("wind_burst") {
|
||||
let center = [
|
||||
@@ -497,6 +499,68 @@ impl Game<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Actual player combat and blocking-item side effects, without a window or connection.
|
||||
fn fixture_shield_hit(&mut self, op: &Value) -> Result<Vec<String>> {
|
||||
let stack = parse_stack(op["item"].as_str().ok_or("shield_hit item")?)?
|
||||
.ok_or("shield_hit requires an item")?;
|
||||
let item = stack.item.clone();
|
||||
self.creative = op["creative"].as_bool().unwrap_or(false);
|
||||
self.mode = if self.creative {
|
||||
GameMode::Creative
|
||||
} else {
|
||||
GameMode::Survival
|
||||
};
|
||||
self.player = Some(PlayerMotion::new(
|
||||
self.motion,
|
||||
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
|
||||
)?);
|
||||
self.yaw = 0.0;
|
||||
self.armor = Default::default();
|
||||
self.offhand = Some(stack);
|
||||
self.using = Some((super::shield::OFFHAND_SLOT, 100));
|
||||
self.combat = Default::default();
|
||||
let before = self.stats.get("used", &item);
|
||||
let from = horizon_core::geometry::Vec3::new(
|
||||
0.0,
|
||||
0.0,
|
||||
if op["behind"].as_bool().unwrap_or(false) {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
);
|
||||
let mut arrow = horizon_client::projectiles::Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
base_damage: op["damage"].as_f64().unwrap_or(8.0),
|
||||
critical: false,
|
||||
effect: None,
|
||||
},
|
||||
from,
|
||||
None,
|
||||
);
|
||||
arrow.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 1.0);
|
||||
arrow.arrow.as_mut().unwrap().pierce = u8::from(op["pierce"].as_bool().unwrap_or(false));
|
||||
self.projectile_hit(
|
||||
&arrow,
|
||||
horizon_client::projectiles::Hit::Entity {
|
||||
who: horizon_client::mobs::Who::Player,
|
||||
at: horizon_core::geometry::Vec3::ZERO,
|
||||
},
|
||||
)?;
|
||||
let out = vec![format!(
|
||||
"shield health={} used={} stack={}",
|
||||
num(f64::from(self.player.as_ref().unwrap().health.health())),
|
||||
self.stats.get("used", &item) - before,
|
||||
self.offhand
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_owned(), |s| format!("{}:{}", s.count, s.damage))
|
||||
)];
|
||||
self.player = None;
|
||||
self.offhand = None;
|
||||
self.using = None;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A player holding the case's item right-clicks the block at `pos`
|
||||
/// (`use_item_as_player`'s block steps): the statistics, the result
|
||||
/// and the held stack, as `VanillaDispenserOracle.use` prints them.
|
||||
|
||||
@@ -909,7 +909,7 @@ impl Game<'_> {
|
||||
};
|
||||
let (Some(slot), true) = (
|
||||
info.equip_slot.as_deref().and_then(Slot::from_name),
|
||||
info.equip_dispensable,
|
||||
horizon_client::item_sync::dispensable(stack, info.equip_dispensable),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1240,6 +1240,7 @@ impl Game<'_> {
|
||||
_ => ProjectileKind::Snowball,
|
||||
};
|
||||
let mut shot = Projectile::new(kind, at, None);
|
||||
shot.set_egg_item(&one);
|
||||
// ArrowItem/SpectralArrowItem.asProjectile: dispensed arrows
|
||||
// may be picked up.
|
||||
if let Some(a) = shot.arrow.as_mut() {
|
||||
|
||||
@@ -381,7 +381,7 @@ pub(super) struct Dragon {
|
||||
pub id: u32,
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
velocity: Vec3,
|
||||
pub(super) velocity: Vec3,
|
||||
pub yaw: f32,
|
||||
pub previous_yaw: f32,
|
||||
yaw_accel: f32,
|
||||
@@ -869,14 +869,14 @@ impl Dragon {
|
||||
/// `ServerExplosion.hurtEntities` knockback on the dragon itself:
|
||||
/// `pushFromExplosion` adds `(1 - dist) * exposure` along the blast to
|
||||
/// its eye to the motion (`Entity.push`); the parts are not pushed.
|
||||
fn whole_box(&self) -> Aabb {
|
||||
pub(super) fn whole_box(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
)
|
||||
}
|
||||
fn explosion_knockback(
|
||||
pub(super) fn explosion_knockback(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
@@ -5831,7 +5831,6 @@ mod dragon_fireball_spawn_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thorns_tests.rs"]
|
||||
mod thorns_tests;
|
||||
@@ -2274,6 +2274,7 @@ mod set_rotation_tests {
|
||||
#[test]
|
||||
fn set_rotation_keeps_yaw_and_clamps_pitch_on_kinds_that_had_none() {
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
|
||||
@@ -109,6 +109,7 @@ pub(super) enum BlockInteraction {
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct ExplosionSource {
|
||||
silent: bool,
|
||||
pub(super) portal_proof: bool,
|
||||
/// The entity that exploded and its kind (`Explosion.getDirectSourceEntity`), when known.
|
||||
entity: Option<(u32, &'static str)>,
|
||||
}
|
||||
@@ -117,6 +118,10 @@ impl ExplosionSource {
|
||||
self.silent = silent;
|
||||
self
|
||||
}
|
||||
pub(super) fn with_portal_proof(mut self, portal_proof: bool) -> Self {
|
||||
self.portal_proof = portal_proof;
|
||||
self
|
||||
}
|
||||
pub(super) fn with_entity(mut self, id: u32, kind: &'static str) -> Self {
|
||||
self.entity = Some((id, kind));
|
||||
self
|
||||
@@ -417,10 +422,15 @@ impl Game<'_> {
|
||||
pos: [i32; 3],
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
cap: Option<f32>,
|
||||
portal_proof: bool,
|
||||
wither_immune: Option<&std::collections::HashSet<String, S>>,
|
||||
) -> (f32, bool) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
// USED_PORTAL_DAMAGE_CALCULATOR returns no resistance and never explodes a portal.
|
||||
if portal_proof && &*f.name == "minecraft:nether_portal" {
|
||||
return (0.0, true);
|
||||
}
|
||||
let fluid = if f.fluid != Fluid::None {
|
||||
100.0f32
|
||||
} else {
|
||||
@@ -453,6 +463,7 @@ impl Game<'_> {
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
portal_proof: bool,
|
||||
) -> Vec<[i32; 3]> {
|
||||
let (min_y, max_y) = self.with_level(|_, level, _| {
|
||||
use horizon_world::level::LevelAccess;
|
||||
@@ -501,8 +512,13 @@ impl Game<'_> {
|
||||
let (taken, spared, again) = match last {
|
||||
Some((at, taken, spared)) if at == pos => (taken, spared, true),
|
||||
_ => {
|
||||
let (taken, spared) =
|
||||
self.blast_step(pos, resists, cap, wither_immune.as_ref());
|
||||
let (taken, spared) = self.blast_step(
|
||||
pos,
|
||||
resists,
|
||||
cap,
|
||||
portal_proof,
|
||||
wither_immune.as_ref(),
|
||||
);
|
||||
last = Some((pos, taken, spared));
|
||||
(taken, spared, false)
|
||||
}
|
||||
@@ -1253,7 +1269,7 @@ impl Game<'_> {
|
||||
// Explosion.explode: the blast is heard.
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.exploded_positions(center, radius, resists);
|
||||
let mut targets = self.exploded_positions(center, radius, resists, source.portal_proof);
|
||||
let dimension = self.dim.id();
|
||||
self.native_explosion_detonate(dimension, center, source.entity, &mut targets);
|
||||
let pushed =
|
||||
@@ -1341,10 +1357,18 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let is_tnt = &*name == "minecraft:tnt";
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
|
||||
// experience, as Horizon does not track a TNT's owner).
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies, and a blast
|
||||
// whose indirect source is a player (a primed TNT's owner) awards
|
||||
// the heart's experience (`tryAwardExperience`).
|
||||
if &*name == "minecraft:creaking_heart" {
|
||||
self.creaking_heart_broken(pos, false);
|
||||
// tryAwardExperience checks the TNT owner's own abilities.
|
||||
let award = match self.combat.blast_guest {
|
||||
Some(conn) => self
|
||||
.command_guest(conn)
|
||||
.is_some_and(|g| !g.creative() && !g.spectator()),
|
||||
None => self.combat.blast_owner_player && !self.creative && !self.spectator(),
|
||||
};
|
||||
self.creaking_heart_broken_by(pos, award);
|
||||
}
|
||||
// Block.onExplosionHit: the drops copy from the block entity, which
|
||||
// then spills its contents as the block turns to air.
|
||||
@@ -1702,7 +1726,7 @@ mod tests {
|
||||
game.world.random.set_seed(7);
|
||||
let expected = exploded_positions_stepwise(&mut game, center, radius, resists);
|
||||
game.world.random.set_seed(7);
|
||||
let got = game.exploded_positions(center, radius, resists);
|
||||
let got = game.exploded_positions(center, radius, resists, false);
|
||||
assert!(expected.len() > 20, "{radius}: {}", expected.len());
|
||||
assert_eq!(got, expected, "radius {radius}, {resists:?}");
|
||||
}
|
||||
|
||||
@@ -756,7 +756,9 @@ impl Game<'_> {
|
||||
.filter_map(|(i, s)| Some((i, s?)))
|
||||
.filter(|(_, s)| counts(s));
|
||||
let (first, stack) = found.next()?;
|
||||
let total = stack.count + found.map(|(_, s)| s.count).sum::<u32>();
|
||||
let total = found
|
||||
.map(|(_, s)| s.count)
|
||||
.fold(stack.count, u32::saturating_add);
|
||||
(ranged || total > 1).then(|| (first, stack.item.clone(), total))
|
||||
}
|
||||
/// One widget's box in `corner`: icons in a column, texts beside them.
|
||||
|
||||
@@ -95,12 +95,12 @@ impl Game<'_> {
|
||||
valid_fov_modifier(modifier, base)
|
||||
});
|
||||
let target = self.zoomed_fov_target(target);
|
||||
// A server's spectator camera on a mob: its eye height. Its modifier
|
||||
// stays 1 (another player's own modifier is not modelled here); the
|
||||
// zoom extra still narrows it.
|
||||
if let Some(m) = self.camera_mob() {
|
||||
let eye = m.eye_height() as f32;
|
||||
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
|
||||
// A server's spectator camera on a mob or an armor stand: its eye
|
||||
// height. Its modifier stays 1 (another player's own modifier is not
|
||||
// modelled here); the zoom extra still narrows it.
|
||||
if let Some(e) = self.camera_entity() {
|
||||
self.view
|
||||
.tick(Some(e.eye_height), self.zoomed_fov_target(1.0));
|
||||
return;
|
||||
}
|
||||
let eye = self.player.as_ref().map(|p| {
|
||||
|
||||
@@ -499,6 +499,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -73,6 +73,9 @@ impl Game<'_> {
|
||||
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
|
||||
_ if let Some(consumable) = self.consumable(stack) => consumable.ticks(),
|
||||
_ if let Some(ticks) = patched_ticks => ticks,
|
||||
_ if let Some(seconds) = horizon_client::item_sync::consumable_seconds(stack) => {
|
||||
(seconds * 20.0) as i32
|
||||
}
|
||||
_ => match info {
|
||||
Some(i) if let Some(c) = &i.consumable => c.ticks(),
|
||||
_ if patched_blocks => 72_000,
|
||||
|
||||
@@ -3477,13 +3477,7 @@ impl Game<'_> {
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return;
|
||||
}
|
||||
// handleAttack: a target inside the world border, then
|
||||
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
|
||||
// mobs, objects and fireballs alike.
|
||||
if let Some(target) = self.guest_target_box(host, gi, id)
|
||||
&& (!self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
|
||||
{
|
||||
if self.guest_attack_stops(host, gi, id) {
|
||||
return;
|
||||
}
|
||||
// Native mods, where Player.attack starts: a canceled blow does
|
||||
@@ -6220,6 +6214,7 @@ fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>
|
||||
[range(at[0]), range(at[2])]
|
||||
}
|
||||
|
||||
mod attack_checks;
|
||||
mod dig_stop;
|
||||
use dig_stop::{StopDig, stop_dig};
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! `ServerGamePacketListenerImpl.handleAttack`'s checks between the target's lookup and
|
||||
//! `Player.attack`: the world border, the attack range, and the targets no player may attack.
|
||||
use super::*;
|
||||
use horizon_client::projectiles::ProjectileKind;
|
||||
use horizon_protocol::{packets::configuration::UpdateTags, text::translatable};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether a guest's attack on entity `id` ends before `Player.attack` (handleAttack).
|
||||
/// The guest who attacks an `ItemEntity`, an `ExperienceOrb`, themselves or an
|
||||
/// `AbstractArrow` that is not `isAttackable` is disconnected with
|
||||
/// `multiplayer.disconnect.invalid_entity_attacked`.
|
||||
pub(super) fn guest_attack_stops(&mut self, host: &mut LanHost, gi: usize, id: i32) -> bool {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
// handleAttack: a target inside the world border, then
|
||||
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
|
||||
// mobs, objects and fireballs alike.
|
||||
if let Some(target) = self.guest_target_box(host, gi, id)
|
||||
&& (!self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some(target) = self.refused_target(host, gi, id) else {
|
||||
return false;
|
||||
};
|
||||
// The same checks for these targets, then `PIERCING_WEAPON` in the main hand
|
||||
// returns before the kick; handleAttack ignores a player whose client has not
|
||||
// loaded (hasClientLoaded) before all of them.
|
||||
if !host.hub.guests[gi].client_loaded()
|
||||
|| !self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| {
|
||||
g.within_attack_range(&target) && !g.holds_piercing_weapon()
|
||||
}) != Some(true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let name = host.hub.guests[gi].name.clone();
|
||||
host.hub.kick(
|
||||
conn,
|
||||
translatable("multiplayer.disconnect.invalid_entity_attacked", &[]),
|
||||
);
|
||||
log::warn!("Player {name} tried to attack an invalid entity");
|
||||
true
|
||||
}
|
||||
|
||||
/// The box of entity `id` when handleAttack refuses it as a target: an `ItemEntity`, an
|
||||
/// `ExperienceOrb`, the attacking player, or an `AbstractArrow` (an arrow, a spectral
|
||||
/// arrow, a trident) that is not `isAttackable`. Only for a guest in the level in place,
|
||||
/// whose entities these are.
|
||||
fn refused_target(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
|
||||
let g = &host.hub.guests[gi];
|
||||
if !host.hub.here(g) {
|
||||
return None;
|
||||
}
|
||||
if g.entity_id == id {
|
||||
return Some(g.bounds());
|
||||
}
|
||||
let id = u32::try_from(id).ok().filter(|&id| id != 0)?;
|
||||
if let Some(item) = self.dropped.iter().find(|i| i.id == id) {
|
||||
return Some(item.bounds());
|
||||
}
|
||||
if let Some(orb) = self.orbs.iter().find(|o| o.id == id) {
|
||||
return Some(orb.bounds());
|
||||
}
|
||||
let sync = host.hub.lan.shared.sync();
|
||||
let refused =
|
||||
|kind: &str| !arrow_attackable(&sync.tags, sync.id("minecraft:entity_type", kind));
|
||||
if let Some(p) = self.creatures.projectiles.iter().find(|p| {
|
||||
p.id == id
|
||||
&& matches!(
|
||||
p.kind,
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Trident
|
||||
)
|
||||
}) {
|
||||
return refused(p.entity_type()).then(|| p.bounds());
|
||||
}
|
||||
// A thrown trident: `EntityType.TRIDENT`, 0.5 x 0.5.
|
||||
let t = self.tridents.iter().find(|t| t.id == id)?;
|
||||
let p = t.position;
|
||||
refused("minecraft:trident").then(|| {
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
|
||||
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The main-hand stack has `minecraft:piercing_weapon` (a spear's).
|
||||
fn holds_piercing_weapon(&self) -> bool {
|
||||
self.held().is_some_and(|stack| {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components_view(&horizon_client::item_render::stack_view(stack))
|
||||
.contains_key("minecraft:piercing_weapon")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractArrow.isAttackable`: the arrow's type (`type_id`) is in
|
||||
/// `#minecraft:redirectable_projectile`, as the server sent its tags to the players, a world's
|
||||
/// data packs included. A type or a tag missing from the data counts as attackable, so no
|
||||
/// player is kicked over data the server does not have.
|
||||
fn arrow_attackable(tags: &UpdateTags, type_id: Option<i32>) -> bool {
|
||||
let Some(type_id) = type_id else {
|
||||
return true;
|
||||
};
|
||||
let tag = tags
|
||||
.registries
|
||||
.iter()
|
||||
.find(|r| r.registry == "minecraft:entity_type")
|
||||
.and_then(|r| {
|
||||
r.tags
|
||||
.iter()
|
||||
.find(|t| t.name == "minecraft:redirectable_projectile")
|
||||
});
|
||||
tag.is_none_or(|t| t.ids.contains(&type_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_protocol::packets::configuration::{
|
||||
ClientInformation, RegistryTags, TagEntry, UpdateTags,
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn tags(redirectable: &[i32]) -> UpdateTags {
|
||||
UpdateTags {
|
||||
registries: vec![RegistryTags {
|
||||
registry: "minecraft:entity_type".into(),
|
||||
tags: vec![TagEntry {
|
||||
name: "minecraft:redirectable_projectile".into(),
|
||||
ids: redirectable.to_vec(),
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractArrow.isAttackable` reads the tag the players were sent: a data pack that
|
||||
/// puts arrows in `#minecraft:redirectable_projectile` makes them attackable, and a
|
||||
/// vanilla client then picks and attacks them, which must not kick.
|
||||
#[test]
|
||||
fn an_arrow_is_attackable_only_in_the_sent_redirectable_tag() {
|
||||
assert!(!arrow_attackable(&tags(&[30, 40]), Some(5)));
|
||||
assert!(arrow_attackable(&tags(&[5, 30, 40]), Some(5)));
|
||||
assert!(arrow_attackable(&tags(&[30]), None), "an unknown type");
|
||||
assert!(
|
||||
arrow_attackable(&UpdateTags { registries: vec![] }, Some(5)),
|
||||
"no entity type tags"
|
||||
);
|
||||
}
|
||||
|
||||
fn disconnects(rx: &mpsc::Receiver<Out>) -> Vec<horizon_world::nbt::Tag> {
|
||||
rx.try_iter()
|
||||
.filter_map(|out| match out {
|
||||
Out::Disconnect(reason) => Some(reason),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// handleAttack disconnects a player who attacks a dropped item, an experience orb,
|
||||
/// themselves, an arrow or a thrown trident (`invalid_entity_attacked`); a pig is
|
||||
/// attacked as before, and an item out of reach, or attacked with a spear in hand,
|
||||
/// ends the attack without a kick.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn attacking_an_item_an_orb_oneself_or_an_arrow_kicks_the_player() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::env::temp_dir().join(format!("horizon-attack-checks-unit-{}", std::process::id())),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
chunk.set(x, 199, z, stone);
|
||||
}
|
||||
}
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game.lan_host(
|
||||
crate::game::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "attack-checks-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let summoned = |game: &mut Game<'_>, command: &str| {
|
||||
let before = game.next_entity;
|
||||
assert!(game.run_command(command), "{command}");
|
||||
game.lan_assign_ids();
|
||||
assert!(game.next_entity > before, "{command}");
|
||||
game.next_entity as i32
|
||||
};
|
||||
let pig = summoned(
|
||||
&mut game,
|
||||
"summon pig 6.5 200 4.5 {NoAI:1b,NoGravity:1b,PersistenceRequired:1b}",
|
||||
);
|
||||
let item = summoned(
|
||||
&mut game,
|
||||
"summon item 4.5 200 6 {NoGravity:1b,Item:{id:\"minecraft:stone\",count:1}}",
|
||||
);
|
||||
let far_item = summoned(
|
||||
&mut game,
|
||||
"summon item 4.5 200 12.5 {NoGravity:1b,Item:{id:\"minecraft:stone\",count:1}}",
|
||||
);
|
||||
let orb = summoned(
|
||||
&mut game,
|
||||
"summon experience_orb 3 200 4.5 {NoGravity:1b,Value:1s}",
|
||||
);
|
||||
let arrow = summoned(&mut game, "summon arrow 4.5 200.5 3 {NoGravity:1b}");
|
||||
let trident = summoned(&mut game, "summon trident 5.5 200.5 3 {NoGravity:1b}");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 332,
|
||||
name: "AttackGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
game.next_entity as i32 + 1,
|
||||
[4.5, 200.0, 4.5],
|
||||
2,
|
||||
);
|
||||
game.next_entity += 1;
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.load_timeout = 0;
|
||||
let me = guest.entity_id;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
let attack = |game: &mut Game<'_>, host: &mut LanHost, id: i32| {
|
||||
game.lan_guest_packet(host, 2, S::Attack(play::Attack { id }));
|
||||
};
|
||||
let kicked = vec![translatable(
|
||||
"multiplayer.disconnect.invalid_entity_attacked",
|
||||
&[],
|
||||
)];
|
||||
|
||||
let hp = |g: &Game<'_>| g.mobs.iter().find(|m| m.id as i32 == pig).unwrap().health;
|
||||
let full = hp(&game);
|
||||
attack(&mut game, &mut host, pig);
|
||||
assert_eq!(disconnects(&rx), vec![], "a pig is a target");
|
||||
assert!(hp(&game) < full, "the pig is not hurt: {full} health left");
|
||||
attack(&mut game, &mut host, far_item);
|
||||
assert_eq!(disconnects(&rx), vec![], "an item out of reach");
|
||||
host.hub.guests[0].inventory[0] = Some(ItemStack::new("minecraft:iron_spear", 1));
|
||||
host.hub.guests[0].selected = 0;
|
||||
attack(&mut game, &mut host, item);
|
||||
assert_eq!(disconnects(&rx), vec![], "a spear in hand");
|
||||
host.hub.guests[0].inventory[0] = None;
|
||||
for (what, id) in [
|
||||
("an item", item),
|
||||
("an experience orb", orb),
|
||||
("themselves", me),
|
||||
("an arrow", arrow),
|
||||
("a thrown trident", trident),
|
||||
] {
|
||||
attack(&mut game, &mut host, id);
|
||||
assert_eq!(disconnects(&rx), kicked, "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,8 +163,13 @@ impl WorldOptions {
|
||||
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
|
||||
/// shows there too; a remote world has no such box.
|
||||
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
|
||||
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
|
||||
.then(|| Text::translate("lanServer.port", &[]))
|
||||
if remote || !ui::hovered(cursor, port.x, port.y, port.width, port.height) {
|
||||
return None;
|
||||
}
|
||||
Some(match port_error(&port.value) {
|
||||
Some(key) => Text::translate(key, &[Text::literal("1024"), Text::literal("65535")]),
|
||||
None => Text::translate("lanServer.port", &[]),
|
||||
})
|
||||
}
|
||||
fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
@@ -556,6 +561,18 @@ impl Restrictions {
|
||||
}
|
||||
}
|
||||
|
||||
/// `WorldOptionsScreen.tryParsePort`'s invalid value: not a number in
|
||||
/// `PORT_LOWER_BOUND..=PORT_HIGHER_BOUND`. A number another process holds
|
||||
/// (`HttpUtil.isPortAvailable`) is not checked: the port map records that gap.
|
||||
fn port_error(value: &str) -> Option<&'static str> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match value.parse::<u32>() {
|
||||
Ok(port) if (1024..=65535).contains(&port) => None,
|
||||
_ => Some("lanServer.port.invalid"),
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn remote_world_options_permission(&mut self, permission: bool) {
|
||||
if let Some(o) = self.world_options.as_mut() {
|
||||
@@ -1019,7 +1036,8 @@ impl Game<'_> {
|
||||
}
|
||||
// The widgets are drawn; the port box's tooltip goes over them.
|
||||
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
|
||||
ui::tooltip(batch, atlas, &[tip], cursor);
|
||||
// Tooltip.splitTooltip: font.split(message, 170).
|
||||
ui::tooltip(batch, atlas, &atlas.font.split(&tip, 170.0), cursor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1918,11 +1936,29 @@ mod tests {
|
||||
}
|
||||
|
||||
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
|
||||
/// label; a remote world (`o.remote`) has no port box to hover.
|
||||
/// label; `setPortError` turns that into the reason a number is rejected.
|
||||
/// A remote world (`o.remote`) has no port box to hover.
|
||||
#[test]
|
||||
fn the_port_box_explains_itself_on_hover() {
|
||||
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
|
||||
let mut port = EditBox::new(158.0, 100.0, 40.0, 20.0);
|
||||
let tip = |port: &EditBox| {
|
||||
WorldOptions::port_tooltip(port, (159.0, 101.0), false).map(|t| t.plain())
|
||||
};
|
||||
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"));
|
||||
// Empty takes the suggestion, a number in range is fine.
|
||||
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"));
|
||||
for bad in ["0", "80", "70000", "abc", "-1"] {
|
||||
port.set_value(bad);
|
||||
assert_eq!(
|
||||
tip(&port).as_deref(),
|
||||
Some("lanServer.port.invalid"),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
for good in ["1024", "25565", "65535"] {
|
||||
port.set_value(good);
|
||||
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"), "{good}");
|
||||
}
|
||||
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Lectern placement and the `block_change` game event of a book going on or
|
||||
//! off (`LecternBlock.getStateForPlacement`, `LecternBlock.placeBook` and
|
||||
//! `LecternBlockEntity.onBookItemRemove`).
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::Game;
|
||||
use crate::game::{Hit, sculk::TEST_POSTED_EVENTS};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
};
|
||||
|
||||
/// A lectern at `pos`: the state's `has_book`, and the entity's book.
|
||||
fn lectern(game: &mut Game<'_>, pos: [i32; 3], has_book: bool, book: Option<ItemStack>) {
|
||||
let registry = game.pack.assets.registry;
|
||||
let default = registry.default_state("minecraft:lectern").unwrap();
|
||||
let mut state = registry.state(default).unwrap().clone();
|
||||
state
|
||||
.properties
|
||||
.insert("has_book".into(), has_book.to_string());
|
||||
let state = registry.id(&state).unwrap();
|
||||
game.world.set(pos[0], pos[1], pos[2], state).unwrap();
|
||||
game.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Lectern(Lectern { book, page: 0 }),
|
||||
);
|
||||
}
|
||||
|
||||
/// The lectern item `/give` hands out with a written book in its
|
||||
/// `block_entity_data`.
|
||||
fn lectern_item() -> ItemStack {
|
||||
let text = r#"{"minecraft:block_entity_data":{id:"minecraft:lectern",Book:{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":{title:"t",author:"a",pages:["p1","p2"]}}}}}"#;
|
||||
let components = horizon_world::nbt::parse_snbt(text).expect("snbt").0;
|
||||
let (damage, extra) = horizon_client::item_data::decode_components(Some(&components));
|
||||
ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..ItemStack::new("minecraft:lectern", 1)
|
||||
}
|
||||
}
|
||||
|
||||
fn click(pos: [i32; 3]) -> Hit {
|
||||
Hit {
|
||||
pos,
|
||||
face: [0, 1, 0],
|
||||
point: [f64::from(pos[0]) + 0.5, f64::from(pos[1]), 0.5],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `has_book` of the lectern state at `pos`, and what a comparator beside
|
||||
/// it reads (`LecternBlock.getAnalogOutputSignal`).
|
||||
fn state_at(game: &Game<'_>, pos: [i32; 3]) -> (String, i32) {
|
||||
let registry = game.pack.assets.registry;
|
||||
let state = game.world.block(pos[0], pos[1], pos[2]);
|
||||
let has_book = registry
|
||||
.state(state)
|
||||
.unwrap()
|
||||
.properties
|
||||
.get("has_book")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let comparator = super::lectern_signal(&game.world, registry, state, at).expect("a lectern");
|
||||
(has_book, comparator)
|
||||
}
|
||||
|
||||
/// `LecternBlock.getStateForPlacement`: the state's `has_book` comes from the
|
||||
/// item's `block_entity_data.Book` for a placer who may use game-master blocks
|
||||
/// (`minecraft:lectern` is in `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`, so the
|
||||
/// block entity only takes the book for one too), and not for anyone else.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_placed_lectern_takes_has_book_from_its_item() {
|
||||
let mut game = server_game();
|
||||
game.set_mode_id(1);
|
||||
game.ui.registries.permission = 2;
|
||||
*game.held_mut() = Some(lectern_item());
|
||||
assert!(game.use_hand(Some(&click([8, 64, 8]))).unwrap());
|
||||
assert!(
|
||||
matches!(game.world.block_entities.get(&(8, 64, 8)), Some(BlockEntity::Lectern(l)) if l.book.is_some())
|
||||
);
|
||||
// A two-page book on page 0: `floor(0 / 1 * 14) + 1`.
|
||||
assert_eq!(state_at(&game, [8, 64, 8]), ("true".into(), 1));
|
||||
|
||||
// The same item from a placer who may not use game-master blocks gains
|
||||
// neither the state nor the block entity book, so the comparator reads 0.
|
||||
game.ui.registries.permission = 0;
|
||||
*game.held_mut() = Some(lectern_item());
|
||||
assert!(game.use_hand(Some(&click([10, 64, 8]))).unwrap());
|
||||
assert!(
|
||||
matches!(game.world.block_entities.get(&(10, 64, 8)), Some(BlockEntity::Lectern(l)) if l.book.is_none())
|
||||
);
|
||||
assert_eq!(state_at(&game, [10, 64, 8]), ("false".into(), 0));
|
||||
}
|
||||
|
||||
fn record_events(record: bool) {
|
||||
TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = record.then(Vec::new));
|
||||
}
|
||||
|
||||
/// The `block_change` game events posted since `record_events(true)`, with
|
||||
/// their position and source entity.
|
||||
fn block_changes() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
|
||||
TEST_POSTED_EVENTS.with(|e| {
|
||||
let events = e.borrow();
|
||||
let changes = events.as_ref().unwrap().iter();
|
||||
changes
|
||||
.filter(|(e, _, _)| *e == "minecraft:block_change")
|
||||
.copied()
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// `LecternBlock.placeBook` -> `resetBookState` with the player as the source
|
||||
/// entity, and `LecternBlockEntity.onBookItemRemove` -> `resetBookState(null,
|
||||
/// ...)`: one `block_change` at the lectern each way, and `has_book` follows.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_book_going_on_or_off_posts_one_block_change() {
|
||||
let mut game = server_game();
|
||||
let key = (8, 64, 8);
|
||||
lectern(&mut game, [8, 64, 8], false, None);
|
||||
*game.held_mut() = Some(ItemStack::new("minecraft:written_book", 1));
|
||||
record_events(true);
|
||||
assert!(game.use_hand(Some(&click([8, 64, 8]))).unwrap());
|
||||
assert_eq!(
|
||||
block_changes(),
|
||||
[(
|
||||
"minecraft:block_change",
|
||||
[8.5, 64.5, 8.5],
|
||||
Some(Who::Player)
|
||||
)]
|
||||
);
|
||||
assert_eq!(state_at(&game, [8, 64, 8]).0, "true");
|
||||
|
||||
// Taking it back off through the lectern's menu.
|
||||
record_events(true);
|
||||
let mut menu = game.lectern_menu(key).expect("the lectern's menu");
|
||||
menu.set_slot(&game.menu_env(), 0, None);
|
||||
game.lectern_menu_done(key, &menu);
|
||||
assert_eq!(
|
||||
block_changes(),
|
||||
[("minecraft:block_change", [8.5, 64.5, 8.5], None)]
|
||||
);
|
||||
assert_eq!(state_at(&game, [8, 64, 8]).0, "false");
|
||||
record_events(false);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::level::Ticks;
|
||||
|
||||
pub(super) struct Bolt {
|
||||
/// The entity id a server's players know it by (0 until it has one).
|
||||
@@ -65,6 +66,13 @@ fn in_viewer_box(bolt: [f64; 3], p: [f64; 3]) -> bool {
|
||||
&& p[1] >= bolt[1] - 15.0
|
||||
&& p[1] <= bolt[1] + 21.0
|
||||
}
|
||||
|
||||
/// `ServerLevel.tickThunder` -> `Level.getBlockRandomPos(minX, 0, minZ, 15)`:
|
||||
/// picks the strike column from the level's separate `randValue` LCG.
|
||||
fn thunder_strike_column(ticks: &mut Ticks, cx: i32, cz: i32) -> (i32, i32) {
|
||||
let r = ticks.block_random_pos(cx * 16, 0, cz * 16, 15);
|
||||
(r.x, r.z)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
|
||||
/// spawning chunk (within 128 blocks of a player) that is in entity
|
||||
@@ -90,8 +98,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
// getBlockRandomPos(minX, 0, minZ, 15)
|
||||
let r = self.world.random.next_int();
|
||||
let (x, z) = (cx * 16 + (r & 15), cz * 16 + (r >> 16 & 15));
|
||||
let (x, z) = thunder_strike_column(&mut self.block_ticks, cx, cz);
|
||||
let Some(pos) = self.lightning_target(x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -445,14 +452,14 @@ impl Game<'_> {
|
||||
// is the same block as before, so its placement does not schedule
|
||||
// it, and without this it stayed powered for good.
|
||||
b.schedule_tick(level, ticks, BlockPos::new(p[0], p[1], p[2]), 8);
|
||||
if level.redstone_experiments {
|
||||
b.lightning_rod_update_neighbors(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
state,
|
||||
);
|
||||
}
|
||||
// `updateNeighbours`: unconditional; the experiments only decide
|
||||
// whether the update carries an orientation.
|
||||
b.lightning_rod_update_neighbors(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
state,
|
||||
);
|
||||
});
|
||||
}
|
||||
// Level event 3002 with the rod's axis: sparks along it.
|
||||
@@ -831,6 +838,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LightningRodBlock.onLightningStrike` updates the neighbours of the block
|
||||
/// in front of the rod whether or not redstone experiments are on (#14371):
|
||||
/// a wire beside that block is strongly powered by the struck rod.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_struck_rod_updates_the_wire_beside_the_block_behind_it() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let wire = registry.default_state("minecraft:redstone_wire").unwrap();
|
||||
let mut rod = registry
|
||||
.state(registry.default_state("minecraft:lightning_rod").unwrap())
|
||||
.cloned()
|
||||
.unwrap();
|
||||
rod.properties.insert("facing".into(), "up".into());
|
||||
let rod = registry.id(&rod).unwrap();
|
||||
// The rod on stone at (8, 64, 8); wire on stone beside that block.
|
||||
game.world.set(8, 64, 8, stone).unwrap();
|
||||
game.world.set(9, 63, 8, stone).unwrap();
|
||||
game.world.set(9, 64, 8, wire).unwrap();
|
||||
game.world.set(8, 65, 8, rod).unwrap();
|
||||
assert!(
|
||||
!game
|
||||
.level
|
||||
.enabled_features
|
||||
.iter()
|
||||
.any(|feature| feature == "minecraft:redstone_experiments")
|
||||
);
|
||||
game.strike_rod([8.5, 66.0, 8.5]);
|
||||
let power = registry
|
||||
.state(game.world.block(9, 64, 8))
|
||||
.unwrap()
|
||||
.properties
|
||||
.get("power")
|
||||
.cloned();
|
||||
assert_eq!(power.as_deref(), Some("15"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_box_reaches_15_around_and_down_and_21_up() {
|
||||
let bolt = [0.0, 64.0, 0.0];
|
||||
@@ -841,6 +886,21 @@ mod tests {
|
||||
assert!(!in_viewer_box(bolt, [15.5, 64.0, 0.0]));
|
||||
}
|
||||
|
||||
/// `ServerLevel.tickThunder` picks its strike column from `Level.randValue`
|
||||
/// (`Ticks::block_random_pos`), using bits `2..=5` for X and `10..=13` for Z
|
||||
/// rather than drawing from `Level.random`.
|
||||
#[test]
|
||||
fn thunder_strike_column_uses_block_random_pos_lcg_bits() {
|
||||
let mut ticks = Ticks::new(12345);
|
||||
let mut expected_lcg = 12345i32;
|
||||
for (cx, cz) in [(0, 0), (-3, 5), (12, -8)] {
|
||||
expected_lcg = expected_lcg.wrapping_mul(3).wrapping_add(1_013_904_223);
|
||||
let r = expected_lcg >> 2;
|
||||
let expected = (cx * 16 + (r & 15), cz * 16 + ((r >> 8) & 15));
|
||||
assert_eq!(thunder_strike_column(&mut ticks, cx, cz), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Pig.thunderHit`: striking a pig with lightning converts it to a zombified
|
||||
/// piglin while preserving age (#7510).
|
||||
#[test]
|
||||
|
||||
@@ -122,6 +122,61 @@ fn a_rocket_posts_its_shot_once_and_a_silent_one_launches_quietly() {
|
||||
assert_eq!(game.audio.heard.get(launch), Some(&1));
|
||||
});
|
||||
}
|
||||
/// #14440: an orb's move adds its drop to the fall outside water; landing
|
||||
/// after a fall posts HIT_GROUND and forgets the fall (`Entity.checkFallDamage`);
|
||||
/// in water the drop adds nothing, so a landing posts nothing.
|
||||
#[test]
|
||||
#[ignore = "prepared resources; actual registered load, Game tick and posted events"]
|
||||
fn a_landing_orb_posts_hit_ground_after_a_fall() {
|
||||
use horizon_client::entities::OrbMove;
|
||||
with_game(|game| {
|
||||
assert!(game.run_command("summon experience_orb 6.5 200 10.5 {Value:1,fall_distance:2d}"));
|
||||
let i = game.orbs.len() - 1;
|
||||
let from = Vec3::new(6.5, 200.5, 10.5);
|
||||
let down = Vec3::new(0.0, -0.5, 0.0);
|
||||
start_events();
|
||||
// In the air: the drop adds to the fall.
|
||||
game.orbs[i].on_ground = false;
|
||||
game.orb_moved(
|
||||
i,
|
||||
OrbMove {
|
||||
from,
|
||||
moved: down,
|
||||
in_water: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(game.orbs[i].fall_distance, 2.5);
|
||||
// On the ground: HIT_GROUND, then the fall is gone.
|
||||
game.orbs[i].on_ground = true;
|
||||
game.orb_moved(
|
||||
i,
|
||||
OrbMove {
|
||||
from,
|
||||
moved: down,
|
||||
in_water: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(game.orbs[i].fall_distance, 0.0);
|
||||
// In water with no fall: no drop, so no landing event.
|
||||
game.orb_moved(
|
||||
i,
|
||||
OrbMove {
|
||||
from,
|
||||
moved: down,
|
||||
in_water: true,
|
||||
},
|
||||
);
|
||||
let events: Vec<_> = crate::game::sculk::TEST_POSTED_EVENTS.with(|v| {
|
||||
std::mem::take(v.borrow_mut().as_mut().unwrap())
|
||||
.into_iter()
|
||||
.filter(|e| e.0 == "minecraft:hit_ground")
|
||||
.collect()
|
||||
});
|
||||
stop_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(game.orbs[i].fall_distance, 0.0);
|
||||
});
|
||||
}
|
||||
/// #14318: an angled rocket falling onto stone builds up its fall and posts
|
||||
/// HIT_GROUND when it lands (`Entity.checkFallDamage`); one falling into water
|
||||
/// splashes once (`Entity.doWaterSplashEffect`: the sound and SPLASH) and lands
|
||||
|
||||
@@ -489,6 +489,29 @@ impl Containers<'_> {
|
||||
}
|
||||
}
|
||||
/// `canTakeItemThroughFace` (furnaces and brewing stands restrict it).
|
||||
/// `Container.canTakeItem` for a hopper's slot of `source`: the vanilla
|
||||
/// shelf rule needs to know where the item is going.
|
||||
fn can_take_into(
|
||||
&self,
|
||||
view: &View,
|
||||
slot: usize,
|
||||
stack: &ItemStack,
|
||||
face: Direction,
|
||||
target: &View,
|
||||
) -> bool {
|
||||
if view.kind == Kind::Bookshelf {
|
||||
// `ChiseledBookShelfBlockEntity.canTakeItem`: the target must have
|
||||
// a free slot or a matching stack with room (`hasAnyMatching`).
|
||||
return (0..target.len()).any(|i| match self.get(target, i) {
|
||||
None => true,
|
||||
Some(other) => {
|
||||
other.same_item_same_components(stack)
|
||||
&& other.count + stack.count <= self.max_stack(other)
|
||||
}
|
||||
});
|
||||
}
|
||||
self.can_take(view, slot, stack, face)
|
||||
}
|
||||
fn can_take(&self, view: &View, slot: usize, stack: &ItemStack, face: Direction) -> bool {
|
||||
if let Kind::Composter(level) = view.kind {
|
||||
// OutputContainer.canTakeItemThroughFace.
|
||||
@@ -1443,7 +1466,7 @@ impl Game<'_> {
|
||||
let Some(stack) = c.insertion_item(source, slot).map(Cow::into_owned) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, &stack, Direction::Down) {
|
||||
if !c.can_take_into(source, slot, &stack, Direction::Down, hopper) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -2482,3 +2505,65 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
#[path = "machines_chest_cat_tests.rs"]
|
||||
mod machines_chest_cat_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod can_take_tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
use horizon_client::containers::ChiseledBookshelf;
|
||||
|
||||
/// `ChiseledBookShelfBlockEntity.canTakeItem`: a hopper takes a book only
|
||||
/// when it has a free slot or a matching stack with room.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_full_hopper_cannot_take_a_book_from_a_chiseled_shelf() {
|
||||
let mut game = server_game();
|
||||
let shelf_pos = (2, 4, 3);
|
||||
let hopper_pos = (2, 5, 3);
|
||||
let shelf_state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:chiseled_bookshelf")
|
||||
.unwrap();
|
||||
let hopper_state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:hopper")
|
||||
.unwrap();
|
||||
game.world
|
||||
.set(shelf_pos.0, shelf_pos.1, shelf_pos.2, shelf_state)
|
||||
.unwrap();
|
||||
game.world
|
||||
.set(hopper_pos.0, hopper_pos.1, hopper_pos.2, hopper_state)
|
||||
.unwrap();
|
||||
let book = ItemStack::new("minecraft:book", 1);
|
||||
let mut shelf = ChiseledBookshelf::default();
|
||||
shelf.items[0] = Some(book.clone());
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(shelf_pos, BlockEntity::ChiseledBookshelf(Box::new(shelf)));
|
||||
let stone = ItemStack::new("minecraft:stone", 64);
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(hopper_pos, BlockEntity::Chest(vec![Some(stone.clone()); 5]));
|
||||
let shelf_view = game.containers().view(shelf_pos, false).expect("shelf");
|
||||
let full_hopper = game.containers().view(hopper_pos, false).expect("hopper");
|
||||
assert!(
|
||||
!game
|
||||
.containers()
|
||||
.can_take_into(&shelf_view, 0, &book, Direction::Down, &full_hopper),
|
||||
"a full hopper takes no book"
|
||||
);
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(hopper_pos, BlockEntity::Chest(vec![None; 5]));
|
||||
let empty_hopper = game.containers().view(hopper_pos, false).expect("hopper");
|
||||
assert!(
|
||||
game.containers()
|
||||
.can_take_into(&shelf_view, 0, &book, Direction::Down, &empty_hopper),
|
||||
"an empty hopper takes the book"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,10 @@ pub(super) struct Minecart {
|
||||
pub push: Vec3,
|
||||
/// TNT minecarts: ticks until the explosion, -1 when not primed.
|
||||
pub fuse: i32,
|
||||
/// TNT minecarts: `explosionPowerBase` and `explosionSpeedFactor`, the
|
||||
/// blast's power at rest and its gain with the cart's speed.
|
||||
pub explosion_power: f32,
|
||||
pub explosion_speed_factor: f32,
|
||||
/// Hopper minecarts: an unpowered activator rail enables them.
|
||||
pub enabled: bool,
|
||||
/// Chest minecarts: the loot table rolled into `items` on load, saved
|
||||
@@ -362,6 +366,8 @@ impl Minecart {
|
||||
fuel: 0,
|
||||
push: Vec3::ZERO,
|
||||
fuse: -1,
|
||||
explosion_power: DEFAULT_EXPLOSION_POWER,
|
||||
explosion_speed_factor: DEFAULT_EXPLOSION_SPEED_FACTOR,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
first_tick: true,
|
||||
@@ -1547,7 +1553,9 @@ impl Game<'_> {
|
||||
}
|
||||
let speed = speed_sqr.sqrt().min(5.0);
|
||||
let r = c.random.next_double();
|
||||
let power = (4.0 + r * 1.5 * speed) as f32;
|
||||
// MinecartTNT.explode: the base power plus the speed's share.
|
||||
let power = (f64::from(c.explosion_power)
|
||||
+ f64::from(c.explosion_speed_factor) * r * 1.5 * speed) as f32;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// The cart is the explosion's source: it is gone before the blast
|
||||
// looks for entities, so it can't hit itself.
|
||||
@@ -2920,6 +2928,11 @@ impl Game<'_> {
|
||||
.render_state(&scene, state.0, m, light, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `MinecartTNT.DEFAULT_EXPLOSION_POWER_BASE` and `DEFAULT_EXPLOSION_SPEED_FACTOR`.
|
||||
const DEFAULT_EXPLOSION_POWER: f32 = 4.0;
|
||||
const DEFAULT_EXPLOSION_SPEED_FACTOR: f32 = 1.0;
|
||||
|
||||
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
|
||||
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
|
||||
/// TNT cart.
|
||||
|
||||
@@ -32,6 +32,8 @@ impl Minecart {
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
map.remove(&JavaString::from("explosion_power"));
|
||||
map.remove(&JavaString::from("explosion_speed_factor"));
|
||||
crate::game::boats::put_ticks_frozen(map, self.ticks_frozen);
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
@@ -62,7 +64,26 @@ impl Minecart {
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// MinecartTNT.addAdditionalSaveData: the powers only off their defaults.
|
||||
CartKind::Tnt => {
|
||||
put("fuse", Tag::Int(self.fuse));
|
||||
for (key, value, default) in [
|
||||
(
|
||||
"explosion_power",
|
||||
self.explosion_power,
|
||||
DEFAULT_EXPLOSION_POWER,
|
||||
),
|
||||
(
|
||||
"explosion_speed_factor",
|
||||
self.explosion_speed_factor,
|
||||
DEFAULT_EXPLOSION_SPEED_FACTOR,
|
||||
),
|
||||
] {
|
||||
if value != default {
|
||||
put(key, Tag::Float(value.to_bits()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
@@ -151,6 +172,21 @@ impl Minecart {
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
if kind == CartKind::Tnt {
|
||||
// MinecartTNT.readAdditionalSaveData: getFloatOr, kept to 0..128.
|
||||
let float = |k: &str, default: f32| match tag.get(k) {
|
||||
Some(Tag::Float(v)) => f32::from_bits(*v),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v) as f32,
|
||||
Some(Tag::Long(v)) => *v as f32,
|
||||
Some(other) => {
|
||||
horizon_client_core::nbt_path::int_value(other).map_or(default, |v| v as f32)
|
||||
}
|
||||
None => default,
|
||||
};
|
||||
c.explosion_power = float("explosion_power", DEFAULT_EXPLOSION_POWER).clamp(0.0, 128.0);
|
||||
c.explosion_speed_factor =
|
||||
float("explosion_speed_factor", DEFAULT_EXPLOSION_SPEED_FACTOR).clamp(0.0, 128.0);
|
||||
}
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Custom names: `NameTagItem.interactLivingEntity` and the name tag
|
||||
//! (`EntityRenderer.submitNameTag`): above the head, facing the camera,
|
||||
//! on a translucent black background, shown when the name is visible or the
|
||||
//! mob is under the crosshair within 64 blocks, as its team's name tag
|
||||
//! mob is under the crosshair within its name-tag distance (64 by default), as its team's name tag
|
||||
//! visibility allows, in the team's colours, with its score in the
|
||||
//! objective displayed below names. Other players' names (on a LAN host
|
||||
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows,
|
||||
@@ -260,10 +260,12 @@ impl Game<'_> {
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
// The tag's 64 block distance is measured to the entity: copy the
|
||||
// name only for a mob the frame can show it on.
|
||||
// The tag's per-entity distance (vanilla's default is 64) is
|
||||
// measured to the entity: copy the name only for a mob the frame
|
||||
// can show it on.
|
||||
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
|
||||
(at.distance_squared(camera) < 64.0 * 64.0).then_some((m, name, false))
|
||||
let max = m.name_tag_distance();
|
||||
(at.distance_squared(camera) < max * max).then_some((m, name, false))
|
||||
});
|
||||
let own = self.own_name_tag().map(|(m, _)| m.id);
|
||||
let players = players
|
||||
@@ -285,7 +287,7 @@ impl Game<'_> {
|
||||
}
|
||||
// EntityAttachment.NAME_TAG: 0.5 above the box of the current pose.
|
||||
let p = name_tag_at(m, alpha);
|
||||
// The name tag distance (64) is measured to the entity.
|
||||
// The name tag distance is measured to the entity.
|
||||
let distance_sqr =
|
||||
glam::DVec3::new(m.position.x, m.position.y, m.position.z).distance_squared(camera);
|
||||
// Match the model's renderer-specific box, not the tag anchor: the
|
||||
@@ -297,6 +299,7 @@ impl Game<'_> {
|
||||
distance_sqr,
|
||||
(lo - camera - inflate).as_vec3(),
|
||||
(hi - camera + inflate).as_vec3(),
|
||||
m.name_tag_distance(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -337,12 +340,13 @@ impl Game<'_> {
|
||||
// MobRenderer.shouldShowName: a visible name, or the mob under the
|
||||
// crosshair; players (LivingEntityRenderer) need neither.
|
||||
// `ClientMannequin.belowNameDisplay`: the description is `scoreText`, drawn
|
||||
// within the below-name distance (10) whether the name shows or not.
|
||||
// within the below-name distance (10 by default) whether the name
|
||||
// shows or not.
|
||||
let description = (m.species() == horizon_client::mobs::Species::Mannequin
|
||||
&& distance_sqr < 10.0 * 10.0)
|
||||
.then(|| m.extra.description.component())
|
||||
.flatten()
|
||||
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
|
||||
&& distance_sqr < m.below_name_distance().powi(2))
|
||||
.then(|| m.extra.description.component())
|
||||
.flatten()
|
||||
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
|
||||
let is_own = Some(m.id) == own;
|
||||
let name_shown = sb::name_shown(team, viewer, view)
|
||||
&& (!is_own || !view.is_vehicle)
|
||||
@@ -400,14 +404,14 @@ impl Game<'_> {
|
||||
.unwrap_or(shown);
|
||||
let mut lines = Vec::new();
|
||||
// EntityRenderer.extractNameTags: the score shows within the
|
||||
// below-name distance (10), under the name.
|
||||
// per-entity below-name distance (10 by default), under the name.
|
||||
let mut lift = 0.0;
|
||||
if m.species() == horizon_client::mobs::Species::Mannequin {
|
||||
if let Some(text) = description {
|
||||
lines.push((text, 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
} else if distance_sqr < 10.0 * 10.0
|
||||
} else if distance_sqr < m.below_name_distance().powi(2)
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
@@ -466,6 +470,7 @@ impl Game<'_> {
|
||||
distance_sqr,
|
||||
(origin - half).as_vec3(),
|
||||
(origin + glam::DVec3::new(half.x, height + grow, half.z)).as_vec3(),
|
||||
a.name_tag_distance(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -507,7 +512,7 @@ impl Game<'_> {
|
||||
let mut lines = Vec::new();
|
||||
let mut lift = 0.0;
|
||||
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
if distance_sqr < a.below_name_distance().powi(2)
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
@@ -829,8 +834,10 @@ fn name_tag_in_view(
|
||||
distance_sqr: f64,
|
||||
min: glam::Vec3,
|
||||
max: glam::Vec3,
|
||||
max_distance: f64,
|
||||
) -> bool {
|
||||
distance_sqr < 64.0 * 64.0 && frustum.is_none_or(|view| view.intersects(min, max))
|
||||
distance_sqr < max_distance * max_distance
|
||||
&& frustum.is_none_or(|view| view.intersects(min, max))
|
||||
}
|
||||
|
||||
fn first_mob_indices(mobs: &[Mob]) -> std::collections::HashMap<u32, usize> {
|
||||
@@ -1145,7 +1152,8 @@ mod tests {
|
||||
Some(&View),
|
||||
181.0,
|
||||
glam::Vec3::new(-0.8, 8.5, -10.8),
|
||||
glam::Vec3::new(0.8, 11.3, -9.2)
|
||||
glam::Vec3::new(0.8, 11.3, -9.2),
|
||||
64.0
|
||||
));
|
||||
for (offset, distance, expected) in [
|
||||
([0.0, 0.0, -10.0], 100.0, true),
|
||||
@@ -1161,11 +1169,21 @@ mod tests {
|
||||
Some(&View),
|
||||
distance,
|
||||
glam::Vec3::from_array(offset) - glam::Vec3::splat(0.5),
|
||||
glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5)
|
||||
glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5),
|
||||
64.0
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
// A server-synced `minecraft:name_tag_distance` of 5 hides a tag
|
||||
// six blocks away even though the vanilla default reaches 64.
|
||||
assert!(!name_tag_in_view(
|
||||
Some(&View),
|
||||
36.0,
|
||||
glam::Vec3::splat(-0.5),
|
||||
glam::Vec3::splat(0.5),
|
||||
5.0
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -702,7 +702,19 @@ impl Game<'_> {
|
||||
});
|
||||
self.portals.before = before;
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
// SulfurCube.tickFuse runs before Entity.handlePortal. A cube removed
|
||||
// there must neither decrement its cooldown nor travel before its blast.
|
||||
let removed_before_portal: std::collections::HashSet<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|_| !vehicles)
|
||||
.filter(|m| horizon_client::mobs::sulfur_cube::removes_before_portal_tick(m))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
for (id, allowed, delay) in ids {
|
||||
if removed_before_portal.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(state) = self.portals.entities.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -258,7 +258,7 @@ impl Game<'_> {
|
||||
.get_or_insert_with(random_source::entity)
|
||||
.next_float()
|
||||
};
|
||||
let stays = {
|
||||
let (stays, step) = {
|
||||
let audio = &mut self.audio;
|
||||
let mut on_sound = |pos: [f64; 3], pitch: f32| {
|
||||
audio.play("minecraft:entity.generic.burn", Some(pos), 0.4, pitch);
|
||||
@@ -280,6 +280,7 @@ impl Game<'_> {
|
||||
// orb that is done is removed.
|
||||
let snow = (o.previous, o.position, o.bounds(), o.velocity);
|
||||
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
|
||||
self.orb_moved(i, step);
|
||||
if !stays {
|
||||
self.orbs.remove(i);
|
||||
}
|
||||
@@ -304,6 +305,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.move`'s fall bookkeeping after orb `i`'s move: a move of a
|
||||
/// block or more casts for a reset block or water, `checkFallDamage` adds
|
||||
/// the drop outside water and, on the ground after a fall, posts
|
||||
/// HIT_GROUND, then forgets it. `Block.fallOn` (farmland's random draw,
|
||||
/// honey's slide) is not run for an orb yet, nor for the other entities
|
||||
/// that are not living.
|
||||
pub(super) fn orb_moved(&mut self, i: usize, step: horizon_client::entities::OrbMove) {
|
||||
let o = &self.orbs[i];
|
||||
let mut fall = self.vehicle_fall_reset(o.fall_distance, step.from, step.moved);
|
||||
if !step.in_water && step.moved.y < 0.0 {
|
||||
fall -= f64::from(step.moved.y as f32);
|
||||
}
|
||||
if o.on_ground {
|
||||
if fall > 0.0 {
|
||||
let (position, bounds) = (o.position, o.bounds());
|
||||
self.post_landing_event(position, bounds, false);
|
||||
}
|
||||
fall = 0.0;
|
||||
}
|
||||
self.orbs[i].fall_distance = fall;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{find_join, first_join};
|
||||
|
||||
@@ -44,6 +44,10 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.position(|e| wants(self, e) && reach.intersects(e.bounds()));
|
||||
if let Some(j) = taken {
|
||||
let before = self.dropped[j].clone();
|
||||
// `Panda.pickUpItem`'s `onItemPickup` comes first: the
|
||||
// advancement of whoever threw it.
|
||||
self.mob_item_pickup_trigger(i, &before);
|
||||
let e = self.remove_dropped(j);
|
||||
self.add_pickup(Taken::Item(Box::new(e.clone())), Collector::Mob(id));
|
||||
panda::hold(&mut self.mobs[i], &e.stack.item, e.stack.count, true);
|
||||
@@ -187,3 +191,98 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pickup_tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
use horizon_client::advancements as adv;
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_world::lazy_json::LazyJson;
|
||||
|
||||
const ADV: &str = "horizon:test/panda_pickup";
|
||||
const TRIGGER: &str = "minecraft:thrown_item_picked_up_by_entity";
|
||||
const THROWER: [i32; 4] = [413, 0, 0, 1];
|
||||
|
||||
/// An advancement on the pickup trigger, as a data pack would add one: no
|
||||
/// vanilla advancement tests it, so the tree needs one of its own.
|
||||
fn install_pickup_advancement(game: &mut Game<'_>) {
|
||||
let mut tree = std::sync::Arc::new(adv::Tree::default());
|
||||
let one = std::sync::Arc::get_mut(&mut tree).unwrap();
|
||||
one.list.push(adv::Advancement {
|
||||
id: ADV.into(),
|
||||
parent: None,
|
||||
display: None,
|
||||
criteria: vec![adv::Criterion {
|
||||
name: "pickup".into(),
|
||||
trigger: TRIGGER.into(),
|
||||
conditions: LazyJson::new(&serde_json::json!({})),
|
||||
}],
|
||||
requirements: vec![vec!["pickup".into()]],
|
||||
rewards: Default::default(),
|
||||
});
|
||||
one.by_id.insert(ADV.into(), 0);
|
||||
one.by_trigger.insert(TRIGGER.into(), vec![(0, 0)]);
|
||||
game.adv.tree = tree;
|
||||
}
|
||||
|
||||
/// `Panda.pickUpItem` calls `onItemPickup`, which fires
|
||||
/// `CriteriaTriggers.THROWN_ITEM_PICKED_UP_BY_ENTITY` for whoever threw the
|
||||
/// bamboo, as the fox's pickup does (`LivingEntity.onItemPickup`).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_panda_that_takes_a_thrown_item_credits_the_thrower() {
|
||||
let mut game = server_game();
|
||||
game.options.dedicated = None;
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 4.5)).unwrap());
|
||||
game.level.player_uuid = Some(THROWER);
|
||||
game.level.health = 20.0;
|
||||
game.creative = false;
|
||||
// A world of its own under the temporary folder, never the cwd.
|
||||
game.world_root =
|
||||
std::env::temp_dir().join(format!("horizon-panda-pickup-world-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&game.world_root);
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
install_pickup_advancement(&mut game);
|
||||
// `DistanceManager` keeps a chunk entity-ticking only while its whole
|
||||
// 5x5 neighbourhood is resident.
|
||||
for cx in -2..=2 {
|
||||
for cz in -2..=2 {
|
||||
game.world.chunks.insert(
|
||||
(cx, cz),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"tick freeze",
|
||||
"fill 0 99 0 15 99 15 stone",
|
||||
"summon minecraft:panda 4.5 100 6.5 {UUID:[I;9,9,9,9],PersistenceRequired:1b,CanPickUpLoot:1b}",
|
||||
"summon minecraft:item 4.5 100 6.5 {Item:{id:\"minecraft:bamboo\",count:1},Thrower:[I;413,0,0,1],PickupDelay:0s}",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
let id = game.mob_by_uuid([9, 9, 9, 9]).unwrap();
|
||||
let holding = |g: &Game<'_>| {
|
||||
g.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == id && m.extra.main_hand.is_some())
|
||||
};
|
||||
for _ in 0..20 {
|
||||
if holding(&game) {
|
||||
break;
|
||||
}
|
||||
assert!(game.run_command("tick step 1"));
|
||||
game.tick_server(false).unwrap();
|
||||
}
|
||||
assert!(holding(&game), "the panda took the bamboo");
|
||||
assert!(
|
||||
game.adv.progress.has(ADV, "pickup"),
|
||||
"the thrower's advancement: {:?}",
|
||||
game.adv.progress.criteria
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&game.world_root);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use super::Game;
|
||||
use horizon_client::mobs::{Species, riders};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -50,6 +50,12 @@ fn mount_rolls(species: Species, baby: bool, natural: bool) -> MountRolls {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SkeletonTrapGoal.tick`: each additional trap horse's horizontal push draws
|
||||
/// two `triangle(0.0, 1.1485)` offsets from the original trap horse's random.
|
||||
fn trap_horse_scatter(random: &mut LegacyRandom) -> (f64, f64) {
|
||||
(random.triangle(0.0, 1.1485), random.triangle(0.0, 1.1485))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.startRiding` for two mobs.
|
||||
pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) {
|
||||
@@ -700,18 +706,21 @@ impl Game<'_> {
|
||||
let Some(other) = self.spawn_mob("minecraft:skeleton_horse", at, true) else {
|
||||
continue;
|
||||
};
|
||||
let (dx, dz) = (
|
||||
self.world.random.triangle(0.0, 1.1485),
|
||||
self.world.random.triangle(0.0, 1.1485),
|
||||
);
|
||||
// SkeletonTrapGoal.createHorse: scatter is drawn after the rider.
|
||||
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == other) {
|
||||
h.invulnerable_time = TRAP_INVULNERABLE;
|
||||
h.extra.tame = true;
|
||||
h.extra.age = 0;
|
||||
h.velocity = Vec3::new(h.velocity.x + dx, h.velocity.y, h.velocity.z + dz);
|
||||
}
|
||||
if let Some(rider) = self.trap_skeleton(at) {
|
||||
self.mount_mob(rider, other);
|
||||
let Some(horse) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let (dx, dz) = trap_horse_scatter(&mut horse.random);
|
||||
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == other) {
|
||||
h.velocity = Vec3::new(h.velocity.x + dx, h.velocity.y, h.velocity.z + dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -768,4 +777,59 @@ mod tests {
|
||||
assert!(!r(Species::Drowned, true).nautilus);
|
||||
assert!(!r(Species::Zombie, false).camel_husk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trap_horse_scatter_draws_two_triangles_from_original_horse_random() {
|
||||
let mut horse_random = LegacyRandom::new(0x5DEECE66D);
|
||||
let mut expected = horse_random.clone();
|
||||
for _ in 0..3 {
|
||||
let (dx, dz) = trap_horse_scatter(&mut horse_random);
|
||||
assert_eq!(dx, expected.triangle(0.0, 1.1485));
|
||||
assert_eq!(dz, expected.triangle(0.0, 1.1485));
|
||||
}
|
||||
assert_eq!(horse_random.next_long(), expected.next_long());
|
||||
}
|
||||
|
||||
/// `SkeletonTrapGoal.tick`: springing a skeleton trap draws the three
|
||||
/// additional horses' horizontal velocity scatter from the original trap
|
||||
/// horse's own `random` after each rider is created.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn spring_skeleton_trap_draws_scatter_from_trap_horse_after_each_rider() {
|
||||
use horizon_client::chunk::Chunk;
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let at = [8.5, 64.0, 8.5];
|
||||
let trap_id = game
|
||||
.spawn_mob_raw("minecraft:skeleton_horse", at, false)
|
||||
.unwrap();
|
||||
let mut expected_horse_rng = LegacyRandom::new(777);
|
||||
if let Some(horse) = game.mobs.iter_mut().find(|m| m.id == trap_id) {
|
||||
horse.extra.trap = true;
|
||||
horse.random = horizon_core::random::EntityRandom::own(expected_horse_rng.clone());
|
||||
}
|
||||
game.spring_skeleton_trap(trap_id);
|
||||
|
||||
let expected_pushes: Vec<(f64, f64)> = (0..3)
|
||||
.map(|_| trap_horse_scatter(&mut expected_horse_rng))
|
||||
.collect();
|
||||
let horse = game.mobs.iter_mut().find(|m| m.id == trap_id).unwrap();
|
||||
assert!(!horse.extra.trap);
|
||||
assert_eq!(horse.random.next_long(), expected_horse_rng.next_long());
|
||||
|
||||
let extra_horses: Vec<&horizon_client::mobs::Mob> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::SkeletonHorse && m.id != trap_id)
|
||||
.collect();
|
||||
assert_eq!(extra_horses.len(), 3);
|
||||
for (h, (dx, dz)) in extra_horses.iter().zip(expected_pushes) {
|
||||
assert_eq!((h.velocity.x, h.velocity.z), (dx, dz));
|
||||
assert!(h.extra.rider.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
collisions::SceneCollisions,
|
||||
};
|
||||
use horizon_client::{
|
||||
entities::nameplate::NameplateDistances,
|
||||
hud::HudBatch,
|
||||
mobs::{Anim, Brain, Mob, MobType},
|
||||
mouse::Rotation,
|
||||
@@ -289,9 +290,9 @@ impl Game<'_> {
|
||||
}
|
||||
// Minecraft.cameraEntity: a server's spectator camera on a mob or
|
||||
// another player.
|
||||
let (entity, descending, entity_kind) = match self.camera_mob() {
|
||||
Some(m) => (
|
||||
super::remote_camera::mob_camera_entity(m),
|
||||
let (entity, descending, entity_kind) = match self.camera_entity() {
|
||||
Some(e) => (
|
||||
e,
|
||||
false,
|
||||
horizon_world::collision_context::EntityContextKind::Living,
|
||||
),
|
||||
@@ -460,11 +461,17 @@ impl Game<'_> {
|
||||
/// Keep the player's model in step with the player (`LivingEntity.tick`
|
||||
/// body turning, `WalkAnimationState`, the swing and the pose).
|
||||
pub(super) fn tick_player_model(&mut self) {
|
||||
let scale = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:scale").ok())
|
||||
.unwrap_or(1.0) as f32;
|
||||
let (scale, nameplate) = self.player.as_mut().map_or_else(
|
||||
|| (1.0, NameplateDistances::default()),
|
||||
|p| {
|
||||
let scale = p.attribute("minecraft:scale").unwrap_or(1.0) as f32;
|
||||
let nameplate = NameplateDistances {
|
||||
name_tag: p.attribute("minecraft:name_tag_distance").ok(),
|
||||
below_name: p.attribute("minecraft:below_name_distance").ok(),
|
||||
};
|
||||
(scale, nameplate)
|
||||
},
|
||||
);
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
self.player_model = None;
|
||||
return;
|
||||
@@ -527,6 +534,7 @@ impl Game<'_> {
|
||||
};
|
||||
m.extra.ints[1] = bed;
|
||||
m.extra.model_scale = scale;
|
||||
m.set_nameplate(nameplate);
|
||||
// The crouch flag is still last tick's here.
|
||||
let (from, at) =
|
||||
player_model_positions(previous_position, position, m.extra.crouching, crouching);
|
||||
|
||||
@@ -176,6 +176,7 @@ mod tests {
|
||||
fn orb_at(x: f64, y: f64, z: f64) -> Taken {
|
||||
let at = horizon_core::geometry::Vec3::new(x, y, z);
|
||||
Taken::Orb(Box::new(ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -338,6 +338,7 @@ impl Game<'_> {
|
||||
window.set_cursor(match effective {
|
||||
Cursor::Arrow => winit::window::CursorIcon::Default,
|
||||
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
|
||||
Cursor::Ibeam => winit::window::CursorIcon::Text,
|
||||
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
|
||||
Cursor::ResizeNs => winit::window::CursorIcon::NsResize,
|
||||
Cursor::ResizeEw => winit::window::CursorIcon::EwResize,
|
||||
|
||||
@@ -64,6 +64,8 @@ use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
mod abilities;
|
||||
#[path = "remote_poll.rs"]
|
||||
mod poll;
|
||||
#[path = "remote_attributes.rs"]
|
||||
mod remote_attributes;
|
||||
#[path = "remote_tables.rs"]
|
||||
mod tables;
|
||||
|
||||
@@ -2806,23 +2808,8 @@ impl Game<'_> {
|
||||
if kind == "minecraft:experience_orb" {
|
||||
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.orbs.push(ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: id as u32,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
air: 300,
|
||||
following: None,
|
||||
});
|
||||
self.orbs
|
||||
.push(ExperienceOrb::new(id as u32, at, Vec3::ZERO, 1));
|
||||
}
|
||||
if let Some(t) = mob_type {
|
||||
let mut mob = Mob::new(id as u32, t, at, head);
|
||||
@@ -3258,60 +3245,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleUpdateAttributes` for a mob, each value from
|
||||
/// `AttributeInstance.calculateValue`: its maximum health (the hearts drawn
|
||||
/// over its mount), and its `minecraft:scale`, which sizes its box
|
||||
/// (`LivingEntity.getDimensions`) and model (`LivingEntityRenderState.scale`)
|
||||
/// as the local player's does: NPC plugins make their fake players bigger
|
||||
/// with it, datapacks scale any mob.
|
||||
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
|
||||
let catalog = &self.motion.attributes;
|
||||
let value = |name: &str| {
|
||||
let attribute = catalog.id(name)?;
|
||||
let s = values
|
||||
.iter()
|
||||
.find(|s| s.attribute == attribute.registry_id())?;
|
||||
Some(
|
||||
catalog
|
||||
.attribute(attribute)
|
||||
.definition
|
||||
.sanitize(snapshot_value(s)),
|
||||
)
|
||||
};
|
||||
let (max_health, scale) = (value("minecraft:max_health"), value("minecraft:scale"));
|
||||
let (speed, jump) = (
|
||||
value("minecraft:movement_speed"),
|
||||
value("minecraft:jump_strength"),
|
||||
);
|
||||
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
|
||||
return;
|
||||
};
|
||||
// A horse its rider's client moves goes at its own speed and jumps
|
||||
// its own height (`getSpeed`, `getCustomJump`).
|
||||
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
|
||||
if let Some(speed) = speed {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MovementSpeed,
|
||||
speed,
|
||||
);
|
||||
}
|
||||
if let Some(jump) = jump {
|
||||
m.extra.vector[0] = jump;
|
||||
}
|
||||
}
|
||||
if let Some(max_health) = max_health {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MaxHealth,
|
||||
max_health,
|
||||
);
|
||||
}
|
||||
if let Some(scale) = scale {
|
||||
m.extra.model_scale = scale as f32;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_packet(&mut self, packet: C) -> Result<()> {
|
||||
self.native_client_packet(&packet);
|
||||
match packet {
|
||||
@@ -4565,7 +4498,16 @@ impl Game<'_> {
|
||||
// HUD draws and the health it may hold), reach, speed and so on.
|
||||
// Then the received health again, which the old maximum clamped.
|
||||
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
|
||||
let nameplate = self.remote_nameplate_values(&p.values);
|
||||
self.apply_own_attributes(p.values);
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
if let Some(value) = nameplate.name_tag {
|
||||
m.set_name_tag_distance(value);
|
||||
}
|
||||
if let Some(value) = nameplate.below_name {
|
||||
m.set_below_name_distance(value);
|
||||
}
|
||||
}
|
||||
self.apply_remote_health();
|
||||
}
|
||||
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! `ClientPacketListener.handleUpdateAttributes` for entity attributes that
|
||||
//! affect the client-side nameplate.
|
||||
use super::{Game, snapshot_value};
|
||||
use horizon_client::entities::nameplate::NameplateDistances;
|
||||
use horizon_protocol::packets::play;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Computes one synced attribute using the catalog's vanilla range.
|
||||
fn remote_attribute_value(
|
||||
&self,
|
||||
name: &str,
|
||||
values: &[play::AttributeSnapshot],
|
||||
) -> Option<f64> {
|
||||
let attribute = self.motion.attributes.id(name)?;
|
||||
let snapshot = values
|
||||
.iter()
|
||||
.find(|snapshot| snapshot.attribute == attribute.registry_id())?;
|
||||
Some(
|
||||
self.motion
|
||||
.attributes
|
||||
.attribute(attribute)
|
||||
.definition
|
||||
.sanitize(snapshot_value(snapshot)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The two attributes read by `LivingEntityRenderer` and
|
||||
/// `EntityRenderer.extractNameTags`.
|
||||
pub(super) fn remote_nameplate_values(
|
||||
&self,
|
||||
values: &[play::AttributeSnapshot],
|
||||
) -> NameplateDistances {
|
||||
NameplateDistances {
|
||||
name_tag: self.remote_attribute_value("minecraft:name_tag_distance", values),
|
||||
below_name: self.remote_attribute_value("minecraft:below_name_distance", values),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleUpdateAttributes` for a mob. The same
|
||||
/// packet can target a player, mob or armor stand.
|
||||
pub(super) fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
|
||||
let max_health = self.remote_attribute_value("minecraft:max_health", values);
|
||||
let scale = self.remote_attribute_value("minecraft:scale", values);
|
||||
let speed = self.remote_attribute_value("minecraft:movement_speed", values);
|
||||
let jump = self.remote_attribute_value("minecraft:jump_strength", values);
|
||||
let nameplate = self.remote_nameplate_values(values);
|
||||
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
|
||||
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id as u32) {
|
||||
if let Some(value) = nameplate.name_tag {
|
||||
a.nameplate.name_tag = Some(value);
|
||||
}
|
||||
if let Some(value) = nameplate.below_name {
|
||||
a.nameplate.below_name = Some(value);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
// A horse its rider's client moves goes at its own speed and jumps
|
||||
// its own height (`getSpeed`, `getCustomJump`).
|
||||
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
|
||||
if let Some(speed) = speed {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MovementSpeed,
|
||||
speed,
|
||||
);
|
||||
}
|
||||
if let Some(jump) = jump {
|
||||
m.extra.vector[0] = jump;
|
||||
}
|
||||
}
|
||||
if let Some(max_health) = max_health {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MaxHealth,
|
||||
max_health,
|
||||
);
|
||||
}
|
||||
if let Some(scale) = scale {
|
||||
m.extra.model_scale = scale as f32;
|
||||
}
|
||||
if let Some(value) = nameplate.name_tag {
|
||||
m.set_name_tag_distance(value);
|
||||
}
|
||||
if let Some(value) = nameplate.below_name {
|
||||
m.set_below_name_distance(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
camera::{CameraEntity, LivingCamera},
|
||||
entities::ArmorStand,
|
||||
mobs::Mob,
|
||||
};
|
||||
|
||||
@@ -53,6 +54,35 @@ pub(super) fn mob_camera_entity(m: &Mob) -> CameraEntity {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Camera.alignWithEntity`'s view of an armor stand, the usual camera of a
|
||||
/// server's cutscene or menu scene. `ArmorStand.setYHeadRot` keeps its head
|
||||
/// on `yRot`, so its view turns with its yaw; its eyes are its dimensions'
|
||||
/// (`getDimensionsMarker`): none for a marker, `BABY_DIMENSIONS`' 0.9875 for
|
||||
/// a small stand, the type's 1.7775 otherwise.
|
||||
pub(super) fn stand_camera_entity(a: &ArmorStand) -> CameraEntity {
|
||||
let eye_height = if a.marker {
|
||||
0.0
|
||||
} else {
|
||||
a.probe_dimensions().2 as f32
|
||||
};
|
||||
CameraEntity {
|
||||
position: [a.position.x, a.position.y, a.position.z],
|
||||
previous: [a.previous.x, a.previous.y, a.previous.z],
|
||||
x_rot: a.pitch,
|
||||
y_rot: a.yaw,
|
||||
x_rot_o: a.pitch,
|
||||
y_rot_o: a.previous_yaw,
|
||||
eye_height,
|
||||
living: Some(LivingCamera {
|
||||
scale: 1.0,
|
||||
camera_distance: 4.0,
|
||||
sleeping: None,
|
||||
dead: None,
|
||||
}),
|
||||
mount: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `GameRenderer.checkEntityPostEffect`: the chain a spectated creeper, spider
|
||||
/// or enderman puts on the screen.
|
||||
pub(super) fn entity_post_effect(kind: &str) -> Option<&'static str> {
|
||||
@@ -68,7 +98,9 @@ pub(super) fn entity_post_effect(kind: &str) -> Option<&'static str> {
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleSetCamera`.
|
||||
pub(super) fn remote_set_camera(&mut self, camera_id: i32) {
|
||||
let in_level = self.mobs.iter().any(|m| m.id == camera_id as u32);
|
||||
let id = camera_id as u32;
|
||||
let in_level =
|
||||
self.mobs.iter().any(|m| m.id == id) || self.armor_stands.iter().any(|a| a.id == id);
|
||||
let player = self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -102,6 +134,18 @@ impl Game<'_> {
|
||||
let id = self.remote_camera_id()?;
|
||||
self.mobs.iter().find(|m| m.id == id)
|
||||
}
|
||||
/// The view of the entity a server attached the camera to, while the
|
||||
/// level still holds it: a mob or an armor stand.
|
||||
pub(super) fn camera_entity(&self) -> Option<CameraEntity> {
|
||||
if let Some(m) = self.camera_mob() {
|
||||
return Some(mob_camera_entity(m));
|
||||
}
|
||||
let id = self.remote_camera_id()?;
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(stand_camera_entity)
|
||||
}
|
||||
/// The entity under a spectator's crosshair (`Minecraft.hitResult` as an
|
||||
/// `EntityHitResult`): the nearest mob or object before any block.
|
||||
pub(super) fn spectator_click_target(&self) -> Option<i32> {
|
||||
@@ -122,7 +166,28 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{camera_after_packet, entity_post_effect};
|
||||
use super::{camera_after_packet, entity_post_effect, stand_camera_entity};
|
||||
use horizon_client::entities::ArmorStand;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// A server's camera on an armor stand looks out of the stand's eyes
|
||||
/// (EntityType.ARMOR_STAND `eyeHeight(1.7775F)`, `BABY_DIMENSIONS`
|
||||
/// 0.9875F for a small one, nothing for a marker), along its yaw.
|
||||
#[test]
|
||||
fn an_armor_stand_holds_the_camera_at_its_eyes() {
|
||||
let mut stand = ArmorStand::new(7, Vec3::new(10.5, 64.0, -3.5), 90.0);
|
||||
stand.yaw = 135.0;
|
||||
stand.pitch = -20.0;
|
||||
let view = stand_camera_entity(&stand);
|
||||
assert_eq!(view.position, [10.5, 64.0, -3.5]);
|
||||
assert_eq!(view.eye_height, 1.7775f32);
|
||||
assert_eq!((view.y_rot_o, view.y_rot), (90.0, 135.0));
|
||||
assert_eq!(view.x_rot, -20.0);
|
||||
stand.small = true;
|
||||
assert_eq!(stand_camera_entity(&stand).eye_height, 0.9875f32);
|
||||
stand.marker = true;
|
||||
assert_eq!(stand_camera_entity(&stand).eye_height, 0.0);
|
||||
}
|
||||
|
||||
/// Only a creeper, spider or enderman has a spectator chain.
|
||||
#[test]
|
||||
|
||||
@@ -221,6 +221,53 @@ impl Game<'_> {
|
||||
self.streaming.radius
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer for the window, with the current block sprites, GUI
|
||||
/// atlas and world border texture.
|
||||
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
|
||||
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
|
||||
pub(super) fn forget_atlas_slots(&mut self) {
|
||||
self.skin_slots.forget();
|
||||
self.maps.slots.forget();
|
||||
self.trim_slots.forget();
|
||||
self.map_patches.clear();
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
}
|
||||
|
||||
pub(super) fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
|
||||
self.renderer_for(window, &self.pack, &self.gui)
|
||||
}
|
||||
|
||||
/// A renderer made again from the resources loaded takes over: what named the old one's
|
||||
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
|
||||
/// settings are applied again.
|
||||
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
|
||||
// A new renderer starts with the vanilla column and without the world border's wall.
|
||||
renderer.clear_sections(self.world.layout());
|
||||
self.border_renderer.invalidate();
|
||||
self.renderer = Some(renderer);
|
||||
self.forget_atlas_slots();
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
|
||||
&mut self.native_shaders.picked,
|
||||
));
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
|
||||
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
|
||||
/// discarded, then every loaded section is rebuilt.
|
||||
pub(super) fn all_sections_changed(&mut self) {
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.clear_sections(self.world.layout());
|
||||
}
|
||||
self.streaming.reload_sections(&mut self.world);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -20,7 +20,7 @@ use super::input_trace::{InputOrigin, TraceEvent, TraceKind};
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, gamepad::Button, keybinds, text::Text, title::world_management};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use horizon_system::scrub::Scrubber;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
|
||||
@@ -21,6 +21,20 @@ mod ram_tests;
|
||||
pub(super) const OFFHAND_SLOT: usize = 40;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `AbstractArrow.onHitEntity` on the player: a piercing arrow passes item blocking, whoever
|
||||
/// shot it, because `applyItemBlocking` reads the direct entity's pierce level.
|
||||
pub(super) fn arrow_hits_player(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
p: &horizon_client::projectiles::Projectile,
|
||||
) -> bool {
|
||||
self.combat.piercing_hit = p.arrow.as_ref().is_some_and(|a| a.pierce > 0);
|
||||
self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
)
|
||||
}
|
||||
/// The stack in an inventory slot, the off hand at `OFFHAND_SLOT`.
|
||||
pub(super) fn stack_in(&self, slot: usize) -> Option<&ItemStack> {
|
||||
if slot == OFFHAND_SLOT {
|
||||
@@ -144,6 +158,7 @@ impl Game<'_> {
|
||||
&& blocked > 0.0
|
||||
{
|
||||
let outcome = BlockOutcome {
|
||||
blocking_item_used: true,
|
||||
blocked,
|
||||
fully_blocked: blocked >= damage,
|
||||
..Default::default()
|
||||
@@ -193,22 +208,23 @@ impl Game<'_> {
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play(sound, at, 1.0, pitch);
|
||||
}
|
||||
if !outcome.blocking_item_used {
|
||||
return;
|
||||
}
|
||||
// BlocksAttacks.hurtBlockingItem: the hit counts the blocking item as
|
||||
// used, before the blocked-damage gate (vanilla awards it on the
|
||||
// server even when the item blocks none of the damage).
|
||||
if self.remote.is_none() {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
if outcome.blocked <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// ItemDamageFunction.apply, then hurtAndBreak.
|
||||
let (threshold, base, factor) = component.item_damage;
|
||||
let item_damage = if outcome.blocked < threshold {
|
||||
0
|
||||
} else {
|
||||
(base + factor * outcome.blocked).floor() as i32
|
||||
};
|
||||
let item_damage = ItemDamageFunction {
|
||||
threshold,
|
||||
base,
|
||||
factor,
|
||||
}
|
||||
.apply(outcome.blocked);
|
||||
if item_damage > 0
|
||||
&& !self.creative
|
||||
&& let Some(max) = blocking_item_max_damage(
|
||||
@@ -242,7 +258,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if projectile {
|
||||
if outcome.blocked <= 0.0 || projectile {
|
||||
return;
|
||||
}
|
||||
let Some(id) = attacker else {
|
||||
@@ -409,6 +425,7 @@ mod tests {
|
||||
direct: 1,
|
||||
};
|
||||
let outcome = BlockOutcome {
|
||||
blocking_item_used: true,
|
||||
hurt: true,
|
||||
blocked: 5.0,
|
||||
fully_blocked: true,
|
||||
|
||||
@@ -296,6 +296,32 @@ impl Game<'_> {
|
||||
self.open_text_edit(key, front);
|
||||
}
|
||||
}
|
||||
/// Whether `SignBlock.useWithoutItem` on the sign at `pos` consumes the
|
||||
/// click. Vanilla answers: a waxed sign (the failed-interaction sound), a
|
||||
/// side whose lines carry a click action (`executeClickCommandsIfPresent`,
|
||||
/// which runs it), and a player who may build (the text editor). Every
|
||||
/// other sign answers PASS, so `ServerPlayerGameMode.useItemOn` goes on to
|
||||
/// `ItemStack.useOn` and an adventure-mode `can_place_on` stack places
|
||||
/// against it. Vanilla also passes while another player is editing the
|
||||
/// sign and when its text has styled lines; neither is modelled here.
|
||||
pub(super) fn sign_consumes_click(&self, pos: [i32; 3]) -> bool {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
|
||||
return false;
|
||||
};
|
||||
if sign.waxed || self.may_build() {
|
||||
return true;
|
||||
}
|
||||
let side = if self.facing_sign_front(key) {
|
||||
&sign.front
|
||||
} else {
|
||||
&sign.back
|
||||
};
|
||||
side.click
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|event| click_action(event).is_some())
|
||||
}
|
||||
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
|
||||
/// honeycomb): the item changes the side the player faces and is used up.
|
||||
/// Whether it did; a sign it does not apply to goes on to the empty hand.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `SignBlock.useWithoutItem`: the click it passes goes on to the item
|
||||
//! (`ServerPlayerGameMode.useItemOn`'s `useWithoutItem` then `ItemStack.useOn`),
|
||||
//! so an adventure player's `can_place_on` block places against the sign.
|
||||
use horizon_client::{
|
||||
command::args::GameMode, containers::BlockEntity, items::ItemStack, mobs::Who, signs::SignData,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
type Posted = (&'static str, [f64; 3], Option<Who>);
|
||||
|
||||
/// A server's game with nobody playing, or `None` without prepared resources.
|
||||
fn game() -> Option<crate::game::Game<'static>> {
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")?;
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.player = None;
|
||||
Some(game)
|
||||
}
|
||||
|
||||
/// `stack` with a `can_place_on` component naming `block`.
|
||||
fn held(name: &str, count: u32, block: &str) -> ItemStack {
|
||||
let mut stack = ItemStack::new(name, count);
|
||||
stack.data_mut().other.push((
|
||||
"minecraft:can_place_on".into(),
|
||||
Tag::compound([("blocks", Tag::List(vec![Tag::String(block.into())]))]),
|
||||
));
|
||||
stack
|
||||
}
|
||||
|
||||
/// An adventure player (`mayBuild` false) clicks the south side of the sign
|
||||
/// at [2, 4, 3] with `stack` in hand, as `Minecraft.startUseItem` does; the
|
||||
/// game events the click posted.
|
||||
fn click_sign(game: &mut crate::game::Game<'_>, sign: SignData, stack: ItemStack) -> Vec<Posted> {
|
||||
let registry = game.pack.assets.registry;
|
||||
let sign_state = registry.default_state("minecraft:oak_sign").unwrap();
|
||||
game.world.set(2, 4, 3, sign_state).unwrap();
|
||||
game.world.set(2, 4, 4, game.res.facts().air).unwrap();
|
||||
game.world
|
||||
.block_entities
|
||||
.insert((2, 4, 3), BlockEntity::Sign(sign));
|
||||
game.set_mode(GameMode::Adventure);
|
||||
*game.held_mut() = Some(stack);
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
let hit = crate::game::Hit {
|
||||
pos: [2, 4, 3],
|
||||
face: [0, 0, 1],
|
||||
point: [2.5, 4.5, 3.9],
|
||||
};
|
||||
game.with_player_actor(|g| g.use_hand(Some(&hit))).unwrap();
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap())
|
||||
}
|
||||
|
||||
/// `SignBlock.useWithoutItem` answers PASS for a sign with no click action to
|
||||
/// run, that is not waxed and that a player who may not build aims at, so
|
||||
/// ServerPlayerGameMode.useItemOn goes on to `ItemStack.useOn`: a
|
||||
/// `can_place_on` block naming the sign places against it.
|
||||
#[test]
|
||||
fn an_adventure_click_on_a_sign_places_a_can_place_on_block() {
|
||||
let Some(mut game) = game() else {
|
||||
return;
|
||||
};
|
||||
let planks = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:oak_planks")
|
||||
.unwrap();
|
||||
|
||||
let events = click_sign(
|
||||
&mut game,
|
||||
SignData::default(),
|
||||
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
|
||||
);
|
||||
assert_eq!(game.world.block(2, 4, 4), planks, "placed against the sign");
|
||||
assert_eq!(game.held().map(|s| s.count), Some(1), "one plank is used");
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![("minecraft:block_place", [2.5, 4.5, 4.5], Some(Who::Player))]
|
||||
);
|
||||
|
||||
// `can_place_on` names another block: nothing places and nothing is used.
|
||||
let events = click_sign(
|
||||
&mut game,
|
||||
SignData::default(),
|
||||
held("minecraft:oak_planks", 2, "minecraft:stone"),
|
||||
);
|
||||
assert_ne!(game.world.block(2, 4, 4), planks, "nothing places");
|
||||
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
|
||||
assert!(events.is_empty(), "{events:?}");
|
||||
}
|
||||
|
||||
/// The signs whose `useWithoutItem` consumes the click keep it, so the
|
||||
/// `can_place_on` block is never placed: a waxed sign (its failed-interaction
|
||||
/// sound) and a sign whose side runs a click command.
|
||||
#[test]
|
||||
fn a_waxed_sign_or_a_click_command_keeps_the_adventure_click() {
|
||||
let Some(mut game) = game() else {
|
||||
return;
|
||||
};
|
||||
let planks = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:oak_planks")
|
||||
.unwrap();
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
|
||||
let waxed = SignData {
|
||||
waxed: true,
|
||||
..SignData::default()
|
||||
};
|
||||
click_sign(
|
||||
&mut game,
|
||||
waxed,
|
||||
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
|
||||
);
|
||||
assert_ne!(game.world.block(2, 4, 4), planks, "a waxed sign keeps it");
|
||||
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
|
||||
|
||||
// A `run_command` line runs, and the click ends there.
|
||||
let mut command = SignData {
|
||||
allow_op_features: true,
|
||||
..SignData::default()
|
||||
};
|
||||
command.front.click[0] = Some(Tag::compound([
|
||||
("action", Tag::String("run_command".into())),
|
||||
(
|
||||
"command",
|
||||
Tag::String("setblock 5 4 3 minecraft:stone".into()),
|
||||
),
|
||||
]));
|
||||
let events = click_sign(
|
||||
&mut game,
|
||||
command,
|
||||
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
|
||||
);
|
||||
assert_eq!(game.world.block(5, 4, 3), stone, "the click command ran");
|
||||
assert_ne!(game.world.block(2, 4, 4), planks, "the command keeps it");
|
||||
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
|
||||
assert!(
|
||||
!events.iter().any(|e| e.0 == "minecraft:block_place"),
|
||||
"{events:?}"
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ impl Game<'_> {
|
||||
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
|
||||
let (knockback, _) = self.extra_knockback("minecraft:ender_dragon", scale);
|
||||
self.attack_effects(area, None, false, super::mace::Spared::Nothing, None);
|
||||
self.post_attack_dragon_burst();
|
||||
self.special_attack_slowdown(knockback);
|
||||
self.special_attack_exhaustion();
|
||||
}
|
||||
|
||||
@@ -1362,6 +1362,76 @@ mod tests {
|
||||
Some(game)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn portal_blast_sulfur_cube_uses_only_positive_cooldown() {
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_client::nether_portal::PortalState;
|
||||
let mut game = server_game().expect("set HORIZON_TEST_RESOURCES");
|
||||
let portal = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:nether_portal")
|
||||
.unwrap();
|
||||
for (cooldown, silent) in [
|
||||
(1, false),
|
||||
(1, true),
|
||||
(0, false),
|
||||
(0, true),
|
||||
(-1, false),
|
||||
(-1, true),
|
||||
] {
|
||||
game.world.set(8, 64, 8, portal).unwrap();
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:sulfur_cube")
|
||||
.unwrap()
|
||||
.clone();
|
||||
let mut cube = Mob::new(9123, kind, Vec3::new(8.5, 64.0, 8.5), 0.0);
|
||||
cube.silent = silent;
|
||||
cube.extra.body = Some("minecraft:tnt".into());
|
||||
cube.extra.counter2 = 2; // fuse 1: explodes on this tick.
|
||||
game.mobs.push(cube);
|
||||
game.portals.entities.insert(
|
||||
9123,
|
||||
PortalState {
|
||||
cooldown,
|
||||
process: None,
|
||||
},
|
||||
);
|
||||
// The actual level tick orders the portal pass before the mob pass.
|
||||
game.audio.heard.clear();
|
||||
game.handle_entity_portals();
|
||||
game.tick_creatures().unwrap();
|
||||
assert_eq!(
|
||||
game.world.block(8, 64, 8) == portal,
|
||||
cooldown > 0,
|
||||
"cooldown {cooldown}, Silent={silent}"
|
||||
);
|
||||
assert_eq!(
|
||||
game.audio
|
||||
.heard
|
||||
.get("minecraft:entity.generic.explode")
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
u32::from(!silent),
|
||||
"cooldown {cooldown}, Silent={silent}"
|
||||
);
|
||||
}
|
||||
// An unrelated native/sourceless blast never inherits the cube's calculator.
|
||||
game.world.set(8, 64, 8, portal).unwrap();
|
||||
game.server_explode(
|
||||
[8.5, 64.0625, 8.5],
|
||||
4.0,
|
||||
super::super::explosion::BlockInteraction::Destroy,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(game.world.block(8, 64, 8), portal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_tnt_fuse_uses_numeric_short_and_saves_without_raw() {
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -54,6 +54,8 @@ pub(super) struct ThrownTrident {
|
||||
pub(super) on_ground: bool,
|
||||
/// `Entity.remainingFireTicks`; on clients, the synchronized on-fire flag.
|
||||
pub(super) fire_ticks: i32,
|
||||
/// `Entity.fallDistance`: kept as loaded; water resets it.
|
||||
fall_distance: f64,
|
||||
/// `AbstractArrow.soundEvent`: the trident's own until it first hits a block.
|
||||
sound_event: String,
|
||||
/// Water membership from the preceding `baseTick` (not updated while stuck).
|
||||
@@ -129,6 +131,7 @@ impl ThrownTrident {
|
||||
self.no_gravity = n.no_gravity;
|
||||
self.on_ground = n.on_ground;
|
||||
self.fire_ticks = n.fire_ticks;
|
||||
self.fall_distance = n.fall_distance;
|
||||
self.sound_event = n.sound_event;
|
||||
}
|
||||
/// `AbstractArrow.addAdditionalSaveData` + `DealtDamage`.
|
||||
@@ -155,6 +158,7 @@ impl ThrownTrident {
|
||||
no_gravity: self.no_gravity,
|
||||
on_ground: self.on_ground,
|
||||
fire_ticks: self.fire_ticks,
|
||||
fall_distance: self.fall_distance,
|
||||
sound_event: &self.sound_event,
|
||||
item: &self.stack,
|
||||
},
|
||||
@@ -201,6 +205,7 @@ impl ThrownTrident {
|
||||
no_gravity: false,
|
||||
on_ground: false,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: horizon_client::entity_nbt::TRIDENT_HIT_GROUND.to_owned(),
|
||||
was_touching_water: false,
|
||||
random: horizon_core::random::LegacyRandom::new(i64::from(id)),
|
||||
@@ -620,6 +625,7 @@ impl Game<'_> {
|
||||
no_gravity: false,
|
||||
on_ground: false,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: horizon_client::entity_nbt::TRIDENT_HIT_GROUND.to_owned(),
|
||||
was_touching_water: false,
|
||||
random: horizon_core::random::LegacyRandom::new(i64::from(id)),
|
||||
@@ -1223,6 +1229,10 @@ impl Game<'_> {
|
||||
t.has_been_shot = true;
|
||||
let rain = self.arrow_rain();
|
||||
t.was_touching_water = self.arrow_scene(&rain).in_water(t.position);
|
||||
// Entity.updateInWaterStateAndDoFluidPushing: resetFallDistance in water.
|
||||
if t.was_touching_water {
|
||||
t.fall_distance = 0.0;
|
||||
}
|
||||
if t.fire_ticks > 0 {
|
||||
t.fire_ticks -= 1;
|
||||
}
|
||||
@@ -1514,6 +1524,27 @@ mod tests {
|
||||
assert_eq!(back.last_state, t.last_state);
|
||||
}
|
||||
|
||||
/// `Entity.saveWithoutId` writes the `fall_distance` `Entity.load` read:
|
||||
/// a trident keeps the one it was loaded with.
|
||||
#[test]
|
||||
fn a_trident_keeps_its_fall_distance_across_a_reload() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let registry = stone_registry();
|
||||
let fall = |tag: &Tag| match tag.get("fall_distance") {
|
||||
Some(Tag::Double(bits)) => Some(f64::from_bits(*bits)),
|
||||
_ => None,
|
||||
};
|
||||
let fresh = ThrownTrident::remote(1, Vec3::ZERO, 0.0, 0.0);
|
||||
assert_eq!(fall(&fresh.to_nbt([1; 4], ®istry, None)), Some(0.0));
|
||||
let tag = Tag::compound([
|
||||
("Pos", Tag::List(vec![Tag::Double(0); 3])),
|
||||
("fall_distance", Tag::Double(5.0_f64.to_bits())),
|
||||
]);
|
||||
let loaded = horizon_client::entity_nbt::trident_from_nbt(&tag).unwrap();
|
||||
let back = ThrownTrident::from_nbt(loaded, &tag, ®istry);
|
||||
assert_eq!(fall(&back.to_nbt([1; 4], ®istry, None)), Some(5.0));
|
||||
}
|
||||
|
||||
/// `AbstractArrow.startFalling` on `new Random(42)`: its first three
|
||||
/// `nextFloat() * 0.2F` draws (computed from Java's LCG) scale x, y, z.
|
||||
#[test]
|
||||
|
||||
@@ -127,6 +127,10 @@ impl Game<'_> {
|
||||
self.with_level(|b, _, _| b.cake_candle_state(cake, item.as_deref()).is_some());
|
||||
let pots_flower = self
|
||||
.with_level(|b, _, _| b.potted_flower_state(cake, item.as_deref()).is_some());
|
||||
// Read before the use runs: a sign's click commands execute in
|
||||
// `process_level_events` and may change or remove the sign, but
|
||||
// vanilla keeps the click consumed (`executedClickCommand`).
|
||||
let sign_consumes = self.sign_consumes_click(hit.pos);
|
||||
let (result, branch) = self.with_level(|b, level, ticks| {
|
||||
b.use_item_on_branch(
|
||||
level,
|
||||
@@ -178,7 +182,15 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if result.consumes_action() {
|
||||
// SignBlock.useWithoutItem answers PASS for a sign that is
|
||||
// not waxed, has no click action to run and that nobody may
|
||||
// build on: ServerPlayerGameMode.useItemOn goes on to
|
||||
// ItemStack.useOn, so an adventure-mode `can_place_on` stack
|
||||
// still places against the sign.
|
||||
let sign_passes = branch == horizon_world::level::UseBranch::WithoutItem
|
||||
&& self.res.facts().get(cake).sign_form().is_some()
|
||||
&& !sign_consumes;
|
||||
if result.consumes_action() && !sign_passes {
|
||||
// ServerPlayerGameMode.useItemOn: ITEM_USED_ON_BLOCK with
|
||||
// the stack before the use, or DEFAULT_BLOCK_USE.
|
||||
match branch {
|
||||
@@ -461,6 +473,8 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
};
|
||||
let target = [pos.x, pos.y, pos.z];
|
||||
let game_master = self.can_use_game_master_blocks();
|
||||
let state = self.lectern_placed(target, state, &stack, game_master)?;
|
||||
let state = self.apply_item_block_state(target, state, &stack)?;
|
||||
let key = (target[0], target[1], target[2]);
|
||||
if !self.world.block_entities.stateful_entity(state.0)
|
||||
@@ -469,7 +483,6 @@ impl Game<'_> {
|
||||
self.world.block_entities.insert(key, entity);
|
||||
}
|
||||
if self.world.block_entities.get(&key).is_some() {
|
||||
let game_master = self.can_use_game_master_blocks();
|
||||
self.apply_placed_item(target, &stack, game_master);
|
||||
// Block.setPlacedBy.
|
||||
match self.world.block_entities.get(&key) {
|
||||
@@ -672,6 +685,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sign_use_tests.rs"]
|
||||
mod sign_use_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sign_chain_tests.rs"]
|
||||
mod sign_chain_tests;
|
||||
@@ -11,10 +11,15 @@ impl Game<'_> {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
// Consumable.canConsume asks the stack's own `food`.
|
||||
let consumable = self.consumable(&stack);
|
||||
if consumable.is_none()
|
||||
|| (info.food.is_some()
|
||||
&& (self.player.is_none() || !self.can_eat(&stack, info.can_always_eat)))
|
||||
|| (horizon_client::item_sync::food(&stack, info.food).is_some()
|
||||
&& (self.player.is_none()
|
||||
|| !self.can_eat(
|
||||
&stack,
|
||||
horizon_client::item_sync::can_always_eat(&stack, info.can_always_eat),
|
||||
)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,50 @@ fn triggers(name: &str) -> bool {
|
||||
|| n == "bell"
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Player.itemAttackInteraction` applies trigger-only held-item effects
|
||||
/// after an EnderDragonPart is successfully hit.
|
||||
pub(super) fn post_attack_dragon_burst(&mut self) {
|
||||
if let Some(held) = self.held().cloned() {
|
||||
self.post_attack_bursts(&held, "minecraft:ender_dragon");
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerExplosion.hurtEntities` knockback for a trigger-only blast such
|
||||
/// as Wind Burst. The dragon is not in the normal entity lists, but its
|
||||
/// body still receives the blast impulse (without taking damage).
|
||||
pub(super) fn wind_burst_knockback(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
multiplier: f32,
|
||||
blocks: &[[i32; 3]],
|
||||
) {
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let whole = d.whole_box();
|
||||
let (lo, hi) = (whole.min_position(), whole.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let Some((id, mut push)) = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.and_then(|d| Some((d.id, d.explosion_knockback(center, radius, exposure)?)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for component in &mut push {
|
||||
*component *= f64::from(multiplier);
|
||||
}
|
||||
self.native_explosion_knockback(self.dim.id(), center, blocks, Some(id), &mut push);
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
d.velocity = Vec3::new(
|
||||
d.velocity.x + push[0],
|
||||
d.velocity.y + push[1],
|
||||
d.velocity.z + push[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `WindChargeItem.use`.
|
||||
pub(super) fn throw_wind_charge(&mut self) -> Result<bool> {
|
||||
let (Some(eye), Some(p)) = (self.eye_position(), self.player.as_ref()) else {
|
||||
@@ -572,6 +616,7 @@ impl Game<'_> {
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.wind_positions(center, radius);
|
||||
let pushed = self.wind_knockback(center, radius, multiplier, breeze, wind_charge, &targets);
|
||||
self.wind_burst_knockback(center, radius, multiplier, &targets);
|
||||
let block_count = targets.len();
|
||||
// Explosion.canTriggerBlocks: a breeze's charge needs mob griefing.
|
||||
if !breeze || self.ui.rules.bool("mob_griefing") {
|
||||
@@ -740,6 +785,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "wind_dragon_tests.rs"]
|
||||
mod wind_dragon_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn end_game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
assert_eq!(
|
||||
game.enter_command_level(crate::game::Dim::End),
|
||||
Some(crate::game::Dim::Overworld)
|
||||
);
|
||||
assert!(game.dimension_type().has_ender_dragon_fight, "the End");
|
||||
for x in -1..2 {
|
||||
for z in -1..2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_wind_burst_pushes_the_dragon_without_damage() {
|
||||
let mut game = end_game();
|
||||
game.spawn_dragon_spawned([0.0, 70.0, 0.0])
|
||||
.expect("spawned dragon");
|
||||
game.dragon.as_mut().expect("dragon").velocity = Vec3::ZERO;
|
||||
|
||||
game.wind_burst([0.0, 66.0, 0.0], 5.0, 1.0, false, false)
|
||||
.unwrap();
|
||||
|
||||
let dragon = game.dragon.as_ref().expect("dragon");
|
||||
assert_eq!(dragon.health, 200.0);
|
||||
assert!(
|
||||
dragon.velocity.y > 0.0,
|
||||
"Wind Burst did not push the dragon"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_wind_burst_scales_the_dragons_push_by_its_multiplier() {
|
||||
let mut game = end_game();
|
||||
game.spawn_dragon_spawned([0.0, 70.0, 0.0])
|
||||
.expect("spawned dragon");
|
||||
game.dragon.as_mut().expect("dragon").velocity = Vec3::ZERO;
|
||||
game.wind_burst([0.0, 66.0, 0.0], 5.0, 1.22, false, false)
|
||||
.unwrap();
|
||||
|
||||
let push = game.dragon.as_ref().expect("dragon").velocity.y;
|
||||
assert!(push > 0.0, "Wind Burst did not push the dragon");
|
||||
assert!(push > 0.6, "Wind Burst multiplier was not applied: {push}");
|
||||
}
|
||||
@@ -512,6 +512,7 @@ impl PlayerMotion {
|
||||
status: self.status.clone(),
|
||||
blocking,
|
||||
blocked: 0.0,
|
||||
blocking_item_used: false,
|
||||
fully_blocked: false,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
@@ -530,6 +531,7 @@ impl PlayerMotion {
|
||||
let outcome = BlockOutcome {
|
||||
hurt,
|
||||
blocked: environment.blocked,
|
||||
blocking_item_used: environment.blocking_item_used,
|
||||
fully_blocked: environment.fully_blocked,
|
||||
sound: environment.block_sound,
|
||||
knockback: environment.knocked,
|
||||
@@ -624,6 +626,7 @@ pub(crate) struct Environment<'a> {
|
||||
/// The raised blocking item, if any, and what it blocked.
|
||||
pub(crate) blocking: Option<Blocking>,
|
||||
pub(crate) blocked: f32,
|
||||
pub(crate) blocking_item_used: bool,
|
||||
/// What it blocked covers the whole hit, as difficulty scaled it.
|
||||
pub(crate) fully_blocked: bool,
|
||||
pub(crate) block_sound: bool,
|
||||
@@ -704,6 +707,8 @@ pub struct AttackRecord {
|
||||
/// The outcome of a hurt with a raised blocking item.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct BlockOutcome {
|
||||
/// `hurtBlockingItem` ran, including when reductions resolve to zero.
|
||||
pub blocking_item_used: bool,
|
||||
pub hurt: bool,
|
||||
pub blocked: f32,
|
||||
/// `applyItemBlocking`'s `damageBlocked >= damage`, against the damage
|
||||
@@ -938,6 +943,7 @@ impl HurtWorld for Environment<'_> {
|
||||
if damage <= 0.0 || b.bypassed || b.pierce {
|
||||
return Ok(0.0);
|
||||
}
|
||||
self.blocking_item_used = true;
|
||||
self.blocked = b.rules.resolve(&mut MatchedSets, damage, b.angle)?;
|
||||
self.fully_blocked = self.blocked >= damage;
|
||||
Ok(self.blocked)
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct EquipmentHurt {
|
||||
pending: Option<PendingHurt<()>>,
|
||||
inner: Option<bool>,
|
||||
blocked: f32,
|
||||
blocking_item_used: bool,
|
||||
fully_blocked: bool,
|
||||
block_sound: bool,
|
||||
knocked: bool,
|
||||
@@ -76,6 +77,7 @@ impl PlayerMotion {
|
||||
status: self.status.clone(),
|
||||
blocking: hit.blocking.clone(),
|
||||
blocked: hit.blocked,
|
||||
blocking_item_used: hit.blocking_item_used,
|
||||
fully_blocked: hit.fully_blocked,
|
||||
block_sound: hit.block_sound,
|
||||
knocked: hit.knocked,
|
||||
@@ -92,6 +94,7 @@ impl PlayerMotion {
|
||||
)?;
|
||||
let result = step(&mut h.hurt, &mut combat);
|
||||
hit.blocked = environment.blocked;
|
||||
hit.blocking_item_used = environment.blocking_item_used;
|
||||
hit.fully_blocked = environment.fully_blocked;
|
||||
hit.block_sound = environment.block_sound;
|
||||
hit.knocked = environment.knocked;
|
||||
@@ -123,6 +126,7 @@ impl PlayerMotion {
|
||||
pending: None,
|
||||
inner: None,
|
||||
blocked: 0.0,
|
||||
blocking_item_used: false,
|
||||
fully_blocked: false,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
@@ -219,6 +223,7 @@ impl PlayerMotion {
|
||||
Ok(BlockOutcome {
|
||||
hurt,
|
||||
blocked: hit.blocked,
|
||||
blocking_item_used: hit.blocking_item_used,
|
||||
fully_blocked: hit.fully_blocked,
|
||||
sound: hit.block_sound,
|
||||
knockback: hit.knocked,
|
||||
|
||||
@@ -183,6 +183,7 @@ impl FoodWorld for TickWorld<'_> {
|
||||
status: Default::default(),
|
||||
blocking: None,
|
||||
blocked: 0.0,
|
||||
blocking_item_used: false,
|
||||
fully_blocked: false,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
|
||||
@@ -143,6 +143,147 @@ pub fn patched_use(stack: &ItemStack) -> (Option<i32>, bool) {
|
||||
out
|
||||
}
|
||||
|
||||
/// `FoodProperties.canAlwaysEat` from the stack's `food` component.
|
||||
pub fn can_always_eat(stack: &ItemStack, default: bool) -> bool {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:food").is_some()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(food) = data.and_then(|d| d.other("minecraft:food")) {
|
||||
return horizon_world::datafix::dynamic::as_bool(food.get("can_always_eat"), false);
|
||||
}
|
||||
default
|
||||
}
|
||||
/// `Consumable.consumeSeconds` from the stack's `consumable` component.
|
||||
pub fn consumable_seconds(stack: &ItemStack) -> Option<f32> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
data.and_then(|d| d.other("minecraft:consumable"))
|
||||
.map(|consumable| {
|
||||
horizon_world::datafix::dynamic::as_float(consumable.get("consume_seconds"), 1.6)
|
||||
})
|
||||
}
|
||||
/// `UseRemainder` from the stack's `use_remainder` component.
|
||||
pub fn use_remainder(stack: &ItemStack, default: Option<String>) -> Option<String> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:use_remainder").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(remainder) = data.and_then(|d| d.other("minecraft:use_remainder")) {
|
||||
return match remainder.get("id") {
|
||||
Some(Tag::String(id)) => Some(id.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
default
|
||||
}
|
||||
/// `Equippable.dispensable` from the stack's `equippable` component.
|
||||
pub fn dispensable(stack: &ItemStack, default: bool) -> bool {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(equippable) = data.and_then(|d| d.other("minecraft:equippable")) {
|
||||
return horizon_world::datafix::dynamic::as_bool(equippable.get("dispensable"), true);
|
||||
}
|
||||
default
|
||||
}
|
||||
/// `FoodProperties.nutrition` and `saturation` from the stack's `food`
|
||||
/// component, else the item's.
|
||||
pub fn food(stack: &ItemStack, default: Option<(u32, f32)>) -> Option<(u32, f32)> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:food").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(food) = data.and_then(|d| d.other("minecraft:food")) {
|
||||
return Some((
|
||||
horizon_world::datafix::dynamic::as_int(food.get("nutrition"), 0).max(0) as u32,
|
||||
horizon_world::datafix::dynamic::as_float(food.get("saturation"), 0.0),
|
||||
));
|
||||
}
|
||||
default
|
||||
}
|
||||
/// `Consumable.animation` from the stack's `consumable` component.
|
||||
pub fn consumable_animation(stack: &ItemStack) -> Option<horizon_world::consumable::UseAnimation> {
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
let Some(Tag::String(animation)) = data
|
||||
.and_then(|d| d.other("minecraft:consumable"))
|
||||
.and_then(|c| c.get("animation"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// ItemUseAnimation.CODEC's names.
|
||||
Some(match &*animation.to_string_lossy() {
|
||||
"none" => UseAnimation::None,
|
||||
"drink" => UseAnimation::Drink,
|
||||
"block" => UseAnimation::Block,
|
||||
"bow" => UseAnimation::Bow,
|
||||
"trident" => UseAnimation::Trident,
|
||||
"crossbow" => UseAnimation::Crossbow,
|
||||
"spyglass" => UseAnimation::Spyglass,
|
||||
"toot_horn" => UseAnimation::TootHorn,
|
||||
"brush" => UseAnimation::Brush,
|
||||
"bundle" => UseAnimation::Bundle,
|
||||
"spear" => UseAnimation::Spear,
|
||||
_ => UseAnimation::Eat,
|
||||
})
|
||||
}
|
||||
/// `Consumable.sound` from the stack's `consumable` component.
|
||||
pub fn consumable_sound(stack: &ItemStack) -> Option<String> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
let sound = data
|
||||
.and_then(|d| d.other("minecraft:consumable"))
|
||||
.and_then(|c| c.get("sound"))?;
|
||||
match sound {
|
||||
Tag::String(id) => Some(id.to_string_lossy()),
|
||||
// SoundEvent.DIRECT_CODEC: `{sound_id, range}`.
|
||||
Tag::Compound(_) => match sound.get("sound_id") {
|
||||
Some(Tag::String(id)) => Some(id.to_string_lossy()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Consumable.hasConsumeParticles` from the stack's `consumable` component.
|
||||
pub fn consumable_particles(stack: &ItemStack) -> Option<bool> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
data.and_then(|d| d.other("minecraft:consumable"))
|
||||
.map(|c| horizon_world::datafix::dynamic::as_bool(c.get("has_consume_particles"), true))
|
||||
}
|
||||
/// The `consumable` component a use reads: the stack's own when it has one
|
||||
/// (its time, animation, sound and particles, with `Consumable.CODEC`'s
|
||||
/// defaults for what it leaves out), else the item's. The item's effects stay:
|
||||
/// a stack's `on_consume_effects` are not read.
|
||||
pub fn stack_consumable<'a, E: Clone>(
|
||||
stack: &ItemStack,
|
||||
item: &'a horizon_world::consumable::Consumable<String, E>,
|
||||
) -> std::borrow::Cow<'a, horizon_world::consumable::Consumable<String, E>> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some())
|
||||
|| !data.is_some_and(|d| d.other("minecraft:consumable").is_some())
|
||||
{
|
||||
return std::borrow::Cow::Borrowed(item);
|
||||
}
|
||||
let mut c = item.clone();
|
||||
c.seconds = consumable_seconds(stack).unwrap_or(1.6);
|
||||
c.animation =
|
||||
consumable_animation(stack).unwrap_or(horizon_world::consumable::UseAnimation::Eat);
|
||||
c.sound = consumable_sound(stack).unwrap_or_else(|| "minecraft:entity.generic.eat".into());
|
||||
c.particles = consumable_particles(stack).unwrap_or(true);
|
||||
std::borrow::Cow::Owned(c)
|
||||
}
|
||||
/// The parts of a stack's `equippable` component (`Equippable`) the client
|
||||
/// draws with.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1427,6 +1568,188 @@ mod tests {
|
||||
assert_eq!(net_c.effects, effects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn food_can_always_eat_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:food",
|
||||
Some(Tag::compound([("can_always_eat", Tag::Byte(1))])),
|
||||
);
|
||||
assert!(can_always_eat(&stack, false));
|
||||
assert!(!can_always_eat(
|
||||
&ItemStack::new("minecraft:apple", 1),
|
||||
false
|
||||
));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:food",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(!can_always_eat(&stack, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_seconds_come_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([(
|
||||
"consume_seconds",
|
||||
Tag::Float(0.5f32.to_bits()),
|
||||
)])),
|
||||
);
|
||||
assert_eq!(consumable_seconds(&stack), Some(0.5));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:consumable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(consumable_seconds(&stack), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_remainder_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:mushroom_stew", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:use_remainder",
|
||||
Some(Tag::compound([(
|
||||
"id",
|
||||
Tag::String("minecraft:bowl".into()),
|
||||
)])),
|
||||
);
|
||||
assert_eq!(
|
||||
use_remainder(&stack, None).as_deref(),
|
||||
Some("minecraft:bowl")
|
||||
);
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:use_remainder",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(
|
||||
use_remainder(&stack, Some("minecraft:glass_bottle".into())),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispensable_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:golden_helmet", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:equippable",
|
||||
Some(Tag::compound([
|
||||
("slot", Tag::String("head".into())),
|
||||
("dispensable", Tag::Byte(0)),
|
||||
])),
|
||||
);
|
||||
assert!(!dispensable(&stack, true));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:equippable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(!dispensable(&stack, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn food_nutrition_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:food",
|
||||
Some(Tag::compound([
|
||||
("nutrition", Tag::Int(9)),
|
||||
("saturation", Tag::Float(4.5f32.to_bits())),
|
||||
])),
|
||||
);
|
||||
assert_eq!(food(&stack, Some((4, 2.4))), Some((9, 4.5)));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:food",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(food(&stack, Some((4, 2.4))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_animation_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([("animation", Tag::String("drink".into()))])),
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_animation(&stack),
|
||||
Some(horizon_world::consumable::UseAnimation::Drink)
|
||||
);
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:consumable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(consumable_animation(&stack), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_sound_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([(
|
||||
"sound",
|
||||
Tag::String("minecraft:entity.generic.drink".into()),
|
||||
)])),
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_sound(&stack).as_deref(),
|
||||
Some("minecraft:entity.generic.drink")
|
||||
);
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:consumable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(consumable_sound(&stack), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_particles_come_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([("has_consume_particles", Tag::Byte(0))])),
|
||||
);
|
||||
assert_eq!(consumable_particles(&stack), Some(false));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:consumable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(consumable_particles(&stack), None);
|
||||
}
|
||||
|
||||
/// A stack's own `consumable` replaces the item's: what it leaves out
|
||||
/// takes the codec's defaults, not the item's values.
|
||||
#[test]
|
||||
fn a_stacks_consumable_takes_the_codecs_defaults() {
|
||||
use horizon_world::consumable::{Consumable, UseAnimation};
|
||||
let honey: Consumable<String> = Consumable {
|
||||
seconds: 2.0,
|
||||
animation: UseAnimation::Drink,
|
||||
sound: "minecraft:item.honey_bottle.drink".into(),
|
||||
particles: false,
|
||||
effects: vec![],
|
||||
};
|
||||
let mut stack = ItemStack::new("minecraft:honey_bottle", 1);
|
||||
assert_eq!(*stack_consumable(&stack, &honey), honey);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([(
|
||||
"sound",
|
||||
Tag::compound([(
|
||||
"sound_id",
|
||||
Tag::String("minecraft:entity.generic.drink".into()),
|
||||
)]),
|
||||
)])),
|
||||
);
|
||||
let c = stack_consumable(&stack, &honey);
|
||||
assert_eq!(c.seconds, 1.6);
|
||||
assert_eq!(c.animation, UseAnimation::Eat);
|
||||
assert_eq!(c.sound, "minecraft:entity.generic.drink");
|
||||
assert!(c.particles);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merchant_water_cost_survives_the_offer_packet() {
|
||||
use horizon_protocol::{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! The converted equipment of one guest. A slot keeps the stack it was converted from; the
|
||||
//! stack is converted again only when the slot holds a different one, compared in full.
|
||||
|
||||
use crate::items::ItemStack;
|
||||
use horizon_protocol::item::Stack;
|
||||
|
||||
/// Equipment slots: held, offhand, then the armor from head to feet.
|
||||
pub const SLOTS: usize = 6;
|
||||
|
||||
/// The wire slot of each equipment slot, in the order the equipment is listed. Armor is stored
|
||||
/// head..feet; the wire slots are feet(2) legs(3) chest(4) head(5).
|
||||
const WIRE_SLOTS: [u8; SLOTS] = [0, 1, 5, 4, 3, 2];
|
||||
|
||||
#[derive(Default)]
|
||||
struct Slot {
|
||||
source: Option<ItemStack>,
|
||||
converted: Option<Stack>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EquipmentCache {
|
||||
slots: [Slot; SLOTS],
|
||||
}
|
||||
|
||||
impl EquipmentCache {
|
||||
/// Whether `source` differs from the stack slot `index` was last converted from.
|
||||
pub fn is_stale(&self, index: usize, source: Option<&ItemStack>) -> bool {
|
||||
self.slots[index].source.as_ref() != source
|
||||
}
|
||||
|
||||
/// Records the stack slot `index` was converted from, and the conversion.
|
||||
pub fn store(&mut self, index: usize, source: Option<ItemStack>, converted: Option<Stack>) {
|
||||
self.slots[index] = Slot { source, converted };
|
||||
}
|
||||
|
||||
/// `(wire slot, stack)` for each equipment slot that holds an item.
|
||||
pub fn equipment(&self) -> Vec<(u8, Option<Stack>)> {
|
||||
WIRE_SLOTS
|
||||
.iter()
|
||||
.zip(&self.slots)
|
||||
.filter(|(_, slot)| slot.source.is_some())
|
||||
.map(|(&wire, slot)| (wire, slot.converted.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,8 @@ pub struct Guest {
|
||||
/// with `armor` so each player compares against their own snapshot.
|
||||
pub location_armor: [Option<ItemStack>; 4],
|
||||
pub offhand: Option<ItemStack>,
|
||||
/// The converted held, offhand and armor stacks, kept while they do not change.
|
||||
pub equipment: super::equipment_cache::EquipmentCache,
|
||||
pub selected: usize,
|
||||
pub carried: Option<ItemStack>,
|
||||
/// The inventory menu's crafting grid (2x2) and its result
|
||||
@@ -557,6 +559,7 @@ impl Guest {
|
||||
armor: [None, None, None, None],
|
||||
location_armor: [None, None, None, None],
|
||||
offhand: None,
|
||||
equipment: Default::default(),
|
||||
selected: 0,
|
||||
carried: None,
|
||||
craft_grid: Default::default(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::{Entities, EntityState, Viewer},
|
||||
equipment_cache,
|
||||
guest::{Guest, SentEffect, stack_to_network},
|
||||
};
|
||||
use crate::{
|
||||
@@ -3279,28 +3280,19 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
fn equipment_of(
|
||||
&mut self,
|
||||
main: &Option<ItemStack>,
|
||||
off: &Option<ItemStack>,
|
||||
armor: [&Option<ItemStack>; 4],
|
||||
) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
// Armor is stored head..feet; the wire slots are feet(2) legs(3) chest(4) head(5).
|
||||
let mut out = Vec::new();
|
||||
let slots = [
|
||||
(0u8, main),
|
||||
(1, off),
|
||||
(5, armor[0]),
|
||||
(4, armor[1]),
|
||||
(3, armor[2]),
|
||||
(2, armor[3]),
|
||||
];
|
||||
for (slot, item) in slots {
|
||||
if item.is_some() {
|
||||
out.push((slot, self.stack(item)));
|
||||
/// The equipment of a placed guest; only the stacks that changed since the last call are converted.
|
||||
fn guest_equipment(&mut self, i: usize) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
for index in 0..equipment_cache::SLOTS {
|
||||
let g = &self.guests[i];
|
||||
let source = equipment_source(g, index);
|
||||
if !g.equipment.is_stale(index, source) {
|
||||
continue;
|
||||
}
|
||||
let source = source.cloned();
|
||||
let converted = self.stack(&source);
|
||||
self.guests[i].equipment.store(index, source, converted);
|
||||
}
|
||||
out
|
||||
self.guests[i].equipment.equipment()
|
||||
}
|
||||
|
||||
/// Whether an entity is tracked by the current dimension's level.
|
||||
@@ -3379,16 +3371,7 @@ impl Hub {
|
||||
let guest_states: Vec<_> = placed
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let (main, off, armor) = {
|
||||
let g = &self.guests[i];
|
||||
(
|
||||
g.inventory.get(g.selected).cloned().flatten(),
|
||||
g.offhand.clone(),
|
||||
g.armor.clone(),
|
||||
)
|
||||
};
|
||||
let equipment =
|
||||
self.equipment_of(&main, &off, [&armor[0], &armor[1], &armor[2], &armor[3]]);
|
||||
let equipment = self.guest_equipment(i);
|
||||
let g = &self.guests[i];
|
||||
let mut state = self.player_state(
|
||||
g.entity_id,
|
||||
@@ -3752,6 +3735,8 @@ impl Hub {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64);
|
||||
for g in &mut self.guests {
|
||||
// Converted under the old registries: convert again under the new ones.
|
||||
g.equipment = Default::default();
|
||||
let mut granted = Vec::new();
|
||||
// PlayerAdvancements.reload: isFirstPacket again, the progress
|
||||
// read against the new tree; a player whose advancements were
|
||||
@@ -5266,6 +5251,15 @@ fn listed_properties(
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack in equipment slot `index` (`equipment_cache::SLOTS`): held, offhand, armor head..feet.
|
||||
fn equipment_source(g: &Guest, index: usize) -> Option<&ItemStack> {
|
||||
match index {
|
||||
0 => g.inventory.get(g.selected)?.as_ref(),
|
||||
1 => g.offhand.as_ref(),
|
||||
_ => g.armor.get(index - 2)?.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hub_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1381,6 +1381,57 @@ fn memory_connection_first_view_sends_every_ready_column() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The equipment as it was before the cache: every equipped slot converted in turn.
|
||||
fn equipment_converted_every_time(
|
||||
hub: &mut Hub,
|
||||
) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
let g = &hub.guests[0];
|
||||
let sources = [
|
||||
(0u8, g.inventory.get(g.selected).cloned().flatten()),
|
||||
(1, g.offhand.clone()),
|
||||
(5, g.armor[0].clone()),
|
||||
(4, g.armor[1].clone()),
|
||||
(3, g.armor[2].clone()),
|
||||
(2, g.armor[3].clone()),
|
||||
];
|
||||
let mut out = Vec::new();
|
||||
for (slot, item) in sources {
|
||||
if item.is_some() {
|
||||
out.push((slot, hub.stack(&item)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipment_is_converted_again_only_when_a_stack_changes() {
|
||||
use crate::items::ItemStack;
|
||||
let stack = |item: &str, count: u32| ItemStack {
|
||||
item: item.into(),
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let (mut hub, _rx) = hub_with_a_guest();
|
||||
hub.guests[0].inventory[0] = Some(stack("minecraft:stone", 1));
|
||||
hub.guests[0].offhand = Some(stack("minecraft:shield", 1));
|
||||
hub.guests[0].armor[0] = Some(stack("minecraft:iron_helmet", 1));
|
||||
let first = equipment_converted_every_time(&mut hub);
|
||||
for _ in 0..3 {
|
||||
assert_eq!(hub.guest_equipment(0), first);
|
||||
}
|
||||
|
||||
hub.guests[0].inventory[0] = Some(stack("minecraft:stone", 3));
|
||||
let changed = equipment_converted_every_time(&mut hub);
|
||||
assert_ne!(changed, first);
|
||||
assert_eq!(hub.guest_equipment(0), changed);
|
||||
|
||||
hub.guests[0].offhand = None;
|
||||
let cleared = equipment_converted_every_time(&mut hub);
|
||||
assert_eq!(cleared.len(), changed.len() - 1);
|
||||
assert_eq!(hub.guest_equipment(0), cleared);
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleAcceptTeleportPacket`: an accept whose id is
|
||||
/// the last teleport's (`awaitingTeleport`) while none is awaited any more
|
||||
/// (`awaitingPositionFromClient` is null) disconnects with
|
||||
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user