mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
381
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976e3f3858 | ||
|
|
af55a73486 | ||
|
|
125875b844 | ||
|
|
68ac6f647e | ||
|
|
57aa58c8f9 | ||
|
|
d9c733419c | ||
|
|
c50f97ec8f | ||
|
|
27cb8f96bf | ||
|
|
c7dcaf2b7d | ||
|
|
bd541458cd | ||
|
|
01bbb4c654 | ||
|
|
38a97ea64e | ||
|
|
2abd1ac013 | ||
|
|
36f4bc00d6 | ||
|
|
3c68c05d0a | ||
|
|
7f99b2e8cc | ||
|
|
c556f5b5b8 | ||
|
|
b546ffb877 | ||
|
|
a6f30aa763 | ||
|
|
bcbf367fc6 | ||
|
|
65d4d06ec5 | ||
|
|
d7c6e36cb9 | ||
|
|
a672a51ebd | ||
|
|
13fd2ee724 | ||
|
|
c3905131bf | ||
|
|
d7d0ead29d | ||
|
|
13ce6bcc64 | ||
|
|
807a818395 | ||
|
|
51c9565411 | ||
|
|
71d7921132 | ||
|
|
9d979caa48 | ||
|
|
09a4cd9baf | ||
|
|
c18ed2db67 | ||
|
|
c41a8d0d96 | ||
|
|
fc680ec318 | ||
|
|
c535285302 | ||
|
|
763528025d | ||
|
|
5bd13dad80 | ||
|
|
03fc2d3f2d | ||
|
|
1dfea0b84e | ||
|
|
eae1e21bd0 | ||
|
|
97b2e10df5 | ||
|
|
5bda60aa5d | ||
|
|
3bffd9b52e | ||
|
|
a648bef38c | ||
|
|
e3b213f8f9 | ||
|
|
530a1253e6 | ||
|
|
ae45f040b7 | ||
|
|
3d1b94f97c | ||
|
|
57bee439bb | ||
|
|
367d519e3c | ||
|
|
13aecbe1e5 | ||
|
|
7aa0e28fae | ||
|
|
202e37981f | ||
|
|
6ee48d0e47 | ||
|
|
836ea22364 | ||
|
|
877d0dac10 | ||
|
|
94b3b4aca5 | ||
|
|
73958d8397 | ||
|
|
37ad1f5c9e | ||
|
|
73db376775 | ||
|
|
84f633b56b | ||
|
|
a3c5fe138e | ||
|
|
4db1495bfb | ||
|
|
ed5565713e | ||
|
|
90994feaca | ||
|
|
f2a99d6238 | ||
|
|
e776e8e94a | ||
|
|
3d0512446d | ||
|
|
a595a30164 | ||
|
|
dffc94645e | ||
|
|
3edd56c5e4 | ||
|
|
b526d836a0 | ||
|
|
0b82e9f872 | ||
|
|
3f722b80a8 | ||
|
|
f554e16b02 | ||
|
|
92415234d9 | ||
|
|
f96b795bf6 | ||
|
|
2d6d006d39 | ||
|
|
6eca577874 | ||
|
|
c559d125b5 | ||
|
|
819cd2eebc | ||
|
|
1e62ad7914 | ||
|
|
57d8a70a2c | ||
|
|
0fb65197a7 | ||
|
|
d4dff9534c | ||
|
|
a73db6ad95 | ||
|
|
6718c7dd9a | ||
|
|
d331af9962 | ||
|
|
389a918c16 | ||
|
|
bdebd8103c | ||
|
|
5e7b9a5075 | ||
|
|
dd94337c2e | ||
|
|
7f969cb3a5 | ||
|
|
0c5074bba1 | ||
|
|
fcaaed088b | ||
|
|
4de90a13d4 | ||
|
|
cbda897a5e | ||
|
|
86a40451e8 | ||
|
|
017afee3a1 | ||
|
|
b2f0b58fc9 | ||
|
|
7f884ad64b | ||
|
|
568159fa1e | ||
|
|
6bbc5cb3b0 | ||
|
|
6e263d79f6 | ||
|
|
2959076c75 | ||
|
|
ff5d14d4c2 | ||
|
|
c39db2247c | ||
|
|
e3cba0ac6f | ||
|
|
e91d4e3f01 | ||
|
|
eac39ece47 | ||
|
|
b251abc575 | ||
|
|
09e315cb24 | ||
|
|
48709c8091 | ||
|
|
f8b5fed2f0 | ||
|
|
4141a127fb | ||
|
|
1ba05096b8 | ||
|
|
19021623be | ||
|
|
d27ec41434 | ||
|
|
5993df106f | ||
|
|
81b30898d7 | ||
|
|
4fc225edb9 | ||
|
|
28754451ea | ||
|
|
142424b58f | ||
|
|
1ad4d8633b | ||
|
|
600058493e | ||
|
|
fdd6f507ec | ||
|
|
7e170c6a64 | ||
|
|
0291f63727 | ||
|
|
27c38eb27b | ||
|
|
2ed80412b1 | ||
|
|
a49b9a6b88 | ||
|
|
53e565516f | ||
|
|
9a4eb4add8 | ||
|
|
c58859aedf | ||
|
|
0d327cc093 | ||
|
|
cffc83bb8b | ||
|
|
f253ba22d4 | ||
|
|
78858b1d42 | ||
|
|
cb59fcd162 | ||
|
|
eda9860e1d | ||
|
|
5b492b240f | ||
|
|
0a28a10e93 | ||
|
|
9e06cafb83 | ||
|
|
7110152122 | ||
|
|
6c78d82c5d | ||
|
|
a82a28179d | ||
|
|
44d3e49db8 | ||
|
|
a3ba9256a4 | ||
|
|
a00475b910 | ||
|
|
e5c713c00b | ||
|
|
8b1758dc73 | ||
|
|
623e7b03dc | ||
|
|
a2bc79274b | ||
|
|
8d9d4d94e3 | ||
|
|
809dd639e2 | ||
|
|
970376b6c5 | ||
|
|
ffb3d2d1ea | ||
|
|
0d1e3dfd41 | ||
|
|
e5dde40e72 | ||
|
|
ff5db37b0e | ||
|
|
37fd66562f | ||
|
|
07042859e1 | ||
|
|
b499251187 | ||
|
|
6197116a9e | ||
|
|
ad531e6374 | ||
|
|
dfb6d1f0fb | ||
|
|
9f2a4433ec | ||
|
|
7c9efae60a | ||
|
|
c829600ac6 | ||
|
|
f4088409ee | ||
|
|
3063237a06 | ||
|
|
e75d4bc1b6 | ||
|
|
ceca6301a6 | ||
|
|
1b62d8d1f2 | ||
|
|
52e186749d | ||
|
|
ba40840da7 | ||
|
|
c97b85dc0b | ||
|
|
fd4a625f7e | ||
|
|
bceac87629 | ||
|
|
e005da2cc0 | ||
|
|
406648ebe2 | ||
|
|
477ae00887 | ||
|
|
3c481aa243 | ||
|
|
ddd00efcbb | ||
|
|
c6cf721a91 | ||
|
|
85c0d5fe3c | ||
|
|
048dafddad | ||
|
|
d33d377090 | ||
|
|
f651954913 | ||
|
|
8f91f896a5 | ||
|
|
27888dc18d | ||
|
|
16fd80bdcf | ||
|
|
b4da90182f | ||
|
|
87a9d03fd8 | ||
|
|
43e29d4556 | ||
|
|
480f6b3d1b | ||
|
|
d4c60e0ac4 | ||
|
|
4af27b50a8 | ||
|
|
0b83386c7e | ||
|
|
a10b26b9ef | ||
|
|
8acefbec58 | ||
|
|
da2acdad41 | ||
|
|
d020b5f93c | ||
|
|
1641b3ecc0 | ||
|
|
e8b180b01e | ||
|
|
c84b15d426 | ||
|
|
23f917e309 | ||
|
|
8ba17d98d9 | ||
|
|
e51c33efdd | ||
|
|
388bdfe072 | ||
|
|
e2b0b817d7 | ||
|
|
678f0ac6f4 | ||
|
|
de76c32fe3 | ||
|
|
af2df073d7 | ||
|
|
b7fef4337b | ||
|
|
465a8fda1e | ||
|
|
69ba6c5645 | ||
|
|
61883f815e | ||
|
|
a25e830b28 | ||
|
|
8bb6e0dc11 | ||
|
|
4bbb1d3b42 | ||
|
|
b043f3977b | ||
|
|
cb8feeeff9 | ||
|
|
e5089d3fe4 | ||
|
|
dd68ab987a | ||
|
|
3bd2b8b820 | ||
|
|
d46cb2ca35 | ||
|
|
e3a24beec6 | ||
|
|
587b85d675 | ||
|
|
d26c8fba0a | ||
|
|
5c5107b72d | ||
|
|
b4a85e32e3 | ||
|
|
8c7779e17f | ||
|
|
717b49cbc9 | ||
|
|
7dd10acb86 | ||
|
|
2a8ae891ae | ||
|
|
d01ff3b8a4 | ||
|
|
a01203c3b2 | ||
|
|
6521dffcee | ||
|
|
c832b61617 | ||
|
|
5fe97cd6d1 | ||
|
|
1f2ebdbd91 | ||
|
|
adf54b0c68 | ||
|
|
d9517672ef | ||
|
|
0b912926a3 | ||
|
|
f98890b386 | ||
|
|
50cd60645b | ||
|
|
814a8592da | ||
|
|
43f142115b | ||
|
|
ed7f8b3998 | ||
|
|
429fda5680 | ||
|
|
fe4b750827 | ||
|
|
e35be2e5bc | ||
|
|
2d0c6e984f | ||
|
|
a5ff7b1b31 | ||
|
|
337692aab3 | ||
|
|
c95194b676 | ||
|
|
7192a53328 | ||
|
|
1f3ac390da | ||
|
|
a834bdb7cb | ||
|
|
a01414e035 | ||
|
|
09ecc06126 | ||
|
|
fbb8f4e6d7 | ||
|
|
53aa834866 | ||
|
|
19de197927 | ||
|
|
ce208cac3a | ||
|
|
98ecd84b54 | ||
|
|
99050f7ac2 | ||
|
|
e73e31c709 | ||
|
|
890e625fc7 | ||
|
|
cd52b16c8f | ||
|
|
4b8f735c7b | ||
|
|
4c88da0096 | ||
|
|
6830f201d5 | ||
|
|
16cb6a1204 | ||
|
|
3c257d870c | ||
|
|
907ae1bbb1 | ||
|
|
23f553e285 | ||
|
|
25950c64ad | ||
|
|
31054f520e | ||
|
|
fb4fcb04ec | ||
|
|
0c9b29d20a | ||
|
|
1698ff41f8 | ||
|
|
7ee905eab2 | ||
|
|
2977badbb8 | ||
|
|
bfeb1e69a8 | ||
|
|
fc4641bc8e | ||
|
|
563ce7338e | ||
|
|
e10efca1bc | ||
|
|
8ae79aaad4 | ||
|
|
f8f1679153 | ||
|
|
0a568f21aa | ||
|
|
669aa04e69 | ||
|
|
6c023bdca0 | ||
|
|
83e8e0ba1f | ||
|
|
9765a0341d | ||
|
|
21ec14c0b2 | ||
|
|
574c600551 | ||
|
|
f7dc8dd714 | ||
|
|
6fa052b4e0 | ||
|
|
46c09f1c70 | ||
|
|
ae9a990929 | ||
|
|
a4a90ad0a1 | ||
|
|
e06a5ced37 | ||
|
|
d2c4e9ac87 | ||
|
|
29b58bb0fc | ||
|
|
e7fae3d587 | ||
|
|
2e52f01d71 | ||
|
|
3fa621d254 | ||
|
|
ddf1be8050 | ||
|
|
32e8e51cfc | ||
|
|
5f0ac95135 | ||
|
|
ca2955ef0c | ||
|
|
78c2b92c5e | ||
|
|
fcfe236b92 | ||
|
|
52a0865aac | ||
|
|
19eeab4a2a | ||
|
|
714f7a76da | ||
|
|
ca27b2de97 | ||
|
|
1d4c9fa4db | ||
|
|
864f314abf | ||
|
|
971e8a90f0 | ||
|
|
4cf3a14ba0 | ||
|
|
234d5e1cd6 | ||
|
|
0416fe600c | ||
|
|
69d6254b70 | ||
|
|
fc485bb996 | ||
|
|
9ea981a2f3 | ||
|
|
a78bee4642 | ||
|
|
ef4f8d3edc | ||
|
|
ea678bc6e6 | ||
|
|
95d06ac8cd | ||
|
|
1bea267634 | ||
|
|
1338b93815 | ||
|
|
5f72e129b2 | ||
|
|
5a453aac0f | ||
|
|
7c5de46635 | ||
|
|
a24fe033cd | ||
|
|
a2d7a9f6c5 | ||
|
|
50fd82435c | ||
|
|
d3be73f9d0 | ||
|
|
4d42e8b877 | ||
|
|
d12fd7341c | ||
|
|
fb2fb00ec4 | ||
|
|
c24f9a1693 | ||
|
|
531cb06614 | ||
|
|
300d048a1c | ||
|
|
4aa8663777 | ||
|
|
6aff9307fe | ||
|
|
d14b3e27ba | ||
|
|
5c4eb67ba2 | ||
|
|
12e23c20a8 | ||
|
|
691f958bc4 | ||
|
|
8c079ed9d7 | ||
|
|
b246a02f55 | ||
|
|
3813fba81c | ||
|
|
93f42ead5b | ||
|
|
05f68f3fdc | ||
|
|
acce84e884 | ||
|
|
e9b2bdef24 | ||
|
|
18da9c988a | ||
|
|
86ebc77e25 | ||
|
|
90823415c3 | ||
|
|
2017653aff | ||
|
|
4b004cf7d6 | ||
|
|
79360c5867 | ||
|
|
f4c6f8dc29 | ||
|
|
32cce66761 | ||
|
|
f2a3a76c07 | ||
|
|
8ee30558da | ||
|
|
d9a2f157a3 | ||
|
|
abeb5845dd | ||
|
|
aba29be6af | ||
|
|
647ec89bfc | ||
|
|
d169963556 | ||
|
|
573f398a2a | ||
|
|
37de786282 | ||
|
|
d36f45e213 | ||
|
|
cc2a2a8741 | ||
|
|
1102fa42df |
No files matched your search
@@ -21,6 +21,12 @@
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
|
||||
|
||||
## Other projects' code
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". -->
|
||||
|
||||
## Self-check
|
||||
|
||||
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
|
||||
|
||||
+70
-12
@@ -1,7 +1,7 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
|
||||
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
@@ -24,7 +24,10 @@ env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
|
||||
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
|
||||
# verify runs always() and fails unless all three passed.
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -34,6 +37,28 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
python tools/check_public_tree.py --history
|
||||
fi
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
@@ -41,22 +66,16 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Keep game artifacts out of Git
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
python tools/check_public_tree.py --history
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
shared-key: verify-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -65,8 +84,35 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# these 4-core runners it was no faster (#4978).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
@@ -75,6 +121,18 @@ jobs:
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
verify:
|
||||
if: always()
|
||||
needs: [python, clippy, tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Did python, clippy and tests pass?
|
||||
env:
|
||||
RESULTS: ${{ join(needs.*.result, ' ') }}
|
||||
run: |
|
||||
echo "python, clippy, tests: $RESULTS"
|
||||
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: game build
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
|
||||
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
|
||||
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
|
||||
# which never runs what this one built. See docs/release-flow.md.
|
||||
@@ -104,6 +105,13 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
|
||||
# programs under Rosetta 2.
|
||||
- name: macos-x64
|
||||
mac: true
|
||||
target: x86_64-apple-darwin
|
||||
dir: horizon-macos-x64
|
||||
asset: horizon-macos-x64.zip
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
@@ -114,6 +122,8 @@ jobs:
|
||||
HORIZON_VERSION: ${{ inputs.version }}
|
||||
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
|
||||
HORIZON_NIGHTLY: ${{ inputs.nightly }}
|
||||
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
|
||||
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -128,6 +138,8 @@ jobs:
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
@@ -138,11 +150,11 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
@@ -157,15 +169,15 @@ jobs:
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
|
||||
codesign --verify --strict --verbose=2 "target/release/$binary"
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
|
||||
codesign --verify --strict --verbose=2 "$BIN/$binary"
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
file="$BIN/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
@@ -176,7 +188,7 @@ jobs:
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
bin=$BIN
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
@@ -202,7 +214,7 @@ jobs:
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
client="$PWD/$BIN/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
@@ -217,8 +229,9 @@ jobs:
|
||||
EOF
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
if: runner.environment == 'self-hosted' && !matrix.target
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
@@ -231,7 +244,7 @@ jobs:
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
|
||||
@@ -8,7 +8,8 @@ name: launcher
|
||||
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
|
||||
# commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
@@ -105,6 +106,13 @@ jobs:
|
||||
mac: true
|
||||
asset: horizon-launcher-macos-arm64.zip
|
||||
binary: horizon-launcher
|
||||
mac_arch: arm64
|
||||
- name: macos-x64
|
||||
mac: true
|
||||
target: x86_64-apple-darwin
|
||||
asset: horizon-launcher-macos-x64.zip
|
||||
binary: horizon-launcher
|
||||
mac_arch: x64
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
@@ -117,6 +125,8 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
|
||||
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
|
||||
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
|
||||
# variable, not a secret. Unset, the launcher says sign-in is not configured.
|
||||
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
|
||||
@@ -140,6 +150,8 @@ jobs:
|
||||
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
@@ -160,17 +172,17 @@ jobs:
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$(target/release/${{ matrix.binary }} --version)
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
echo "$out"
|
||||
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
|
||||
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
|
||||
- name: Only system libraries
|
||||
run: |
|
||||
file=target/release/${{ matrix.binary }}
|
||||
file="$BIN/${{ matrix.binary }}"
|
||||
case "$RUNNER_OS" in
|
||||
Windows) python ../tools/check_imports.py "$file" ;;
|
||||
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
@@ -185,13 +197,28 @@ jobs:
|
||||
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
|
||||
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
launcher="$PWD/target/release/${{ matrix.binary }}"
|
||||
launcher="$PWD/$BIN/${{ matrix.binary }}"
|
||||
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
|
||||
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
|
||||
retry "$launcher" versions
|
||||
"$launcher" links --json
|
||||
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
|
||||
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
|
||||
# (exit 3); a launcher or a script that fails still fails the step.
|
||||
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
|
||||
versions=$(retry "$launcher" --json versions)
|
||||
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
|
||||
status=0
|
||||
python3 -c "$no_build" <<<"$versions" || status=$?
|
||||
if [[ $status == 3 ]]; then
|
||||
echo "::warning::No game build has an asset for ${{ matrix.name }} yet: install not tested"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
exit 0
|
||||
fi
|
||||
[[ $status == 0 ]] || exit $status
|
||||
fi
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
@@ -224,11 +251,11 @@ jobs:
|
||||
# Developer ID): packaging/macos.
|
||||
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
mkdir -p dist
|
||||
cp target/release/${{ matrix.binary }} dist/
|
||||
cp "$BIN/${{ matrix.binary }}" dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
|
||||
else
|
||||
|
||||
@@ -172,7 +172,8 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -235,7 +236,8 @@ jobs:
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
|
||||
+26
-8
@@ -47,14 +47,31 @@ them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code
|
||||
## Only original code, or code whose license allows it
|
||||
|
||||
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
|
||||
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
|
||||
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
|
||||
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
|
||||
had been written from another project's source; they come back only as a clean-room rewrite from a written
|
||||
description of the pack format.
|
||||
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
|
||||
another project's source may also be read and used, **only when that project's license allows it**:
|
||||
|
||||
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
|
||||
public-domain dedication. That code may be read, copied or adapted.
|
||||
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
|
||||
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
|
||||
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
|
||||
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
|
||||
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
|
||||
at your commit counts. In doubt, don't: ask in #contributors first.
|
||||
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
|
||||
pull request that used another project's source without saying so is closed.
|
||||
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
|
||||
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
|
||||
was changed.
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
|
||||
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
|
||||
another project's source; they come back only as a clean-room rewrite from a written description of the pack
|
||||
format.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
@@ -82,7 +99,8 @@ claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the
|
||||
enough), then claim it. `/unclaim` gives it back.
|
||||
|
||||
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
|
||||
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. Agents claim the same way.
|
||||
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
|
||||
pull requests already fix. Agents claim the same way.
|
||||
|
||||
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
|
||||
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -24,34 +24,32 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## What works today
|
||||
|
||||
The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
|
||||
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
|
||||
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
In the Rust client, with vanilla assets and no mods or shaders:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -69,15 +67,15 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
|
||||
|
||||
## Host a server
|
||||
|
||||
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
|
||||
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
|
||||
|
||||
## Modpacks
|
||||
|
||||
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
|
||||
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
|
||||
|
||||
## Agent control
|
||||
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, reads its state as JSON and acts like a player; `horizon-ctl mcp` serves the same commands over MCP.
|
||||
|
||||
```sh
|
||||
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
|
||||
@@ -88,16 +86,14 @@ horizon-ctl break -1 65 -3 # mines it; replies with
|
||||
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
|
||||
```
|
||||
|
||||
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
|
||||
## Build from source
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
|
||||
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
|
||||
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
|
||||
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
|
||||
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/horizon-rust/horizon.git
|
||||
@@ -107,62 +103,49 @@ cargo run --release -p horizon-prepare -- sounds # optional: sounds from your
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
|
||||
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/HcTeMZxe5E). Before a pull request, run the self-check and paste its output:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
python3 tools/pr_ready.py
|
||||
```
|
||||
|
||||
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
|
||||
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
|
||||
and verified against vanilla by differential tests). The percentage is the
|
||||
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
2026-10-13 against the harness results: all 281 harnesses pass on Linux, each
|
||||
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
|
||||
them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
|
||||
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
|
||||
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
|
||||
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
|
||||
| Assets and resources | 1 | None: item models match vanilla over 196,290 stacks and the client draws every item through them; the resource pack screen matches vanilla's drawing over 3,000 sessions of clicks, scrolling and searching, its incompatibility confirmation included |
|
||||
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server (`--dedicated`) matches vanilla's files, status ping, login, configuration, play, chunks, chat, recipes, advancements and command tree, a client built from vanilla's packet code joins it, Horizon joins a vanilla server, and LAN discovery matches over 462 cases (offline mode only) |
|
||||
| Window, input and game loop | 1 | None: game loop, options, camera, auto-jump, toggle sprint/sneak and frame limiter match |
|
||||
| Assets and resources | 1 | None: item models match over 196,290 stacks; the resource pack screen over 3,000 sessions |
|
||||
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server matches vanilla's files, login, configuration, play and command tree; LAN discovery over 462 cases (offline mode only) |
|
||||
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
|
||||
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
|
||||
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
|
||||
| Rendering | 1 | None: entity models, block entity renderers (407 cases) and animations (296), HUD overlays (2,200 states), the recipe book (600), player list, subtitles and world border match |
|
||||
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
|
||||
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
|
||||
| Region files and saves | 1 | None: saves round-trip with vanilla, and worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do, superflat worlds included; old options.txt and hotbar.nbt files are upgraded too |
|
||||
| Entities and physics | 1 | None: attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases), spawned mobs' gear, enchantments and state (`finalizeSpawn`, 24,000 cases) and mob AI (92 scenarios: villager breeding and gossip, the warden, ghastlings and copper golems included) match vanilla |
|
||||
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
|
||||
| Menus, HUD and chat | 1 | None: every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), the player list matches over 872,000 draw calls with heads drawn from players' skins (authlib's textures, SkinManager's lookup, legacy skin conversion), and the chat matches focused and scrolled; the resource pack screen (selection over 4,000 cases, drawing and its incompatibility confirmation over 3,000 sessions) and the chat input with its command suggestions, usage lines, Tab completion and history (3,000 sessions on vanilla's client command tree) match vanilla |
|
||||
| Region files and saves | 1 | None: saves round-trip with vanilla; worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do |
|
||||
| Entities and physics | 1 | None: attributes, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900), `finalizeSpawn` (24,000) and mob AI (92 scenarios) match |
|
||||
| Interaction and inventory | 1 | None: menu clicks over 8,000 sequences; recipe book placement (6,000), buttons (1,200), saved book (1,500), loom and crafter screens (1,600) |
|
||||
| Menus, HUD and chat | 1 | None: every command (758 over 39,685 inputs), scoreboards (81,000 operations), the player list (872,000 draw calls), chat and its suggestions (3,000 sessions) match |
|
||||
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
|
||||
|
||||
### Known issues
|
||||
|
||||
@@ -170,9 +153,7 @@ Skins served over https show the default skin unless already cached; pack icons
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
|
||||
|
||||
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
|
||||
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
+2
-2
@@ -326,11 +326,11 @@ A mapping table from intermediary and SRG names so pre-26.1 jars can be classifi
|
||||
|
||||
#### A modpack's own resource and shader packs
|
||||
|
||||
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
|
||||
Imported modpacks carry their resource packs into a profile of their own. Shader-pack conversion is still pending.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
|
||||
Resource-pack carry-over is implemented locally: Modrinth common/client overrides, CurseForge's override folder, installed instances (ZIP and directory packs), and hash-checked indexed ZIP downloads. The launcher keeps profile edits and lets resource-only profiles play; packs are enabled in Resource Packs. Synthetic import and profile tests cover the file pipeline; in-game rendering has not been verified. Shader-pack conversion is still pending, so this combined item remains partial ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
|
||||
|
||||
python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50
|
||||
python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
|
||||
python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50
|
||||
|
||||
Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the
|
||||
server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds:
|
||||
|
||||
- TPS: `time query gametime` at the start and the end of the window, as game ticks per wall-clock second;
|
||||
- MSPT: `tick query` every 5 s (average, P50, P95, P99 over the last 100 ticks), averaged over the window;
|
||||
- RSS and CPU of the server's whole process tree, once a second (needs `ps`: Linux and macOS);
|
||||
- bandwidth: `bytes_in`/`bytes_out` from the bots' report, per player and second online (each bot's whole time
|
||||
on the server, its join included), when the bots report them (they do not yet: the field is null until S-6); join time: the bots' `joined_after_ms`.
|
||||
|
||||
It writes one JSON report (--out) and prints the Markdown table of the design (docs/design.md, section 1).
|
||||
|
||||
Vanilla is the owner's own server.jar (--jar): nothing of Mojang's is fetched or committed. Pumpkin is a released
|
||||
binary run as a black box (--exe); its Bedrock listeners, LAN broadcast and telemetry are turned off in the
|
||||
config this script writes (--pumpkin-config adds a TOML file of your own on top if a release names a key
|
||||
differently). `--pre-generated DIR` copies a finished world into every run's scratch folder as `world`, so runs
|
||||
repeat on the same terrain. Build the bots first: cargo build --release -p horizon-bots
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EXE = '.exe' if os.name == 'nt' else ''
|
||||
SERVERS = ('horizon', 'vanilla', 'pumpkin')
|
||||
|
||||
PROPERTIES = {
|
||||
'level-seed': '12345',
|
||||
'view-distance': '10',
|
||||
'simulation-distance': '10',
|
||||
'online-mode': 'false',
|
||||
'white-list': 'false',
|
||||
'gamemode': 'creative',
|
||||
'force-gamemode': 'true',
|
||||
'spawn-protection': '0',
|
||||
'max-players': '200',
|
||||
'difficulty': 'easy',
|
||||
'server-ip': '127.0.0.1',
|
||||
'enable-query': 'false',
|
||||
'enable-rcon': 'false',
|
||||
}
|
||||
|
||||
# Pumpkin's two config files. Bedrock, LAN broadcast and telemetry (plugins' included) are off.
|
||||
PUMPKIN_CONFIGURATION = '''\
|
||||
server_address = "127.0.0.1:{port}"
|
||||
seed = "{seed}"
|
||||
max_players = 200
|
||||
view_distance = {view}
|
||||
simulation_distance = {view}
|
||||
online_mode = false
|
||||
default_difficulty = "Easy"
|
||||
allow_chat_reports = false
|
||||
encryption = false
|
||||
lan_broadcast = false
|
||||
|
||||
[networking.bedrock]
|
||||
enabled = false
|
||||
|
||||
[networking.lan_broadcast]
|
||||
enabled = false
|
||||
|
||||
[networking.query]
|
||||
enabled = false
|
||||
|
||||
[networking.rcon]
|
||||
enabled = false
|
||||
'''
|
||||
PUMPKIN_FEATURES = '''\
|
||||
[telemetry]
|
||||
enabled = false
|
||||
|
||||
[plugins]
|
||||
enabled = false
|
||||
'''
|
||||
|
||||
DONE = re.compile(r'Done|listening|Server started', re.IGNORECASE)
|
||||
TICK_AVERAGE = re.compile(r'Average time per tick: ([0-9.]+) ?ms')
|
||||
TICK_PERCENTILES = re.compile(r'P50: ([0-9.]+) ?ms P95: ([0-9.]+) ?ms P99: ([0-9.]+) ?ms')
|
||||
GAMETIME = re.compile(r'The time is (-?\d+)')
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def parse_tick_query(lines):
|
||||
"""MSPT numbers from the lines `tick query` printed, or None until both lines are there."""
|
||||
average = percentiles = None
|
||||
for line in lines:
|
||||
if (m := TICK_AVERAGE.search(line)) and average is None:
|
||||
average = float(m.group(1))
|
||||
if (m := TICK_PERCENTILES.search(line)) and percentiles is None:
|
||||
percentiles = tuple(float(g) for g in m.groups())
|
||||
if average is None or percentiles is None:
|
||||
return None
|
||||
return {'average': average, 'p50': percentiles[0], 'p95': percentiles[1], 'p99': percentiles[2]}
|
||||
|
||||
|
||||
def parse_gametime(lines):
|
||||
"""The game time `time query gametime` printed, or None."""
|
||||
for line in lines:
|
||||
if m := GAMETIME.search(line):
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def tps(start_ticks, end_ticks, seconds):
|
||||
"""Game ticks per wall-clock second over the window, or None when a query went unanswered."""
|
||||
if start_ticks is None or end_ticks is None or seconds <= 0:
|
||||
return None
|
||||
return round((end_ticks - start_ticks) / seconds, 2)
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(p / 100 * len(ordered)))]
|
||||
|
||||
|
||||
def cpu_seconds(text):
|
||||
"""ps's cumulative CPU time: [[DD-]HH:]MM:SS[.ss]."""
|
||||
days, _, rest = text.rpartition('-')
|
||||
seconds = 0.0
|
||||
for part in rest.split(':'):
|
||||
seconds = seconds * 60 + float(part)
|
||||
return seconds + (float(days) * 86400 if days else 0)
|
||||
|
||||
|
||||
def tree_usage(root):
|
||||
"""RSS (bytes) and CPU seconds of a process and all its descendants; None without `ps`."""
|
||||
try:
|
||||
out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], capture_output=True, text=True).stdout
|
||||
except OSError:
|
||||
return None
|
||||
rows = {}
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 4:
|
||||
rows[int(parts[0])] = (int(parts[1]), int(parts[2]) * 1024, cpu_seconds(parts[3]))
|
||||
tree, frontier = {root}, [root]
|
||||
while frontier:
|
||||
parent = frontier.pop()
|
||||
for pid, (ppid, _, _) in rows.items():
|
||||
if ppid == parent and pid not in tree:
|
||||
tree.add(pid)
|
||||
frontier.append(pid)
|
||||
return sum(rows[p][1] for p in tree if p in rows), sum(rows[p][2] for p in tree if p in rows)
|
||||
|
||||
|
||||
def prepare(kind, directory, port, args):
|
||||
"""Writes the server's settings into its scratch folder and returns the command that starts it."""
|
||||
directory = Path(directory)
|
||||
if args.pre_generated:
|
||||
shutil.copytree(args.pre_generated, directory / 'world')
|
||||
props = dict(PROPERTIES, **{'server-port': str(port), 'level-seed': str(args.seed),
|
||||
'view-distance': str(args.view_distance),
|
||||
'simulation-distance': str(args.view_distance)})
|
||||
if kind == 'pumpkin':
|
||||
config = directory / 'config'
|
||||
config.mkdir()
|
||||
(config / 'configuration.toml').write_text(PUMPKIN_CONFIGURATION.format(
|
||||
port=port, seed=args.seed, view=args.view_distance))
|
||||
(config / 'features.toml').write_text(PUMPKIN_FEATURES)
|
||||
if args.pumpkin_config:
|
||||
with (config / 'configuration.toml').open('a') as f:
|
||||
f.write('\n' + Path(args.pumpkin_config).read_text())
|
||||
return [Path(args.exe).resolve()]
|
||||
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
|
||||
(directory / 'eula.txt').write_text('eula=true\n')
|
||||
if kind == 'vanilla':
|
||||
return ['java', f'-Xms{args.heap}', f'-Xmx{args.heap}', '-jar', Path(args.jar).resolve(), 'nogui']
|
||||
server = Path(args.exe).resolve()
|
||||
subprocess.run([server, '--dir', directory, 'import', directory / 'server.properties'], check=True,
|
||||
capture_output=True)
|
||||
toml = directory / 'server.toml'
|
||||
# No web panel in a bench.
|
||||
toml.write_text(re.sub(r'(?m)^bind = "127\.0\.0\.1:8130"', 'bind = ""', toml.read_text()))
|
||||
return [server, '--dir', directory, 'run']
|
||||
|
||||
|
||||
class Server:
|
||||
"""A server with its console on stdin and its output collected."""
|
||||
|
||||
def __init__(self, command, directory, log_path):
|
||||
self.lines = []
|
||||
self.lock = threading.Lock()
|
||||
self.log = log_path.open('w', encoding='utf-8')
|
||||
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'),
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE='1')
|
||||
self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
encoding='utf-8', errors='replace', env=env)
|
||||
threading.Thread(target=self._read, daemon=True).start()
|
||||
|
||||
def _read(self):
|
||||
for line in self.process.stdout:
|
||||
with self.lock:
|
||||
self.lines.append(line.rstrip('\n'))
|
||||
self.log.write(line)
|
||||
self.log.flush()
|
||||
|
||||
def count(self):
|
||||
with self.lock:
|
||||
return len(self.lines)
|
||||
|
||||
def since(self, n):
|
||||
with self.lock:
|
||||
return self.lines[n:]
|
||||
|
||||
def wait_for(self, regex, timeout):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if any(regex.search(line) for line in self.since(0)):
|
||||
return
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError('the server exited:\n' + '\n'.join(self.since(0)[-20:]))
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f'the server was not up within {timeout} s')
|
||||
|
||||
def ask(self, command, parse, timeout=5):
|
||||
"""Sends a console command and returns parse(its output lines), or None if nothing parsed in time."""
|
||||
start = self.count()
|
||||
try:
|
||||
self.process.stdin.write(command + '\n')
|
||||
self.process.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
return None
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if (value := parse(self.since(start))) is not None:
|
||||
return value
|
||||
time.sleep(0.05)
|
||||
return None
|
||||
|
||||
def stop(self):
|
||||
if self.process.poll() is None:
|
||||
try:
|
||||
self.process.stdin.write('stop\n')
|
||||
self.process.stdin.flush()
|
||||
self.process.wait(timeout=60)
|
||||
except (BrokenPipeError, OSError, subprocess.TimeoutExpired):
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
self.log.close()
|
||||
|
||||
|
||||
def bots_command(args, port, total):
|
||||
return [str(args.bots_bin), '--server', f'127.0.0.1:{port}', '--bots', str(args.bots),
|
||||
'--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)),
|
||||
'--seed', str(args.seed), '--view-distance', str(args.view_distance), '--report', '-']
|
||||
|
||||
|
||||
def split_bot_output(lines):
|
||||
"""The per-second samples (JSON lines with a `t`) and the final summary the bots printed."""
|
||||
samples, rest = [], []
|
||||
for line in lines:
|
||||
try:
|
||||
sample = json.loads(line)
|
||||
except ValueError:
|
||||
sample = None
|
||||
if isinstance(sample, dict) and 't' in sample:
|
||||
samples.append(sample)
|
||||
else:
|
||||
rest.append(line)
|
||||
text = ''.join(rest)
|
||||
return samples, (json.loads(text) if text.strip() else {})
|
||||
|
||||
|
||||
def bytes_per_player_second(per_bot, run_seconds, join_every):
|
||||
"""The bots' bytes over the seconds they were connected: bot i connects i * join_every after the first and
|
||||
stays to the end of the run. So it is each bot's average from its login on, its join included, not the
|
||||
measuring window's alone (the bots report one total at the end). None until the bots count bytes (S-6)."""
|
||||
counted = [(i, b) for i, b in enumerate(per_bot) if 'bytes_in' in b and not b.get('error')]
|
||||
online = sum(max(run_seconds - i * join_every, 0) for i, _ in counted)
|
||||
if not counted or online <= 0:
|
||||
return None
|
||||
return {k: round(sum(b.get(k, 0) for _, b in counted) / online) for k in ('bytes_in', 'bytes_out')}
|
||||
|
||||
|
||||
def summarize(kind, args, ticks, rss, cpu, gametime, window_seconds, summary, start_seconds, run_seconds):
|
||||
mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None
|
||||
joins = [b['joined_after_ms'] for b in summary.get('per_bot', []) if b.get('joined_after_ms', 0) > 0]
|
||||
bandwidth = bytes_per_player_second(summary.get('per_bot', []), run_seconds, args.join_every)
|
||||
return {
|
||||
'server': kind,
|
||||
'bots': args.bots,
|
||||
'behaviour': args.behaviour,
|
||||
'start_seconds': start_seconds,
|
||||
'tps': tps(*gametime, window_seconds),
|
||||
'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')},
|
||||
'mspt_worst_p99': max((t['p99'] for t in ticks), default=None),
|
||||
'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None},
|
||||
'cpu_percent': mean(cpu),
|
||||
'bytes_per_player_second': bandwidth,
|
||||
'join_ms': {'p50': percentile(joins, 50), 'worst': max(joins, default=None)},
|
||||
'bots_joined': summary.get('joined'),
|
||||
'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')],
|
||||
'chunks_total': summary.get('chunks'),
|
||||
'tick_queries': len(ticks),
|
||||
}
|
||||
|
||||
|
||||
def run_once(kind, args, out):
|
||||
scratch = Path(tempfile.mkdtemp(prefix=f'horizon-bench-{kind}-'))
|
||||
result = {'server': kind, 'bots': args.bots, 'behaviour': args.behaviour}
|
||||
server = None
|
||||
try:
|
||||
port = free_port()
|
||||
command = prepare(kind, scratch, port, args)
|
||||
started = time.time()
|
||||
server = Server(command, scratch, out / f'{kind}-{args.bots}.log')
|
||||
server.wait_for(DONE, args.start_timeout)
|
||||
start_seconds = round(time.time() - started, 2)
|
||||
total = args.bots * args.join_every + args.warmup + args.seconds
|
||||
bot_process = subprocess.Popen(bots_command(args, port, total), stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True)
|
||||
bot_lines = []
|
||||
threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start()
|
||||
time.sleep(args.bots * args.join_every + args.warmup)
|
||||
first_time = server.ask('time query gametime', parse_gametime)
|
||||
began = time.time()
|
||||
rss, cpu, ticks = [], [], []
|
||||
usage = tree_usage(server.process.pid)
|
||||
last_cpu, last_time = (usage[1] if usage else 0), time.time()
|
||||
next_query = time.time()
|
||||
while time.time() - began < args.seconds:
|
||||
time.sleep(1)
|
||||
now = time.time()
|
||||
if usage and (u := tree_usage(server.process.pid)):
|
||||
rss.append(u[0])
|
||||
cpu.append((u[1] - last_cpu) / (now - last_time) * 100)
|
||||
last_cpu, last_time = u[1], now
|
||||
if now >= next_query:
|
||||
if (t := server.ask('tick query', parse_tick_query)) is not None:
|
||||
ticks.append(t)
|
||||
next_query = now + 5
|
||||
window = time.time() - began
|
||||
last = server.ask('time query gametime', parse_gametime)
|
||||
bot_process.wait(timeout=total + 120)
|
||||
time.sleep(0.5)
|
||||
_, summary = split_bot_output(list(bot_lines))
|
||||
result.update(summarize(kind, args, ticks, rss, cpu, (first_time, last), window, summary, start_seconds,
|
||||
total))
|
||||
except Exception as e: # a failed run is reported, and the next one still runs
|
||||
result['error'] = str(e)
|
||||
finally:
|
||||
if server:
|
||||
server.stop()
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
return result
|
||||
|
||||
|
||||
def table(runs):
|
||||
"""The Markdown table of the design: one column per server, one row per measure."""
|
||||
ok = [r for r in runs if 'error' not in r]
|
||||
head = '| | ' + ' | '.join(r['server'] for r in ok) + ' |\n|---|' + '---|' * len(ok) + '\n'
|
||||
def cell(r, path):
|
||||
value = r
|
||||
for key in path:
|
||||
value = value.get(key) if isinstance(value, dict) else None
|
||||
return '-' if value is None else str(value)
|
||||
rows = [
|
||||
('Players', ('bots',)),
|
||||
('TPS', ('tps',)),
|
||||
('MSPT average', ('mspt', 'average')),
|
||||
('MSPT p99 (mean of 5 s queries)', ('mspt', 'p99')),
|
||||
('MSPT p99 (worst query)', ('mspt_worst_p99',)),
|
||||
('RSS mean MB', ('rss_mb', 'mean')),
|
||||
('CPU %', ('cpu_percent',)),
|
||||
('Bytes in per player and s online', ('bytes_per_player_second', 'bytes_in')),
|
||||
('Join p50 ms', ('join_ms', 'p50')),
|
||||
('Join worst ms', ('join_ms', 'worst')),
|
||||
('Start s', ('start_seconds',)),
|
||||
]
|
||||
body = ''.join(f'| {name} | ' + ' | '.join(cell(r, path) for r in ok) + ' |\n' for name, path in rows)
|
||||
failed = ''.join(f'\n{r["server"]} failed: {r["error"].splitlines()[0]}\n' for r in runs if 'error' in r)
|
||||
return head + body + failed
|
||||
|
||||
|
||||
def check(args):
|
||||
for kind in args.server:
|
||||
if kind not in SERVERS:
|
||||
sys.exit(f'--server: {kind!r} is not one of {", ".join(SERVERS)}')
|
||||
if 'vanilla' in args.server and not args.jar:
|
||||
sys.exit('vanilla needs --jar, your own server.jar')
|
||||
if any(k != 'vanilla' for k in args.server) and not args.exe:
|
||||
sys.exit('horizon and pumpkin need --exe, the server binary')
|
||||
if not Path(args.bots_bin).exists():
|
||||
sys.exit(f'{args.bots_bin} is missing: cargo build --release -p horizon-bots')
|
||||
if args.pre_generated and not Path(args.pre_generated).is_dir():
|
||||
sys.exit(f'--pre-generated {args.pre_generated} is not a folder')
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
|
||||
parser.add_argument('--server', type=lambda s: s.split(','), default=['horizon'],
|
||||
help='horizon, vanilla or pumpkin (comma-separated, one run each)')
|
||||
parser.add_argument('--exe', help='the horizon-server or Pumpkin binary')
|
||||
parser.add_argument('--jar', help="vanilla's server.jar (your own copy)")
|
||||
parser.add_argument('--bots-bin', default=str(ROOT / 'target/release' / f'horizon-bots{EXE}'))
|
||||
parser.add_argument('--bots', type=int, default=50)
|
||||
parser.add_argument('--behaviour', default='mixed')
|
||||
parser.add_argument('--join-every', type=float, default=0.5)
|
||||
parser.add_argument('--warmup', type=int, default=30)
|
||||
parser.add_argument('--seconds', type=int, default=60)
|
||||
parser.add_argument('--seed', type=int, default=12345)
|
||||
parser.add_argument('--view-distance', type=int, default=10)
|
||||
parser.add_argument('--heap', default='4g', help='the Java heap for vanilla')
|
||||
parser.add_argument('--pre-generated', help='a finished world folder, copied into every run as `world`')
|
||||
parser.add_argument('--pumpkin-config', help='extra TOML appended to Pumpkin\'s configuration.toml')
|
||||
parser.add_argument('--start-timeout', type=int, default=600)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / '.local/bench', help='the folder for report.json and logs')
|
||||
args = parser.parse_args(argv)
|
||||
check(args)
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
runs = []
|
||||
for kind in args.server:
|
||||
print(f'{kind}, {args.bots} {args.behaviour} bots...', flush=True)
|
||||
runs.append(run_once(kind, args, args.out))
|
||||
print(json.dumps(runs[-1]), flush=True)
|
||||
report = {
|
||||
'kind': 'horizon-bench',
|
||||
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
|
||||
'machine': {'system': platform.system(), 'machine': platform.machine(), 'cpus': os.cpu_count()},
|
||||
'settings': {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
|
||||
'runs': runs,
|
||||
}
|
||||
path = args.out / 'report.json'
|
||||
path.write_text(json.dumps(report, indent=2) + '\n')
|
||||
print('\n' + table(runs) + f'\nWrote {path}')
|
||||
return 1 if any('error' in r for r in runs) else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -3,8 +3,8 @@ name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
|
||||
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks and items to your worlds".
|
||||
# registries: "May add blocks, items and mob effects to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod prediction;
|
||||
pub mod timer;
|
||||
|
||||
/// What one check's violation level leads to.
|
||||
@@ -131,6 +132,20 @@ checks! {
|
||||
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back.
|
||||
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
|
||||
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
|
||||
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
|
||||
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
|
||||
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
|
||||
/// until the moves agree again.
|
||||
prediction: Prediction => PredictionConfig [5.0, 15.0, 60.0, 2.0] {
|
||||
/// How far, in blocks, a move may land from where the replay puts it.
|
||||
tolerance: f64 = 0.03,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
@@ -171,6 +186,10 @@ impl Config {
|
||||
));
|
||||
}
|
||||
}
|
||||
let tolerance = self.prediction.tolerance;
|
||||
if !tolerance.is_finite() || tolerance <= 0.0 {
|
||||
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -198,6 +217,8 @@ pub struct Player {
|
||||
pub timer: timer::Balance,
|
||||
/// The server tick and height where the ground last held the player.
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
@@ -257,6 +278,8 @@ mod tests {
|
||||
assert_eq!(c.timer.kick, 0.0);
|
||||
assert_eq!(c.timer.tolerance_ms, 500.0);
|
||||
assert_eq!(c.timer.alert, TimerConfig::default().alert);
|
||||
let zero = "[anticheat]\n[anticheat.prediction]\ntolerance = 0\n";
|
||||
assert!(Config::from_server_toml(zero).is_err());
|
||||
let off = "[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n";
|
||||
assert!(!Config::from_server_toml(off).unwrap().runs(Check::Timer));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The movement prediction's record: each of a player's client ticks as the server heard them, so
|
||||
//! the server can play the tick again with vanilla's movement code and compare (docs/anticheat.md).
|
||||
//!
|
||||
//! A vanilla client ends every tick with ServerboundClientTickEndPacket, after the tick's input
|
||||
//! (ServerboundPlayerInputPacket, sent when the keys change) and its move (ServerboundMovePlayerPacket,
|
||||
//! when it moved or turned). Those packets arrive in order, so the packets between two tick ends are one
|
||||
//! client tick.
|
||||
//!
|
||||
//! What the server changes about the player (a push, a teleport, an effect) takes effect on the client
|
||||
//! when it handles the packet, which is some client ticks after the server sent it. At the end of each of
|
||||
//! its ticks the server sends a ClientboundPingPacket whose id is that server tick; the client answers
|
||||
//! with ServerboundPongPacket as soon as it handles it, before its next tick runs. A client tick knows
|
||||
//! the last server tick it had handled: every change of that server tick or an earlier one was in effect
|
||||
//! during the client tick, none of a later one.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Client ticks kept: two seconds at 20 a second.
|
||||
pub const KEPT: usize = 40;
|
||||
|
||||
/// Server changes kept, the oldest dropped first.
|
||||
const CHANGES_KEPT: usize = 64;
|
||||
|
||||
/// Changes near the player kept until every kept tick has seen them.
|
||||
const NEAR_KEPT: usize = 1024;
|
||||
|
||||
/// Something the server sent the player that changes how its client moves. The client applies it when
|
||||
/// it handles the packet, before its next tick.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Change {
|
||||
/// ClientboundSetEntityMotionPacket for the player itself: its new velocity, as the client
|
||||
/// decodes it.
|
||||
Velocity([f64; 3]),
|
||||
/// ClientboundExplodePacket's player knockback, added to the velocity.
|
||||
Knockback([f64; 3]),
|
||||
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
|
||||
/// attributes and effects, as they are when the replay reaches it.
|
||||
State,
|
||||
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
|
||||
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
|
||||
/// tick played before the client had them may be off.
|
||||
Abilities,
|
||||
}
|
||||
|
||||
/// A teleport the client accepted: the replay starts again from it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Restart {
|
||||
/// Where it put the player.
|
||||
pub at: [f64; 3],
|
||||
/// It kept the player's motion (an End gateway's).
|
||||
pub kept_motion: bool,
|
||||
/// Client ticks ended and changes heard before it: the ones after are the replay's.
|
||||
pub ended: u64,
|
||||
pub changed: u64,
|
||||
}
|
||||
|
||||
/// One client tick.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ClientTick {
|
||||
/// The keys held (ServerboundPlayerInputPacket's flags: forward, backward, left, right, jump,
|
||||
/// shift, sprint), as last sent.
|
||||
pub input: u8,
|
||||
/// Sprinting, as the player's commands left it (ServerboundPlayerCommandPacket).
|
||||
pub sprinting: bool,
|
||||
/// The rotation the client last sent.
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
/// Where the tick's move said the player is, if it said: a move with a position.
|
||||
pub position: Option<[f64; 3]>,
|
||||
/// The tick's move packet's flags (1: on the ground, 2: against a wall), if it sent one.
|
||||
pub flags: Option<u8>,
|
||||
/// The last server tick whose packets the client had handled (its pong) before this tick ended.
|
||||
pub seen: Option<u64>,
|
||||
}
|
||||
|
||||
/// A player's last client ticks.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Record {
|
||||
/// The tick being heard.
|
||||
current: ClientTick,
|
||||
/// The ended ones, oldest first, at most [`KEPT`].
|
||||
ticks: VecDeque<ClientTick>,
|
||||
/// The last server tick the client answered for.
|
||||
seen: Option<u64>,
|
||||
/// Client ticks ended since the record began.
|
||||
ended: u64,
|
||||
/// Where an accepted teleport put the player, until the replay starts again from there.
|
||||
restart: Option<Restart>,
|
||||
/// What the server changed, with the server tick that sent it, oldest first.
|
||||
changes: VecDeque<(u64, Change)>,
|
||||
/// Changes heard since the record began.
|
||||
changed: u64,
|
||||
/// The blocks the server changed near the player (None: an entity it stopped showing the player
|
||||
/// from near it, which counts wherever the player has gone since), with the server tick that sent
|
||||
/// each, until every kept tick has seen them.
|
||||
near: VecDeque<(u64, Option<[i32; 3]>)>,
|
||||
/// The server tick of the last of them dropped early ([`NEAR_KEPT`] came after it).
|
||||
near_dropped: Option<u64>,
|
||||
}
|
||||
|
||||
impl Record {
|
||||
/// The tick's move: its position if it has one, the rotation if it has one, and its flags.
|
||||
pub fn moved(&mut self, position: Option<[f64; 3]>, rotation: Option<(f32, f32)>, flags: u8) {
|
||||
if position.is_some() {
|
||||
self.current.position = position;
|
||||
}
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
(self.current.yaw, self.current.pitch) = (yaw, pitch);
|
||||
}
|
||||
self.current.flags = Some(flags);
|
||||
}
|
||||
|
||||
/// ServerboundPongPacket for the ping sent at the end of `server_tick`. False for an answer to a
|
||||
/// ping that was never sent, or one older than an answer already heard: a vanilla client answers
|
||||
/// every ping, once, in order.
|
||||
pub fn pong(&mut self, server_tick: u64, sent_up_to: u64) -> bool {
|
||||
if server_tick > sent_up_to || self.seen.is_some_and(|seen| server_tick <= seen) {
|
||||
return false;
|
||||
}
|
||||
self.seen = Some(server_tick);
|
||||
true
|
||||
}
|
||||
|
||||
/// ServerboundClientTickEndPacket: the tick ends with these keys held and this sprinting. The
|
||||
/// next tick starts with the same keys and rotation, until the client says otherwise.
|
||||
pub fn tick_end(&mut self, input: u8, sprinting: bool) -> &ClientTick {
|
||||
self.current.input = input;
|
||||
self.current.sprinting = sprinting;
|
||||
self.current.seen = self.seen;
|
||||
let next = ClientTick {
|
||||
yaw: self.current.yaw,
|
||||
pitch: self.current.pitch,
|
||||
..ClientTick::default()
|
||||
};
|
||||
let ended = std::mem::replace(&mut self.current, next);
|
||||
if self.ticks.len() == KEPT {
|
||||
self.ticks.pop_front();
|
||||
}
|
||||
self.ticks.push_back(ended);
|
||||
self.ended += 1;
|
||||
// What the oldest tick kept had seen, every later one had too.
|
||||
if let Some(seen) = self.ticks.front().and_then(|t| t.seen) {
|
||||
while self.near.front().is_some_and(|&(sent, _)| sent <= seen) {
|
||||
self.near.pop_front();
|
||||
}
|
||||
self.near_dropped = self.near_dropped.filter(|&sent| sent > seen);
|
||||
}
|
||||
self.ticks.back().expect("just pushed")
|
||||
}
|
||||
|
||||
/// The ended ticks, oldest first.
|
||||
pub fn ticks(&self) -> impl DoubleEndedIterator<Item = &ClientTick> + ExactSizeIterator {
|
||||
self.ticks.iter()
|
||||
}
|
||||
|
||||
/// The last server tick the client answered for.
|
||||
pub fn seen(&self) -> Option<u64> {
|
||||
self.seen
|
||||
}
|
||||
|
||||
/// Client ticks ended since the record began.
|
||||
pub fn ended(&self) -> u64 {
|
||||
self.ended
|
||||
}
|
||||
|
||||
/// The ticks ended after the first `played`, oldest first; None when some of them are no longer
|
||||
/// kept.
|
||||
pub fn since(&self, played: u64) -> Option<impl Iterator<Item = &ClientTick>> {
|
||||
let new = self.ended.checked_sub(played)?;
|
||||
let new = usize::try_from(new)
|
||||
.ok()
|
||||
.filter(|&n| n <= self.ticks.len())?;
|
||||
Some(self.ticks.iter().skip(self.ticks.len() - new))
|
||||
}
|
||||
|
||||
/// `change`, sent during server tick `server_tick` (before that tick's ping).
|
||||
pub fn changed(&mut self, server_tick: u64, change: Change) {
|
||||
if self.changes.len() == CHANGES_KEPT {
|
||||
self.changes.pop_front();
|
||||
}
|
||||
self.changes.push_back((server_tick, change));
|
||||
self.changed += 1;
|
||||
}
|
||||
|
||||
/// Changes heard since the record began.
|
||||
pub fn changes(&self) -> u64 {
|
||||
self.changed
|
||||
}
|
||||
|
||||
/// The changes after the first `applied`, oldest first; None when some of them are no longer kept.
|
||||
pub fn changes_since(
|
||||
&self,
|
||||
applied: u64,
|
||||
) -> Option<impl Iterator<Item = &(u64, Change)> + Clone> {
|
||||
let new = self.changed.checked_sub(applied)?;
|
||||
let new = usize::try_from(new)
|
||||
.ok()
|
||||
.filter(|&n| n <= self.changes.len())?;
|
||||
Some(self.changes.iter().skip(self.changes.len() - new))
|
||||
}
|
||||
|
||||
/// During server tick `server_tick` (before that tick's ping) the server sent the client the block
|
||||
/// at `pos`.
|
||||
pub fn changed_near(&mut self, server_tick: u64, pos: [i32; 3]) {
|
||||
self.note_near(server_tick, Some(pos));
|
||||
}
|
||||
|
||||
/// During server tick `server_tick` the server stopped showing the client an entity near it (a
|
||||
/// firework it flew with, a mob that pushed it): the client may still have it, wherever it goes.
|
||||
pub fn entity_gone(&mut self, server_tick: u64) {
|
||||
if self.near.back() != Some(&(server_tick, None)) {
|
||||
self.note_near(server_tick, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn note_near(&mut self, server_tick: u64, pos: Option<[i32; 3]>) {
|
||||
if self.near.len() == NEAR_KEPT {
|
||||
self.near_dropped = self.near.pop_front().map(|(sent, _)| sent);
|
||||
}
|
||||
self.near.push_back((server_tick, pos));
|
||||
}
|
||||
|
||||
/// Whether a client tick that had `seen` up to that server tick (its [`ClientTick::seen`]) may
|
||||
/// not have had a change at a position for which `near` holds: it still had the old block, or the
|
||||
/// entity, where the server no longer has it.
|
||||
pub fn unseen_near(&self, seen: Option<u64>, near: impl Fn([i32; 3]) -> bool) -> bool {
|
||||
let unseen = |sent: u64| seen.is_none_or(|seen| sent > seen);
|
||||
self.near_dropped.is_some_and(unseen)
|
||||
|| self
|
||||
.near
|
||||
.iter()
|
||||
.any(|&(sent, pos)| unseen(sent) && pos.is_none_or(&near))
|
||||
}
|
||||
|
||||
/// The client accepted a teleport to `at` (ServerboundAcceptTeleportationPacket): it goes on from
|
||||
/// there, standing still unless the teleport `kept_motion` (an End gateway's). The ticks it ended
|
||||
/// before stay, for a replay that hasn't played them yet; the one under way starts again.
|
||||
pub fn teleported(&mut self, at: [f64; 3], kept_motion: bool) {
|
||||
self.restart_tick();
|
||||
self.restart = Some(Restart {
|
||||
at,
|
||||
kept_motion,
|
||||
ended: self.ended,
|
||||
changed: self.changed,
|
||||
});
|
||||
}
|
||||
|
||||
/// The last teleport the client accepted, once.
|
||||
pub fn take_restart(&mut self) -> Option<Restart> {
|
||||
self.restart.take()
|
||||
}
|
||||
|
||||
/// The ticks before now say nothing about the ones after.
|
||||
pub fn clear(&mut self) {
|
||||
self.ticks.clear();
|
||||
self.restart_tick();
|
||||
}
|
||||
|
||||
fn restart_tick(&mut self) {
|
||||
self.current = ClientTick {
|
||||
yaw: self.current.yaw,
|
||||
pitch: self.current.pitch,
|
||||
..ClientTick::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_change_near_is_unseen_by_the_ticks_before_its_pong() {
|
||||
let mut r = Record::default();
|
||||
r.changed_near(4, [1, 2, 3]);
|
||||
r.changed_near(5, [7, 2, 3]);
|
||||
let near = |p: [i32; 3]| p[0] < 5;
|
||||
assert!(r.unseen_near(None, near));
|
||||
assert!(r.unseen_near(Some(3), near));
|
||||
assert!(!r.unseen_near(Some(4), near));
|
||||
assert!(r.unseen_near(Some(4), |_| true));
|
||||
// Kept until the oldest tick kept had seen it: one that ended before the pong hadn't.
|
||||
r.tick_end(0, false);
|
||||
assert!(r.pong(5, 5));
|
||||
r.tick_end(0, false);
|
||||
assert!(r.unseen_near(Some(3), near));
|
||||
for _ in 0..KEPT {
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
assert!(!r.unseen_near(None, |_| true));
|
||||
// An entity gone counts anywhere.
|
||||
r.entity_gone(5);
|
||||
r.entity_gone(5);
|
||||
assert!(r.unseen_near(Some(4), |_| false));
|
||||
assert!(!r.unseen_near(Some(5), |_| false));
|
||||
// One dropped early counts for the ticks that hadn't seen it.
|
||||
for i in 0..=NEAR_KEPT as u64 {
|
||||
r.changed_near(6 + i, [100, 0, 0]);
|
||||
}
|
||||
assert!(
|
||||
r.unseen_near(Some(5), near),
|
||||
"a change dropped unseen counts"
|
||||
);
|
||||
assert!(!r.unseen_near(Some(6), near));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_is_the_packets_between_two_tick_ends() {
|
||||
let mut r = Record::default();
|
||||
r.moved(Some([1.0, 2.0, 3.0]), Some((90.0, 10.0)), 1);
|
||||
let t = *r.tick_end(0b1001, true);
|
||||
assert_eq!(t.position, Some([1.0, 2.0, 3.0]));
|
||||
assert_eq!((t.yaw, t.pitch, t.flags), (90.0, 10.0, Some(1)));
|
||||
assert_eq!((t.input, t.sprinting, t.seen), (0b1001, true, None));
|
||||
// A tick without a move keeps the rotation and has no position.
|
||||
let t = *r.tick_end(0, false);
|
||||
assert_eq!((t.position, t.flags, t.yaw), (None, None, 90.0));
|
||||
assert_eq!(r.ticks().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pongs_say_which_server_ticks_a_client_tick_had_seen() {
|
||||
let mut r = Record::default();
|
||||
assert!(r.pong(5, 6));
|
||||
r.tick_end(0, false);
|
||||
assert!(r.pong(6, 6));
|
||||
r.tick_end(0, false);
|
||||
r.tick_end(0, false);
|
||||
let seen: Vec<_> = r.ticks().map(|t| t.seen).collect();
|
||||
assert_eq!(seen, [Some(5), Some(6), Some(6)]);
|
||||
// Never sent, answered twice, or out of order.
|
||||
assert!(!r.pong(9, 6));
|
||||
assert!(!r.pong(6, 6));
|
||||
assert!(!r.pong(4, 6));
|
||||
assert_eq!(r.seen(), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_two_seconds_are_kept() {
|
||||
let mut r = Record::default();
|
||||
for i in 0..KEPT + 5 {
|
||||
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
assert_eq!(r.ticks().len(), KEPT);
|
||||
assert_eq!(r.ticks().next().unwrap().position, Some([5.0, 0.0, 0.0]));
|
||||
r.clear();
|
||||
assert_eq!(r.ticks().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_replay_takes_the_ticks_it_has_not_played() {
|
||||
let mut r = Record::default();
|
||||
for i in 0..3 {
|
||||
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
fn x(r: &Record, played: u64) -> Option<Vec<f64>> {
|
||||
r.since(played)
|
||||
.map(|t| t.map(|t| t.position.unwrap()[0]).collect())
|
||||
}
|
||||
assert_eq!(x(&r, 1), Some(vec![1.0, 2.0]));
|
||||
assert_eq!(x(&r, 3), Some(vec![]));
|
||||
// Played ahead of the record, or so far behind the ticks are gone.
|
||||
assert_eq!(x(&r, 4), None);
|
||||
r.clear();
|
||||
assert_eq!(x(&r, 2), None);
|
||||
assert_eq!(x(&r, 3), Some(vec![]));
|
||||
r.moved(Some([8.0, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
r.moved(Some([9.0, 0.0, 0.0]), None, 0);
|
||||
r.teleported([5.0, 6.0, 7.0], false);
|
||||
// A tick that ends before the replay starts again is still one after the teleport.
|
||||
r.tick_end(0, false);
|
||||
let restart = r.take_restart().unwrap();
|
||||
assert_eq!((restart.at, restart.kept_motion), ([5.0, 6.0, 7.0], false));
|
||||
assert_eq!(r.since(restart.ended).unwrap().count(), 1);
|
||||
// The tick ended before it stays for a replay that hasn't played it; the move of the one
|
||||
// under way is gone.
|
||||
let positions: Vec<_> = r.since(3).unwrap().map(|t| t.position).collect();
|
||||
assert_eq!(positions, [Some([8.0, 0.0, 0.0]), None]);
|
||||
assert_eq!(r.take_restart(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_keep_the_server_tick_that_sent_them() {
|
||||
let mut r = Record::default();
|
||||
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
|
||||
r.changed(5, Change::State);
|
||||
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
|
||||
assert_eq!(
|
||||
all,
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
|
||||
);
|
||||
assert_eq!(r.changes_since(1).unwrap().count(), 1);
|
||||
assert!(r.changes_since(3).is_none());
|
||||
for i in 0..CHANGES_KEPT as u64 {
|
||||
r.changed(6 + i, Change::State);
|
||||
}
|
||||
// The first two are gone.
|
||||
assert!(r.changes_since(1).is_none());
|
||||
assert_eq!(r.changes_since(2).unwrap().count(), CHANGES_KEPT);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,13 @@
|
||||
//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the
|
||||
//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly").
|
||||
|
||||
//! The report also has wire bytes in and out (`bytes_in`, `bytes_out`, per bot and in total, after
|
||||
//! compression) and join-time percentiles (`join_time`: p50, p99 and max in milliseconds).
|
||||
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION,
|
||||
buf::{Reader, Writer},
|
||||
connection::{ConnWriter, Connection},
|
||||
connection::{ConnWriter, Connection, Counters},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
item::Stack,
|
||||
@@ -33,7 +36,7 @@ use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, LazyLock, Mutex,
|
||||
Arc, LazyLock, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -81,6 +84,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
registry id, count), to test what a server takes
|
||||
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
|
||||
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
|
||||
--raw TICK:ID:HEX,... raw serverbound play packets (id, body in hex) sent TICK ticks after joining,
|
||||
to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
";
|
||||
@@ -142,6 +147,8 @@ struct Options {
|
||||
no_loaded: bool,
|
||||
height: f64,
|
||||
ground: bool,
|
||||
/// --raw: (tick after joining, packet id, body).
|
||||
raw: Vec<(u64, i32, Vec<u8>)>,
|
||||
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
|
||||
give: Vec<(i16, i32, i32)>,
|
||||
/// --click: the block to open, then the slot, button and click type in its window.
|
||||
@@ -172,6 +179,7 @@ fn options() -> Result<Options, String> {
|
||||
no_loaded: false,
|
||||
height: 24.0,
|
||||
ground: false,
|
||||
raw: Vec::new(),
|
||||
give: Vec::new(),
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
@@ -246,6 +254,27 @@ fn options() -> Result<Options, String> {
|
||||
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
|
||||
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
|
||||
}
|
||||
"--raw" => {
|
||||
for part in value.split(',') {
|
||||
let bad = || number("TICK:ID:HEX");
|
||||
let [tick, id, hex] = part.split(':').collect::<Vec<_>>()[..] else {
|
||||
return Err(bad());
|
||||
};
|
||||
let body = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
hex.get(i..i + 2)
|
||||
.and_then(|b| u8::from_str_radix(b, 16).ok())
|
||||
})
|
||||
.collect::<Option<Vec<u8>>>()
|
||||
.ok_or_else(bad)?;
|
||||
o.raw.push((
|
||||
tick.parse().map_err(|_| bad())?,
|
||||
id.parse().map_err(|_| bad())?,
|
||||
body,
|
||||
));
|
||||
}
|
||||
}
|
||||
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
|
||||
"--timer" => {
|
||||
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
|
||||
@@ -302,6 +331,8 @@ struct Bot {
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The connection's byte counters (wire bytes, after compression and encryption), set once it is open.
|
||||
counters: OnceLock<Arc<Counters>>,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
|
||||
@@ -311,6 +342,39 @@ struct Bot {
|
||||
health: Mutex<Option<f32>>,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
fn bytes_in(&self) -> u64 {
|
||||
self.counters
|
||||
.get()
|
||||
.map_or(0, |c| c.bytes_in.load(Ordering::Relaxed).max(0) as u64)
|
||||
}
|
||||
fn bytes_out(&self) -> u64 {
|
||||
self.counters
|
||||
.get()
|
||||
.map_or(0, |c| c.bytes_out.load(Ordering::Relaxed).max(0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest-rank percentile (`p` in 0..=100) of an ascending-sorted slice; 0 when it is empty.
|
||||
fn percentile(sorted: &[u64], p: u64) -> u64 {
|
||||
if sorted.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let rank = (p * sorted.len() as u64).div_ceil(100).max(1) as usize;
|
||||
sorted[rank - 1]
|
||||
}
|
||||
|
||||
/// Join-time summary in milliseconds over the bots that got in: count, p50, p99 and max.
|
||||
fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
times.sort_unstable();
|
||||
json!({
|
||||
"count": times.len(),
|
||||
"p50_ms": percentile(×, 50),
|
||||
"p99_ms": percentile(×, 99),
|
||||
"max_ms": times.last().copied().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -736,6 +800,7 @@ fn run_bot(
|
||||
}
|
||||
};
|
||||
let (reader, writer) = connection.split();
|
||||
let _ = bot.counters.set(reader.counters.clone());
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
@@ -935,6 +1000,11 @@ fn run_bot(
|
||||
);
|
||||
}
|
||||
}
|
||||
for (at, id, body) in &o.raw {
|
||||
if *at == tick {
|
||||
send(raw(*id, |w| w.bytes(body)));
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
@@ -1083,10 +1153,20 @@ fn main() {
|
||||
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
|
||||
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
|
||||
"join_time": join_summary(
|
||||
bots.iter()
|
||||
.map(|b| b.joined_after_ms.load(Ordering::SeqCst))
|
||||
.filter(|&ms| ms > 0)
|
||||
.collect(),
|
||||
),
|
||||
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
|
||||
"name": format!("{}{i}", o.name),
|
||||
"behaviour": behaviour_of(&o.behaviour, i).name(),
|
||||
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
|
||||
"bytes_in": b.bytes_in(),
|
||||
"bytes_out": b.bytes_out(),
|
||||
"chunks": b.chunks.load(Ordering::Relaxed),
|
||||
"unique_chunks": b.unique.load(Ordering::Relaxed),
|
||||
"resent": b.resent.load(Ordering::Relaxed),
|
||||
@@ -1121,3 +1201,39 @@ fn main() {
|
||||
}
|
||||
std::process::exit(if errors.is_empty() { 0 } else { 1 });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percentiles_use_nearest_rank() {
|
||||
assert_eq!(percentile(&[], 50), 0);
|
||||
assert_eq!(percentile(&[7], 99), 7);
|
||||
let v: Vec<u64> = (1..=100).collect();
|
||||
assert_eq!(percentile(&v, 50), 50);
|
||||
assert_eq!(percentile(&v, 99), 99);
|
||||
assert_eq!(percentile(&[10, 20, 30, 40], 50), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_summary_sorts_and_reports_max() {
|
||||
let s = join_summary(vec![300, 100, 200]);
|
||||
assert_eq!(s["count"], 3);
|
||||
assert_eq!(s["p50_ms"], 200);
|
||||
assert_eq!(s["p99_ms"], 300);
|
||||
assert_eq!(s["max_ms"], 300);
|
||||
assert_eq!(join_summary(vec![])["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_bytes_come_from_the_connection_counters() {
|
||||
let bot = Bot::default();
|
||||
assert_eq!((bot.bytes_in(), bot.bytes_out()), (0, 0));
|
||||
let counters = Arc::new(Counters::default());
|
||||
counters.bytes_in.store(1234, Ordering::Relaxed);
|
||||
counters.bytes_out.store(56, Ordering::Relaxed);
|
||||
let _ = bot.counters.set(counters);
|
||||
assert_eq!((bot.bytes_in(), bot.bytes_out()), (1234, 56));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,73 @@ impl Parser<'_> {
|
||||
Err("unterminated string".into())
|
||||
}
|
||||
}
|
||||
/// `click_event` / `hover_event`, or the pre-datafixer `clickEvent` /
|
||||
/// `hoverEvent` names SNBT and older chat JSON still use.
|
||||
fn style_event_object<'a>(
|
||||
o: &'a serde_json::Map<String, Value>,
|
||||
modern: &str,
|
||||
legacy: &str,
|
||||
) -> Option<&'a serde_json::Map<String, Value>> {
|
||||
o.get(modern)
|
||||
.or_else(|| o.get(legacy))
|
||||
.and_then(Value::as_object)
|
||||
}
|
||||
|
||||
/// Style flags from JSON bools, or SNBT `1b`/`0b` which parse as numbers.
|
||||
fn style_flag(v: Option<&Value>) -> Option<bool> {
|
||||
match v {
|
||||
Some(Value::Bool(b)) => Some(*b),
|
||||
Some(Value::Number(n)) => n.as_i64().map(|i| i != 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn untrusted_http_url(url: &str) -> bool {
|
||||
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
|
||||
// https only, scheme case-insensitive.
|
||||
url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
}
|
||||
|
||||
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// renames (`url` / `command` / `page`).
|
||||
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
let legacy = || text("value");
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("open_url") => text("url")
|
||||
.or_else(legacy)
|
||||
.filter(|u| untrusted_http_url(u))
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| e.get("value").and_then(Value::as_i64))
|
||||
.or_else(|| {
|
||||
e.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse().ok())
|
||||
})
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command")
|
||||
.or_else(legacy)
|
||||
.map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command")
|
||||
.or_else(legacy)
|
||||
.map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => text("value").map(crate::text::ClickEvent::CopyToClipboard),
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The style fields of a component object.
|
||||
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
let mut style = Style::default();
|
||||
@@ -196,52 +263,27 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
});
|
||||
}
|
||||
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.bold = style_flag(o.get("bold"));
|
||||
style.italic = style_flag(o.get("italic"));
|
||||
style.underlined = style_flag(o.get("underlined"));
|
||||
style.strikethrough = style_flag(o.get("strikethrough"));
|
||||
style.obfuscated = style_flag(o.get("obfuscated"));
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
// ClickEvent.CODEC: the actions a chat line acts on here. A component
|
||||
// from a server never opens a file.
|
||||
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
|
||||
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
|
||||
Some("open_url") => text("url")
|
||||
.filter(|u| {
|
||||
u.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
})
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(e) = style_event_object(o, "click_event", "clickEvent") {
|
||||
style.click = parse_click_event(e);
|
||||
}
|
||||
// HoverEvent.ShowText, ShowItem and ShowEntity.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
if let Some(e) = style_event_object(o, "hover_event", "hoverEvent") {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
if let Some(value) = e.get("value") {
|
||||
// Modern `value`, or the older `contents` / nested `text`.
|
||||
let value = e
|
||||
.get("value")
|
||||
.or_else(|| e.get("contents"))
|
||||
.or_else(|| e.get("text"));
|
||||
if let Some(value) = value {
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
}
|
||||
}
|
||||
@@ -506,10 +548,43 @@ mod tests {
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.plain(), "Hi there");
|
||||
assert_eq!(t.spans[1].style.color, named_color("red"));
|
||||
// SNBT `1b` is a number in JSON; style flags still take it as true.
|
||||
assert_eq!(t.spans[1].style.bold, Some(true));
|
||||
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
|
||||
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_click_event_open_url_uses_value_field() {
|
||||
use crate::text::ClickEvent;
|
||||
let modern = serde_json::json!({
|
||||
"text": "site",
|
||||
"click_event": {"action": "open_url", "url": "https://example.com"},
|
||||
"underlined": true
|
||||
});
|
||||
let legacy = serde_json::json!({
|
||||
"text": "site",
|
||||
"clickEvent": {"action": "open_url", "value": "https://example.com"},
|
||||
"underlined": 1
|
||||
});
|
||||
for component in [modern, legacy] {
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
assert_eq!(
|
||||
text.spans[0].style.click,
|
||||
Some(ClickEvent::OpenUrl("https://example.com".into()))
|
||||
);
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
let snbt = r#"{text:"site",underlined:1b,clickEvent:{action:open_url,value:"https://example.com"}}"#;
|
||||
let (v, _) = parse_snbt(snbt).unwrap();
|
||||
let text = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(
|
||||
text.spans[0].style.click,
|
||||
Some(ClickEvent::OpenUrl("https://example.com".into()))
|
||||
);
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
|
||||
@@ -66,6 +66,15 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -287,6 +296,13 @@ mod tests {
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Teclado en pantalla")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,8 @@ pub enum Category {
|
||||
Harmful,
|
||||
Neutral,
|
||||
}
|
||||
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
|
||||
/// A `MobEffect`: id (vanilla's without namespace, a mod's `<ns>:<path>`),
|
||||
/// colour, category, instantaneous.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EffectDef {
|
||||
pub id: &'static str,
|
||||
@@ -84,11 +85,52 @@ pub const EFFECTS: &[EffectDef] = &[
|
||||
fx("infested", 9214860, H),
|
||||
fx("breath_of_the_nautilus", 65518, B),
|
||||
];
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
EFFECTS.iter().find(|e| e.id == id)
|
||||
/// The effects content mods declare, registered after vanilla's.
|
||||
static MOD_EFFECTS: OnceLock<Vec<EffectDef>> = OnceLock::new();
|
||||
/// Registers the content mods' effects, `<ns>:<path>` ids in registry order.
|
||||
/// The list is set once per process: false when it already was, and the
|
||||
/// effects stay those of the first call until the game restarts.
|
||||
pub fn register_mod_effects(effects: impl IntoIterator<Item = (String, u32, Category)>) -> bool {
|
||||
if MOD_EFFECTS.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let defs: Vec<EffectDef> = effects
|
||||
.into_iter()
|
||||
.map(|(id, color, category)| {
|
||||
debug_assert!(id.contains(':'), "{id} has no namespace");
|
||||
fx(Box::leak(id.into_boxed_str()), color, category)
|
||||
})
|
||||
.collect();
|
||||
MOD_EFFECTS.set(defs).is_ok()
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
/// The content mods' effects, in registry order.
|
||||
pub fn mod_effects() -> &'static [EffectDef] {
|
||||
MOD_EFFECTS.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
/// Every registered effect: vanilla's, then the content mods'.
|
||||
pub fn all_effects() -> impl Iterator<Item = &'static EffectDef> {
|
||||
EFFECTS.iter().chain(mod_effects())
|
||||
}
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let short = id.trim_start_matches("minecraft:");
|
||||
match EFFECTS.iter().find(|e| e.id == short) {
|
||||
Some(e) => Some(e),
|
||||
None => mod_effect(id),
|
||||
}
|
||||
}
|
||||
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
fn mod_effect(id: &str) -> Option<&'static EffectDef> {
|
||||
mod_effects().iter().find(|e| e.id == id)
|
||||
}
|
||||
/// `MobEffect.getDescriptionId` (`Util.makeDescriptionId("effect", key)`):
|
||||
/// `effect.<namespace>.<path>`, `/` in the path as `.`.
|
||||
pub fn description_id(id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
format!("effect.{namespace}.{}", path.replace('/', "."))
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage; a mod's id already is.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let key = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
@@ -248,3 +290,40 @@ pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
_ => BASE_POTION_COLOR,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn description_ids_keep_the_namespace() {
|
||||
assert_eq!(description_id("speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
|
||||
}
|
||||
|
||||
/// The only test that registers: the registry is set once per process.
|
||||
#[test]
|
||||
fn mod_effects_follow_vanilla_once_registered() {
|
||||
assert!(register_mod_effects([(
|
||||
"ruby:glow".to_owned(),
|
||||
0xFF0000,
|
||||
Category::Neutral
|
||||
)]));
|
||||
assert!(!register_mod_effects([(
|
||||
"ruby:late".to_owned(),
|
||||
1,
|
||||
Category::Harmful
|
||||
)]));
|
||||
let glow = effect("ruby:glow").unwrap();
|
||||
assert_eq!(
|
||||
(glow.color, glow.category, glow.instant),
|
||||
(0xFF0000, Category::Neutral, false)
|
||||
);
|
||||
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
|
||||
assert_eq!(namespaced(glow.id), "ruby:glow");
|
||||
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
|
||||
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
|
||||
assert_eq!(ids.len(), EFFECTS.len() + 1);
|
||||
assert_eq!(ids.last(), Some(&"ruby:glow"));
|
||||
}
|
||||
}
|
||||
@@ -673,6 +673,11 @@ mod tests {
|
||||
vsync_present_mode(&[PresentMode::Fifo], false),
|
||||
Some(PresentMode::Fifo)
|
||||
);
|
||||
// Regression guard: GLES capabilities report [Fifo, Immediate] without Mailbox.
|
||||
assert_eq!(
|
||||
vsync_present_mode(&[PresentMode::Fifo, PresentMode::Immediate], false),
|
||||
Some(PresentMode::Immediate)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn vsync_on_waits_for_the_display_whatever_the_present_mode() {
|
||||
|
||||
@@ -192,9 +192,8 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the item models
|
||||
/// and the sprites' layers, without the block models or the textures'
|
||||
/// pixels.
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
|
||||
/// layers, without the block or item models or the textures' pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
@@ -209,8 +208,13 @@ impl RenderAssets {
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
// A server draws no block: its models (about 24 MB baked) stay empty.
|
||||
// A server draws no block and no item: its block models (about 24 MB
|
||||
// baked) and item model library (7 MB) stay empty.
|
||||
let parsed = if draws {
|
||||
crate::model_library::ParsedModels::load(root)
|
||||
} else {
|
||||
crate::model_library::ParsedModels::default()
|
||||
};
|
||||
let models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
|
||||
@@ -203,6 +203,46 @@ pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<St
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// `Minecraft`'s constructor futures for the title screen: `UserApiService.fetchProperties`
|
||||
/// (the `player` service's `getAttributes`, with the bearer token) and
|
||||
/// `SessionService.fetchProfile(uuid, true)` (`getProfileById`). A failed
|
||||
/// attributes call falls back to the offline properties, a failed profile call
|
||||
/// to no actions, as vanilla does. Blocks: call it off the main thread.
|
||||
pub fn fetch_account_status(session: &AuthSession) -> crate::ban_notice::AccountStatus {
|
||||
use crate::ban_notice::{AccountStatus, parse_profile_actions, parse_properties};
|
||||
let Ok(doc) = discovery() else {
|
||||
return AccountStatus::default();
|
||||
};
|
||||
let properties = endpoint(&doc, "player", "getAttributes")
|
||||
.and_then(|url| {
|
||||
agent()
|
||||
.get(&url)
|
||||
.set("Authorization", &format!("Bearer {}", session.access_token))
|
||||
.call()
|
||||
.ok()?
|
||||
.into_string()
|
||||
.ok()
|
||||
})
|
||||
.and_then(|body| parse_properties(&body))
|
||||
.unwrap_or_default();
|
||||
let actions = endpoint(&doc, "session", "getProfileById")
|
||||
.and_then(|url| {
|
||||
let url = url.replace("{profileId}", &format!("{:032x}", session.uuid));
|
||||
agent()
|
||||
.get(&format!("{url}?unsigned=true"))
|
||||
.call()
|
||||
.ok()?
|
||||
.into_string()
|
||||
.ok()
|
||||
})
|
||||
.map(|body| parse_profile_actions(&body))
|
||||
.unwrap_or_default();
|
||||
AccountStatus {
|
||||
properties,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
|
||||
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
|
||||
serde_json::json!({
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! The ban notices `Gui.addInitialScreens` queues before the title screen:
|
||||
//! `Minecraft.multiplayerBan()` (the account's MULTIPLAYER ban from the services
|
||||
//! attributes call), the forced name change and the banned skin
|
||||
//! (`ProfileResult.actions()`), and the text `BanNoticeScreens` shows for each.
|
||||
//! The network calls are in `auth.rs`; this file parses what they return.
|
||||
use crate::text::Text;
|
||||
|
||||
/// `CommonLinks.SUSPENSION_HELP`.
|
||||
pub const SUSPENSION_HELP: &str = "https://aka.ms/mcjavamoderation";
|
||||
|
||||
/// authlib's `BanDetails` (the id is never shown).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BanDetails {
|
||||
/// Epoch milliseconds the ban ends; `None` is permanent.
|
||||
pub expires_ms: Option<i64>,
|
||||
pub reason: Option<String>,
|
||||
pub reason_message: Option<String>,
|
||||
}
|
||||
|
||||
/// authlib's `UserProperties`, as far as the title screen reads it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserProperties {
|
||||
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
|
||||
pub servers_allowed: bool,
|
||||
/// `bannedScopes.get("MULTIPLAYER")`.
|
||||
pub multiplayer_ban: Option<BanDetails>,
|
||||
}
|
||||
impl Default for UserProperties {
|
||||
/// `UserApiService.OFFLINE_PROPERTIES`: what a failed fetch falls back to.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers_allowed: true,
|
||||
multiplayer_ban: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// authlib's `ProfileResult.actions()`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProfileActions {
|
||||
pub forced_name_change: bool,
|
||||
pub using_banned_skin: bool,
|
||||
}
|
||||
|
||||
/// What the title screen learns about the account.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AccountStatus {
|
||||
pub properties: UserProperties,
|
||||
pub actions: ProfileActions,
|
||||
}
|
||||
impl AccountStatus {
|
||||
/// `Minecraft.allowsMultiplayer` (without the `--disableMultiplayer` flag).
|
||||
pub fn allows_multiplayer(&self) -> bool {
|
||||
self.properties.servers_allowed
|
||||
&& self.properties.multiplayer_ban.is_none()
|
||||
&& !self.actions.forced_name_change
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the screens `Gui.addInitialScreens` adds.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Notice {
|
||||
/// `BanNoticeScreens.create`.
|
||||
Multiplayer(BanDetails),
|
||||
/// `BanNoticeScreens.createNameBan` with the profile's name.
|
||||
Name(String),
|
||||
/// `BanNoticeScreens.createSkinBan`.
|
||||
Skin,
|
||||
}
|
||||
|
||||
/// `Gui.addInitialScreens`' ban part, in its order: the multiplayer ban, the
|
||||
/// name ban, the skin ban.
|
||||
pub fn initial_notices(status: &AccountStatus, profile_name: &str) -> Vec<Notice> {
|
||||
let mut notices = Vec::new();
|
||||
if let Some(ban) = &status.properties.multiplayer_ban {
|
||||
notices.push(Notice::Multiplayer(ban.clone()));
|
||||
}
|
||||
if status.actions.forced_name_change {
|
||||
notices.push(Notice::Name(profile_name.to_owned()));
|
||||
}
|
||||
if status.actions.using_banned_skin {
|
||||
notices.push(Notice::Skin);
|
||||
}
|
||||
notices
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
/// The screen's title (bold).
|
||||
pub fn title(&self) -> Text {
|
||||
let key = match self {
|
||||
Self::Multiplayer(ban) if ban.expires_ms.is_some() => "gui.banned.title.temporary",
|
||||
Self::Multiplayer(_) => "gui.banned.title.permanent",
|
||||
Self::Name(_) => "gui.banned.name.title",
|
||||
Self::Skin => "gui.banned.skin.title",
|
||||
};
|
||||
Text::translate(key, &[]).bold()
|
||||
}
|
||||
/// The screen's message; `now_ms` is `Instant.now()`.
|
||||
pub fn message(&self, now_ms: i64) -> Text {
|
||||
let link = || Text::literal(SUSPENSION_HELP);
|
||||
match self {
|
||||
Self::Multiplayer(ban) => Text::translate(
|
||||
"gui.banned.description",
|
||||
&[reason_text(ban), status_text(ban, now_ms), link()],
|
||||
),
|
||||
Self::Name(name) => Text::translate(
|
||||
"gui.banned.name.description",
|
||||
&[Text::colored(name.clone(), YELLOW), link()],
|
||||
),
|
||||
Self::Skin => Text::translate("gui.banned.skin.description", &[link()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatFormatting.YELLOW`.
|
||||
const YELLOW: u32 = 0xFFFF55;
|
||||
|
||||
/// `BanReason.byId`: the translation key suffix.
|
||||
fn ban_reason(id: i32) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
2 => "false_reporting",
|
||||
5 => "hate_speech",
|
||||
16 | 25 => "hate_terrorism_notorious_figure",
|
||||
17 | 19 | 23 | 31 => "generic_violation",
|
||||
21 => "harassment_or_bullying",
|
||||
27 => "defamation_impersonation_false_information",
|
||||
28 => "drugs",
|
||||
29 => "fraud",
|
||||
30 => "spam_or_advertising",
|
||||
32 => "nudity_or_pornography",
|
||||
33 | 35 | 36 => "sexually_inappropriate",
|
||||
34 => "extreme_violence_or_gore",
|
||||
53 => "imminent_harm_to_person_or_property",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanReasonText`.
|
||||
fn reason_text(ban: &BanDetails) -> Text {
|
||||
let numeric = ban
|
||||
.reason
|
||||
.as_deref()
|
||||
.filter(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()));
|
||||
let Some(reason) = numeric else {
|
||||
return Text::translate("gui.banned.description.unknownreason", &[]);
|
||||
};
|
||||
// Integer.parseInt: past i32 the vanilla screen would throw; show the code as is.
|
||||
let id = reason.parse::<i32>().ok();
|
||||
let text = if let Some(key) = id.and_then(ban_reason) {
|
||||
Text::translate(&format!("gui.banned.reason.{key}"), &[]).bold()
|
||||
} else if let Some(message) = &ban.reason_message {
|
||||
Text::translate(
|
||||
"gui.banned.description.reason_id_message",
|
||||
&[Text::literal(reason), Text::literal(message.clone())],
|
||||
)
|
||||
.bold()
|
||||
} else {
|
||||
Text::translate("gui.banned.description.reason_id", &[Text::literal(reason)]).bold()
|
||||
};
|
||||
Text::translate("gui.banned.description.reason", &[text])
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanStatusText`.
|
||||
fn status_text(ban: &BanDetails, now_ms: i64) -> Text {
|
||||
match ban.expires_ms {
|
||||
Some(expires) => {
|
||||
let duration = Text::translate(
|
||||
"gui.banned.description.temporary.duration",
|
||||
&[duration_text(expires - now_ms)],
|
||||
)
|
||||
.bold();
|
||||
Text::translate("gui.banned.description.temporary", &[duration])
|
||||
}
|
||||
None => Text::translate("gui.banned.description.permanent", &[]).bold(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanDurationText`: days past 72 hours, else hours,
|
||||
/// else minutes (`Duration.toHours` and friends truncate toward zero).
|
||||
fn duration_text(millis: i64) -> Text {
|
||||
let count = |n: i64| Text::literal(n.to_string());
|
||||
let hours = millis / 3_600_000;
|
||||
if hours > 72 {
|
||||
Text::translate("gui.days", &[count(millis / 86_400_000)])
|
||||
} else if hours < 1 {
|
||||
Text::translate("gui.minutes", &[count(millis / 60_000)])
|
||||
} else {
|
||||
Text::translate("gui.hours", &[count(hours)])
|
||||
}
|
||||
}
|
||||
|
||||
/// `Instant` as authlib's gson adapter reads it: an ISO-8601 string, or epoch milliseconds.
|
||||
fn parse_instant(value: &serde_json::Value) -> Option<i64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_i64(),
|
||||
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
|
||||
.ok()
|
||||
.map(|t| t.timestamp_millis())
|
||||
.or_else(|| s.parse().ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `UserAttributesResponse` into `UserProperties` (`fetchProperties`). Bad JSON
|
||||
/// is an error, which vanilla answers with the offline properties.
|
||||
pub fn parse_properties(body: &str) -> Option<UserProperties> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let servers_allowed = v
|
||||
.pointer("/privileges/multiplayerServer/enabled")
|
||||
.and_then(|e| e.as_bool())
|
||||
.unwrap_or(false);
|
||||
let multiplayer_ban = v
|
||||
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
|
||||
.filter(|b| b.is_object())
|
||||
.map(|b| {
|
||||
let text = |k: &str| b.get(k).and_then(|s| s.as_str()).map(str::to_owned);
|
||||
BanDetails {
|
||||
expires_ms: b.get("expires").and_then(parse_instant),
|
||||
reason: text("reason"),
|
||||
reason_message: text("reasonMessage"),
|
||||
}
|
||||
});
|
||||
Some(UserProperties {
|
||||
servers_allowed,
|
||||
multiplayer_ban,
|
||||
})
|
||||
}
|
||||
|
||||
/// `MinecraftProfilePropertiesResponse.profileActions` into `ProfileActionType`s.
|
||||
pub fn parse_profile_actions(body: &str) -> ProfileActions {
|
||||
let mut actions = ProfileActions::default();
|
||||
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
|
||||
for action in v
|
||||
.get("profileActions")
|
||||
.and_then(|a| a.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
match action.get("type").and_then(|t| t.as_str()) {
|
||||
Some("FORCED_NAME_CHANGE") => actions.forced_name_change = true,
|
||||
Some("USING_BANNED_SKIN") => actions.using_banned_skin = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HOUR: i64 = 3_600_000;
|
||||
/// 2026-11-01T00:00:00Z.
|
||||
const EXPIRES: i64 = 1_793_491_200_000;
|
||||
|
||||
#[test]
|
||||
fn parses_a_multiplayer_ban() {
|
||||
let body = r#"{"privileges":{"multiplayerServer":{"enabled":false}},
|
||||
"banStatus":{"bannedScopes":{"MULTIPLAYER":{"banId":"6b0f0a0e-0000-4000-8000-000000000000",
|
||||
"expires":"2026-11-01T00:00:00Z","reason":"21","reasonMessage":"x"}}}}"#;
|
||||
let p = parse_properties(body).unwrap();
|
||||
assert!(!p.servers_allowed);
|
||||
let ban = p.multiplayer_ban.unwrap();
|
||||
assert_eq!(ban.expires_ms, Some(EXPIRES));
|
||||
assert_eq!(ban.reason.as_deref(), Some("21"));
|
||||
let numeric =
|
||||
parse_properties(r#"{"banStatus":{"bannedScopes":{"MULTIPLAYER":{"expires":5}}}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(numeric.multiplayer_ban.unwrap().expires_ms, Some(5));
|
||||
assert!(parse_properties("nope").is_none());
|
||||
let clean = parse_properties(
|
||||
r#"{"privileges":{"multiplayerServer":{"enabled":true}},"banStatus":{"bannedScopes":{}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_profile_actions() {
|
||||
let a = parse_profile_actions(
|
||||
r#"{"id":"x","profileActions":[{"type":"FORCED_NAME_CHANGE"},{"type":"OTHER"}]}"#,
|
||||
);
|
||||
assert!(a.forced_name_change && !a.using_banned_skin);
|
||||
let a = parse_profile_actions(r#"{"profileActions":[{"type":"USING_BANNED_SKIN"}]}"#);
|
||||
assert!(!a.forced_name_change && a.using_banned_skin);
|
||||
assert_eq!(parse_profile_actions("{}"), ProfileActions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queues_in_vanilla_order() {
|
||||
let ban = BanDetails {
|
||||
expires_ms: None,
|
||||
reason: None,
|
||||
reason_message: None,
|
||||
};
|
||||
let status = AccountStatus {
|
||||
properties: UserProperties {
|
||||
servers_allowed: true,
|
||||
multiplayer_ban: Some(ban.clone()),
|
||||
},
|
||||
actions: ProfileActions {
|
||||
forced_name_change: true,
|
||||
using_banned_skin: true,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
initial_notices(&status, "Steve"),
|
||||
vec![
|
||||
Notice::Multiplayer(ban),
|
||||
Notice::Name("Steve".into()),
|
||||
Notice::Skin
|
||||
]
|
||||
);
|
||||
assert!(!status.allows_multiplayer());
|
||||
assert!(initial_notices(&AccountStatus::default(), "Steve").is_empty());
|
||||
assert!(AccountStatus::default().allows_multiplayer());
|
||||
let no_servers = AccountStatus {
|
||||
properties: UserProperties {
|
||||
servers_allowed: false,
|
||||
multiplayer_ban: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!no_servers.allows_multiplayer());
|
||||
}
|
||||
|
||||
/// The message's component JSON: the keys and arguments, whatever language is loaded.
|
||||
fn json(notice: &Notice, now: i64) -> String {
|
||||
notice.message(now).component().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ban_text_follows_banned_screens() {
|
||||
let temp = |reason: Option<&str>, message: Option<&str>| {
|
||||
Notice::Multiplayer(BanDetails {
|
||||
expires_ms: Some(100 * HOUR),
|
||||
reason: reason.map(str::to_owned),
|
||||
reason_message: message.map(str::to_owned),
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
temp(None, None).title().translation_key(),
|
||||
Some("gui.banned.title.temporary")
|
||||
);
|
||||
let perm = Notice::Multiplayer(BanDetails {
|
||||
expires_ms: None,
|
||||
reason: Some("5".into()),
|
||||
reason_message: None,
|
||||
});
|
||||
assert_eq!(
|
||||
perm.title().translation_key(),
|
||||
Some("gui.banned.title.permanent")
|
||||
);
|
||||
let text = json(&perm, 0);
|
||||
assert!(text.contains("gui.banned.reason.hate_speech"));
|
||||
assert!(text.contains("gui.banned.description.permanent"));
|
||||
assert!(text.contains(SUSPENSION_HELP));
|
||||
// Unknown ids show their code, with the message when there is one.
|
||||
let with_message = json(&temp(Some("999"), Some("why")), 0);
|
||||
assert!(with_message.contains("gui.banned.description.reason_id_message"));
|
||||
assert!(with_message.contains("why"));
|
||||
assert!(json(&temp(Some("999"), None), 0).contains("gui.banned.description.reason_id\""));
|
||||
assert!(json(&temp(Some("abc"), None), 0).contains("gui.banned.description.unknownreason"));
|
||||
// 100 hours left is past 72: days (4); less is hours; under an hour is minutes.
|
||||
let duration = |now| json(&temp(None, None), now);
|
||||
assert!(duration(0).contains("gui.days") && duration(0).contains("[4]"));
|
||||
assert!(duration(60 * HOUR).contains("gui.hours") && duration(60 * HOUR).contains("[40]"));
|
||||
let minutes = duration(100 * HOUR - 90_000);
|
||||
assert!(minutes.contains("gui.minutes") && minutes.contains("[1]"));
|
||||
assert!(json(&Notice::Name("Bob".into()), 0).contains("Bob"));
|
||||
assert!(json(&Notice::Skin, 0).contains(SUSPENSION_HELP));
|
||||
}
|
||||
}
|
||||
@@ -789,6 +789,8 @@ pub struct Scene<'a> {
|
||||
pub glyphs: u64,
|
||||
}
|
||||
|
||||
/// Empty by default (a dedicated server): no state has a block entity model.
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityRenderer {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
kinds: Vec<Kind>,
|
||||
@@ -1490,6 +1492,7 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_profile = skin.is_some();
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
@@ -1524,7 +1527,13 @@ impl BlockEntityRenderer {
|
||||
"right_ear" => p[5] = (f64::from(t * PI * 0.2).cos() + 2.5) as f32 * 0.2,
|
||||
_ => {}
|
||||
};
|
||||
let start = out.len();
|
||||
draw_part(model, "root", m, &anim, paint, out);
|
||||
// `SkullBlockRenderer.resolveSkullRenderType`: a head with a profile takes
|
||||
// the player skin's `entityTranslucent` render type.
|
||||
if has_profile {
|
||||
crate::gfx::mark_oit(&mut out[start..]);
|
||||
}
|
||||
}
|
||||
Kind::Bell => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
|
||||
@@ -12,8 +12,8 @@ use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
|
||||
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
@@ -80,8 +80,7 @@ pub fn effect_lines<'a>(
|
||||
.into_iter()
|
||||
.map(|(e, duration, amp)| {
|
||||
let e = e.trim_start_matches("minecraft:");
|
||||
let mut line =
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
let mut line = translate(&description_id(e)).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_str(
|
||||
|
||||
@@ -6,7 +6,8 @@ use horizon_client_core::potions;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectInstance {
|
||||
/// Effect id without namespace (`speed`, `poison`, ...).
|
||||
/// Effect id: vanilla's without namespace (`speed`, `poison`, ...), a
|
||||
/// content mod's `<ns>:<path>`.
|
||||
pub id: &'static str,
|
||||
/// Remaining ticks; -1 is infinite.
|
||||
pub duration: i32,
|
||||
@@ -219,7 +220,7 @@ impl PlayerEffects {
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
format!("minecraft:{}", e.id),
|
||||
potions::namespaced(e.id).to_owned(),
|
||||
e.duration,
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
@@ -525,6 +526,30 @@ mod tests {
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
|
||||
}
|
||||
/// The only test here that registers mod effects (once per process).
|
||||
#[test]
|
||||
fn a_mod_effect_saves_under_its_namespace() {
|
||||
potions::register_mod_effects([(
|
||||
"ruby:glow".to_owned(),
|
||||
0xFF0000,
|
||||
potions::Category::Neutral,
|
||||
)]);
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new(
|
||||
potions::effect("ruby:glow").unwrap().id,
|
||||
600,
|
||||
1,
|
||||
));
|
||||
let saved = fx.saved();
|
||||
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
|
||||
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
|
||||
// Without its mod the saved effect is dropped, as an unknown id is.
|
||||
assert!(
|
||||
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
|
||||
.list
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn raid_omen_acts_on_its_last_tick() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
|
||||
@@ -314,8 +314,10 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
|
||||
// (the sun), terrain.wgsl's mark for the lower ones (the moon).
|
||||
let mark = 1.0 - 0.7 * f32(4u - min(in.tier, 4u)) / 4.0;
|
||||
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
|
||||
// geometry that sets none (the End sky, the End flash): it stays brightest.
|
||||
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
|
||||
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
|
||||
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
|
||||
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
WATER_MASK_OVERLAY,
|
||||
WATER_MASK_OVERLAY, mark_oit,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
@@ -3218,7 +3218,7 @@ impl Renderer {
|
||||
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
|
||||
);
|
||||
if let Some(section) = section {
|
||||
self.sections.insert(key, section);
|
||||
self.sections.insert_with_bounds(key, section, mesh.bounds);
|
||||
}
|
||||
}
|
||||
fn upload_mesh(&mut self, mesh: &SectionMesh) -> Option<SectionGpu> {
|
||||
@@ -3617,7 +3617,8 @@ impl Renderer {
|
||||
// Only HDR output asks the system about the display, so with HDR off the
|
||||
// game never waits on the compositor for it.
|
||||
self.display = if output.is_some() {
|
||||
let (watch, now) = DisplayWatch::new(&self.window);
|
||||
let (watch, now) =
|
||||
DisplayWatch::new(&self.window, self.surface.as_ref(), &self.adapter);
|
||||
crate::graphics::report(now);
|
||||
self.display_fresh = true;
|
||||
watch
|
||||
@@ -3656,7 +3657,7 @@ impl Renderer {
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(now) = self.display.changed() else {
|
||||
let Some(now) = self.display.changed(self.surface.as_ref(), &self.adapter) else {
|
||||
return false;
|
||||
};
|
||||
crate::graphics::report(now);
|
||||
@@ -4847,7 +4848,7 @@ impl Renderer {
|
||||
);
|
||||
let all_entities = &self.entity_batch.combined;
|
||||
let hand_quads = (frame.hand.len() / 4) as u32;
|
||||
let additive_quads = (frame.additive.len() / 4) as u32;
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
let see_through_quads = (frame.see_through.len() / 4) as u32;
|
||||
let entity_vertices = all_entities.len() as u64;
|
||||
if entity_vertices > self.entity_capacity {
|
||||
@@ -5645,6 +5646,15 @@ impl Renderer {
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth, true);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// HDR output's mark on the eyes drawn in the stages, as the
|
||||
// classic pass marks them after the entities.
|
||||
if oit_entity_quads > 0 && self.hdr.is_some() {
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..oit_entity_quads * 6, (base_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
|
||||
@@ -6431,6 +6441,46 @@ mod tests {
|
||||
assert!(!drawable_size(984, 65496, 32768));
|
||||
assert!(!drawable_size(984, u32::MAX, 32768));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_luminances_become_auto_values() {
|
||||
use super::os_reported;
|
||||
use crate::graphics::Reported;
|
||||
let info = |max, white| wgpu::DisplayHdrInfo {
|
||||
luminance: Some(wgpu::DisplayLuminance {
|
||||
max_nits: max,
|
||||
sdr_white_nits: white,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
// Windows' SDR content brightness slider runs 80..480 nits.
|
||||
assert_eq!(
|
||||
os_reported(&info(Some(998.4), Some(240.0))),
|
||||
Some(Reported {
|
||||
paper_white: 240,
|
||||
peak: 998
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
os_reported(&info(Some(120.0), Some(1000.0))),
|
||||
Some(Reported {
|
||||
paper_white: 480,
|
||||
peak: 480
|
||||
})
|
||||
);
|
||||
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
|
||||
assert_eq!(
|
||||
os_reported(&info(Some(380.0), Some(456.0))),
|
||||
Some(Reported {
|
||||
paper_white: 456,
|
||||
peak: 456
|
||||
})
|
||||
);
|
||||
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
|
||||
assert_eq!(os_reported(&info(Some(f32::NAN), Some(80.0))), None);
|
||||
assert_eq!(os_reported(&wgpu::DisplayHdrInfo::default()), None);
|
||||
}
|
||||
#[test]
|
||||
fn surface_is_8_bit_and_not_srgb() {
|
||||
use super::surface_format;
|
||||
@@ -6960,32 +7010,65 @@ mod sprite_upload_tests {
|
||||
}
|
||||
|
||||
/// What the system says about the window's display, kept up to date: on Wayland, the
|
||||
/// compositor's preferred image description of the surface.
|
||||
/// compositor's preferred image description of the surface; elsewhere what wgpu reads
|
||||
/// from the OS (on Windows, DXGI's luminances and the SDR white level).
|
||||
#[derive(Default)]
|
||||
struct DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: Option<horizon_hdr::wayland::Watch>,
|
||||
/// Without Wayland's event, what wgpu last read and when to read it again: the OS
|
||||
/// sends no event for a moved window or a changed SDR brightness slider.
|
||||
polled: Option<(Option<crate::graphics::Reported>, std::time::Instant)>,
|
||||
}
|
||||
/// How often [`DisplayWatch`] reads the OS's values again where no event says they changed.
|
||||
const DISPLAY_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
impl DisplayWatch {
|
||||
/// Starts watching, with what the system reports now.
|
||||
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
|
||||
fn new(
|
||||
window: &Window,
|
||||
surface: Option<&wgpu::Surface>,
|
||||
adapter: &wgpu::Adapter,
|
||||
) -> (Self, Option<crate::graphics::Reported>) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
|
||||
let watch = Self {
|
||||
wayland: Some(wayland),
|
||||
polled: None,
|
||||
};
|
||||
return (watch, now.map(reported));
|
||||
}
|
||||
let _ = window;
|
||||
(Self::default(), None)
|
||||
let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter)));
|
||||
log_os_reported(now);
|
||||
let watch = Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: None,
|
||||
polled: Some((now, std::time::Instant::now() + DISPLAY_POLL)),
|
||||
};
|
||||
(watch, now)
|
||||
}
|
||||
/// What the system reports, if that changed since the last call.
|
||||
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
|
||||
fn changed(
|
||||
&mut self,
|
||||
surface: Option<&wgpu::Surface>,
|
||||
adapter: &wgpu::Adapter,
|
||||
) -> Option<Option<crate::graphics::Reported>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(wayland) = &mut self.wayland {
|
||||
return wayland.changed().map(|now| now.map(reported));
|
||||
}
|
||||
None
|
||||
let (last, next) = self.polled.as_mut()?;
|
||||
let time = std::time::Instant::now();
|
||||
if time < *next {
|
||||
return None;
|
||||
}
|
||||
*next = time + DISPLAY_POLL;
|
||||
let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter)));
|
||||
(now != *last).then(|| {
|
||||
log_os_reported(now);
|
||||
*last = now;
|
||||
now
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7005,8 +7088,6 @@ static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(Display
|
||||
pending: None,
|
||||
});
|
||||
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
// Only Wayland's HDR reports call it so far.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn log_display(line: String) {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
@@ -7036,6 +7117,33 @@ fn flush_display_log() {
|
||||
(log.last, log.at) = (line, Some(now));
|
||||
}
|
||||
}
|
||||
fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
log_display(match reported {
|
||||
Some(r) => format!(
|
||||
"Horizon renderer: the system reports SDR white at {} nits and a peak of {} nits",
|
||||
r.paper_white, r.peak
|
||||
),
|
||||
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
|
||||
/// white level the brightness slider sets), which is HDR output's scale there: SDR
|
||||
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
|
||||
/// the settings' ranges. The OS's figures often come from the display's EDID and may
|
||||
/// run optimistic; the player's own value always wins.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
let peak = luminance.max_nits.filter(|&n| usable(n))?;
|
||||
let white = luminance.sdr_white_nits.filter(|&n| usable(n))?;
|
||||
let paper_white = (white.round() as u32).clamp(80, 480);
|
||||
Some(crate::graphics::Reported {
|
||||
paper_white,
|
||||
// Windows' SDR brightness slider can set white above the reported peak (480 against
|
||||
// a calibrated 380): the highlights then stay at white instead of dropping below it.
|
||||
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a compositor's luminances mean in HDR output's scale.
|
||||
///
|
||||
|
||||
@@ -20,6 +20,13 @@ const MATERIALS: usize = 10;
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
/// `breezeWind`). Only the improved transparency path reads it.
|
||||
pub const OIT_ENTITY: u32 = 1 << 24;
|
||||
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
|
||||
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
|
||||
pub fn mark_oit(quads: &mut [Vertex]) {
|
||||
for vertex in quads {
|
||||
vertex.layer |= OIT_ENTITY;
|
||||
}
|
||||
}
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
|
||||
@@ -64,6 +71,10 @@ pub(super) struct EntityBatch {
|
||||
// frame, keeping their storage.
|
||||
runs: [Vec<(usize, usize)>; MATERIALS],
|
||||
pub(super) combined: Vec<Vertex>,
|
||||
/// Additive quads tagged `OIT_ENTITY` that the last `update` moved into the
|
||||
/// last material (improved transparency on): the dragon's eyes, which share
|
||||
/// the additive buffer with its rays.
|
||||
pub(super) moved_additive: u32,
|
||||
}
|
||||
|
||||
impl EntityBatch {
|
||||
@@ -101,7 +112,32 @@ impl EntityBatch {
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(additive);
|
||||
// `RenderTypes.eyes` is blended like the other entities, not added: with
|
||||
// improved transparency on, tagged additive quads join the last
|
||||
// material, drawn without the additive overlay and the baked shading.
|
||||
self.moved_additive = 0;
|
||||
if oit {
|
||||
for quad in additive.as_chunks::<4>().0 {
|
||||
if quad[0].layer & OIT_ENTITY != 0 {
|
||||
self.combined.extend(quad.iter().map(|v| Vertex {
|
||||
layer: v.layer & !0xffff,
|
||||
normal: [0; 4],
|
||||
..*v
|
||||
}));
|
||||
counts[9] += 1;
|
||||
self.moved_additive += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.moved_additive == 0 {
|
||||
self.combined.extend_from_slice(additive);
|
||||
} else {
|
||||
for quad in additive.as_chunks::<4>().0 {
|
||||
if quad[0].layer & OIT_ENTITY == 0 {
|
||||
self.combined.extend_from_slice(quad);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(hand);
|
||||
self.combined.extend_from_slice(see_through);
|
||||
counts.map(|count| count as u32)
|
||||
@@ -210,6 +246,38 @@ mod tests {
|
||||
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_additive_quads_join_the_last_material_only_when_on() {
|
||||
let quad = |layer: u32, x: f32| {
|
||||
(0..4).map(move |_| Vertex {
|
||||
pos: [x, 0.0, 0.0],
|
||||
layer,
|
||||
normal: [1, 2, 3, 4],
|
||||
..geometry(1, false)[0]
|
||||
})
|
||||
};
|
||||
// The dragon's eyes (tagged, overlay 2) and a ray (plain).
|
||||
let additive: Vec<Vertex> = [(2 | OIT_ENTITY | 3 << 16, 1.0), (2, 2.0)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let entities: Vec<Vertex> = quad(0, 0.0).collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: untouched.
|
||||
let counts = batch.update(&entities, &additive, &[], &[], false);
|
||||
assert_eq!((counts[9], batch.moved_additive), (0, 0));
|
||||
assert_eq!(batch.combined.len(), 12);
|
||||
assert_eq!(batch.combined[4].layer, 2 | OIT_ENTITY | 3 << 16);
|
||||
// On: the eyes are an entity quad without the additive overlay, the ray stays.
|
||||
let counts = batch.update(&entities, &additive, &[], &[], true);
|
||||
assert_eq!((counts[0], counts[9], batch.moved_additive), (1, 1, 1));
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0]);
|
||||
assert_eq!(batch.combined[4].layer, OIT_ENTITY | 3 << 16);
|
||||
assert_eq!(batch.combined[4].normal, [0; 4]);
|
||||
assert_eq!(batch.combined[8].layer, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_material_order_tails_and_reuse() {
|
||||
let mut batch = EntityBatch::default();
|
||||
|
||||
@@ -203,9 +203,9 @@ fn body(angle: f32, tilt: f32) -> Vec3 {
|
||||
}
|
||||
|
||||
/// `sunPathRotation`'s turn of the sky's bodies: `tilt` degrees about the world's X axis, so a
|
||||
/// positive angle moves the noon sun toward +Z (south).
|
||||
/// positive angle moves the noon sun toward -Z (north), as Iris does.
|
||||
pub fn tilt_matrix(tilt: f32) -> Mat4 {
|
||||
Mat4::from_rotation_x(tilt.to_radians())
|
||||
Mat4::from_rotation_x(-tilt.to_radians())
|
||||
}
|
||||
|
||||
/// The world direction of the shadow light (7.1): the sun by day, the moon by night.
|
||||
@@ -232,38 +232,39 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
|
||||
depth * gl * view
|
||||
}
|
||||
|
||||
/// Distance from the snapped point to the shadow camera, toward the light: the middle of the
|
||||
/// default near and far planes (OQ-6).
|
||||
const SHADOW_EYE: f64 = 128.0;
|
||||
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
|
||||
/// not of `shadowModelView` (Iris's matrices, measured).
|
||||
const SHADOW_EYE: f32 = 100.0;
|
||||
|
||||
/// The shadow camera (7.1; OQ-6, impl-plan.md): its view (player space to shadow view space)
|
||||
/// and GL projection. The camera looks from the light, world +Z up (+Y when the light is near
|
||||
/// Z); the point it centres moves in steps of `shadowIntervalSize` along the shadow view's
|
||||
/// own axes, and it sits `SHADOW_EYE` blocks toward the light from it. Orthographic over
|
||||
/// `shadowDistance` each side, or perspective with `shadowMapFov`.
|
||||
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
|
||||
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
|
||||
/// light's path about Z by the light's angle from the zenith, then X by 90 degrees, so the
|
||||
/// light lies on the view's +Z. Its origin is `shadowIntervalSize / 2 - camera % interval`
|
||||
/// (the remainder takes the camera's sign) from the player on each world axis, so the map moves
|
||||
/// in whole steps. The orthographic projection covers `shadowDistance` each side and depths
|
||||
/// from `-near` to `far`, counted from `SHADOW_EYE` blocks toward the light; or perspective
|
||||
/// with `shadowMapFov`.
|
||||
pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4) {
|
||||
let light = light.as_dvec3();
|
||||
let up = if light.z.abs() > 0.99 {
|
||||
DVec3::Y
|
||||
} else {
|
||||
DVec3::Z
|
||||
};
|
||||
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
|
||||
let at = rotation.transform_vector3(camera);
|
||||
let tilt = f64::from(shadow.sun_path_rotation).to_radians();
|
||||
let untilted = DMat4::from_rotation_x(tilt).transform_vector3(light.as_dvec3());
|
||||
let zenith = untilted.x.atan2(untilted.y);
|
||||
let rotation = DMat4::from_rotation_x(std::f64::consts::FRAC_PI_2)
|
||||
* DMat4::from_rotation_z(zenith)
|
||||
* DMat4::from_rotation_x(tilt);
|
||||
let step = f64::from(shadow.interval);
|
||||
let snapped = if step > 0.0 {
|
||||
(at / step).floor() * step
|
||||
let origin = if step > 0.0 {
|
||||
DVec3::splat(step / 2.0) - camera % step
|
||||
} else {
|
||||
at
|
||||
DVec3::ZERO
|
||||
};
|
||||
let offset = at - snapped - DVec3::new(0.0, 0.0, SHADOW_EYE);
|
||||
let view = (DMat4::from_translation(offset) * rotation).as_mat4();
|
||||
let view = (rotation * DMat4::from_translation(-origin)).as_mat4();
|
||||
let (near, far) = (shadow.near, shadow.far);
|
||||
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
|
||||
let projection = match shadow.fov {
|
||||
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far),
|
||||
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye,
|
||||
None => {
|
||||
let d = shadow.distance;
|
||||
Mat4::orthographic_rh_gl(-d, d, -d, d, near, far)
|
||||
Mat4::orthographic_rh_gl(-d, d, -d, d, -near, far) * eye
|
||||
}
|
||||
};
|
||||
(view, projection)
|
||||
@@ -360,7 +361,8 @@ impl FrameUniforms {
|
||||
"viewHeight" => Value::f(&[size[1] as f32]),
|
||||
"aspectRatio" => Value::f(&[aspect]),
|
||||
"near" => Value::f(&[0.05]),
|
||||
"far" => Value::f(&[frame.distance]),
|
||||
// The chunk render distance, not the distant terrain's reach.
|
||||
"far" => Value::f(&[frame.detail_distance]),
|
||||
"rainStrength" => Value::f(&[inputs.rain]),
|
||||
"wetness" => Value::f(&[self.wetness]),
|
||||
"thunderStrength" => Value::f(&[inputs.thunder]),
|
||||
@@ -486,8 +488,8 @@ mod tests {
|
||||
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
|
||||
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
|
||||
assert!(body(90.0, 0.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
|
||||
// sunPathRotation tilts noon toward +Z and leaves sunset on the horizon.
|
||||
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, 50.0), 1e-3));
|
||||
// sunPathRotation tilts noon toward -Z and leaves sunset on the horizon.
|
||||
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, -50.0), 1e-3));
|
||||
assert!(body(90.0, 30.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
|
||||
}
|
||||
|
||||
@@ -500,10 +502,13 @@ mod tests {
|
||||
let at = |p: Vec3| view.transform_point3(p);
|
||||
// Straight down: a block below the player is farther from the light.
|
||||
assert!(at(Vec3::new(0.0, -1.0, 0.0)).z < at(Vec3::ZERO).z);
|
||||
// The player is less than a step from the centre, 128 blocks from the light.
|
||||
let centre = at(Vec3::ZERO);
|
||||
assert!(centre.x.abs() <= 2.0 && centre.y.abs() <= 2.0, "{centre}");
|
||||
assert!((centre.z + 128.0).abs() < 1e-3, "{centre}");
|
||||
// The view's origin is interval / 2 - camera % interval from the player on each axis,
|
||||
// the remainder with the camera's sign: up to a step and a half on negative axes.
|
||||
let origin = view.inverse().w_axis.truncate();
|
||||
assert!(
|
||||
origin.abs_diff_eq(Vec3::new(0.7, 1.0, 1.7), 1e-3),
|
||||
"{origin}"
|
||||
);
|
||||
// Moving within a step keeps the world where it was in the map.
|
||||
camera.x = 1000.6;
|
||||
let (moved, _) = shadow_camera(Vec3::Y, camera, &shadow);
|
||||
@@ -515,7 +520,38 @@ mod tests {
|
||||
.abs_diff_eq(at(world), 1e-3)
|
||||
);
|
||||
// shadowDistance blocks reach the map's edge.
|
||||
let edge = projection.project_point3(Vec3::new(160.0, 0.0, -128.0));
|
||||
let edge = projection.project_point3(Vec3::new(160.0, 0.0, 0.0));
|
||||
assert!((edge.x.abs() - 1.0).abs() < 1e-4, "{edge}");
|
||||
}
|
||||
|
||||
/// Iris's matrices at 3000 ticks with `sunPathRotation = 30` and the camera at
|
||||
/// (0.5, -58.38, 0.5), read from its uniforms by a probe pack.
|
||||
#[test]
|
||||
fn the_shadow_camera_matches_iris() {
|
||||
let shadow = Shadow {
|
||||
sun_path_rotation: 30.0,
|
||||
..Shadow::default()
|
||||
};
|
||||
let light = Vec3::new(0.56782, 0.71287, -0.41158).normalize();
|
||||
let (view, projection) = shadow_camera(light, DVec3::new(0.5, -58.38, 0.5), &shadow);
|
||||
let iris_view = Mat4::from_cols_array_2d(&[
|
||||
[0.82315, 0.0, 0.56782, 0.0],
|
||||
[-0.49175, -0.5, 0.71287, 0.0],
|
||||
[0.28391, -0.86603, -0.41158, 0.0],
|
||||
[0.12508, 1.12301, -1.06188, 1.0],
|
||||
]);
|
||||
assert!(view.abs_diff_eq(iris_view, 2e-4), "{view}");
|
||||
assert!(
|
||||
(projection.z_axis.z + 0.0078110).abs() < 1e-6,
|
||||
"{projection}"
|
||||
);
|
||||
assert!((projection.w_axis.z + 0.21851).abs() < 1e-4, "{projection}");
|
||||
// Elsewhere the origin keeps to the steps: Iris put it at (-0.5, 1.38, 2.5) here.
|
||||
let (view, _) = shadow_camera(light, DVec3::new(3.5, -58.38, -1.5), &shadow);
|
||||
let origin = view.inverse().w_axis.truncate();
|
||||
assert!(
|
||||
origin.abs_diff_eq(Vec3::new(-0.5, 1.38, 2.5), 1e-3),
|
||||
"{origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub struct Resident<T> {
|
||||
/// The minimum Y of section index 0 (the level's column).
|
||||
min_y: i32,
|
||||
items: Vec<(SectionKey, T)>,
|
||||
bounds: Vec<Option<[Vec3; 2]>>,
|
||||
slots: FxHashMap<SectionKey, u32>,
|
||||
columns: Vec<Column>,
|
||||
column_index: FxHashMap<(i32, i32), u32>,
|
||||
@@ -40,6 +41,7 @@ impl<T> Default for Resident<T> {
|
||||
Self {
|
||||
min_y: crate::chunk::MIN_Y,
|
||||
items: Vec::new(),
|
||||
bounds: Vec::new(),
|
||||
slots: FxHashMap::default(),
|
||||
columns: Vec::new(),
|
||||
column_index: FxHashMap::default(),
|
||||
@@ -78,18 +80,26 @@ impl<T> Resident<T> {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.bounds.clear();
|
||||
self.slots.clear();
|
||||
self.columns.clear();
|
||||
self.column_index.clear();
|
||||
}
|
||||
/// Adds or replaces `key`'s value.
|
||||
/// Adds or replaces `key`'s value with default 16x16x16 bounds.
|
||||
#[cfg(test)]
|
||||
pub fn insert(&mut self, key: SectionKey, value: T) {
|
||||
self.insert_with_bounds(key, value, None);
|
||||
}
|
||||
/// Adds or replaces `key`'s value with optional tight geometry bounds.
|
||||
pub fn insert_with_bounds(&mut self, key: SectionKey, value: T, bounds: Option<[Vec3; 2]>) {
|
||||
if let Some(&slot) = self.slots.get(&key) {
|
||||
self.items[slot as usize].1 = value;
|
||||
self.bounds[slot as usize] = bounds;
|
||||
return;
|
||||
}
|
||||
let slot = self.items.len() as u32;
|
||||
self.items.push((key, value));
|
||||
self.bounds.push(bounds);
|
||||
self.slots.insert(key, slot);
|
||||
let column = match self.column_index.get(&(key.0, key.2)) {
|
||||
Some(&c) => &mut self.columns[c as usize],
|
||||
@@ -129,6 +139,7 @@ impl<T> Resident<T> {
|
||||
} else {
|
||||
column.bounds();
|
||||
}
|
||||
let _ = self.bounds.swap_remove(slot as usize);
|
||||
let (_, value) = self.items.swap_remove(slot as usize);
|
||||
// The last item now sits in `slot`: point its key and column at it.
|
||||
if let Some(&(moved, _)) = self.items.get(slot as usize) {
|
||||
@@ -175,31 +186,57 @@ fn camera_column(camera: DVec3) -> (i32, i32) {
|
||||
)
|
||||
}
|
||||
|
||||
/// The world AABB (min, max) of a section from its origin and optional tight bounds.
|
||||
#[inline]
|
||||
fn section_box(min: Vec3, bounds: Option<[Vec3; 2]>) -> (Vec3, Vec3) {
|
||||
match bounds {
|
||||
Some([bmin, bmax]) => (min + bmin, min + bmax),
|
||||
None => (min, min + Vec3::splat(16.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the section at `key` is drawn: its column within the render
|
||||
/// distance of the camera's (`SectionOcclusionGraph.isInViewDistance`), and
|
||||
/// partly inside the frustum. `None` when it is not, else its squared centre
|
||||
/// distance and origin. `Resident::visible` walks columns instead.
|
||||
#[cfg(test)]
|
||||
fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Option<(f64, Vec3)> {
|
||||
in_view_with_bounds(key, camera, radius, frustum, None)
|
||||
}
|
||||
|
||||
/// `in_view` with optional tight bounds.
|
||||
#[cfg(test)]
|
||||
fn in_view_with_bounds(
|
||||
key: SectionKey,
|
||||
camera: DVec3,
|
||||
radius: i32,
|
||||
frustum: &Frustum,
|
||||
bounds: Option<[Vec3; 2]>,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false, bounds)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
#[cfg(test)]
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
min_y: i32,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
bounds: Option<[Vec3; 2]>,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, min_y, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
if !inside {
|
||||
let (bmin, bmax) = section_box(min, bounds);
|
||||
if !frustum.intersects(bmin, bmax) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
|
||||
}
|
||||
@@ -239,17 +276,50 @@ impl<T> Resident<T> {
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
|
||||
let ox = f64::from(column.x * 16) - camera.x;
|
||||
let oz = f64::from(column.z * 16) - camera.z;
|
||||
let min_x = ox as f32;
|
||||
let min_z = oz as f32;
|
||||
let dx8 = ox + 8.0;
|
||||
let dz8 = oz + 8.0;
|
||||
let xz_sq = dx8 * dx8 + dz8 * dz8;
|
||||
|
||||
if inside {
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
let oy = f64::from(self.min_y + y * 16) - camera.y;
|
||||
let min = Vec3::new(min_x, oy as f32, min_z);
|
||||
let dy8 = oy + 8.0;
|
||||
let distance = xz_sq + dy8 * dy8;
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
distance: distance.to_bits(),
|
||||
key,
|
||||
slot,
|
||||
origin: o.to_array(),
|
||||
origin: min.to_array(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
let oy = f64::from(self.min_y + y * 16) - camera.y;
|
||||
let min = Vec3::new(min_x, oy as f32, min_z);
|
||||
let (bmin, bmax) = section_box(min, self.bounds[slot as usize]);
|
||||
if !frustum.intersects(bmin, bmax) {
|
||||
continue;
|
||||
}
|
||||
let dy8 = oy + 8.0;
|
||||
let distance = xz_sq + dy8 * dy8;
|
||||
out.push(Visible {
|
||||
distance: distance.to_bits(),
|
||||
key,
|
||||
slot,
|
||||
origin: min.to_array(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -271,7 +341,15 @@ mod tests {
|
||||
for y in 0..24 {
|
||||
// Leave holes so columns have uneven bounds.
|
||||
if (x * 7 + y * 3 + z * 5).rem_euclid(4) != 0 {
|
||||
r.insert((x, y, z), ());
|
||||
// Mix of sections with fallback None and randomized tight bounds.
|
||||
let bounds = if (x + y + z).rem_euclid(2) == 0 {
|
||||
let y_min = ((x + z).rem_euclid(4)) as f32;
|
||||
let y_max = y_min + 2.0 + ((y.rem_euclid(5)) as f32);
|
||||
Some([Vec3::new(0.0, y_min, 0.0), Vec3::new(16.0, y_max, 16.0)])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
r.insert_with_bounds((x, y, z), (), bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,12 +375,21 @@ mod tests {
|
||||
let mut slow: Vec<(SectionKey, f64, [f32; 3])> = r
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|&(k, _)| {
|
||||
.enumerate()
|
||||
.filter_map(|(slot, &(k, _))| {
|
||||
let near = in_view_distance(camera_column(camera), radius, k.0, k.2);
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
in_view_with_bounds(k, camera, radius, &frustum, r.bounds[slot])
|
||||
.filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
|
||||
in_frustum(
|
||||
k,
|
||||
crate::chunk::MIN_Y,
|
||||
camera,
|
||||
&frustum,
|
||||
false,
|
||||
r.bounds[slot],
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -471,4 +558,152 @@ mod tests {
|
||||
list.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tight_mesh_bounds_culls_section_outside_frustum() {
|
||||
let mut r = Resident::default();
|
||||
let key = (0, 0, 0);
|
||||
// Section geometry only occupies local Y in [0.0, 2.0]
|
||||
let tight = Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, 2.0, 16.0)]);
|
||||
r.insert_with_bounds(key, (), tight);
|
||||
|
||||
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 15), -2.0);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let view = crate::camera::view_matrix(0.0, -60.0);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * view),
|
||||
};
|
||||
|
||||
// Tight bounds culls the section whose geometry is entirely outside the frustum
|
||||
let mut out = Vec::new();
|
||||
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"tight bounds should cull section whose geometry is below frustum"
|
||||
);
|
||||
|
||||
// With fallback 16x16x16 bounds, the section intersects the frustum and is visible
|
||||
r.insert_with_bounds(key, (), None);
|
||||
out.clear();
|
||||
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"full 16x16x16 bounds intersect frustum and are accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn tight_bounds_benchmark() {
|
||||
use std::time::Instant;
|
||||
|
||||
let mut r_fallback = Resident::default();
|
||||
let mut r_tight = Resident::default();
|
||||
|
||||
let radius = 16;
|
||||
for x in -radius..=radius {
|
||||
for z in -radius..=radius {
|
||||
// Section 0..4: underground rock
|
||||
for y in 0..4 {
|
||||
r_fallback.insert((x, y, z), ());
|
||||
r_tight.insert_with_bounds((x, y, z), (), None);
|
||||
}
|
||||
// Section 4: surface terrain with grass / hills (e.g. Y in [0, 4] or [0, 6])
|
||||
let height = 2.0 + (((x * 3 + z * 5).rem_euclid(5)) as f32) * 1.5;
|
||||
let surface_bounds =
|
||||
Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, height, 16.0)]);
|
||||
r_fallback.insert((x, 4, z), ());
|
||||
r_tight.insert_with_bounds((x, 4, z), (), surface_bounds);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n=== Tight Bounds Culling Benchmark ===");
|
||||
println!("Total Resident Sections: {}", r_tight.len());
|
||||
|
||||
let test_views = [
|
||||
("Looking level across terrain (pitch 0°)", 0.0, 0.0),
|
||||
("Looking slightly up (pitch -15°)", 45.0, -15.0),
|
||||
("Looking up towards sky (pitch -35°)", 90.0, -35.0),
|
||||
("Looking down at ground (pitch 25°)", 180.0, 25.0),
|
||||
("Diagonal view (yaw 135°, pitch -10°)", 135.0, -10.0),
|
||||
];
|
||||
|
||||
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 4 * 16 + 10), 8.0);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
|
||||
for (desc, yaw, pitch) in test_views {
|
||||
let view = crate::camera::view_matrix(yaw, pitch);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * view),
|
||||
};
|
||||
|
||||
let mut out_fallback = Vec::new();
|
||||
let mut out_tight = Vec::new();
|
||||
|
||||
r_fallback.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_fallback,
|
||||
);
|
||||
r_tight.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_tight,
|
||||
);
|
||||
|
||||
let culled_extra = out_fallback.len().saturating_sub(out_tight.len());
|
||||
let pct_saved = if !out_fallback.is_empty() {
|
||||
(culled_extra as f64 / out_fallback.len() as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Timing over 1,000 passes
|
||||
let iters = 1_000;
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..iters {
|
||||
out_fallback.clear();
|
||||
r_fallback.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_fallback,
|
||||
);
|
||||
}
|
||||
let dur_fallback = t0.elapsed();
|
||||
|
||||
let t1 = Instant::now();
|
||||
for _ in 0..iters {
|
||||
out_tight.clear();
|
||||
r_tight.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_tight,
|
||||
);
|
||||
}
|
||||
let dur_tight = t1.elapsed();
|
||||
|
||||
println!(
|
||||
"{desc}\n Fallback 16x16: {} sections | {:?}\n Tight Bounds: {} sections | {:?}\n Reduction: -{} sections ({:.1}% culled)\n",
|
||||
out_fallback.len(),
|
||||
dur_fallback / iters,
|
||||
out_tight.len(),
|
||||
dur_tight / iters,
|
||||
culled_extra,
|
||||
pct_saved
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,9 @@ mod tests {
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let mut best = 0.0f32;
|
||||
let mut k0 = d.dot(n(TRIANGLES));
|
||||
for i in 0..TRIANGLES {
|
||||
for (i, triangle) in normals.iter().take(TRIANGLES).enumerate() {
|
||||
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
|
||||
let w = normals[i][3];
|
||||
let w = triangle[3];
|
||||
if k0 * w <= 0.0 && k1 * w >= 0.0 {
|
||||
let apex = d.dot(n(i));
|
||||
let s = apex + k1 - k0;
|
||||
@@ -75,6 +75,115 @@ mod tests {
|
||||
best
|
||||
}
|
||||
|
||||
// Projecting the fan onto the YZ plane leaves uniformly spaced rim
|
||||
// angles after undoing its Y squash. Test both ray directions because
|
||||
// the projection plane goes through the camera.
|
||||
fn sector_weight(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let normals = normals(alpha, sun);
|
||||
if d.y * d.y + d.z * d.z < 1e-10 {
|
||||
return weight(&normals, d);
|
||||
}
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let apex = d.dot(n(0));
|
||||
if apex * normals[0][3] < -1e-4 {
|
||||
return 0.0;
|
||||
}
|
||||
let y = d.dot(n(16));
|
||||
let x = -d.dot(n(20));
|
||||
let ax = x.abs();
|
||||
let ay = y.abs();
|
||||
let t = std::f32::consts::SQRT_2 - 1.0;
|
||||
let q = if ay > ax {
|
||||
if ax < ay * t { 3 } else { 2 }
|
||||
} else if ay > ax * t {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let sector = if x >= 0.0 {
|
||||
if y >= 0.0 { q } else { 15 - q }
|
||||
} else if y >= 0.0 {
|
||||
7 - q
|
||||
} else {
|
||||
8 + q
|
||||
};
|
||||
let mut best = 0.0f32;
|
||||
let k0 = d.dot(n(16 + sector as usize));
|
||||
let k1 = d.dot(n(16 + ((sector + 1) % 16) as usize));
|
||||
let offsets: &[i32] = if k0.abs().min(k1.abs()) < ax.max(ay) * 0.001 {
|
||||
&[0, 8, -1, 1, 7, 9]
|
||||
} else {
|
||||
&[0, 8]
|
||||
};
|
||||
for &offset in offsets {
|
||||
let i = (sector + offset).rem_euclid(16) as usize;
|
||||
let k0 = d.dot(n(16 + i));
|
||||
let k1 = d.dot(n(16 + (i + 1) % 16));
|
||||
let w = normals[i][3];
|
||||
let apex = d.dot(n(i));
|
||||
let sum = apex + k1 - k0;
|
||||
if sum.abs() >= 1e-6
|
||||
&& sum * w > 0.0
|
||||
&& apex * sum >= 0.0
|
||||
&& k1 * sum >= 0.0
|
||||
&& k0 * sum <= 0.0
|
||||
{
|
||||
best = best.max(apex / sum);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
#[test]
|
||||
fn angular_sector_matches_random_and_exact_rim_directions() {
|
||||
let mut state = 19u32;
|
||||
let mut random = || {
|
||||
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(state >> 8) as f32 / 16777216.0
|
||||
};
|
||||
for _ in 0..1000 {
|
||||
let alpha = 0.0011 + random() * 0.9989;
|
||||
let z = if random() > 0.5 { 0.3 } else { -0.2 };
|
||||
let sun = Vec3::new(0.0, (1.0f32 - z * z).sqrt(), z);
|
||||
let normals = normals(alpha, sun);
|
||||
let (_, rim) = vertices(alpha, sun);
|
||||
let random_ray = Vec3::new(
|
||||
random() * 2.0 - 1.0,
|
||||
random() * 2.0 - 1.0,
|
||||
random() * 2.0 - 1.0,
|
||||
)
|
||||
.normalize();
|
||||
for ray in rim.into_iter().chain([random_ray]) {
|
||||
let d = ray.normalize();
|
||||
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"{alpha} {sun} {d}: {got} != {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_sector_matches_all_triangle_search() {
|
||||
for alpha in [0.0011f32, 0.05, 0.6, 1.0] {
|
||||
for z in [-0.2f32, 0.3] {
|
||||
let sun = Vec3::new(0.0, (1.0 - z * z).sqrt(), z);
|
||||
let normals = normals(alpha, sun);
|
||||
for yaw in 0..360 {
|
||||
for pitch in -89..90 {
|
||||
let (y, p) = ((yaw as f32).to_radians(), (pitch as f32).to_radians());
|
||||
let d = Vec3::new(y.cos() * p.cos(), p.sin(), y.sin() * p.cos());
|
||||
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"alpha={alpha} z={z} d={d} {got} != {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
|
||||
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
|
||||
@@ -93,7 +93,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
/// A `MobEffectInstance` as `extractEffects` sees it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectIcon {
|
||||
/// The effect id without namespace (`speed`): the sprite is `mob_effect/<id>`.
|
||||
/// The effect id: vanilla's without namespace (`speed`), a mod's `<ns>:<path>`.
|
||||
pub id: String,
|
||||
/// `MobEffect.isBeneficial`.
|
||||
pub beneficial: bool,
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn panels(
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (effect, icon))| {
|
||||
let mut name = translate(&format!("effect.minecraft.{}", effect.id))
|
||||
let mut name = translate(&horizon_client_core::potions::description_id(effect.id))
|
||||
.unwrap_or_else(|| effect.id.to_owned());
|
||||
if (1..=9).contains(&effect.amplifier) {
|
||||
let level = effect.amplifier + 1;
|
||||
|
||||
@@ -321,10 +321,25 @@ pub fn smooth(smoothed: f32, progress: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// `ARGB.opaque` as the batch's RGBA.
|
||||
fn opaque(rgb: u32) -> u32 {
|
||||
pub const fn opaque(rgb: u32) -> u32 {
|
||||
0xFF00_0000 | (rgb & 0xFF) << 16 | (rgb & 0xFF00) | (rgb >> 16 & 0xFF)
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR` (RGB: 239, 50, 61).
|
||||
pub const LOGO_BACKGROUND_COLOR: u32 = opaque(0xEF323D);
|
||||
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR_DARK` (black).
|
||||
pub const LOGO_BACKGROUND_COLOR_DARK: u32 = opaque(0x000000);
|
||||
|
||||
/// `LoadingOverlay.BRAND_BACKGROUND`: black when `darkMojangStudiosBackground` is set,
|
||||
/// Mojang red otherwise.
|
||||
pub fn brand_background(dark: bool) -> u32 {
|
||||
if dark {
|
||||
LOGO_BACKGROUND_COLOR_DARK
|
||||
} else {
|
||||
LOGO_BACKGROUND_COLOR
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadingScreen.extractRenderState` over its background: the text,
|
||||
/// the progress bar and the chunk map, `status(x, z)` for x and z in
|
||||
/// `0..=2 * STATUS_RADIUS` from the map's north-west corner.
|
||||
@@ -370,6 +385,27 @@ pub fn draw(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
|
||||
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
|
||||
/// progress line and bar.
|
||||
pub fn draw_loading_overlay(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
line: &str,
|
||||
progress: f32,
|
||||
dark: bool,
|
||||
) {
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
brand_background(dark),
|
||||
);
|
||||
draw_progress(batch, atlas, line, progress);
|
||||
}
|
||||
|
||||
/// The startup loading screen: the stage's line and a progress bar, centred
|
||||
/// like `LevelLoadingScreen`'s text and bar.
|
||||
pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progress: f32) {
|
||||
@@ -546,6 +582,14 @@ mod tests {
|
||||
assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brand_background_matches_dark_mojang_studios_setting() {
|
||||
assert_eq!(brand_background(true), LOGO_BACKGROUND_COLOR_DARK);
|
||||
assert_eq!(brand_background(false), LOGO_BACKGROUND_COLOR);
|
||||
assert_eq!(LOGO_BACKGROUND_COLOR_DARK, 0xFF00_0000);
|
||||
assert_eq!(LOGO_BACKGROUND_COLOR, crate::hud::rgba(239, 50, 61, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_fixer_cancel_sits_below_the_centre() {
|
||||
assert_eq!(file_fixer_cancel(427.0, 240.0), (113.0, 220.0));
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod assets;
|
||||
pub mod atlas_sources;
|
||||
pub mod audio;
|
||||
pub mod auth;
|
||||
pub mod ban_notice;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
pub mod block_entities;
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, VecDeque},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
@@ -240,7 +241,8 @@ impl LightTables {
|
||||
/// `edge_occluded` for two packed cells.
|
||||
#[inline]
|
||||
fn packed_edge_occluded(&self, top: u16, bottom: u16) -> bool {
|
||||
bottom & 0xF != 0 || self.packed_blocked(top, bottom, Direction::Down as usize)
|
||||
bottom & 0xF != 0
|
||||
|| ((top | bottom) != 0 && self.packed_blocked(top, bottom, Direction::Down as usize))
|
||||
}
|
||||
pub fn emission(&self, state: u32) -> u8 {
|
||||
self.cells[state as usize].emission
|
||||
@@ -266,12 +268,64 @@ const OFFSETS: [(i32, i32, i32); 6] = [
|
||||
(1, 0, 0),
|
||||
];
|
||||
|
||||
struct LightScratch {
|
||||
states: Vec<u16>,
|
||||
sky: Vec<u8>,
|
||||
block: Vec<u8>,
|
||||
queue: Vec<u32>,
|
||||
sky_queue: Vec<u32>,
|
||||
present: Vec<bool>,
|
||||
lowest: Vec<i32>,
|
||||
ids: Box<[u32; 4096]>,
|
||||
}
|
||||
|
||||
impl LightScratch {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
states: Vec::new(),
|
||||
sky: Vec::new(),
|
||||
block: Vec::new(),
|
||||
queue: Vec::new(),
|
||||
sky_queue: Vec::new(),
|
||||
present: vec![false; W * W],
|
||||
lowest: vec![0; W * W],
|
||||
ids: Box::new([0u32; 4096]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear queues and shrink them if capacity exceeds the retention cap.
|
||||
fn clear_queues(&mut self) {
|
||||
const MAX_QUEUE_CAPACITY: usize = 65_536;
|
||||
self.queue.clear();
|
||||
if self.queue.capacity() > MAX_QUEUE_CAPACITY {
|
||||
self.queue.shrink_to(MAX_QUEUE_CAPACITY);
|
||||
}
|
||||
self.sky_queue.clear();
|
||||
if self.sky_queue.capacity() > MAX_QUEUE_CAPACITY {
|
||||
self.sky_queue.shrink_to(MAX_QUEUE_CAPACITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCRATCH: RefCell<LightScratch> = RefCell::new(LightScratch::new());
|
||||
}
|
||||
|
||||
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
|
||||
/// `sky` is false in dimensions without skylight (the Nether).
|
||||
pub fn light_chunk(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
SCRATCH.with_borrow_mut(|scratch| light_chunk_with_scratch(region, tables, sky_light, scratch))
|
||||
}
|
||||
|
||||
fn light_chunk_with_scratch(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
scratch: &mut LightScratch,
|
||||
) -> Vec<Light> {
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
// Highest occupied section in the region bounds the work.
|
||||
@@ -287,142 +341,194 @@ pub fn light_chunk(
|
||||
// One extra section above the highest occupied one: block light rises
|
||||
// up to 14 blocks into it (sky light there is always 15).
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
|
||||
// then never touches the large `LightTables::cells` table.
|
||||
let mut states = vec![0u16; W * W * h];
|
||||
let mut sky = vec![0u8; W * W * h];
|
||||
let mut block = vec![0u8; W * W * h];
|
||||
let mut queue = VecDeque::new();
|
||||
let mut sky_queue = VecDeque::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
let total = W * W * h;
|
||||
|
||||
scratch.states.resize(total, 0);
|
||||
scratch.states.fill(0);
|
||||
scratch.sky.resize(total, 0);
|
||||
scratch.sky.fill(0);
|
||||
scratch.block.resize(total, 0);
|
||||
scratch.block.fill(0);
|
||||
scratch.clear_queues();
|
||||
|
||||
let states = &mut scratch.states[..total];
|
||||
let sky = &mut scratch.sky[..total];
|
||||
let block = &mut scratch.block[..total];
|
||||
let queue = &mut scratch.queue;
|
||||
let sky_queue = &mut scratch.sky_queue;
|
||||
let ids = &mut scratch.ids;
|
||||
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
let z_base = dz * 16;
|
||||
for (dx, chunk) in row.iter().enumerate() {
|
||||
let Some(chunk) = chunk else { continue };
|
||||
let x_base = dx * 16;
|
||||
for (si, section) in chunk.sections.iter().enumerate().take(top_section) {
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut ids);
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push_back(p as u32);
|
||||
section.unpack(ids);
|
||||
let y_base = si * 16;
|
||||
for ly in 0..16 {
|
||||
let y = y_base + ly;
|
||||
let y_offset = y * (W * W);
|
||||
for lz in 0..16 {
|
||||
let z = z_base + lz;
|
||||
let p_row = y_offset + z * W + x_base;
|
||||
let i_row = (ly << 8) | (lz << 4);
|
||||
for lx in 0..16 {
|
||||
let id = ids[i_row + lx];
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
let p = p_row + lx;
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Columns of missing neighbour chunks neither hold nor pass light.
|
||||
let mut present = vec![false; W * W];
|
||||
scratch.present.resize(W * W, false);
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
scratch.present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
}
|
||||
}
|
||||
let mut lowest = vec![h as i32; W * W];
|
||||
let present = &scratch.present[..W * W];
|
||||
|
||||
scratch.lowest.resize(W * W, h as i32);
|
||||
let lowest = &mut scratch.lowest[..W * W];
|
||||
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
let col = z * W + x;
|
||||
if !present[col] || !sky_light {
|
||||
lowest[col] = h as i32;
|
||||
continue;
|
||||
}
|
||||
// The top layer (above the occupied range) is always open sky.
|
||||
let mut y = h as i32 - 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
while y > 0
|
||||
&& !tables.packed_edge_occluded(
|
||||
states[at(x, y as usize, z)],
|
||||
states[at(x, y as usize - 1, z)],
|
||||
)
|
||||
{
|
||||
let mut p = (h - 1) * (W * W) + col;
|
||||
sky[p] = 15;
|
||||
while y > 0 {
|
||||
let next_p = p - (W * W);
|
||||
if tables.packed_edge_occluded(states[p], states[next_p]) {
|
||||
break;
|
||||
}
|
||||
y -= 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
p = next_p;
|
||||
sky[p] = 15;
|
||||
}
|
||||
lowest[z * W + x] = y;
|
||||
lowest[col] = y;
|
||||
}
|
||||
}
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let own = lowest[z * W + x];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
|
||||
if sky_light {
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
let col = z * W + x;
|
||||
if !present[col] {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Seed propagation from the source cells that border darker cells.
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h - 1);
|
||||
for y in from..=to {
|
||||
if sky[at(x, y, z)] == 15 {
|
||||
sky_queue.push_back(at(x, y, z) as u32);
|
||||
let own = lowest[col];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
}
|
||||
}
|
||||
// Seed propagation from the source cells that border darker cells.
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h.saturating_sub(1));
|
||||
if from <= to {
|
||||
let mut p = from * (W * W) + col;
|
||||
for _ in from..=to {
|
||||
if sky[p] == 15 {
|
||||
sky_queue.push(p as u32);
|
||||
}
|
||||
p += W * W;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let spread = |light: &mut [u8], queue: &mut VecDeque<u32>| {
|
||||
while let Some(p) = queue.pop_front() {
|
||||
let p = p as usize;
|
||||
|
||||
let block_masks = &tables.block_masks;
|
||||
let types = tables.types;
|
||||
|
||||
let spread = |light: &mut [u8], queue: &mut Vec<u32>| {
|
||||
let mut head = 0;
|
||||
while head < queue.len() {
|
||||
let p = queue[head] as usize;
|
||||
head += 1;
|
||||
let level = light[p];
|
||||
if level <= 1 {
|
||||
continue;
|
||||
}
|
||||
let x = p % W;
|
||||
let z = (p / W) % W;
|
||||
let min_threshold = level - 1;
|
||||
let y = p / (W * W);
|
||||
let col = p % (W * W);
|
||||
let z = col / W;
|
||||
let x = col % W;
|
||||
let from = states[p];
|
||||
let col = z * W + x;
|
||||
let from_row = (from >> 4) as usize * types;
|
||||
|
||||
// Neighbours by linear offset; `present` only depends on the
|
||||
// column, and the axis bounds rule out wrapping.
|
||||
macro_rules! neighbour {
|
||||
($d:expr, $q:expr, $ncol:expr) => {{
|
||||
if present[$ncol] {
|
||||
let q = $q;
|
||||
macro_rules! step_neighbour {
|
||||
($d:expr, $q:expr) => {{
|
||||
let q = $q;
|
||||
if light[q] < min_threshold {
|
||||
let to = states[q];
|
||||
let next = level.saturating_sub((to & 0xF).max(1) as u8);
|
||||
if next > light[q] && !tables.packed_blocked(from, to, $d) {
|
||||
light[q] = next;
|
||||
queue.push_back(q as u32);
|
||||
let damp = (to & 0xF).max(1) as u8;
|
||||
if level > light[q] + damp {
|
||||
if block_masks[from_row + (to >> 4) as usize] & (1 << $d) == 0 {
|
||||
light[q] = level - damp;
|
||||
queue.push(q as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
if y > 0 {
|
||||
neighbour!(0, p - W * W, col);
|
||||
step_neighbour!(0, p - W * W);
|
||||
}
|
||||
if y + 1 < h {
|
||||
neighbour!(1, p + W * W, col);
|
||||
step_neighbour!(1, p + W * W);
|
||||
}
|
||||
if z > 0 {
|
||||
neighbour!(2, p - W, col - W);
|
||||
if z > 0 && present[col - W] {
|
||||
step_neighbour!(2, p - W);
|
||||
}
|
||||
if z + 1 < W {
|
||||
neighbour!(3, p + W, col + W);
|
||||
if z + 1 < W && present[col + W] {
|
||||
step_neighbour!(3, p + W);
|
||||
}
|
||||
if x > 0 {
|
||||
neighbour!(4, p - 1, col - 1);
|
||||
if x > 0 && present[col - 1] {
|
||||
step_neighbour!(4, p - 1);
|
||||
}
|
||||
if x + 1 < W {
|
||||
neighbour!(5, p + 1, col + 1);
|
||||
if x + 1 < W && present[col + 1] {
|
||||
step_neighbour!(5, p + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
spread(sky, sky_queue);
|
||||
spread(block, queue);
|
||||
let result = pack_lighting(sections, h, sky, block, sky_light);
|
||||
scratch.clear_queues();
|
||||
result
|
||||
}
|
||||
|
||||
/// Extract just the centre column's light (`sections` of it); never copy
|
||||
@@ -434,7 +540,6 @@ fn pack_lighting(
|
||||
block: &[u8],
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
@@ -447,10 +552,13 @@ fn pack_lighting(
|
||||
if y >= h {
|
||||
continue;
|
||||
}
|
||||
let y_offset = y * (W * W);
|
||||
for lz in 0..16 {
|
||||
let p_row = y_offset + (16 + lz) * W + 16;
|
||||
let out_row = (ly << 8) | (lz << 4);
|
||||
for lx in 0..16 {
|
||||
let p = at(16 + lx, y, 16 + lz);
|
||||
data[index(lx as i32, ly as i32, lz as i32)] = sky[p] << 4 | block[p];
|
||||
let p = p_row + lx;
|
||||
data[out_row + lx] = sky[p] << 4 | block[p];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1832,4 +1940,336 @@ mod tests {
|
||||
changed.sections().count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn benchmark_light_chunk() {
|
||||
use std::time::Instant;
|
||||
let t = tables();
|
||||
let layout = ColumnLayout::VANILLA;
|
||||
let mut chunks = Vec::new();
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
let mut c = Chunk::with_layout(cx, cz, layout);
|
||||
for y in layout.min_y()..64 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
for x in 2..14 {
|
||||
for z in 2..14 {
|
||||
c.set(x, 64, z, SLAB);
|
||||
}
|
||||
}
|
||||
c.set(8, 65, 8, TORCH);
|
||||
c.set(4, 30, 4, TORCH);
|
||||
chunks.push(c);
|
||||
}
|
||||
}
|
||||
let region = [
|
||||
[Some(&chunks[0]), Some(&chunks[1]), Some(&chunks[2])],
|
||||
[Some(&chunks[3]), Some(&chunks[4]), Some(&chunks[5])],
|
||||
[Some(&chunks[6]), Some(&chunks[7]), Some(&chunks[8])],
|
||||
];
|
||||
|
||||
for _ in 0..5 {
|
||||
let _ = light_chunk(®ion, &t, true);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let iters = 200;
|
||||
for _ in 0..iters {
|
||||
let _ = std::hint::black_box(light_chunk(std::hint::black_box(®ion), &t, true));
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
println!(
|
||||
"benchmark_light_chunk: {:?} total, {:.2} µs/chunk",
|
||||
elapsed,
|
||||
elapsed.as_micros() as f64 / iters as f64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_queues_shrink_above_capacity_cap() {
|
||||
let mut scratch = LightScratch::new();
|
||||
scratch.queue.resize(100_000, 0);
|
||||
scratch.sky_queue.resize(100_000, 0);
|
||||
assert!(scratch.queue.capacity() >= 100_000);
|
||||
assert!(scratch.sky_queue.capacity() >= 100_000);
|
||||
scratch.clear_queues();
|
||||
assert_eq!(scratch.queue.len(), 0);
|
||||
assert_eq!(scratch.sky_queue.len(), 0);
|
||||
assert!(scratch.queue.capacity() < 100_000);
|
||||
assert!(scratch.sky_queue.capacity() < 100_000);
|
||||
}
|
||||
|
||||
/// The baseline flood-fill lighting algorithm before the scratch and wavefront optimizations.
|
||||
fn old_light_chunk(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
let mut top_section = 0;
|
||||
for chunk in region.iter().flatten().flatten() {
|
||||
for (i, s) in chunk.sections.iter().enumerate().rev() {
|
||||
if !s.is_uniform(0) {
|
||||
top_section = top_section.max(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let mut states = vec![0u16; W * W * h];
|
||||
let mut sky = vec![0u8; W * W * h];
|
||||
let mut block = vec![0u8; W * W * h];
|
||||
let mut queue = VecDeque::new();
|
||||
let mut sky_queue = VecDeque::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
for (dx, chunk) in row.iter().enumerate() {
|
||||
let Some(chunk) = chunk else { continue };
|
||||
for (si, section) in chunk.sections.iter().enumerate().take(top_section) {
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut ids);
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push_back(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut present = vec![false; W * W];
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
}
|
||||
}
|
||||
let mut lowest = vec![h as i32; W * W];
|
||||
let old_packed_edge_occluded = |top: u16, bottom: u16| -> bool {
|
||||
bottom & 0xF != 0 || tables.packed_blocked(top, bottom, Direction::Down as usize)
|
||||
};
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let mut y = h as i32 - 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
while y > 0
|
||||
&& !old_packed_edge_occluded(
|
||||
states[at(x, y as usize, z)],
|
||||
states[at(x, y as usize - 1, z)],
|
||||
)
|
||||
{
|
||||
y -= 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
}
|
||||
lowest[z * W + x] = y;
|
||||
}
|
||||
}
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let own = lowest[z * W + x];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
}
|
||||
}
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h.saturating_sub(1));
|
||||
for y in from..=to {
|
||||
if sky[at(x, y, z)] == 15 {
|
||||
sky_queue.push_back(at(x, y, z) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let spread = |light: &mut [u8], queue: &mut VecDeque<u32>| {
|
||||
while let Some(p) = queue.pop_front() {
|
||||
let p = p as usize;
|
||||
let level = light[p];
|
||||
if level <= 1 {
|
||||
continue;
|
||||
}
|
||||
let x = p % W;
|
||||
let z = (p / W) % W;
|
||||
let y = p / (W * W);
|
||||
let from = states[p];
|
||||
let col = z * W + x;
|
||||
macro_rules! neighbour {
|
||||
($d:expr, $q:expr, $ncol:expr) => {{
|
||||
if present[$ncol] {
|
||||
let q = $q;
|
||||
let to = states[q];
|
||||
let next = level.saturating_sub((to & 0xF).max(1) as u8);
|
||||
if next > light[q] && !tables.packed_blocked(from, to, $d) {
|
||||
light[q] = next;
|
||||
queue.push_back(q as u32);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
if y > 0 {
|
||||
neighbour!(0, p - W * W, col);
|
||||
}
|
||||
if y + 1 < h {
|
||||
neighbour!(1, p + W * W, col);
|
||||
}
|
||||
if z > 0 {
|
||||
neighbour!(2, p - W, col - W);
|
||||
}
|
||||
if z + 1 < W {
|
||||
neighbour!(3, p + W, col + W);
|
||||
}
|
||||
if x > 0 {
|
||||
neighbour!(4, p - 1, col - 1);
|
||||
}
|
||||
if x + 1 < W {
|
||||
neighbour!(5, p + 1, col + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_chunk_matches_old_algorithm() {
|
||||
let t = tables();
|
||||
let layout = ColumnLayout::VANILLA;
|
||||
|
||||
let mut rng = 0x1234_5678_u64;
|
||||
let mut next = |max: usize| -> usize {
|
||||
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
((rng >> 32) as usize) % max
|
||||
};
|
||||
|
||||
let cases = [
|
||||
// (sky_light, missing_pattern)
|
||||
// 0 = full 3x3
|
||||
// 1 = missing corners
|
||||
// 2 = missing edges
|
||||
// 3 = random missing neighbours
|
||||
// 4 = only centre and single neighbour
|
||||
(true, 0),
|
||||
(false, 0),
|
||||
(true, 1),
|
||||
(false, 1),
|
||||
(true, 2),
|
||||
(false, 2),
|
||||
(true, 3),
|
||||
(false, 3),
|
||||
(true, 4),
|
||||
(false, 4),
|
||||
];
|
||||
|
||||
for (round, &(sky_light, pattern)) in cases.iter().enumerate() {
|
||||
let mut chunks = Vec::new();
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
let mut c = Chunk::with_layout(cx, cz, layout);
|
||||
let base_y = -64 + next(64) as i32;
|
||||
for y in layout.min_y()..base_y {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
let features = 10 + next(20);
|
||||
for _ in 0..features {
|
||||
let fx = next(16) as i32;
|
||||
let fz = next(16) as i32;
|
||||
let fy = base_y + next(32) as i32;
|
||||
let block_type = match next(4) {
|
||||
0 => AIR,
|
||||
1 => STONE,
|
||||
2 => TORCH,
|
||||
_ => SLAB,
|
||||
};
|
||||
c.set(fx, fy, fz, block_type);
|
||||
}
|
||||
let roof_y = base_y + 10 + next(10) as i32;
|
||||
for rx in 4..12 {
|
||||
for rz in 4..12 {
|
||||
if next(3) != 0 {
|
||||
c.set(rx, roof_y, rz, SLAB);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.set(8, roof_y - 1, 8, TORCH);
|
||||
c.set(next(16) as i32, roof_y + 2, next(16) as i32, TORCH);
|
||||
c.set(0, base_y + 1, 0, TORCH);
|
||||
c.set(15, base_y + 1, 15, TORCH);
|
||||
|
||||
chunks.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
let mut region: [[Option<&Chunk>; 3]; 3] = [[None; 3]; 3];
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
let is_centre = dz == 1 && dx == 1;
|
||||
let chunk_ref = &chunks[dz * 3 + dx];
|
||||
let include = if is_centre {
|
||||
true
|
||||
} else {
|
||||
match pattern {
|
||||
0 => true,
|
||||
1 => (dz + dx) % 2 == 1,
|
||||
2 => (dz + dx) % 2 == 0,
|
||||
3 => next(10) < 6,
|
||||
4 => dz == 1 && dx == 2,
|
||||
_ => true,
|
||||
}
|
||||
};
|
||||
if include {
|
||||
region[dz][dx] = Some(chunk_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let old = old_light_chunk(®ion, &t, sky_light);
|
||||
let new = light_chunk(®ion, &t, sky_light);
|
||||
|
||||
assert_eq!(
|
||||
old.len(),
|
||||
new.len(),
|
||||
"round {round}: section count mismatch"
|
||||
);
|
||||
for si in 0..old.len() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
new[si].get(i),
|
||||
old[si].get(i),
|
||||
"mismatch at round {round} (sky_light={sky_light}, pattern={pattern}), section {si}, index {i} (lx={}, ly={}, lz={}): new={:#04x}, old={:#04x}",
|
||||
i & 15,
|
||||
i >> 8,
|
||||
(i >> 4) & 15,
|
||||
new[si].get(i),
|
||||
old[si].get(i)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,41 @@ mod vertex_tests {
|
||||
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_mesh_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compute_bounds_empty_solid_and_translucent() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
assert_eq!(mesh.compute_bounds(), None);
|
||||
|
||||
let v1 = TerrainVertex::new([1.0, 2.0, 3.0], [0.0, 0.0], 0, 0, 0);
|
||||
let v2 = TerrainVertex::new([4.0, 5.0, 6.0], [0.0, 0.0], 0, 0, 0);
|
||||
mesh.solid.push(v1);
|
||||
mesh.solid.push(v2);
|
||||
|
||||
let pad = glam::Vec3::splat(SectionMesh::BOUNDS_PAD);
|
||||
assert_eq!(
|
||||
mesh.compute_bounds(),
|
||||
Some([
|
||||
glam::Vec3::new(1.0, 2.0, 3.0) - pad,
|
||||
glam::Vec3::new(4.0, 5.0, 6.0) + pad,
|
||||
])
|
||||
);
|
||||
|
||||
let v3 = TerrainVertex::new([0.5, 7.0, 8.0], [0.0, 0.0], 0, 0, 0);
|
||||
mesh.translucent.push(v3);
|
||||
assert_eq!(
|
||||
mesh.compute_bounds(),
|
||||
Some([
|
||||
glam::Vec3::new(0.5, 2.0, 3.0) - pad,
|
||||
glam::Vec3::new(4.0, 7.0, 8.0) + pad,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
@@ -162,6 +197,8 @@ pub struct SectionMesh {
|
||||
/// each frame by `block_entity_render` like vanilla's per-section
|
||||
/// `SectionCompiler.Results.blockEntities`.
|
||||
pub block_entities: Vec<([i32; 3], u32)>,
|
||||
/// Axis-aligned bounding box [min, max] of actual terrain mesh vertices in section-local space [0, 16].
|
||||
pub bounds: Option<[glam::Vec3; 2]>,
|
||||
}
|
||||
impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
@@ -171,19 +208,66 @@ impl Default for SectionMesh {
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
bounds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectionMesh {
|
||||
/// Padding applied to mesh vertex bounds to prevent clipping at the frustum
|
||||
/// boundary when shader packs with vertex displacement (e.g. waving foliage, water) are active.
|
||||
pub const BOUNDS_PAD: f32 = 0.5;
|
||||
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
self.sides = Sides::default();
|
||||
self.visibility = ALL_VISIBLE;
|
||||
self.bounds = None;
|
||||
}
|
||||
pub(crate) fn capacity_bytes(&self) -> usize {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
}
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
/// leave out those facing away from the eye (`sides`).
|
||||
pub fn sort_solid(&mut self) {
|
||||
self.sides = sides::sort(&mut self.solid);
|
||||
self.bounds = self.compute_bounds();
|
||||
}
|
||||
|
||||
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
|
||||
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
|
||||
if self.solid.is_empty() && self.translucent.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut min = glam::Vec3::splat(f32::INFINITY);
|
||||
let mut max = glam::Vec3::splat(f32::NEG_INFINITY);
|
||||
for v in &self.solid {
|
||||
let p = glam::Vec3::from(v.pos);
|
||||
min = min.min(p);
|
||||
max = max.max(p);
|
||||
}
|
||||
for v in &self.translucent {
|
||||
let p = glam::Vec3::from(v.pos);
|
||||
min = min.min(p);
|
||||
max = max.max(p);
|
||||
}
|
||||
let pad = glam::Vec3::splat(Self::BOUNDS_PAD);
|
||||
Some([min - pad, max + pad])
|
||||
}
|
||||
}
|
||||
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
#[cfg(test)]
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
visibility_reusing(pad, info, &mut Vec::new())
|
||||
}
|
||||
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
|
||||
// The all-visible shortcut can leave a partially filled stack behind.
|
||||
stack.clear();
|
||||
// Opaque cells are already visited: resolve each state's opacity once,
|
||||
// then reuse this map for the flood fill and the uniform-section shortcuts.
|
||||
let mut seen = [false; 4096];
|
||||
@@ -205,7 +289,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
return 0;
|
||||
}
|
||||
let mut result = 0u64;
|
||||
let mut stack = Vec::new();
|
||||
for start in 0..4096usize {
|
||||
if seen[start] {
|
||||
continue;
|
||||
@@ -462,7 +545,12 @@ impl MeshTables {
|
||||
});
|
||||
let mut face = [0u8; 6];
|
||||
let mut cover = [0f32; 6];
|
||||
for d in Direction::ALL {
|
||||
// A server's visibility knows no state (`FaceVisibility::empty`):
|
||||
// it meshes no fluid, so these stay unset.
|
||||
for d in Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|_| !visibility.is_empty())
|
||||
{
|
||||
let (face_id, shape) = visibility
|
||||
.occlusion_face(BlockStateId(id as u32), d)
|
||||
.ok_or("missing occlusion face")?;
|
||||
@@ -635,21 +723,30 @@ fn pi(x: i32, y: i32, z: i32) -> usize {
|
||||
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
|
||||
}
|
||||
/// Blocks and light of one section plus a `B`-block border.
|
||||
#[derive(Default)]
|
||||
struct Padded {
|
||||
blocks: Vec<u32>,
|
||||
light: Vec<u8>,
|
||||
}
|
||||
impl Padded {
|
||||
#[cfg(test)]
|
||||
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
|
||||
let mut blocks = vec![0u32; P * P * P];
|
||||
let mut light = vec![0u8; P * P * P];
|
||||
let mut pad = Self::default();
|
||||
pad.fill(region, sy, &mut Box::new([0; 4096]));
|
||||
pad
|
||||
}
|
||||
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
|
||||
self.blocks.resize(P * P * P, 0);
|
||||
self.blocks.fill(0);
|
||||
self.light.resize(P * P * P, 0);
|
||||
let (blocks, light) = (&mut self.blocks, &mut self.light);
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
let mut own = Box::new([0u32; 4096]);
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(&mut own);
|
||||
section.unpack(own);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
@@ -681,9 +778,18 @@ impl Padded {
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { blocks, light }
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 88 KiB per worker.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
@@ -864,14 +970,30 @@ pub struct Mesher<'a> {
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = SectionMesh::default();
|
||||
self.mesh_reusing(
|
||||
region,
|
||||
sy,
|
||||
SectionMesh::default(),
|
||||
&mut MeshScratch::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
mut out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
out.clear();
|
||||
let center = region[1][1].expect("centre chunk");
|
||||
let section = ¢er.sections[sy as usize];
|
||||
if section.is_uniform(0) {
|
||||
return out;
|
||||
}
|
||||
let pad = Padded::new(region, sy);
|
||||
out.visibility = visibility(&pad, &self.tables.info);
|
||||
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
@@ -906,7 +1028,7 @@ impl Mesher<'_> {
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let nether = self.nether_shading;
|
||||
fluid::tesselate(self.tables, nether, &pad, water, &mut out, [x, y, z]);
|
||||
fluid::tesselate(self.tables, nether, pad, water, &mut out, [x, y, z]);
|
||||
}
|
||||
if !state.model {
|
||||
continue;
|
||||
@@ -956,7 +1078,7 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
self.face(
|
||||
&pad,
|
||||
pad,
|
||||
&mut tints,
|
||||
&mut out,
|
||||
[x, y, z],
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
|
||||
let mut pad = Padded::default();
|
||||
let mut own = Box::new([u32::MAX; 4096]);
|
||||
for min_y in [MIN_Y, -256, 0, 256] {
|
||||
let layout = crate::chunk::ColumnLayout::new(min_y, 32).unwrap();
|
||||
let base_y = min_y + 16;
|
||||
let mut center = Chunk::with_layout(0, 0, layout);
|
||||
center.set(0, base_y, 0, 7);
|
||||
center.set(15, base_y + 15, 15, 9);
|
||||
let mut neighbor = Chunk::with_layout(1, 0, layout);
|
||||
neighbor.set(0, base_y + 2, 3, 11);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
region[1][2] = Some(&neighbor);
|
||||
pad.fill(®ion, 1, &mut own);
|
||||
let buffers = (pad.blocks.as_ptr(), pad.light.as_ptr());
|
||||
for current in [region, [[None; 3]; 3], region] {
|
||||
pad.fill(¤t, 1, &mut own);
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let chunk = current[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
// A missing neighbour continues the centre's edge column.
|
||||
let (block, light) = match (chunk, current[1][1]) {
|
||||
(Some(c), _) => (
|
||||
c.get(x & 15, base_y + y, z & 15),
|
||||
c.light(x & 15, base_y + y, z & 15),
|
||||
),
|
||||
(None, Some(c)) => {
|
||||
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
|
||||
(c.get(ex, base_y + y, ez), c.light(ex, base_y + y, ez))
|
||||
}
|
||||
(None, None) => (0, 0xF0),
|
||||
};
|
||||
assert_eq!(pad.blocks[pi(x, y, z)], block);
|
||||
assert_eq!(pad.light[pi(x, y, z)], light);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(buffers, (pad.blocks.as_ptr(), pad.light.as_ptr()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let names = [
|
||||
"minecraft:stone",
|
||||
"minecraft:glass",
|
||||
"minecraft:grass_block",
|
||||
"minecraft:oak_leaves",
|
||||
"minecraft:water",
|
||||
"minecraft:chest",
|
||||
];
|
||||
let mut dense = Chunk::empty(0, 0);
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
dense.set(
|
||||
x,
|
||||
MIN_Y + 4,
|
||||
z,
|
||||
state(names[((x + z) as usize) % names.len()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut sparse = Chunk::empty(-3, 5);
|
||||
sparse.set(0, MIN_Y + 15, 0, state("minecraft:short_grass"));
|
||||
sparse.set(15, MIN_Y + 1, 15, state("minecraft:lava"));
|
||||
let empty = Chunk::empty(0, 0);
|
||||
let mut neighbor = Chunk::empty(1, 0);
|
||||
neighbor.set(0, MIN_Y + 4, 0, state("minecraft:stone"));
|
||||
let mut custom = Chunk::with_layout(2, -4, crate::chunk::ColumnLayout::new(-256, 64).unwrap());
|
||||
custom.set(1, -252, 2, state("minecraft:chest"));
|
||||
let mut scratch = MeshScratch::default();
|
||||
let mut recycled = SectionMesh::default();
|
||||
// The same scratch and output buffers survive a resource reload. They must
|
||||
// contain no references into the previous RenderAssets or MeshTables.
|
||||
for reload in 0..2 {
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility,
|
||||
zoom: BiomeZoom::new(42),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: reload == 0,
|
||||
cutout_leaves: reload == 0,
|
||||
};
|
||||
for (case, (center, east)) in [
|
||||
(&empty, None),
|
||||
(&dense, Some(&neighbor)),
|
||||
(&sparse, None),
|
||||
(&empty, None),
|
||||
(&dense, None),
|
||||
(&custom, None),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(center);
|
||||
region[1][2] = east;
|
||||
let mut fresh = mesher.mesh(®ion, 0);
|
||||
recycled = mesher.mesh_reusing(®ion, 0, recycled, &mut scratch);
|
||||
if reload == 0 && case == 0 {
|
||||
assert!(scratch.own.is_none());
|
||||
assert_eq!(scratch.padded.blocks.capacity(), 0);
|
||||
assert_eq!(scratch.padded.light.capacity(), 0);
|
||||
assert_eq!(scratch.visibility.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.solid),
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.solid)
|
||||
);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.translucent),
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.translucent)
|
||||
);
|
||||
assert_eq!(fresh.block_entities, recycled.block_entities);
|
||||
assert_eq!(fresh.visibility, recycled.visibility);
|
||||
assert_eq!(fresh.sides, recycled.sides);
|
||||
assert_eq!(fresh.bounds, recycled.bounds);
|
||||
if std::ptr::eq(center, &dense) {
|
||||
assert!(!fresh.solid.is_empty());
|
||||
assert!(!fresh.translucent.is_empty());
|
||||
assert!(!fresh.block_entities.is_empty());
|
||||
}
|
||||
if std::ptr::eq(center, &custom) {
|
||||
assert_eq!(
|
||||
fresh.block_entities,
|
||||
[([33, -252, -62], state("minecraft:chest"))]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An isolated scratch-stage comparison, not a whole-mesher or FPS benchmark.
|
||||
/// Output each sample so the caller can retain the observations and spread.
|
||||
#[test]
|
||||
#[ignore = "opt-in scratch microbenchmark; run with --nocapture --test-threads=1"]
|
||||
fn benchmark_padded_reuse() {
|
||||
const ITERATIONS: usize = 4096;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
chunk.set(x, MIN_Y + y, z, 1 + ((x + y + z) % 7) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let mut pad = Padded::new(®ion, 0);
|
||||
let mut own = Box::new([0; 4096]);
|
||||
let pointers = (pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr());
|
||||
let bytes = pad.blocks.capacity() * 4 + pad.light.capacity() + 4096 * 4;
|
||||
// Warm both code paths and the process allocator before recording samples.
|
||||
for _ in 0..1024 {
|
||||
std::hint::black_box(Padded::new(std::hint::black_box(®ion), 0));
|
||||
pad.fill(std::hint::black_box(®ion), 0, &mut own);
|
||||
std::hint::black_box(&pad);
|
||||
}
|
||||
for sample in 0..9 {
|
||||
// Alternate execution order to expose, rather than hide, order effects.
|
||||
for reused in [sample % 2 == 0, sample % 2 != 0] {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..ITERATIONS {
|
||||
if reused {
|
||||
pad.fill(std::hint::black_box(®ion), 0, &mut own);
|
||||
std::hint::black_box(&pad);
|
||||
} else {
|
||||
std::hint::black_box(Padded::new(std::hint::black_box(®ion), 0));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{{\"sample\":{sample},\"reused\":{reused},\"iterations\":{ITERATIONS},\"elapsed_ms\":{:.6},\"scratch_bytes\":{bytes}}}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
assert_eq!(
|
||||
pointers,
|
||||
(pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,21 @@ fn visibility_matches_reference() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let mut stack = vec![usize::MAX];
|
||||
for _ in 0..3 {
|
||||
for (name, pad) in cases() {
|
||||
assert_eq!(
|
||||
visibility_reusing(&pad, &info, &mut stack),
|
||||
reference(&pad, &info),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_preserves_every_face_pair() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
|
||||
@@ -12,6 +12,11 @@ use std::{
|
||||
pub const FILE: &str = "horizon-mods.txt";
|
||||
/// The folder holding `.hmod` files, beside `options.txt`.
|
||||
pub const DIR: &str = "horizon-mods";
|
||||
/// Where the launcher installs native mods (`<id>.hnmod`), beside [`DIR`]. They run only in a
|
||||
/// game the launcher built with them.
|
||||
pub const NATIVE_DIR: &str = "horizon-native-mods";
|
||||
/// Why a native mod installed in [`NATIVE_DIR`] doesn't run in this game.
|
||||
pub const NOT_BUILT: &str = "Installed, not built into this game: build the game with it in the launcher (Settings > Native mods), then play the instance that build is in.";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModList {
|
||||
|
||||
@@ -128,7 +128,6 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
@@ -873,6 +872,7 @@ impl Opt {
|
||||
| Opt::HideLightningFlashes
|
||||
| Opt::HideSplashTexts
|
||||
| Opt::HighContrastBlockOutlines
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
@@ -1988,7 +1988,9 @@ impl OptionsUi {
|
||||
.map(|(i, installed)| {
|
||||
let mut label = Text::literal(format!("{}: ", installed.title))
|
||||
.append(on_off(s.mods.is_on(i)));
|
||||
if installed.problem.is_some() {
|
||||
if installed.problem.as_deref() == Some(crate::mod_list::NOT_BUILT) {
|
||||
label = label.append(tr("horizon.mods.not_built"));
|
||||
} else if installed.problem.is_some() {
|
||||
label = label.append(tr("horizon.mods.stopped"));
|
||||
} else if installed.dev {
|
||||
label = label.append(tr("horizon.mods.dev"));
|
||||
@@ -3500,10 +3502,12 @@ mod tests {
|
||||
assert_eq!(loaded.file.double(opt.file_key().unwrap()), expected);
|
||||
}
|
||||
}
|
||||
assert!(Opt::MonochromeLogo.available());
|
||||
for opt in [
|
||||
Opt::TextBackground,
|
||||
Opt::HideLightningFlashes,
|
||||
Opt::HideSplashTexts,
|
||||
Opt::MonochromeLogo,
|
||||
] {
|
||||
let key = opt.file_key().unwrap();
|
||||
let before = s.file.bool(key);
|
||||
|
||||
@@ -582,31 +582,11 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
|
||||
}
|
||||
}
|
||||
/// A directory junction, which Windows creates without privileges (a directory symlink needs
|
||||
/// developer mode). The mirror falls back to copying the tree when it fails.
|
||||
/// A directory junction (`horizon_threads::junction`); the mirror falls back to copying the
|
||||
/// tree when it fails.
|
||||
#[cfg(windows)]
|
||||
fn junction(target: &Path, at: &Path) -> io::Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an
|
||||
// unquoted path would split or change the command. A Windows path cannot hold `"`.
|
||||
let quoted = |path: &Path| -> io::Result<std::ffi::OsString> {
|
||||
let mut quoted = std::ffi::OsString::from("\"");
|
||||
quoted.push(std::path::absolute(path)?);
|
||||
quoted.push("\"");
|
||||
Ok(quoted)
|
||||
};
|
||||
let status = std::process::Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.raw_arg(quoted(at)?)
|
||||
.raw_arg(quoted(target)?)
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()?;
|
||||
status
|
||||
.success()
|
||||
.then_some(())
|
||||
.ok_or_else(|| io::Error::other("mklink /J failed"))
|
||||
horizon_threads::junction(target, at)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn junction(_: &Path, _: &Path) -> io::Result<()> {
|
||||
|
||||
@@ -844,6 +844,12 @@ impl SkinSlots {
|
||||
kept: Default::default(),
|
||||
}
|
||||
}
|
||||
/// The atlas was replaced (a resource pack reload): every slot is blank,
|
||||
/// so no skin counts as stored and each uploads again.
|
||||
pub fn forget(&mut self) {
|
||||
self.slots.clear();
|
||||
self.kept.clear();
|
||||
}
|
||||
/// Whether `location` is in a slot.
|
||||
pub fn contains(&self, location: &str) -> bool {
|
||||
self.slots.iter().any(|(l, _)| l == location)
|
||||
@@ -910,6 +916,23 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
|
||||
/// table holds nothing and hands out slots from the start again, even for
|
||||
/// skins drawn in the last frame.
|
||||
#[test]
|
||||
fn a_new_atlas_forgets_every_skin_slot() {
|
||||
use super::SkinSlots;
|
||||
let mut slots = SkinSlots::new(2);
|
||||
assert_eq!(slots.place("skins/a".into()), Some((0, None)));
|
||||
assert_eq!(slots.place("skins/b".into()), Some((1, None)));
|
||||
assert_eq!(slots.place("skins/c".into()), None);
|
||||
slots.forget();
|
||||
assert!(!slots.contains("skins/a"));
|
||||
assert!(!slots.contains("skins/b"));
|
||||
assert_eq!(slots.place("skins/c".into()), Some((0, None)));
|
||||
assert_eq!(slots.place("skins/a".into()), Some((1, None)));
|
||||
}
|
||||
|
||||
/// The launcher's offline skin: a 64x64 or legacy 64x32 PNG laid out as
|
||||
/// a downloaded skin, slim only for the `slim` model; anything else is
|
||||
/// refused and the player keeps their default skin.
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
@@ -234,10 +234,39 @@ struct Shared {
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
/// Only workers use this pool; frames never wait for its lock. Retaining
|
||||
/// cleared buffers avoids racing the renderer for the process heap.
|
||||
meshes: Mutex<MeshPool>,
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MeshPool {
|
||||
meshes: Vec<SectionMesh>,
|
||||
bytes: usize,
|
||||
}
|
||||
impl MeshPool {
|
||||
const BYTES: usize = 16 << 20;
|
||||
const COUNT: usize = 128;
|
||||
fn take(&mut self) -> SectionMesh {
|
||||
let mesh = self.meshes.pop().unwrap_or_default();
|
||||
self.bytes -= mesh.capacity_bytes();
|
||||
mesh
|
||||
}
|
||||
/// A rejected mesh is dropped by the caller, after releasing the lock.
|
||||
fn put(&mut self, mut mesh: SectionMesh) -> Option<SectionMesh> {
|
||||
let bytes = mesh.capacity_bytes();
|
||||
if bytes == 0 || self.meshes.len() == Self::COUNT || bytes > Self::BYTES - self.bytes {
|
||||
return Some(mesh);
|
||||
}
|
||||
mesh.clear();
|
||||
self.bytes += bytes;
|
||||
self.meshes.push(mesh);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
/// half way through a send, and a worker descheduled there on a busy machine
|
||||
@@ -536,6 +565,7 @@ impl Streaming {
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
meshes: Mutex::new(MeshPool::default()),
|
||||
released: AtomicUsize::new(0),
|
||||
});
|
||||
let workers = threads.max(1);
|
||||
@@ -556,6 +586,7 @@ impl Streaming {
|
||||
// the worker across mesh jobs, like vanilla's per-thread cache:
|
||||
// cloning it per job copied every state and face shape.
|
||||
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
|
||||
let mut mesh_scratch = MeshScratch::default();
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
@@ -580,10 +611,12 @@ impl Streaming {
|
||||
if wait.timed_out() && queue.is_empty() {
|
||||
// A server nobody is on, a client standing still:
|
||||
// free the generator's buffers (and the mesher's
|
||||
// face cache) until the next job needs them.
|
||||
// face cache and scratch) until the next job
|
||||
// needs them.
|
||||
drop(queue);
|
||||
generator.idle(&mut worker);
|
||||
faces = None;
|
||||
mesh_scratch = MeshScratch::default();
|
||||
released = true;
|
||||
// The allocator keeps freed pages until memory
|
||||
// runs short: once every worker has freed its own,
|
||||
@@ -591,6 +624,12 @@ impl Streaming {
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== workers
|
||||
{
|
||||
// No worker needs retained mesh capacity now.
|
||||
// Drop it outside the pool lock before asking
|
||||
// the allocator to return freed pages.
|
||||
let meshes =
|
||||
std::mem::take(&mut *shared.meshes.lock().unwrap());
|
||||
drop(meshes);
|
||||
horizon_threads::release_free_memory();
|
||||
}
|
||||
queue = shared.queue.lock().unwrap();
|
||||
@@ -620,7 +659,10 @@ impl Streaming {
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
drop(meshes);
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
drop(rejected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
@@ -648,7 +690,13 @@ impl Streaming {
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let mut mesh = mesher.mesh(&borrow(®ion), sy);
|
||||
let recycled = shared.meshes.lock().unwrap().take();
|
||||
let mut mesh = mesher.mesh_reusing(
|
||||
&borrow(®ion),
|
||||
sy,
|
||||
recycled,
|
||||
&mut mesh_scratch,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
@@ -2139,6 +2187,68 @@ fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
|
||||
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
|
||||
mesh.block_entities.push(([1, 2, 3], 7));
|
||||
mesh.visibility = 0;
|
||||
mesh.sides = crate::mesher::Sides::unsorted(16);
|
||||
let capacities = mesh.capacity_bytes();
|
||||
let pointers = (mesh.solid.as_ptr(), mesh.translucent.as_ptr());
|
||||
let mut pool = MeshPool::default();
|
||||
assert!(pool.put(mesh).is_none());
|
||||
assert_eq!(pool.bytes, capacities);
|
||||
let reused = pool.take();
|
||||
assert_eq!(reused.capacity_bytes(), capacities);
|
||||
assert_eq!(
|
||||
pointers,
|
||||
(reused.solid.as_ptr(), reused.translucent.as_ptr())
|
||||
);
|
||||
assert!(reused.solid.is_empty());
|
||||
assert!(reused.translucent.is_empty());
|
||||
assert!(reused.block_entities.is_empty());
|
||||
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
|
||||
assert_eq!(reused.sides, crate::mesher::Sides::default());
|
||||
assert_eq!(pool.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_pool_caps_retained_count_and_bytes() {
|
||||
let mut pool = MeshPool::default();
|
||||
for _ in 0..MeshPool::COUNT {
|
||||
let mut mesh = SectionMesh::default();
|
||||
mesh.solid.reserve_exact(1);
|
||||
assert!(pool.put(mesh).is_none());
|
||||
}
|
||||
let mut extra = SectionMesh::default();
|
||||
extra.solid.reserve_exact(1);
|
||||
assert!(pool.put(extra).is_some());
|
||||
for _ in 0..MeshPool::COUNT {
|
||||
pool.take();
|
||||
}
|
||||
assert_eq!(pool.bytes, 0);
|
||||
for i in 0..2 {
|
||||
let mut mesh = SectionMesh::default();
|
||||
mesh.solid.reserve_exact(
|
||||
MeshPool::BYTES / std::mem::size_of::<crate::mesher::TerrainVertex>() / 2 + 1,
|
||||
);
|
||||
assert_eq!(pool.put(mesh).is_some(), i == 1);
|
||||
assert!(pool.bytes <= MeshPool::BYTES);
|
||||
assert_eq!(pool.meshes.len(), 1);
|
||||
}
|
||||
pool.take();
|
||||
assert_eq!(pool.bytes, 0);
|
||||
let mut huge = SectionMesh::default();
|
||||
huge.solid.reserve_exact(
|
||||
MeshPool::BYTES / std::mem::size_of::<crate::mesher::TerrainVertex>() + 1,
|
||||
);
|
||||
assert!(pool.put(huge).is_some());
|
||||
assert!(pool.meshes.is_empty());
|
||||
assert_eq!(pool.bytes, 0);
|
||||
}
|
||||
|
||||
/// The frame never waits for a worker: while one holds the mailbox, the
|
||||
/// frame takes nothing and finds the results, in order, the next time.
|
||||
#[test]
|
||||
|
||||
@@ -375,7 +375,7 @@ fn sky_fog(color: vec3<f32>, dist: vec2<f32>) -> vec3<f32> {
|
||||
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed):
|
||||
// Moller-Trumbore from the origin along d, over normals built once a frame
|
||||
// (gfx/sunrise_fan.rs). The barycentrics are apex/s, k1/s and -k0/s; s is -det.
|
||||
fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
fn sunrise_weight_full(d: vec3<f32>) -> f32 {
|
||||
if globals.sunrise.a <= 0.001 { return 0.0; }
|
||||
var best = 0.0;
|
||||
var k0 = dot(d, globals.sunrise_fan[16].xyz);
|
||||
@@ -395,6 +395,50 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
}
|
||||
return best;
|
||||
}
|
||||
// The rim's YZ projection is an ellipse. Comparing its unsquashed X/Y
|
||||
// magnitudes with tan(PI/8) selects one of sixteen sectors without atan2.
|
||||
fn sunrise_triangle_weight(d: vec3<f32>, i: u32) -> f32 {
|
||||
let k0 = dot(d, globals.sunrise_fan[16u+i].xyz);
|
||||
let k1 = dot(d, globals.sunrise_fan[16u+(i+1u)%16u].xyz);
|
||||
let triangle = globals.sunrise_fan[i];
|
||||
let apex = dot(d, triangle.xyz);
|
||||
let s = apex + k1 - k0;
|
||||
if abs(s) >= 1e-6 && s * triangle.w > 0.0 && apex * s >= 0.0
|
||||
&& k1 * s >= 0.0 && k0 * s <= 0.0 {
|
||||
return apex / s;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
if globals.sunrise.a <= 0.001 { return 0.0; }
|
||||
if dot(d.yz, d.yz) < 1e-10 { return sunrise_weight_full(d); }
|
||||
// The camera lies in the rim plane. A forward ray can reach the fan
|
||||
// only on the apex side. Keep a guard for rounded coplanar normals.
|
||||
let apex = dot(d, globals.sunrise_fan[0].xyz);
|
||||
if apex * globals.sunrise_fan[0].w < -1e-4 { return 0.0; }
|
||||
let y = dot(d, globals.sunrise_fan[16].xyz);
|
||||
let x = -dot(d, globals.sunrise_fan[20].xyz);
|
||||
let ax = abs(x);
|
||||
let ay = abs(y);
|
||||
let tangent = 0.414213562373095;
|
||||
let quadrant_sector = select(select(0u, 1u, ay > ax * tangent),
|
||||
select(2u, 3u, ax < ay * tangent), ay > ax);
|
||||
let sector = select(select(8u + quadrant_sector, 7u - quadrant_sector, y >= 0.0),
|
||||
select(15u - quadrant_sector, quadrant_sector, y >= 0.0), x >= 0.0);
|
||||
var best = max(sunrise_triangle_weight(d, sector),
|
||||
sunrise_triangle_weight(d, (sector + 8u) % 16u));
|
||||
// Test adjacent sectors only at a rounded boundary; their exact rim
|
||||
// normals remain the authority for hit tests and interpolation weights.
|
||||
let k0 = dot(d, globals.sunrise_fan[16u+sector].xyz);
|
||||
let k1 = dot(d, globals.sunrise_fan[16u+(sector+1u)%16u].xyz);
|
||||
if min(abs(k0), abs(k1)) < max(ax, ay) * 0.001 {
|
||||
best = max(best, sunrise_triangle_weight(d, (sector + 15u) % 16u));
|
||||
best = max(best, sunrise_triangle_weight(d, (sector + 1u) % 16u));
|
||||
best = max(best, sunrise_triangle_weight(d, (sector + 7u) % 16u));
|
||||
best = max(best, sunrise_triangle_weight(d, (sector + 9u) % 16u));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input:
|
||||
// gfx/native_sky.rs builds that stage from this section).
|
||||
fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#[path = "../src/gfx/sunrise_fan.rs"]
|
||||
mod sunrise_fan;
|
||||
|
||||
#[path = "../src/gfx/wgsl_prefix.rs"]
|
||||
mod wgsl_prefix;
|
||||
|
||||
fn sunrise_shader() -> String {
|
||||
let terrain = include_str!("../src/terrain.wgsl");
|
||||
let start = terrain.find("fn sunrise_weight_full").unwrap();
|
||||
let end = terrain[start..].find("// The sky's colour").unwrap() + start;
|
||||
let mut source = String::from(
|
||||
"struct Globals { sunrise: vec4<f32>, sunrise_fan: array<vec4<f32>,32> }\n\
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;\n\
|
||||
@group(0) @binding(1) var<storage,read> rays: array<vec4<f32>>;\n\
|
||||
@group(0) @binding(2) var<storage,read_write> results: array<vec2<f32>>;\n",
|
||||
);
|
||||
source.push_str(&terrain[start..end]);
|
||||
source.push_str(
|
||||
"@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) {\n\
|
||||
if id.x < arrayLength(&rays) {\n\
|
||||
let d = rays[id.x].xyz;\n\
|
||||
results[id.x] = vec2(sunrise_weight_full(d), sunrise_weight(d));\n\
|
||||
}\n\
|
||||
}\n",
|
||||
);
|
||||
source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracted_sunrise_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(&sunrise_shader()).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Execute the actual optimized and reference WGSL on a host GPU.
|
||||
// This opt-in test compares shipped WGSL with the full fan search, not FPS.
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn sunrise_sectors_match_reference_on_real_gpu() {
|
||||
use wgpu::util::DeviceExt;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Sunrise GPU correctness: {}", adapter.get_info().name);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(sunrise_shader().into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let mut rays = Vec::<[f32; 4]>::new();
|
||||
for yaw in 0..360 {
|
||||
for pitch in -89..90 {
|
||||
let (sy, cy) = (yaw as f32).to_radians().sin_cos();
|
||||
let (sp, cp) = (pitch as f32).to_radians().sin_cos();
|
||||
rays.push([sy * cp, sp, cy * cp, 0.]);
|
||||
}
|
||||
}
|
||||
rays.extend([
|
||||
[1., 0., 0., 0.],
|
||||
[-1., 0., 0., 0.],
|
||||
[0., 1., 0., 0.],
|
||||
[0., -1., 0., 0.],
|
||||
]);
|
||||
let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&rays),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
let size = (rays.len() * 8) as u64;
|
||||
let output = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
for alpha in [0.01, 0.2, 0.65, 1.] {
|
||||
for hemisphere in [-1., 1.] {
|
||||
let mut globals = vec![[1., 1., 1., alpha]];
|
||||
globals.extend(sunrise_fan::normals(
|
||||
alpha,
|
||||
glam::Vec3::new(0., 0., hemisphere),
|
||||
));
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let bindings = [&uniform, &input, &output];
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &bindings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: b.as_entire_binding(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups((rays.len() as u32).div_ceil(64), 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&output, 0, &read, 0, size);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
{
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
let pairs: &[[f32; 2]] = bytemuck::cast_slice(&mapped);
|
||||
for (i, pair) in pairs.iter().enumerate() {
|
||||
assert!(
|
||||
(pair[0] - pair[1]).abs() < 1e-4,
|
||||
"alpha={alpha} hemisphere={hemisphere} ray={:?} values={pair:?}",
|
||||
rays[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
read.unmap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_pack_sky_keeps_sunrise_helpers_private() {
|
||||
use wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
// Use the same complete terrain sections and prefixer as native_sky.rs.
|
||||
let sky = prefixed(
|
||||
&[
|
||||
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
|
||||
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
|
||||
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
|
||||
]
|
||||
.concat(),
|
||||
"horizon_sky_",
|
||||
);
|
||||
for name in [
|
||||
"sunrise_weight",
|
||||
"sunrise_weight_full",
|
||||
"sunrise_triangle_weight",
|
||||
] {
|
||||
assert!(sky.contains(&format!("fn horizon_sky_{name}(")));
|
||||
}
|
||||
// A native pack may declare the same helper names as the renderer.
|
||||
let own = "fn sunrise_weight(d: vec3<f32>) -> f32 { return d.x; }\n\
|
||||
fn sunrise_weight_full(d: vec3<f32>) -> f32 { return d.y; }\n\
|
||||
fn sunrise_triangle_weight(d: vec3<f32>, i: u32) -> f32 { return d.z + f32(i); }\n";
|
||||
let module = naga::front::wgsl::parse_str(&format!("{own}\n{sky}")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_terrain_shader_still_validates_for_fallback_sky() {
|
||||
let module = naga::front::wgsl::parse_str(wgsl_prefix::TERRAIN).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -97,6 +97,10 @@ audio = ["horizon-client-render/audio"]
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
"horizon.mods.not_built": " (Not built)",
|
||||
"horizon.mods.dev": " (Dev)",
|
||||
"horizon.mods.settings": "Settings",
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Mando",
|
||||
"horizon.options.vibration": "Vibración",
|
||||
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
|
||||
"horizon.options.lightbar": "Barra de luz",
|
||||
"horizon.options.look_sensitivity": "Sensibilidad de vista",
|
||||
"horizon.options.invert_x": "Invertir vista X",
|
||||
"horizon.options.invert_y": "Invertir vista Y",
|
||||
"horizon.options.sneak_button": "Botón de agacharse",
|
||||
"horizon.options.move_deadzone": "Zona muerta de movimiento",
|
||||
"horizon.options.look_deadzone": "Zona muerta de vista",
|
||||
"horizon.options.button_glyphs": "Símbolos de botones",
|
||||
"horizon.options.menu_navigation": "Navegación de menús",
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon
|
||||
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
|
||||
//! The optional Segment Heap manifest is embedded in the same resource compilation.
|
||||
fn main() {
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
@@ -8,8 +9,13 @@ fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
resource.set_icon(&ico);
|
||||
if std::env::var_os("CARGO_FEATURE_WINDOWS_SEGMENT_HEAP").is_some() {
|
||||
let manifest = format!("{dir}/windows-segment-heap.manifest");
|
||||
println!("cargo:rerun-if-changed={manifest}");
|
||||
resource.set_manifest_file(&manifest);
|
||||
}
|
||||
if let Err(e) = resource.compile() {
|
||||
panic!("embedding {ico}: {e}");
|
||||
panic!("embedding Windows resources: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,26 @@ fn tool(name: &str) -> Option<ItemStack> {
|
||||
}
|
||||
}
|
||||
|
||||
/// An equipment asset's first humanoid layers as files under the root, as worn armour reads them.
|
||||
fn equipment_textures(root: &Path, asset: &str) -> Value {
|
||||
let (ns, path) = asset.split_once(':').unwrap_or(("minecraft", asset));
|
||||
let Ok(doc) = fs::read(root.join(format!("assets/{ns}/equipment/{path}.json"))) else {
|
||||
return Value::Null;
|
||||
};
|
||||
let doc: Value = serde_json::from_slice(&doc).unwrap_or_default();
|
||||
let mut out = Map::new();
|
||||
for kind in ["humanoid", "humanoid_leggings"] {
|
||||
if let Some(texture) = doc["layers"][kind][0]["texture"].as_str() {
|
||||
let (tns, tpath) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let file = root.join(format!(
|
||||
"assets/{tns}/textures/entity/equipment/{kind}/{tpath}.png"
|
||||
));
|
||||
out.insert(kind.into(), json!(file.exists().then_some(file)));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [base, work, out, wanted @ ..] = args.as_slice() else {
|
||||
@@ -226,6 +246,7 @@ fn main() -> Result<()> {
|
||||
"repairable": repairable,
|
||||
"equip_slot": i.equip_slot,
|
||||
"equip_asset": i.equip_asset,
|
||||
"equipment": i.equip_asset.as_deref().map(|a| equipment_textures(&root, a)),
|
||||
"food": i.food.map(|(nutrition, saturation)| json!({"nutrition": nutrition, "saturation": saturation})),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -600,6 +600,7 @@ fn player_damage(e: &MobEvent) -> f32 {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::CubeHurtPlayer { amount, .. }
|
||||
| MobEvent::StingPlayer { amount, .. }
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
MobEvent::SonicBoom {
|
||||
@@ -618,6 +619,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::CubeHurtPlayer { amount, .. }
|
||||
| MobEvent::StingPlayer { amount, .. }
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
MobEvent::SonicBoom {
|
||||
|
||||
@@ -301,7 +301,9 @@ fn candle_offsets(count: u8) -> &'static [[f64; 3]] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-state animate behaviour and the block tags it needs.
|
||||
/// Per-state animate behaviour and the block tags it needs. Empty by default
|
||||
/// (a dedicated server): every lookup finds nothing to animate.
|
||||
#[derive(Default)]
|
||||
pub struct AnimateTables {
|
||||
kinds: Vec<Animate>,
|
||||
impermeable: Vec<bool>,
|
||||
|
||||
@@ -105,7 +105,7 @@ impl UpdateWorld<Key> for PlayerMotion {
|
||||
}
|
||||
}
|
||||
impl LiveAttributeWorld for PlayerMotion {
|
||||
type Identity = Box<dyn FnMut() -> i32>;
|
||||
type Identity = Box<dyn horizon_world::attributes::live::Identities>;
|
||||
fn get_attributes(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Identity of the ordered block-state registry used by chunk packets.
|
||||
//! Identity of the ordered block-state registry used by chunk packets, and of
|
||||
//! the mob effect registry once content mods add to it (effect packets carry
|
||||
//! its network ids).
|
||||
use serde::{Deserialize, Serialize, de::IgnoredAny};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{BTreeMap, HashMap},
|
||||
fmt, io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Mutex, OnceLock},
|
||||
};
|
||||
@@ -9,47 +13,128 @@ use std::{
|
||||
pub const CHANNEL: &str = "horizon:block_registry";
|
||||
pub const MARKER: &str = "horizon-content-blocks";
|
||||
pub const MISMATCH: &str =
|
||||
"Content block registry mismatch: install the same content blocks as the server.";
|
||||
"Content registry mismatch: install the same content mods as the server.";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum IdentityError {
|
||||
Read(io::Error),
|
||||
Json(serde_json::Error),
|
||||
Registries(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for IdentityError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Read(error) => write!(f, "cannot read facts.json: {error}"),
|
||||
Self::Json(error) => write!(f, "invalid facts.json: {error}"),
|
||||
Self::Registries(error) => write!(f, "invalid registries.json: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for IdentityError {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Facts {
|
||||
states: Vec<StateRow>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StateRow(IgnoredAny, IgnoredAny, IgnoredAny, IgnoredAny, State);
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct State {
|
||||
id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
properties: Option<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Hash the network order and each state's name and properties, not its gameplay facts.
|
||||
pub fn identity(root: &Path) -> Result<[u8; 32], String> {
|
||||
pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
|
||||
// Keep each resource root stable and avoid reparsing its large facts file for every join.
|
||||
static IDENTITIES: OnceLock<Mutex<HashMap<PathBuf, [u8; 32]>>> = OnceLock::new();
|
||||
let mut identities = IDENTITIES.get_or_init(Default::default).lock().unwrap();
|
||||
if let Some(hash) = identities.get(root) {
|
||||
return Ok(*hash);
|
||||
}
|
||||
let bytes = std::fs::read(root.join("facts.json")).map_err(|e| e.to_string())?;
|
||||
let hash = hash_facts(&bytes)?;
|
||||
let bytes = std::fs::read(root.join("facts.json")).map_err(IdentityError::Read)?;
|
||||
let mut hash = hash_facts(&bytes)?;
|
||||
let effects = match std::fs::read(root.join("registries.json")) {
|
||||
Ok(registries) => mod_effects(®istries)?,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
if !effects.is_empty() {
|
||||
hash = hash_effects(hash, &effects);
|
||||
}
|
||||
identities.insert(root.to_owned(), hash);
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], String> {
|
||||
let facts: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
|
||||
let states = facts
|
||||
.get("states")
|
||||
.and_then(|s| s.as_array())
|
||||
.ok_or("facts.json has no block states")?;
|
||||
fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], IdentityError> {
|
||||
let facts: Facts = serde_json::from_slice(bytes).map_err(IdentityError::Json)?;
|
||||
let mut hash = Sha256::new();
|
||||
for row in states {
|
||||
let state = row
|
||||
.as_array()
|
||||
.and_then(|r| r.get(4))
|
||||
.ok_or("facts.json has an invalid block state")?;
|
||||
let encoded = serde_json::to_vec(state).map_err(|e| e.to_string())?;
|
||||
for row in facts.states {
|
||||
let encoded = serde_json::to_vec(&row.4).map_err(IdentityError::Json)?;
|
||||
hash.update((encoded.len() as u64).to_le_bytes());
|
||||
hash.update(encoded);
|
||||
}
|
||||
Ok(hash.finalize().into())
|
||||
}
|
||||
|
||||
/// The content mods' mob effects in `registries.json`, by network id.
|
||||
fn mod_effects(bytes: &[u8]) -> Result<Vec<(u64, String)>, IdentityError> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| IdentityError::Registries(e.to_string()))?;
|
||||
let report = horizon_protocol::registry::Builtin::parse(text)
|
||||
.map_err(|e| IdentityError::Registries(e.to_string()))?;
|
||||
// Names come indexed by network id, so they are already in order.
|
||||
Ok(report
|
||||
.get("minecraft:mob_effect")
|
||||
.into_iter()
|
||||
.flat_map(|ids| ids.names.iter().enumerate())
|
||||
.filter(|(_, name)| !name.is_empty() && !name.starts_with("minecraft:"))
|
||||
.map(|(id, name)| (id as u64, name.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The block identity extended with the mods' effects; a root without them
|
||||
/// keeps its block-only identity.
|
||||
fn hash_effects(blocks: [u8; 32], effects: &[(u64, String)]) -> [u8; 32] {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(blocks);
|
||||
for (id, name) in effects {
|
||||
hash.update(id.to_le_bytes());
|
||||
hash.update((name.len() as u64).to_le_bytes());
|
||||
hash.update(name);
|
||||
}
|
||||
hash.finalize().into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::hash_facts;
|
||||
use super::{hash_effects, hash_facts, mod_effects};
|
||||
|
||||
#[test]
|
||||
fn mod_effects_join_the_identity() {
|
||||
let report = |extra: &str| {
|
||||
format!(
|
||||
r#"{{"minecraft:mob_effect":{{"entries":{{"minecraft:speed":{{"protocol_id":0}}{extra}}}}}}}"#
|
||||
)
|
||||
};
|
||||
assert!(mod_effects(report("").as_bytes()).unwrap().is_empty());
|
||||
let glow = mod_effects(report(r#","ruby:glow":{"protocol_id":40}"#).as_bytes()).unwrap();
|
||||
assert_eq!(glow, [(40, "ruby:glow".to_owned())]);
|
||||
let zap = mod_effects(report(r#","ruby:zap":{"protocol_id":40}"#).as_bytes()).unwrap();
|
||||
let blocks = [7; 32];
|
||||
assert_ne!(hash_effects(blocks, &glow), hash_effects(blocks, &zap));
|
||||
assert_ne!(hash_effects(blocks, &glow), blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_tracks_order_and_properties_not_gameplay_data() {
|
||||
let facts = |a: &str, b: &str| format!(r#"{{"states":[["x",0,0,0,{a}],["y",0,0,0,{b}]]}}"#);
|
||||
let facts = |a: &str, b: &str| {
|
||||
format!(r#"{{"states":[["x",false,false,"x",{a}],["y",false,false,"y",{b}]]}}"#)
|
||||
};
|
||||
let a = r#"{"id":"demo:a","properties":{"facing":"north"}}"#;
|
||||
let b = r#"{"id":"demo:b"}"#;
|
||||
let first = hash_facts(facts(a, b).as_bytes()).unwrap();
|
||||
@@ -62,9 +147,10 @@ mod tests {
|
||||
);
|
||||
assert_ne!(
|
||||
first,
|
||||
hash_facts(format!(r#"{{"states":[["x",0,0,0,{a}]]}}"#).as_bytes()).unwrap()
|
||||
hash_facts(format!(r#"{{"states":[["x",false,false,"x",{a}]]}}"#).as_bytes()).unwrap()
|
||||
);
|
||||
assert!(hash_facts(br#"{"states":[[0,0,0,0,"minecraft:stone"]]}"#).is_ok());
|
||||
assert!(hash_facts(br#"{"states":[["x",false,false,"x","minecraft:stone"]]}"#).is_err());
|
||||
assert!(hash_facts(br#"{"states":[[]]}"#).is_err());
|
||||
assert!(hash_facts(br#"{"states":[["x",false,false,"x",{"id":"x"},0]]}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ impl Ingredient {
|
||||
pub fn of(items: Vec<String>) -> Self {
|
||||
Self(items.iter().cloned().collect(), items)
|
||||
}
|
||||
pub(crate) fn parse(v: &Value, items: &Items) -> Option<Self> {
|
||||
pub fn parse(v: &Value, items: &Items) -> Option<Self> {
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
let mut add = |s: &str| {
|
||||
let more: Vec<String> = match s.strip_prefix('#') {
|
||||
@@ -1099,9 +1099,30 @@ pub struct Cooking {
|
||||
pub experience: f32,
|
||||
pub time: u32,
|
||||
}
|
||||
/// A recipe of a type of a mod's own, kept as its file says: the mod reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CustomRecipe {
|
||||
/// `<namespace>:<path>`, its place under `data/<namespace>/recipe/`.
|
||||
pub id: String,
|
||||
pub json: Arc<Value>,
|
||||
}
|
||||
/// An item stack as a recipe's `result` names it (`ItemStackTemplate`); none
|
||||
/// with no `id` or a count outside 1 to 99.
|
||||
pub fn recipe_result(v: &Value) -> Option<ItemStack> {
|
||||
let template = Template::parse(v)?;
|
||||
template.item.is_some().then(|| template.create())
|
||||
}
|
||||
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
|
||||
fn custom_type(v: &Value) -> Option<&str> {
|
||||
v["type"]
|
||||
.as_str()
|
||||
.filter(|t| t.contains(':') && !t.starts_with("minecraft:"))
|
||||
}
|
||||
pub struct Recipes {
|
||||
pub crafting: Vec<(String, Recipe)>,
|
||||
pub cooking: Vec<Cooking>,
|
||||
/// The recipes of types of mods' own, by type, in file order.
|
||||
pub custom: HashMap<String, Vec<CustomRecipe>>,
|
||||
/// `category` and `group` of each crafting recipe, by file name.
|
||||
pub meta: HashMap<String, (String, String)>,
|
||||
facts: HashMap<Arc<str>, Facts>,
|
||||
@@ -1184,6 +1205,7 @@ impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
let mut meta = HashMap::new();
|
||||
let json_files = |dir: &Path| -> Result<Vec<PathBuf>> {
|
||||
let mut files: Vec<_> = fs::read_dir(dir)?
|
||||
@@ -1241,6 +1263,20 @@ impl Recipes {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(kind) = custom_type(&v) {
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let kind = kind.to_owned();
|
||||
let json = Arc::new(v);
|
||||
custom
|
||||
.entry(kind)
|
||||
.or_default()
|
||||
.push(CustomRecipe { id, json });
|
||||
continue;
|
||||
}
|
||||
let text = |k: &str| v[k].as_str().unwrap_or("").to_owned();
|
||||
meta.insert(name.clone(), (text("category"), text("group")));
|
||||
let ing = |k: &str| Ingredient::parse(&v[k], items);
|
||||
@@ -1447,6 +1483,7 @@ impl Recipes {
|
||||
Ok(Self {
|
||||
crafting,
|
||||
cooking,
|
||||
custom,
|
||||
meta,
|
||||
facts,
|
||||
curses,
|
||||
@@ -1728,6 +1765,7 @@ mod tests {
|
||||
Recipes {
|
||||
crafting: Vec::new(),
|
||||
cooking,
|
||||
custom: HashMap::new(),
|
||||
meta: HashMap::new(),
|
||||
facts: HashMap::new(),
|
||||
curses: HashSet::new(),
|
||||
@@ -1735,6 +1773,101 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn only_types_outside_minecraft_are_custom() {
|
||||
let kind = |json: &str| {
|
||||
let v: Value = serde_json::from_str(json).unwrap();
|
||||
custom_type(&v).map(str::to_owned)
|
||||
};
|
||||
assert_eq!(
|
||||
kind(r#"{"type":"mymod:crusher"}"#).as_deref(),
|
||||
Some("mymod:crusher")
|
||||
);
|
||||
assert_eq!(kind(r#"{"type":"minecraft:smelting"}"#), None);
|
||||
assert_eq!(kind(r#"{"type":"minecraft:from_a_later_version"}"#), None);
|
||||
assert_eq!(kind(r#"{"type":"no_namespace"}"#), None);
|
||||
assert_eq!(kind(r#"{"type":7}"#), None);
|
||||
assert_eq!(kind("{}"), None);
|
||||
}
|
||||
/// Recipes of a mod's own types land in `custom` by type, in file order,
|
||||
/// with the id the file has; a data pack overrides by id and adds more; a
|
||||
/// `minecraft` type the game doesn't know is dropped.
|
||||
#[test]
|
||||
fn custom_recipes_load_by_type_with_pack_override() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-custom-recipes-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::create_dir_all(root.join("data/mymod/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MARKER),
|
||||
br#"["mymod"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let file =
|
||||
|kind: &str, tag: &str| format!(r#"{{"type":"{kind}","tag":"{tag}"}}"#).into_bytes();
|
||||
fs::write(
|
||||
root.join("data/mymod/recipe/a.json"),
|
||||
file("mymod:crusher", "a"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/mymod/recipe/b.json"),
|
||||
file("mymod:crusher", "b"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/mymod/recipe/c.json"),
|
||||
file("mymod:mixer", "c"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/later.json"),
|
||||
file("minecraft:from_a_later_version", "x"),
|
||||
)
|
||||
.unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let ids = |kind: &str| -> Vec<String> {
|
||||
recipes.custom[kind].iter().map(|r| r.id.clone()).collect()
|
||||
};
|
||||
assert_eq!(ids("mymod:crusher"), ["mymod:a", "mymod:b"]);
|
||||
assert_eq!(ids("mymod:mixer"), ["mymod:c"]);
|
||||
assert_eq!(recipes.custom.len(), 2);
|
||||
// Not crafted, and no recipe-book meta of their own.
|
||||
assert!(recipes.crafting.is_empty() && recipes.cooking.is_empty());
|
||||
assert!(!recipes.meta.keys().any(|k| k.starts_with("mymod:")));
|
||||
// A pack replaces `mymod:b`, and adds `pack:d` and a `minecraft:p` of its own type.
|
||||
let packs = [
|
||||
(
|
||||
"recipe/mymod:b".to_owned(),
|
||||
vec![file("mymod:crusher", "pack-b")],
|
||||
),
|
||||
("recipe/pack:d".to_owned(), vec![file("mymod:crusher", "d")]),
|
||||
(
|
||||
"recipe/minecraft:p".to_owned(),
|
||||
vec![file("pack:press", "p")],
|
||||
),
|
||||
];
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
|
||||
let crusher = &recipes.custom["mymod:crusher"];
|
||||
assert_eq!(
|
||||
crusher.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
["mymod:a", "mymod:b", "pack:d"]
|
||||
);
|
||||
assert_eq!(crusher[1].json["tag"], "pack-b");
|
||||
assert_eq!(recipes.custom["pack:press"][0].id, "minecraft:p");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn a_recipe_result_needs_an_item_and_a_sane_count() {
|
||||
let result = |json: &str| recipe_result(&serde_json::from_str(json).unwrap());
|
||||
let stack = result(r#"{"id":"minecraft:iron_ingot","count":2}"#).unwrap();
|
||||
assert_eq!((&*stack.item, stack.count), ("minecraft:iron_ingot", 2));
|
||||
assert_eq!(result(r#"{"id":"minecraft:iron_ingot"}"#).unwrap().count, 1);
|
||||
assert!(result(r#"{"count":2}"#).is_none());
|
||||
assert!(result(r#"{"id":"minecraft:iron_ingot","count":0}"#).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn cook_finds_the_first_recipe_the_scan_finds() {
|
||||
let recipes = cooking_book();
|
||||
for kind in [
|
||||
@@ -1780,6 +1913,7 @@ mod tests {
|
||||
let recipes = Recipes {
|
||||
crafting: Vec::new(),
|
||||
cooking: Vec::new(),
|
||||
custom: HashMap::new(),
|
||||
meta: HashMap::new(),
|
||||
facts: HashMap::new(),
|
||||
curses: HashSet::new(),
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
|
||||
//! a ground bounce of -0.5, slime landing restitution (0.8, including a moving
|
||||
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
|
||||
//! and `HoneyBlock.entityInside` when falling against a honey block's side.
|
||||
//! `HoneyBlock.entityInside` when falling against a honey block's side, and
|
||||
//! `BubbleColumnBlock.entityInside` (lift / drag, including the surface jump).
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
collisions::SceneCollisions,
|
||||
@@ -219,6 +220,7 @@ impl ItemEntity {
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
@@ -229,7 +231,6 @@ impl ItemEntity {
|
||||
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
|
||||
self.pickup_delay -= 1;
|
||||
}
|
||||
// Entity.baseTick pushes before ItemEntity applies buoyancy and moves.
|
||||
let (water_height, lava_height) = self.update_fluids(world, fluids);
|
||||
let in_water = water_height > f64::from(0.1f32);
|
||||
if in_water {
|
||||
@@ -249,6 +250,10 @@ impl ItemEntity {
|
||||
self.velocity.y -= 0.04;
|
||||
}
|
||||
let horizontal = self.velocity.x * self.velocity.x + self.velocity.z * self.velocity.z;
|
||||
// ItemEntity.tick: skip move 3 of 4 ticks while grounded with no
|
||||
// horizontal speed. The idle branch still runs bubble-column push
|
||||
// (`applyEffectsFromBlocksForLastMovements`), so lift builds up and
|
||||
// the item moves on the next wake-up (#4558).
|
||||
if !self.on_ground || horizontal > 1.0e-5 || (self.age + self.id).is_multiple_of(4) {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
@@ -285,8 +290,17 @@ impl ItemEntity {
|
||||
let slime = item_support_is_slime(world, facts, self.position);
|
||||
self.velocity.y = item_vertical_collision_velocity(wanted.y, slime);
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: HoneyBlock.entityInside.
|
||||
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
|
||||
// (before air drag), matching ItemEntity.tick.
|
||||
self.apply_honey_slide(world, facts);
|
||||
crate::motion::bubble_column_push(
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
collision,
|
||||
self.bounds(),
|
||||
&mut self.velocity,
|
||||
);
|
||||
let mut friction = 0.98;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
@@ -304,9 +318,19 @@ impl ItemEntity {
|
||||
if self.on_ground && self.velocity.y < 0.0 {
|
||||
self.velocity.y *= -0.5;
|
||||
}
|
||||
} else {
|
||||
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
|
||||
crate::motion::bubble_column_push(
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
collision,
|
||||
self.bounds(),
|
||||
&mut self.velocity,
|
||||
);
|
||||
}
|
||||
self.age += 1;
|
||||
// ItemEntity.tick refreshes immersion and pushes again after moving.
|
||||
// ItemEntity.tick refreshes immersion after moving.
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
}
|
||||
@@ -1385,6 +1409,101 @@ mod merge_tests {
|
||||
mod honey_slide_tests {
|
||||
use super::*;
|
||||
|
||||
/// Gravity still stacks on idle ticks; waking every 4th tick zeroes Y on
|
||||
/// the ground (`ItemEntity.tick` with `onGround`).
|
||||
#[test]
|
||||
fn a_resting_item_skips_move_three_of_four_ticks() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let mut item = falling_item(at.x, at.y, at.z, 0.0, 0.0, 0.0);
|
||||
item.on_ground = true;
|
||||
item.age = 0;
|
||||
item.id = 1;
|
||||
let mut ys = Vec::new();
|
||||
for _ in 0..8 {
|
||||
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
// Idle ticks keep gravity; the 4th tick's move hits the floor and
|
||||
// clears Y (vanilla's sequence for a resting item).
|
||||
assert_eq!(ys, vec![-0.04, -0.08, -0.12, 0.0, -0.04, -0.08, -0.12, 0.0]);
|
||||
assert_eq!(item.position.y, at.y);
|
||||
}
|
||||
|
||||
/// #4558: idle ticks still run `BubbleColumnBlock.entityInside`, so lift
|
||||
/// builds and the next wake-up rises off the soul sand.
|
||||
#[test]
|
||||
fn a_grounded_item_in_a_soul_sand_column_rises() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let soul = assets
|
||||
.registry
|
||||
.default_state("minecraft:soul_sand")
|
||||
.unwrap();
|
||||
let mut column = assets
|
||||
.registry
|
||||
.state(
|
||||
assets
|
||||
.registry
|
||||
.default_state("minecraft:bubble_column")
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.clone();
|
||||
column.properties.insert("drag".into(), "false".into());
|
||||
let column = assets.registry.resolve(&column).unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, soul).unwrap();
|
||||
for y in 4..=8 {
|
||||
world.set(x, y, z, column).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let mut item = falling_item(at.x, at.y, at.z, 0.0, 0.0, 0.0);
|
||||
item.on_ground = true;
|
||||
item.age = 0;
|
||||
item.id = 1;
|
||||
let before = item.position.y;
|
||||
for _ in 0..16 {
|
||||
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(
|
||||
item.position.y > before + 0.5,
|
||||
"expected rise from soul-sand column, y={} (was {})",
|
||||
item.position.y,
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
|
||||
@@ -200,17 +200,17 @@ mod tests {
|
||||
ticks: 0,
|
||||
};
|
||||
let mut still = item.clone();
|
||||
assert!(still.tick(&world, &facts, &collision, false, &fluids));
|
||||
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = item;
|
||||
assert!(carried.tick(&world, &facts, &collision, false, &fluids));
|
||||
assert!(carried.tick(&world, assets.registry, &facts, &collision, false, &fluids));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
carried.tick(&world, &facts, &collision, false, &fluids);
|
||||
carried.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
@@ -2949,7 +2949,11 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
}
|
||||
data
|
||||
}
|
||||
pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
/// `Entity.applyComponentsFromItemStack` → the spawned mob's
|
||||
/// `applyImplicitComponents`: a bucket's contents (`MobBucketItem.spawn`) and
|
||||
/// a spawn egg (`PostSpawnProcessor.apply`) both land here. Vanilla applies
|
||||
/// these after `finalizeSpawn`, so a component wins over the biome variant.
|
||||
pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
let Some(components) = stack.data() else {
|
||||
return;
|
||||
};
|
||||
@@ -3030,6 +3034,51 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
m.extra.variant3 = id as u8;
|
||||
}
|
||||
}
|
||||
// Chicken, Cow and Pig.applyImplicitComponents: their variant and sound
|
||||
// variant components, both registry names (`minecraft:warm`,
|
||||
// `minecraft:picky`).
|
||||
let species = m.species();
|
||||
let (variant, sound) = match species {
|
||||
crate::mobs::Species::Chicken => (
|
||||
Some("minecraft:chicken/variant"),
|
||||
Some("minecraft:chicken/sound_variant"),
|
||||
),
|
||||
crate::mobs::Species::Cow => (
|
||||
Some("minecraft:cow/variant"),
|
||||
Some("minecraft:cow/sound_variant"),
|
||||
),
|
||||
crate::mobs::Species::Pig => (
|
||||
Some("minecraft:pig/variant"),
|
||||
Some("minecraft:pig/sound_variant"),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
|
||||
m.extra.variant = crate::mobs::finalize::variant_index(species, &name);
|
||||
}
|
||||
if let Some(name) = sound.and_then(|id| text(components.other(id)))
|
||||
&& let Some(i) = crate::mobs::finalize::sound_variants(species)
|
||||
.iter()
|
||||
.position(|v| *v == name.trim_start_matches("minecraft:"))
|
||||
{
|
||||
m.extra.sound_variant = Some(i as u8);
|
||||
}
|
||||
// MushroomCow.applyImplicitComponents: `Variant.DEFAULT` is red, as the
|
||||
// save's `Type` reads it.
|
||||
if species == crate::mobs::Species::Mooshroom
|
||||
&& let Some(name) = text(components.other("minecraft:mooshroom/variant"))
|
||||
{
|
||||
m.extra.variant = u8::from(name.trim_start_matches("minecraft:") == "brown");
|
||||
}
|
||||
// Sheep.applyImplicitComponents: the wool colour, a DyeColor id.
|
||||
if species == crate::mobs::Species::Sheep
|
||||
&& let Some(name) = text(components.other("minecraft:sheep/color"))
|
||||
&& let Some(i) = crate::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|c| *c == name.trim_start_matches("minecraft:"))
|
||||
{
|
||||
m.extra.color = i as u8;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3101,7 +3150,7 @@ mod axolotl_nbt_tests {
|
||||
assert!(bucket_data(&m).get("Variant").is_none());
|
||||
let stack = item_from_nbt(&item_to_nbt(&stack)).unwrap();
|
||||
let mut restored = Mob::new(2, kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
load_bucket_data(&mut restored, &stack);
|
||||
apply_item_components(&mut restored, &stack);
|
||||
assert_eq!(
|
||||
crate::mobs::animal_brain::hunting_cooldown(&restored),
|
||||
Some(123)
|
||||
@@ -3140,10 +3189,10 @@ mod axolotl_nbt_tests {
|
||||
.data_mut()
|
||||
.set_other("minecraft:bucket_entity_data", Some(data));
|
||||
restored.persistent = false;
|
||||
load_bucket_data(&mut restored, &bucket);
|
||||
apply_item_components(&mut restored, &bucket);
|
||||
assert_eq!(restored.persistent, expected);
|
||||
restored.persistent = true;
|
||||
load_bucket_data(&mut restored, &bucket);
|
||||
apply_item_components(&mut restored, &bucket);
|
||||
assert!(restored.persistent, "bucket data cannot clear persistence");
|
||||
}
|
||||
restored.ticks_frozen = 140;
|
||||
@@ -3161,7 +3210,7 @@ mod axolotl_nbt_tests {
|
||||
other: vec![("minecraft:axolotl/variant".into(), string(name))],
|
||||
..Default::default()
|
||||
}));
|
||||
load_bucket_data(&mut restored, &vanilla);
|
||||
apply_item_components(&mut restored, &vanilla);
|
||||
assert_eq!(
|
||||
usize::from(restored.extra.variant),
|
||||
id,
|
||||
@@ -3176,12 +3225,12 @@ mod axolotl_nbt_tests {
|
||||
)],
|
||||
..Default::default()
|
||||
}));
|
||||
load_bucket_data(&mut restored, &legacy);
|
||||
apply_item_components(&mut restored, &legacy);
|
||||
assert_eq!(restored.extra.variant, 3);
|
||||
legacy
|
||||
.data_mut()
|
||||
.set_other("minecraft:axolotl/variant", Some(string("gold")));
|
||||
load_bucket_data(&mut restored, &legacy);
|
||||
apply_item_components(&mut restored, &legacy);
|
||||
assert_eq!(
|
||||
restored.extra.variant, 2,
|
||||
"component wins over old Horizon data"
|
||||
@@ -3241,6 +3290,37 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
/// The only lib test that registers mod effects (once per process).
|
||||
#[test]
|
||||
fn a_mob_keeps_a_mod_effect() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types: HashMap<String, Arc<MobType>> = crate::mobs::MobTypes::load(&root)
|
||||
.unwrap()
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Arc::new(v)))
|
||||
.collect();
|
||||
crate::brewing::register_mod_effects([(
|
||||
"testfx:glow".to_owned(),
|
||||
1,
|
||||
crate::brewing::Category::Harmful,
|
||||
)]);
|
||||
let mut m = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
m.add_effect(crate::brewing::effect("testfx:glow").unwrap().id, 200, 2);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let Some(Tag::List(saved)) = tag.get("active_effects") else {
|
||||
panic!("no active_effects");
|
||||
};
|
||||
assert_eq!(text(saved[0].get("id")).as_deref(), Some("testfx:glow"));
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!(back.extra.effects, [("testfx:glow", 200, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn farm_animals_keep_their_variant_and_sound_variant() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -3397,6 +3477,46 @@ mod sheep_nbt_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod item_component_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_spawn_egg_beats_the_biome_variant() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let spawn = |kind: &str, id: u32| {
|
||||
let mut m = Mob::new(
|
||||
id,
|
||||
Arc::new(data.types[kind].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
// What `finalizeSpawn` leaves in a temperate biome.
|
||||
m.extra.variant = 0;
|
||||
m
|
||||
};
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:chicken_spawn_egg", 1);
|
||||
egg.data_mut()
|
||||
.set_other("minecraft:chicken/variant", Some(string("minecraft:warm")));
|
||||
let mut hen = spawn("minecraft:chicken", 1);
|
||||
assert_eq!(hen.extra.variant, 0);
|
||||
apply_item_components(&mut hen, &egg);
|
||||
assert_eq!(hen.extra.variant, 1, "minecraft:warm, not the biome's");
|
||||
// Sheep.applyImplicitComponents: the egg's colour over the mob's.
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:sheep_spawn_egg", 1);
|
||||
egg.data_mut()
|
||||
.set_other("minecraft:sheep/color", Some(string("lime")));
|
||||
let mut sheep = spawn("minecraft:sheep", 2);
|
||||
apply_item_components(&mut sheep, &egg);
|
||||
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fireball_nbt_tests {
|
||||
use super::*;
|
||||
|
||||
+700
-240
File diff suppressed because it is too large.
Load diff
@@ -212,8 +212,8 @@ impl adv::World for View<'_, '_> {
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
|
||||
let items = &self.0.res.items();
|
||||
match registry {
|
||||
"item" => items.item_tags.get(tag).is_some_and(|t| t.contains(id)),
|
||||
"block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)),
|
||||
"item" => items.item_in_tag(tag, id),
|
||||
"block" => items.block_in_tag(tag, id),
|
||||
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
|
||||
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
|
||||
"damage_type" => self
|
||||
@@ -432,7 +432,10 @@ impl Game<'_> {
|
||||
.effects
|
||||
.list
|
||||
.iter()
|
||||
.map(|e| (format!("minecraft:{}", e.id), e.amplifier, e.duration))
|
||||
.map(|e| {
|
||||
let id = horizon_client::brewing::namespaced(e.id);
|
||||
(id.to_owned(), e.amplifier, e.duration)
|
||||
})
|
||||
.collect(),
|
||||
gamemode: Some(self.mode.name().to_owned()),
|
||||
..Default::default()
|
||||
@@ -594,6 +597,39 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `KnowledgeBookItem.use`: the recipes the book names become known and it
|
||||
/// is used up. A book naming a recipe this server does not have is
|
||||
/// vanilla's `Invalid recipe: {}` and knows nothing. Whether the click
|
||||
/// ends here: `InteractionResult.SUCCESS` does and swings, `FAIL` leaves
|
||||
/// the other hand its turn (`Minecraft.startUseItem`).
|
||||
pub(super) fn use_knowledge_book(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:knowledge_book" {
|
||||
return false;
|
||||
}
|
||||
let slot = self.held_slot();
|
||||
let mut hand = self.stack_in(slot).cloned();
|
||||
let infinite = self.creative;
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
let result = self
|
||||
.level
|
||||
.recipe_book
|
||||
.use_knowledge_book(index, &mut hand, infinite);
|
||||
*self.stack_in_mut(slot) = hand;
|
||||
match result {
|
||||
Ok(unlocked) => {
|
||||
self.recipe_unlocked(&unlocked);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
Err(invalid) => {
|
||||
if let Some(id) = invalid {
|
||||
log::error!("Invalid recipe: {id}");
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.award` once done: rewards, toast, announcement.
|
||||
fn award(&mut self, index: usize) {
|
||||
let tree = self.adv.tree.clone();
|
||||
|
||||
@@ -638,6 +638,7 @@ impl Game<'_> {
|
||||
json!({ "mode": "multiplayer-warning", "check": check })
|
||||
}
|
||||
Mode::Onboarding(_) => json!({ "mode": "accessibility-onboarding" }),
|
||||
Mode::BanNotice(_) => json!({ "mode": "ban-notice" }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.mobs[i].extra.flag =
|
||||
dancing && !horizon_client::mobs::goals::should_panic(&self.mobs[i]);
|
||||
let Some(held) = self.mobs[i].extra.main_hand.clone() else {
|
||||
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
|
||||
self.mobs[i].extra.point = None;
|
||||
continue;
|
||||
};
|
||||
@@ -45,10 +45,10 @@ impl Game<'_> {
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
.get(&held)
|
||||
.get(&held.item)
|
||||
.map_or(64, |i| i.max_stack as i32);
|
||||
// GoToWantedItem / wantsToPickUp: the same item within 32 that the
|
||||
// allay sees (NearestItemSensor's hasLineOfSight, eye to eye).
|
||||
// GoToWantedItem / wantsToPickUp: a stack it considers equal within 32
|
||||
// that the allay sees (NearestItemSensor's hasLineOfSight, eye to eye).
|
||||
let wanted = if griefing && carried < max && self.mobs[i].extra.ints[3] <= 0 {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let eye = horizon_core::geometry::Vec3::new(pos.x, self.mobs[i].eye_y(), pos.z);
|
||||
@@ -58,7 +58,9 @@ impl Game<'_> {
|
||||
self.dropped
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| e.stack.item == held && e.pickup_delay == 0)
|
||||
.filter(|(_, e)| {
|
||||
e.pickup_delay == 0 && allay::considers_item_equal(&held, &e.stack)
|
||||
})
|
||||
.map(|(j, e)| (j, e.position.distance_squared(pos)))
|
||||
.filter(|(_, d)| *d < 32.0 * 32.0)
|
||||
.filter(|(j, _)| {
|
||||
@@ -92,6 +94,11 @@ impl Game<'_> {
|
||||
if b.contains(e.position) {
|
||||
let take = (max - carried).min(e.stack.count as i32);
|
||||
let (item, id) = (e.clone(), self.mobs[i].id);
|
||||
// It carries the fetched stack, potion and all, not just its id.
|
||||
self.mobs[i].extra.set_main_hand_stack(Some(ItemStack {
|
||||
count: 1,
|
||||
..e.stack.clone()
|
||||
}));
|
||||
self.add_pickup(
|
||||
super::pickups::Taken::Item(Box::new(item)),
|
||||
super::pickups::Collector::Mob(id),
|
||||
@@ -178,7 +185,7 @@ impl Game<'_> {
|
||||
if target.distance_squared(eye) >= 9.0 {
|
||||
return;
|
||||
}
|
||||
let Some(item) = m.extra.main_hand.clone() else {
|
||||
let Some(item) = m.extra.main_hand_stack().cloned() else {
|
||||
return;
|
||||
};
|
||||
let from = [
|
||||
@@ -200,7 +207,8 @@ impl Game<'_> {
|
||||
target.z.floor() as i32,
|
||||
];
|
||||
let uuid = self.allay_liked_uuid(i);
|
||||
let stack = ItemStack::new(&item, 1);
|
||||
// AllayAi.throwItem: one item of the held stack, its components and all.
|
||||
let stack = ItemStack { count: 1, ..item };
|
||||
self.for_each_player(|g| {
|
||||
if g.acting_uuid() == uuid && !g.spectator() {
|
||||
g.trigger_block("minecraft:allay_drop_item_on_block", below, &stack);
|
||||
@@ -210,7 +218,7 @@ impl Game<'_> {
|
||||
m.extra.ints[1] -= 1;
|
||||
m.extra.ints[3] = 60;
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
|
||||
self.spawn_item(stack, from, v, 10);
|
||||
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
|
||||
let allay = self.mob_uuid(self.mobs[i].id);
|
||||
if let Some(thrown) = self.dropped.last_mut() {
|
||||
@@ -228,13 +236,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Allay.mobInteract`: duplicate when dancing with an amethyst shard,
|
||||
/// take the held item, or hand back what it holds.
|
||||
pub(super) fn interact_allay(&mut self, index: usize, item: &str) -> bool {
|
||||
pub(super) fn interact_allay(&mut self, index: usize, item: Option<&ItemStack>) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
if m.species() != Species::Allay || !m.alive() {
|
||||
return false;
|
||||
}
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if m.extra.flag && item == "minecraft:amethyst_shard" && m.extra.egg_time == 0 {
|
||||
let shard = item.is_some_and(|s| &*s.item == "minecraft:amethyst_shard");
|
||||
if m.extra.flag && shard && m.extra.egg_time == 0 {
|
||||
let pos = at;
|
||||
self.mobs[index].extra.egg_time = 6000;
|
||||
if let Some(id) = self.spawn_mob("minecraft:allay", pos, true)
|
||||
@@ -252,9 +261,11 @@ impl Game<'_> {
|
||||
}
|
||||
let acting = self.acting_uuid();
|
||||
let holds = m.extra.main_hand.is_some();
|
||||
if !holds && !item.is_empty() {
|
||||
if !holds && let Some(mut given) = item.cloned() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.set_main_hand(Some(item.into()));
|
||||
// `Allay.mobInteract` hands over one item, its components and all.
|
||||
given.count = 1;
|
||||
m.extra.set_main_hand_stack(Some(given));
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
@@ -263,13 +274,15 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
return true;
|
||||
}
|
||||
if holds && item.is_empty() {
|
||||
if holds && item.is_none() {
|
||||
let m = &mut self.mobs[index];
|
||||
// The hand's stack keeps the components (a potion) the items came with.
|
||||
let held = m
|
||||
.extra
|
||||
.main_hand
|
||||
.take()
|
||||
.unwrap_or_else(|| "minecraft:air".into());
|
||||
.main_hand_stack()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 1));
|
||||
m.extra.set_main_hand(None);
|
||||
let carried = std::mem::take(&mut m.extra.ints[1]);
|
||||
m.extra.tame = false;
|
||||
m.extra.owner = None;
|
||||
@@ -277,13 +290,16 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
|
||||
if carried > 0 {
|
||||
self.spawn_item(
|
||||
ItemStack::new(&held, carried as u32),
|
||||
ItemStack {
|
||||
count: carried as u32,
|
||||
..held.clone()
|
||||
},
|
||||
at,
|
||||
[0.0, 0.2, 0.0],
|
||||
40,
|
||||
);
|
||||
}
|
||||
if let Some(rest) = self.give(ItemStack::new(&held, 1))
|
||||
if let Some(rest) = self.give(ItemStack { count: 1, ..held })
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0; 3], 0);
|
||||
|
||||
@@ -576,21 +576,43 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// The stand's box and id, before the hit may remove it.
|
||||
let (area, id) = (self.armor_stands[i].bounds(), self.armor_stands[i].id);
|
||||
let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true);
|
||||
let hurt = self.punch_armor_stand(i);
|
||||
if hurt {
|
||||
self.attack_effects(
|
||||
let hurt = self.hit_armor_stand(i, |g, area, id| {
|
||||
g.attack_effects(
|
||||
area,
|
||||
Some(id as i32),
|
||||
super::mace::Spared::Stand(id),
|
||||
magic.then_some(id as i32),
|
||||
);
|
||||
});
|
||||
if hurt {
|
||||
self.swing_arm();
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// A player's attack on the stand `i` once it is picked: the stand's own
|
||||
/// hurt, `before_weapon` (the attack's sounds and particles, given the
|
||||
/// stand's box and id) and `Player.itemAttackInteraction`, which treats
|
||||
/// the stand as a living target: the held weapon's hit and post-attack
|
||||
/// effects.
|
||||
pub(super) fn hit_armor_stand(
|
||||
&mut self,
|
||||
i: usize,
|
||||
before_weapon: impl FnOnce(&mut Self, Aabb, u32),
|
||||
) -> bool {
|
||||
// The stand's box, id and place, before the hit may remove it.
|
||||
let (area, id) = (self.armor_stands[i].bounds(), self.armor_stands[i].id);
|
||||
let (center, on_ground) = (
|
||||
self.armor_stands[i].position,
|
||||
self.armor_stands[i].on_ground,
|
||||
);
|
||||
let hurt = self.punch_armor_stand(i);
|
||||
if hurt {
|
||||
before_weapon(self, area, id);
|
||||
self.weapon_hit_stand(id, center, on_ground);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `ArmorStand.showBreakingParticles`: ten oak plank crumbs from the box,
|
||||
/// spread by a quarter of its width and height, sent like `sendParticles`.
|
||||
fn show_breaking_particles(&mut self, at: [f64; 3], width: f64, height: f64) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The GUI atlas's fixed slots for runtime images (`horizon/skin{N}`,
|
||||
//! `horizon/map{N}`): which image each slot holds, least recently shown
|
||||
//! first. A new atlas (a resource pack reload) has every slot blank, so the
|
||||
//! table is forgotten with it and each image uploads again.
|
||||
|
||||
pub(super) struct AtlasSlots<K> {
|
||||
count: usize,
|
||||
/// (image, slot), most recently shown last.
|
||||
held: Vec<(K, usize)>,
|
||||
}
|
||||
|
||||
impl<K: PartialEq> AtlasSlots<K> {
|
||||
pub(super) fn new(count: usize) -> Self {
|
||||
Self {
|
||||
count,
|
||||
held: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The slot holding `key`, marking it recently shown.
|
||||
pub(super) fn show<Q: PartialEq + ?Sized>(&mut self, key: &Q) -> Option<usize>
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
{
|
||||
let i = self.held.iter().position(|e| e.0.borrow() == key)?;
|
||||
let e = self.held.remove(i);
|
||||
let slot = e.1;
|
||||
self.held.push(e);
|
||||
Some(slot)
|
||||
}
|
||||
/// A slot for `key`: a free one, else the least recently shown, returned
|
||||
/// with the image it held.
|
||||
pub(super) fn claim(&mut self, key: K) -> (usize, Option<K>) {
|
||||
let (slot, evicted) = if self.held.len() < self.count {
|
||||
(self.held.len(), None)
|
||||
} else {
|
||||
let (old, slot) = self.held.remove(0);
|
||||
(slot, Some(old))
|
||||
};
|
||||
self.held.push((key, slot));
|
||||
(slot, evicted)
|
||||
}
|
||||
/// The atlas was replaced: every slot is blank.
|
||||
pub(super) fn forget(&mut self) {
|
||||
self.held.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_least_recently_shown_image_gives_up_its_slot() {
|
||||
let mut slots = AtlasSlots::new(2);
|
||||
assert_eq!(slots.claim("a"), (0, None));
|
||||
assert_eq!(slots.claim("b"), (1, None));
|
||||
assert_eq!(slots.show(&"a"), Some(0));
|
||||
assert_eq!(slots.claim("c"), (1, Some("b")));
|
||||
assert_eq!(slots.show(&"b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_atlas_shows_no_stored_skin() {
|
||||
// #3385: a skin stored before a pack reload must upload again, not
|
||||
// name a sprite the new atlas does not have.
|
||||
let mut slots = AtlasSlots::new(4);
|
||||
slots.claim("skins/abc".to_string());
|
||||
assert_eq!(slots.show("skins/abc"), Some(0));
|
||||
slots.forget();
|
||||
assert_eq!(slots.show("skins/abc"), None);
|
||||
assert_eq!(slots.claim("skins/abc".to_string()), (0, None));
|
||||
}
|
||||
}
|
||||
@@ -356,12 +356,8 @@ impl Game<'_> {
|
||||
.map(|(n, &j)| {
|
||||
let m = &self.mobs[j];
|
||||
let animal = is_animal(m.species());
|
||||
// Another player, on a server's client.
|
||||
let offset = if &*m.kind.id == "minecraft:player" {
|
||||
PLAYER_RIDING_OFFSET
|
||||
} else {
|
||||
riders::vehicle_attachment(m)
|
||||
};
|
||||
// Another player's too, on a server's client.
|
||||
let offset = riders::vehicle_attachment(m);
|
||||
let index = players + n;
|
||||
// calculatePassengerBodyYRot.
|
||||
let turn = match (animal && full, m.id.is_multiple_of(2)) {
|
||||
|
||||
@@ -19,13 +19,14 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// The tree the chat parses and completes with: a server's, once it has
|
||||
/// sent one (ClientPacketListener.commands), else the game's own.
|
||||
/// The tree the chat parses and completes with: `Remote::chat_tree`, which is
|
||||
/// a server's, once it has sent one (ClientPacketListener.commands), else the
|
||||
/// game's own, plus the commands native mods own (`chat_tree_for`).
|
||||
pub(super) fn chat_tree(
|
||||
remote: &Option<super::remote::Remote>,
|
||||
) -> &horizon_client::command::Dispatcher {
|
||||
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
|
||||
Some(tree) => tree,
|
||||
match remote.as_ref() {
|
||||
Some(server) => &server.chat_tree,
|
||||
None => horizon_client::command::vanilla::game_dispatcher(),
|
||||
}
|
||||
}
|
||||
@@ -34,6 +35,42 @@ use winit::{
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
};
|
||||
|
||||
/// `base` with the commands native mods own (`native_commands`) added: each a
|
||||
/// literal that takes the rest of the line as it was typed, run by
|
||||
/// `native_command` (`Dispatcher::with_mod_commands`), so the chat box colours
|
||||
/// and completes them as it does the game's own.
|
||||
pub(super) fn with_native_commands(
|
||||
base: &horizon_client::command::Dispatcher,
|
||||
names: &[&str],
|
||||
) -> horizon_client::command::Dispatcher {
|
||||
// A name the tree already has keeps that command (colour, completion).
|
||||
base.with_mod_commands(
|
||||
names
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|name| base.find(name).is_none()),
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The tree the chat box colours, completes and parses with for the
|
||||
/// world's connection: `base`, the tree the server sent, or the game's own
|
||||
/// before it does. Built where that base changes — once per connection —
|
||||
/// rather than at each keystroke, and asking the mods then, not per key.
|
||||
pub(super) fn chat_tree_for(
|
||||
&mut self,
|
||||
base: Option<&horizon_client::command::Dispatcher>,
|
||||
) -> horizon_client::command::Dispatcher {
|
||||
let mut names = Vec::new();
|
||||
self.native_commands(&mut names);
|
||||
let base = match base {
|
||||
Some(tree) => tree,
|
||||
None => horizon_client::command::vanilla::game_dispatcher(),
|
||||
};
|
||||
with_native_commands(base, &names)
|
||||
}
|
||||
}
|
||||
|
||||
/// The local player's name (offline single player).
|
||||
pub const PLAYER_NAME: &str = "Player";
|
||||
|
||||
@@ -437,9 +474,8 @@ impl Game<'_> {
|
||||
};
|
||||
// The mob effect registry: every effect `/effect` and the
|
||||
// `mob_effect` resource arguments accept.
|
||||
reg.effects = horizon_client::brewing::EFFECTS
|
||||
.iter()
|
||||
.map(|e| format!("minecraft:{}", e.id))
|
||||
reg.effects = horizon_client::brewing::all_effects()
|
||||
.map(|e| horizon_client::brewing::namespaced(e.id).to_owned())
|
||||
.collect();
|
||||
reg.effects.sort();
|
||||
reg.enchantments = list("data/minecraft/enchantment");
|
||||
@@ -952,7 +988,7 @@ impl Game<'_> {
|
||||
if !self.settings.chat_links {
|
||||
return;
|
||||
}
|
||||
if self.settings.file.bool("chatLinksPrompt") {
|
||||
if self.settings.chat_links_prompt {
|
||||
self.link_prompt = Some(url);
|
||||
} else {
|
||||
super::packs::open_uri(&url);
|
||||
@@ -1136,9 +1172,9 @@ impl Game<'_> {
|
||||
.focused_tags(&self.gui.atlas.font, height, cx, cy);
|
||||
horizon_client::hud_layout::paint(batch, &self.gui.atlas, &tag_icons);
|
||||
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
|
||||
// DrawingFocusedGraphicsAccess.handleMessage: the pointing hand over
|
||||
// insertion text, which ChatScreen only asks for while Shift is held.
|
||||
if style.insertion.is_some() && self.has_shift_down() {
|
||||
// ActiveTextCollector.ClickableStyleFinder: a click event, or an
|
||||
// insertion while Shift is held (ChatScreen.includeInsertions).
|
||||
if style.click.is_some() || (style.insertion.is_some() && self.has_shift_down()) {
|
||||
horizon_client::widgets::request_cursor(
|
||||
horizon_client::widgets::Cursor::PointingHand,
|
||||
);
|
||||
@@ -1317,6 +1353,8 @@ impl Game<'_> {
|
||||
/// cause is known to the client).
|
||||
pub(super) fn death_message(&mut self) {
|
||||
let cause = self.ui.death_cause.take();
|
||||
let player = self.native_acting_id();
|
||||
self.native_player_died(player, cause.as_deref());
|
||||
self.ui.died_at = Some(std::time::Instant::now());
|
||||
self.ui.death_text = None;
|
||||
self.ui.death_confirm = None;
|
||||
@@ -1490,4 +1528,40 @@ mod tests {
|
||||
assert!(command_export(&dir.join("missing")).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A native mod's command is in the tree the chat box uses (#456): known
|
||||
/// rather than red, and offered as it types. With no mod claiming one the
|
||||
/// tree is the game's own, as it was before the mods were asked.
|
||||
#[test]
|
||||
fn native_mod_commands_join_the_chat_tree() {
|
||||
let reg = Registries::default();
|
||||
let vanilla = horizon_client::command::vanilla::game_dispatcher();
|
||||
let plain = with_native_commands(vanilla, &[]);
|
||||
assert_eq!(plain.nodes.len(), vanilla.nodes.len());
|
||||
assert!(plain.find("flyspeed").is_none());
|
||||
let tree = with_native_commands(vanilla, &["flyspeed"]);
|
||||
assert!(tree.find("flyspeed").is_some());
|
||||
// The rest of the line goes to the mod, as `native_command` gets it.
|
||||
let parse = tree.parse("flyspeed 4", ®);
|
||||
assert_eq!(
|
||||
parse.executable().unwrap().1,
|
||||
horizon_client::command::MOD_COMMAND
|
||||
);
|
||||
let parse = tree.parse("fly", ®);
|
||||
let offered: Vec<String> = tree
|
||||
.client_suggestions(&parse, 3, ®)
|
||||
.1
|
||||
.into_iter()
|
||||
.map(|s| s.text)
|
||||
.collect();
|
||||
assert!(offered.contains(&"flyspeed".to_owned()), "{offered:?}");
|
||||
// A name the game has keeps that command: the chat still knows it.
|
||||
let shared = with_native_commands(vanilla, &["help"]);
|
||||
assert!(shared.find("help").is_some());
|
||||
assert_eq!(
|
||||
shared.parse("help", ®).executable().map(|e| e.1),
|
||||
vanilla.parse("help", ®).executable().map(|e| e.1)
|
||||
);
|
||||
assert_eq!(shared.nodes.len(), vanilla.nodes.len());
|
||||
}
|
||||
}
|
||||
@@ -565,13 +565,7 @@ impl Game<'_> {
|
||||
let direct = (attacker.is_none() && e.source.kind == DamageKind::Arrow)
|
||||
.then(|| Text::translate("entity.minecraft.arrow", &[]));
|
||||
Entry {
|
||||
// DamageSources.explosion: player_explosion whenever
|
||||
// something caused the blast (the only kind that asks).
|
||||
damage_type: super::creatures::damage_type(
|
||||
e.source.kind,
|
||||
e.source.attacker.is_some(),
|
||||
)
|
||||
.to_owned(),
|
||||
damage_type: super::creatures::source_damage_type(e.source).to_owned(),
|
||||
damage: e.damage,
|
||||
fall_location: e.in_water.then_some("water"),
|
||||
fall_distance: e.fall_distance,
|
||||
|
||||
@@ -1891,7 +1891,7 @@ impl Game<'_> {
|
||||
self.feedback(Text::translate(key, &[]));
|
||||
Ok(duration.unwrap_or(-1))
|
||||
}
|
||||
fn kill_target(&mut self, t: Target) {
|
||||
pub(super) fn kill_target(&mut self, t: Target) {
|
||||
// An entity in another level is killed in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
|
||||
}
|
||||
|
||||
@@ -785,6 +785,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
place(&mut tag, pos);
|
||||
Ok(self.spawn_entity_from_nbt(tag))
|
||||
let from = self.mobs.len();
|
||||
let target = self.spawn_entity_from_nbt(tag);
|
||||
self.native_mobs_joined(from, false);
|
||||
Ok(target)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ impl Tracker {
|
||||
}
|
||||
}
|
||||
fn effect_name(id: &str) -> Text {
|
||||
Text::translate(&format!("effect.minecraft.{id}"), &[])
|
||||
Text::translate(&brewing::description_id(id), &[])
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn effect_arg(ctx: &Context) -> R<&'static brewing::EffectDef> {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! `/place template` (PlaceCommand): a saved structure template placed with
|
||||
//! rotation, mirroring and integrity.
|
||||
use super::{Arg, Context, Game, R, Screen, err};
|
||||
use horizon_client::{containers::BlockEntity, save, text::Text};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use super::{Arg, Context, Game, R, err};
|
||||
use crate::game::structure_blocks::{load_settings, template_size};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
compression::Compression,
|
||||
nbt::{self, Limits, Tag},
|
||||
registry::BlockStateId,
|
||||
worldgen::structure::{Mirror, Rotation, StateTransforms, StructureTemplate},
|
||||
worldgen::structure::{Mirror, Rotation, StateTransforms},
|
||||
};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
Text::literal(n.to_string())
|
||||
@@ -30,16 +30,6 @@ impl Game<'_> {
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
/// A template by id: a structure block's save in the world, then the
|
||||
/// vanilla templates.
|
||||
fn load_template(&self, id: &str) -> Option<Arc<StructureTemplate>> {
|
||||
let tag = self.read_template_tag(id)?;
|
||||
Some(Arc::new(StructureTemplate::from_nbt(
|
||||
&tag,
|
||||
self.pack.assets.registry,
|
||||
self.state_transforms()?,
|
||||
)))
|
||||
}
|
||||
/// A template's tree by id, as `StructureTemplateManager` reads it from
|
||||
/// disk.
|
||||
pub(in crate::game) fn read_template_tag(&self, id: &str) -> Option<Tag> {
|
||||
@@ -219,28 +209,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The barrier step of `StructureTemplate.placeInWorld`, run where a block
|
||||
/// with saved data goes, even over the same state: `LevelChunk.setBlockState`
|
||||
/// removes what is there, with no side effects. A removed jukebox stops its
|
||||
/// song (`place_state` does that itself when the block's name changes), and
|
||||
/// a container screen open on it closes.
|
||||
fn clear_template_block_entity(&mut self, at: [i32; 3], state: BlockStateId) {
|
||||
let key = (at[0], at[1], at[2]);
|
||||
self.world.block_names.remove(&key);
|
||||
let Some(replaced) = self.world.block_entities.remove(&key) else {
|
||||
return;
|
||||
};
|
||||
let previous = self.world.block(at[0], at[1], at[2]);
|
||||
let same_name = self.res.facts().get(previous).name == self.res.facts().get(state).name;
|
||||
if same_name && matches!(replaced, BlockEntity::Jukebox(_)) {
|
||||
self.jukebox_removed(at);
|
||||
}
|
||||
if self.container == Some(key) {
|
||||
self.container_opener_event(key, false);
|
||||
self.container = None;
|
||||
self.screen = Screen::Playing;
|
||||
}
|
||||
}
|
||||
pub(super) fn cmd_place(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(Arg::Resource(id)) = ctx.get("template").cloned() else {
|
||||
return Ok(0);
|
||||
@@ -263,76 +231,20 @@ impl Game<'_> {
|
||||
_ => 1.0,
|
||||
};
|
||||
let seed = match ctx.get("seed") {
|
||||
Some(Arg::Int(s)) => i64::from(*s),
|
||||
Some(Arg::Int(s)) => *s,
|
||||
_ => 0,
|
||||
};
|
||||
let strict = ctx.has("strict");
|
||||
let template = self
|
||||
.load_template(&id)
|
||||
.filter(|t| !t.palettes().is_empty())
|
||||
let entry = self
|
||||
.template_entry(&id)
|
||||
.ok_or_else(|| err("commands.place.template.invalid", &[&id]))?;
|
||||
let transforms = self
|
||||
.state_transforms()
|
||||
.ok_or_else(|| err("commands.place.template.invalid", &[&id]))?;
|
||||
let mut random = LegacyRandom::new(if seed == 0 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(1, |d| d.as_millis() as i64)
|
||||
} else {
|
||||
seed
|
||||
});
|
||||
let palette = template.palette_index(&mut random);
|
||||
let blocks = template.palettes()[palette].blocks().to_vec();
|
||||
let mut placed = 0;
|
||||
for b in &blocks {
|
||||
if integrity < 1.0 && random.next_float() > integrity {
|
||||
continue;
|
||||
}
|
||||
let t = StructureTemplate::transform(b.pos, mirror, rotation, [0, 0, 0]);
|
||||
let at = [pos[0] + t[0], pos[1] + t[1], pos[2] + t[2]];
|
||||
if !self.world.chunks.contains_key(&(at[0] >> 4, at[2] >> 4)) {
|
||||
continue;
|
||||
}
|
||||
let state = transforms.rotate(transforms.mirror(b.state, mirror), rotation);
|
||||
if b.nbt.is_some() {
|
||||
self.clear_template_block_entity(at, state);
|
||||
}
|
||||
if self.place_state(at, state, strict).is_ok() {
|
||||
placed += 1;
|
||||
}
|
||||
if let Some(data) = &b.nbt {
|
||||
let mut tag = Tag::Compound(data.clone());
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("x".into(), Tag::Int(at[0]));
|
||||
c.insert("y".into(), Tag::Int(at[1]));
|
||||
c.insert("z".into(), Tag::Int(at[2]));
|
||||
}
|
||||
if let Some((_, entity)) = save::decode_block_entity(&tag) {
|
||||
let key = (at[0], at[1], at[2]);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.update_spawner_display(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Entities saved with the template.
|
||||
for e in &template.entities {
|
||||
let p = StructureTemplate::transform_vec(e.pos, mirror, rotation, [0, 0, 0]);
|
||||
let at = [
|
||||
f64::from(pos[0]) + p[0],
|
||||
f64::from(pos[1]) + p[1],
|
||||
f64::from(pos[2]) + p[2],
|
||||
];
|
||||
let mut tag = Tag::Compound(e.nbt.clone());
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert(
|
||||
"Pos".into(),
|
||||
Tag::List(at.iter().map(|v| Tag::Double(v.to_bits())).collect()),
|
||||
);
|
||||
c.remove(&"UUID".into());
|
||||
}
|
||||
self.spawn_entity_from_nbt(tag);
|
||||
}
|
||||
if placed == 0 {
|
||||
let size = template_size(&entry.tag);
|
||||
let end = [pos[0] + size[0], pos[2] + size[2]];
|
||||
self.place_check_loaded([pos[0] >> 4, pos[2] >> 4], [end[0] >> 4, end[1] >> 4])?;
|
||||
// `placeInWorld(level, pos, pos, settings, createRandom(seed), flags)`.
|
||||
let settings = load_settings(mirror, rotation, integrity, i64::from(seed), strict);
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
if !self.place_template_entry(&entry, at, settings, mirror, rotation) {
|
||||
return Err(err("commands.place.template.failed", &[]));
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
|
||||
@@ -235,7 +235,7 @@ impl Game<'_> {
|
||||
// ChunkMap.resendBiomesForChunks: the players who see the changed
|
||||
// columns are sent their new biomes, in the order the columns were
|
||||
// gathered (z, then x).
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
let columns: Vec<(i32, i32)> = ((lo[2] >> 4)..=(hi[2] >> 4))
|
||||
.flat_map(|cz| ((lo[0] >> 4)..=(hi[0] >> 4)).map(move |cx| (cx, cz)))
|
||||
.filter(|key| touched.contains(key))
|
||||
|
||||
@@ -345,6 +345,11 @@ impl Game<'_> {
|
||||
if action == "tap" {
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// The merchant's focused offer takes Enter and Space, as the window's.
|
||||
if action != "up" && self.screen == Screen::Inventory && self.merchant_key(physical)
|
||||
{
|
||||
return ok();
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
@@ -825,6 +830,7 @@ impl Game<'_> {
|
||||
amount,
|
||||
true,
|
||||
horizon_client::mobs::DamageKind::PlayerAttack,
|
||||
None,
|
||||
);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -40,6 +40,16 @@ fn arrow_damage(arrow: &mut Projectile, fallback: &mut impl RandomSource) -> f32
|
||||
damage
|
||||
}
|
||||
|
||||
/// A sound a native mod queued, sent when the host is free.
|
||||
pub(super) struct NativeSound {
|
||||
pub dimension: &'static str,
|
||||
pub sound: String,
|
||||
pub at: [f64; 3],
|
||||
pub source: i32,
|
||||
pub volume: f32,
|
||||
pub pitch: f32,
|
||||
}
|
||||
|
||||
/// Mob-system state owned by the game.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Creatures {
|
||||
@@ -68,6 +78,8 @@ pub(super) struct Creatures {
|
||||
pub shoulder_time: std::collections::HashMap<[i32; 4], i64>,
|
||||
/// The cat spawner's countdown (`CatSpawner.nextTick`).
|
||||
pub cat_tick: i32,
|
||||
/// Native sounds waiting for the server host to be available.
|
||||
pub lan_sounds: Vec<NativeSound>,
|
||||
/// Particles from a guest's turn, waiting for it to end.
|
||||
pub lan_particles: Vec<super::lan_particles::QueuedParticle>,
|
||||
}
|
||||
@@ -376,6 +388,7 @@ impl Game<'_> {
|
||||
}
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
self.enchant_spawn_equipment(id);
|
||||
if !persistent && !scripted {
|
||||
self.spawn_jockeys(id, true);
|
||||
@@ -415,6 +428,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
Some(id)
|
||||
}
|
||||
/// The `summon` command: spawn a mob in front of the player or at a position.
|
||||
@@ -1050,7 +1064,7 @@ impl Game<'_> {
|
||||
None => source.clone(),
|
||||
};
|
||||
horizon_client::advancements::DamageView {
|
||||
kind: damage_type(d.kind, matches!(d.attacker, Some(Who::Player))).to_owned(),
|
||||
kind: source_damage_type(d).to_owned(),
|
||||
// DamageSource.isDirect: the causing entity is the direct one.
|
||||
is_direct: projectile.is_none(),
|
||||
direct,
|
||||
@@ -1142,9 +1156,8 @@ impl Game<'_> {
|
||||
}),
|
||||
_ => source.clone(),
|
||||
};
|
||||
let player_source = matches!(d.attacker, Some(Who::Player));
|
||||
LootDamage {
|
||||
kind: damage_type(d.kind, player_source).into(),
|
||||
kind: source_damage_type(d).into(),
|
||||
source,
|
||||
direct,
|
||||
}
|
||||
@@ -1765,6 +1778,7 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
m.id = self.next_entity;
|
||||
self.mobs.push(*m);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
}
|
||||
MobEvent::SpawnKind { kind, at, setup } => {
|
||||
if let Some(id) = self.spawn_mob(&kind, at, setup.persistent) {
|
||||
@@ -3505,6 +3519,7 @@ impl Game<'_> {
|
||||
amount,
|
||||
by_player,
|
||||
horizon_client::mobs::DamageKind::Arrow,
|
||||
Some(&p.kind),
|
||||
) {
|
||||
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
|
||||
super::dragon::Hurt::Refused => EntityHit::Refused,
|
||||
@@ -3519,7 +3534,8 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
let amount = match p.kind {
|
||||
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => 6.0,
|
||||
ProjectileKind::SmallFireball => 5.0,
|
||||
ProjectileKind::LargeFireball { .. } => 6.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let kind = match p.kind {
|
||||
@@ -3528,13 +3544,19 @@ impl Game<'_> {
|
||||
}
|
||||
_ => horizon_client::mobs::DamageKind::Thrown,
|
||||
};
|
||||
self.hit_special(id, amount, by_player, kind);
|
||||
self.hit_special(id, amount, by_player, kind, Some(&p.kind));
|
||||
// ThrowableProjectile.onHit goes on after onHitEntity: a pearl
|
||||
// still takes its owner to where it broke (into the crystal's
|
||||
// blast), an egg still hatches, a snowball still breaks.
|
||||
// blast), an egg still hatches, a snowball still breaks. A large
|
||||
// fireball still explodes (LargeFireball.onHit), whatever the
|
||||
// part or crystal made of its hit (both refuse fire), and the
|
||||
// blast takes the crystal.
|
||||
if !matches!(
|
||||
p.kind,
|
||||
ProjectileKind::EnderPearl | ProjectileKind::Egg | ProjectileKind::Snowball
|
||||
ProjectileKind::EnderPearl
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -3938,7 +3960,11 @@ impl Game<'_> {
|
||||
self.note_attacker("minecraft:explosion", owner);
|
||||
}
|
||||
self.combat.explosion_owner = self.combat.projectile_owner;
|
||||
// The blast's source names the owner, so a dragon it kills
|
||||
// remembers the player who shot it.
|
||||
self.set_blast_by_player(p.owner == Some(Who::Player));
|
||||
let result = self.explode(atp, *power as f32);
|
||||
self.set_blast_by_player(false);
|
||||
self.combat.explosion_owner = None;
|
||||
result?;
|
||||
}
|
||||
@@ -4276,7 +4302,7 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.interact_allay(index, &item) {
|
||||
if self.interact_allay(index, held.as_ref()) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -5086,14 +5112,13 @@ impl Game<'_> {
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
];
|
||||
let Some(mob_id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Entity.applyImplicitComponents: a name from an anvil names the mob.
|
||||
if let Some(name) = stack.custom_name()
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id)
|
||||
{
|
||||
m.custom_name = Some(name.to_owned());
|
||||
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
|
||||
// `PostSpawnProcessor` puts the egg's components on it.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
@@ -5501,7 +5526,9 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// The translucent shell blends over the opaque core.
|
||||
// The translucent shell blends over the opaque core
|
||||
// (`RenderTypes.entityTranslucent`).
|
||||
let shell = out.len();
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
|
||||
@@ -5512,6 +5539,7 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
horizon_client::gfx::mark_oit(&mut out[shell..]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -5648,9 +5676,15 @@ fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Mob.getBaseExperienceReward
|
||||
/// The damage type id of a Horizon damage kind.
|
||||
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
|
||||
/// The damage type id of a mob's damage source (`DamageSources.explosion`: a
|
||||
/// blast with a causing entity is `player_explosion` whoever it is, an
|
||||
/// exploding creeper too).
|
||||
pub(super) fn source_damage_type(d: Damage) -> &'static str {
|
||||
damage_type(d.kind, d.attacker.is_some())
|
||||
}
|
||||
/// The damage type id of a Horizon damage kind. `caused`: the source has a
|
||||
/// causing entity (only a blast asks).
|
||||
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, caused: bool) -> &'static str {
|
||||
use horizon_client::mobs::DamageKind as K;
|
||||
match kind {
|
||||
K::PlayerAttack => "minecraft:player_attack",
|
||||
@@ -5660,7 +5694,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::Arrow => "minecraft:arrow",
|
||||
K::Thrown => "minecraft:thrown",
|
||||
K::Fireball => "minecraft:fireball",
|
||||
K::Explosion if player => "minecraft:player_explosion",
|
||||
K::Explosion if caused => "minecraft:player_explosion",
|
||||
K::Explosion => "minecraft:explosion",
|
||||
K::Fall => "minecraft:fall",
|
||||
K::FallingBlock => "minecraft:falling_block",
|
||||
@@ -5907,6 +5941,32 @@ mod breeding_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod damage_type_tests {
|
||||
use super::*;
|
||||
|
||||
/// `DamageSources.explosion`: a blast with a causing entity is
|
||||
/// `player_explosion` whoever caused it, so an exploding creeper's too.
|
||||
#[test]
|
||||
fn a_creeper_blast_is_a_player_explosion() {
|
||||
let blast = |attacker| Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker,
|
||||
from: Some([0.0; 3]),
|
||||
};
|
||||
let creeper = blast(Some(Who::Mob(7)));
|
||||
assert_eq!(source_damage_type(creeper), "minecraft:player_explosion");
|
||||
// No causing entity.
|
||||
assert_eq!(source_damage_type(blast(None)), "minecraft:explosion");
|
||||
// The other kinds do not ask.
|
||||
let bite = Damage {
|
||||
kind: DamageKind::MobAttack,
|
||||
..creeper
|
||||
};
|
||||
assert_eq!(source_damage_type(bite), "minecraft:mob_attack");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pearl_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -431,6 +431,9 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
),
|
||||
];
|
||||
|
||||
/// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it).
|
||||
const FAR_PROPERTY: &str = "horizon-max-view-distance";
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
fn parse_identifier(text: &str) -> Option<String> {
|
||||
let (namespace, path) = text.split_once(':').unwrap_or(("minecraft", text));
|
||||
@@ -695,6 +698,8 @@ impl Game<'_> {
|
||||
log("INFO", &format!("Preparing level \"{level_name}\""));
|
||||
let folder = world_dir.to_string_lossy().into_owned();
|
||||
self.native_lifecycle_open_started();
|
||||
// The registries the server sends are read while the world prepares.
|
||||
let sync = super::lan::preload_sync_data(&self.options.resources);
|
||||
self.load_world(WorldRequest::new(
|
||||
&folder,
|
||||
Some(&level_name),
|
||||
@@ -831,6 +836,9 @@ impl Game<'_> {
|
||||
guest_commands: true,
|
||||
force_game_mode: props.force_game_mode,
|
||||
};
|
||||
if let Some(sync) = sync {
|
||||
sync.adopt(&self.pack_files());
|
||||
}
|
||||
if let Err(e) = self.lan_host(settings, net, config, None) {
|
||||
log("WARN", "**** FAILED TO BIND TO PORT!");
|
||||
log("WARN", &format!("The exception was: {e}"));
|
||||
@@ -839,7 +847,22 @@ impl Game<'_> {
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
let anticheat = load_anticheat();
|
||||
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
|
||||
let far = props
|
||||
.raw
|
||||
.get(FAR_PROPERTY)
|
||||
.and_then(|v| v.trim().parse::<i32>().ok())
|
||||
.unwrap_or(0)
|
||||
.clamp(0, 32);
|
||||
let (registry, biomes) = (self.pack.assets.registry, self.res.biomes().clone());
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
if far > host.hub.config.view_distance {
|
||||
host.hub.far = Some(horizon_client::lan::far::Far::new(far, registry, biomes));
|
||||
log(
|
||||
"INFO",
|
||||
&format!("Sending saved chunks up to {far} chunks away (max_view_distance)"),
|
||||
);
|
||||
}
|
||||
host.hub.set_anticheat(anticheat);
|
||||
host.hub.spawn_protection = props.spawn_protection;
|
||||
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
|
||||
@@ -882,6 +905,7 @@ impl Game<'_> {
|
||||
let mut next = Instant::now();
|
||||
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
|
||||
let watchdog = Watchdog::start(props.max_tick_time);
|
||||
let mut braked = Instant::now();
|
||||
while !state.stop {
|
||||
let tick_start = Instant::now();
|
||||
if let Some(watchdog) = &watchdog {
|
||||
@@ -941,6 +965,15 @@ impl Game<'_> {
|
||||
}
|
||||
// MinecraftServer.tickServer records each tick's time, for /tick query (and the status).
|
||||
self.clock.record(tick_start.elapsed());
|
||||
// The far ring's brake, once a second on the average tick.
|
||||
if braked.elapsed() >= Duration::from_secs(1)
|
||||
&& let Some(host) = self.lan.as_mut()
|
||||
&& let Some(far) = host.hub.far.as_mut()
|
||||
{
|
||||
braked = Instant::now();
|
||||
let mspt = self.clock.average_nanos() as f64 / 1e6;
|
||||
far.brake(mspt, host.hub.config.view_distance);
|
||||
}
|
||||
state.ticked(Instant::now());
|
||||
self.wait_for_next_server_tick(&mut next, paused);
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ impl Game<'_> {
|
||||
// the dialog stays unless the confirmation takes over (Web Links off,
|
||||
// or Prompt on Links off, opens nothing or the browser behind it).
|
||||
if let Some(Click::OpenUrl(url)) = &click
|
||||
&& !(self.settings.chat_links && self.settings.file.bool("chatLinksPrompt"))
|
||||
&& !(self.settings.chat_links && self.settings.chat_links_prompt)
|
||||
{
|
||||
if self.settings.chat_links {
|
||||
super::packs::open_uri(url);
|
||||
|
||||
@@ -644,29 +644,41 @@ impl Game<'_> {
|
||||
let used_before = stack
|
||||
.as_ref()
|
||||
.map_or(0, |s| self.stats.get("used", &s.item));
|
||||
let Some(&Target::Mob(id)) = targets.first() else {
|
||||
return Err("an attack case hits a mob".into());
|
||||
};
|
||||
let index = self
|
||||
.mobs
|
||||
.iter()
|
||||
.position(|m| m.id == id)
|
||||
.ok_or("the target is gone")?;
|
||||
let kind = self.mobs[index].kind.id.clone();
|
||||
let base = op["base"].as_f64().unwrap_or(0.0) as f32;
|
||||
let bonus = self.smash_damage_bonus(&kind);
|
||||
let source = self.attack_source(feet);
|
||||
let hurt = self.hurt_by_player(index, base + bonus, source);
|
||||
// The hurt sound the mob raised, where it stands.
|
||||
for event in std::mem::take(&mut self.mobs[index].outbox) {
|
||||
if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
|
||||
let (bonus, hurt) = match targets.first() {
|
||||
Some(&Target::Mob(id)) => {
|
||||
let index = self
|
||||
.mobs
|
||||
.iter()
|
||||
.position(|m| m.id == id)
|
||||
.ok_or("the target is gone")?;
|
||||
let kind = self.mobs[index].kind.id.clone();
|
||||
let bonus = self.smash_damage_bonus(&kind);
|
||||
let hurt = self.hurt_by_player(index, base + bonus, source);
|
||||
// The hurt sound the mob raised, where it stands.
|
||||
for event in std::mem::take(&mut self.mobs[index].outbox) {
|
||||
if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
|
||||
}
|
||||
}
|
||||
if hurt {
|
||||
self.weapon_hit(index);
|
||||
}
|
||||
(bonus, hurt)
|
||||
}
|
||||
}
|
||||
if hurt {
|
||||
self.weapon_hit(index);
|
||||
}
|
||||
Some(&Target::Stand(id)) => {
|
||||
let index = self
|
||||
.armor_stands
|
||||
.iter()
|
||||
.position(|s| s.id == id)
|
||||
.ok_or("the target is gone")?;
|
||||
let bonus = self.smash_damage_bonus("minecraft:armor_stand");
|
||||
(bonus, self.hit_armor_stand(index, |_, _, _| {}))
|
||||
}
|
||||
None => return Err("an attack case has a target".into()),
|
||||
};
|
||||
self.process_level_events()?;
|
||||
let mut out = Vec::new();
|
||||
if let Some(s) = &stack {
|
||||
@@ -692,7 +704,10 @@ impl Game<'_> {
|
||||
},
|
||||
Target::Stand(id) => match self.armor_stands.iter().find(|s| s.id == id) {
|
||||
Some(s) => ("-".to_owned(), s.velocity),
|
||||
None => continue,
|
||||
None => {
|
||||
out.push(format!("V {i} gone"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
out.push(format!(
|
||||
@@ -903,6 +918,9 @@ impl Game<'_> {
|
||||
let mut stand = horizon_client::entities::ArmorStand::new(id, at, yaw);
|
||||
stand.marker = e["marker"].as_bool().unwrap_or(false);
|
||||
stand.on_ground = e["ground"].as_bool().unwrap_or(false);
|
||||
if let Some(last_hit) = e["last_hit"].as_i64() {
|
||||
stand.last_hit = last_hit;
|
||||
}
|
||||
self.armor_stands.push(stand);
|
||||
return Ok(Target::Stand(id));
|
||||
}
|
||||
|
||||
@@ -908,11 +908,9 @@ impl Game<'_> {
|
||||
let Some(id) = self.spawn_mob_at(&kind, front, facing != Direction::Up) else {
|
||||
return Ok((Some(stack), CLICK));
|
||||
};
|
||||
// Entity.applyImplicitComponents: a name from an anvil names the mob.
|
||||
if let Some(name) = stack.custom_name()
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.custom_name = Some(name.to_owned());
|
||||
// EntityType.create: `finalizeSpawn` first, the egg's components after.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, &stack);
|
||||
}
|
||||
self.mob_ambient(id)?;
|
||||
// SpawnEggItemBehavior: the entity was placed by the dispenser.
|
||||
@@ -1221,7 +1219,7 @@ impl Game<'_> {
|
||||
// persistent) calls out, then ENTITY_PLACE.
|
||||
if let Some(id) = self.spawn_mob_at(kind, target, true) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::load_bucket_data(m, &stack);
|
||||
horizon_client::entity_nbt::apply_item_components(m, &stack);
|
||||
}
|
||||
self.mob_placed(id)?;
|
||||
}
|
||||
@@ -1755,6 +1753,7 @@ impl Game<'_> {
|
||||
let id = mob.id;
|
||||
self.mobs[index].removed = true;
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
Some(id)
|
||||
}
|
||||
/// `ShearsDispenseItemBehavior.tryShearEntity`: the first live, ready
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, Who},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::ProjectileKind,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -51,8 +52,9 @@ pub(super) struct Fight {
|
||||
respawn: Option<(Respawn, i32)>,
|
||||
/// The four crystals on the exit portal driving the respawn.
|
||||
respawn_crystals: Vec<Vec3>,
|
||||
/// The player behind the crystal blast under way, whose `explosion`
|
||||
/// damage source names them (`EndCrystal.hurtServer`).
|
||||
/// The player behind the blast under way, whose `explosion` damage
|
||||
/// source names them: a crystal's (`EndCrystal.hurtServer`) or a large
|
||||
/// fireball's (`Explosion.getIndirectSourceEntity`, its owner).
|
||||
blast_by: Option<FightWho>,
|
||||
/// The last hit the dragon took (`die(source)`'s source, for the
|
||||
/// `player_killed_entity` trigger's `killing_blow`): its damage type and
|
||||
@@ -293,8 +295,9 @@ pub(super) struct Dragon {
|
||||
pub parts: [(Vec3, Vec3); 8],
|
||||
nodes: Option<[[i32; 3]; 24]>,
|
||||
nearest_crystal: Option<usize>,
|
||||
/// A crystal destroyed while it held its pattern (taken on its next tick).
|
||||
strafe_request: Option<StrafeRequest>,
|
||||
/// The player a crystal destroyed while it held its pattern sends it
|
||||
/// after (`strafe_after_crystal`), taken on its next tick.
|
||||
strafe_request: Option<FightWho>,
|
||||
random: LegacyRandom,
|
||||
}
|
||||
/// A player in the fight: the window's own player, or a guest by
|
||||
@@ -344,15 +347,6 @@ fn nearest_enemy(
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
/// Whom a destroyed crystal sends a holding dragon after
|
||||
/// (`DragonHoldingPatternPhase.onCrystalDestroyed`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum StrafeRequest {
|
||||
/// The player who destroyed it.
|
||||
Player(FightWho),
|
||||
/// Anything else: the player nearest the crystal.
|
||||
Nearest(Vec3),
|
||||
}
|
||||
/// Whether `EndCrystal.hurtServer` creates a blast before notifying the fight.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum CrystalDamage {
|
||||
@@ -365,12 +359,22 @@ pub(super) enum Hurt {
|
||||
/// It returned true; `full` when it was a new hit, not just the excess
|
||||
/// over the last one (the hurt sound and animation).
|
||||
Landed { full: bool },
|
||||
/// It returned false (dying, too little damage).
|
||||
/// It returned false (dying, too little damage, fire on a part).
|
||||
Refused,
|
||||
/// A perched dragon's `onHurt` took an arrow's damage away (and set the
|
||||
/// arrow alight for a second).
|
||||
/// A perched dragon's `onHurt` took a direct arrow's or wind charge's
|
||||
/// damage away (and set it alight for a second).
|
||||
Shrugged,
|
||||
}
|
||||
/// `#minecraft:is_fire`, which the dragon's parts and the crystals refuse
|
||||
/// (`isInvulnerableToBase`: both types are fire immune).
|
||||
fn is_fire(kind: DamageKind) -> bool {
|
||||
let source = Damage {
|
||||
kind,
|
||||
attacker: None,
|
||||
from: None,
|
||||
};
|
||||
source.is_fire()
|
||||
}
|
||||
const PART_SIZES: [(f64, f64); 8] = [
|
||||
(1.0, 1.0),
|
||||
(3.0, 3.0),
|
||||
@@ -455,6 +459,30 @@ impl Dragon {
|
||||
Vec3::new(p.x + w / 2.0, p.y + h, p.z + w / 2.0),
|
||||
)
|
||||
}
|
||||
/// `EnderDragonPart.hurtServer`: `isInvulnerableToBase` refuses fire
|
||||
/// (the dragon's type is fire immune) before `EnderDragon.hurt`.
|
||||
/// `direct` is the projectile that hit: a direct `AbstractArrow` or a
|
||||
/// `WindCharge` is the arrow a perched dragon shrugs off, a breeze's
|
||||
/// `BreezeWindCharge` is neither.
|
||||
fn hurt_part(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: bool,
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
) -> Hurt {
|
||||
if is_fire(kind) {
|
||||
return Hurt::Refused;
|
||||
}
|
||||
let arrow = matches!(
|
||||
direct,
|
||||
Some(
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Trident | ProjectileKind::WindCharge
|
||||
)
|
||||
);
|
||||
self.hurt(part, damage, counts, arrow)
|
||||
}
|
||||
/// `EnderDragon.hurt(part, source, damage)`: whether the hit landed
|
||||
/// (an arrow is spent, else it turns back). `counts` is
|
||||
/// `source.getEntity() instanceof Player` or a damage type that always
|
||||
@@ -552,6 +580,19 @@ impl Dragon {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `DragonHoldingPatternPhase.onCrystalDestroyed`: strafe `player` if
|
||||
/// the dragon can attack them (`EnderDragon.canAttack`, on Peaceful too).
|
||||
/// The first such crystal wins: the dragon strafes from then on, and
|
||||
/// `onCrystalDestroyed` does nothing in that phase.
|
||||
fn strafe_after_crystal(&mut self, player: Option<&FightPlayer>) {
|
||||
if let Some(p) = player
|
||||
&& p.enemy()
|
||||
&& matches!(self.phase, Phase::Holding { .. })
|
||||
&& self.strafe_request.is_none()
|
||||
{
|
||||
self.strafe_request = Some(p.who);
|
||||
}
|
||||
}
|
||||
fn set_phase(&mut self, phase: Phase) {
|
||||
if self.phase.id() == phase.id() && phase.id() != 1 {
|
||||
return;
|
||||
@@ -712,6 +753,11 @@ impl Game<'_> {
|
||||
fn acting_player(&self) -> FightWho {
|
||||
self.lan_acting.map_or(FightWho::Host, FightWho::Guest)
|
||||
}
|
||||
/// Names the acting player behind the blast about to go off (a large
|
||||
/// fireball they shot), or no one, for the dragon's kill credit.
|
||||
pub(super) fn set_blast_by_player(&mut self, by_player: bool) {
|
||||
self.fight.blast_by = by_player.then(|| self.acting_player());
|
||||
}
|
||||
/// The players in the level the dragon sees (`level.players()`): the
|
||||
/// window's own, then every guest in the level.
|
||||
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
|
||||
@@ -925,25 +971,29 @@ impl Game<'_> {
|
||||
}
|
||||
// EnderDragonFight.onCrystalDestroyed.
|
||||
self.count_crystals();
|
||||
// EnderDragon.onCrystalDestroyed: the player behind the damage, else
|
||||
// the one nearest the crystal's block (`CRYSTAL_DESTROY_TARGETING`,
|
||||
// whose range `TargetingConditions.test` skips without a targeter).
|
||||
let player = by.or_else(|| {
|
||||
let p = c.position;
|
||||
let at = Vec3::new(p.x.floor(), p.y.floor(), p.z.floor());
|
||||
let peaceful = self.ui.difficulty == 0;
|
||||
nearest_enemy(&self.fight_players(), peaceful, at, None, |_| true).map(|p| p.who)
|
||||
});
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if d.nearest_crystal == Some(index) {
|
||||
d.nearest_crystal = None;
|
||||
self.hurt_dragon(0, 10.0, true, player, DamageKind::Explosion);
|
||||
} else if let Some(n) = d.nearest_crystal.as_mut()
|
||||
&& *n > index
|
||||
{
|
||||
*n -= 1;
|
||||
}
|
||||
let target = player.and_then(|who| self.fight_players().into_iter().find(|p| p.who == who));
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
if d.nearest_crystal == Some(index) {
|
||||
d.nearest_crystal = None;
|
||||
self.hurt_dragon(0, 10.0, true, by, DamageKind::Explosion);
|
||||
} else if let Some(n) = d.nearest_crystal.as_mut()
|
||||
&& *n > index
|
||||
{
|
||||
*n -= 1;
|
||||
}
|
||||
// EnderDragon.onCrystalDestroyed: the player who did it, else the
|
||||
// nearest; DragonHoldingPatternPhase strafes them.
|
||||
if let Some(d) = self.dragon.as_mut()
|
||||
&& matches!(d.phase, Phase::Holding { .. })
|
||||
{
|
||||
d.strafe_request = Some(match by {
|
||||
Some(who) => StrafeRequest::Player(who),
|
||||
None => StrafeRequest::Nearest(c.position),
|
||||
});
|
||||
}
|
||||
d.strafe_after_crystal(target.as_ref());
|
||||
}
|
||||
}
|
||||
/// Targets for projectiles: dragon parts and crystals.
|
||||
@@ -991,18 +1041,20 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// Damage to a special id (from projectiles and melee): what the
|
||||
/// dragon part or crystal made of it. `arrow` for a direct arrow.
|
||||
/// dragon part or crystal made of it. `direct` is the projectile that hit.
|
||||
pub(super) fn hit_special(
|
||||
&mut self,
|
||||
id: u32,
|
||||
amount: f32,
|
||||
by_player: bool,
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
) -> Hurt {
|
||||
if id >= CRYSTAL_IDS {
|
||||
// EndCrystal.hurtServer: false while invulnerable, else it blows up.
|
||||
// EndCrystal.hurtServer: false while invulnerable and for fire
|
||||
// (its type is fire immune), else it blows up.
|
||||
let index = (id - CRYSTAL_IDS) as usize;
|
||||
if self.crystals.get(index).is_none_or(|c| c.invulnerable) {
|
||||
if is_fire(kind) || self.crystals.get(index).is_none_or(|c| c.invulnerable) {
|
||||
return Hurt::Refused;
|
||||
}
|
||||
let by = by_player.then(|| self.acting_player());
|
||||
@@ -1010,13 +1062,13 @@ impl Game<'_> {
|
||||
Hurt::Landed { full: true }
|
||||
} else if id >= SPECIAL {
|
||||
let by = by_player.then(|| self.acting_player());
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, kind)
|
||||
let part = (id - SPECIAL) as usize;
|
||||
self.hurt_dragon_part(part, amount, by_player, by, kind, direct)
|
||||
} else {
|
||||
Hurt::Refused
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.hurt(part, source, damage)` on the dragon in the level.
|
||||
/// `by` is the player behind the hit, remembered for the kill credit.
|
||||
/// `hurt_dragon_part` with no projectile behind the hit.
|
||||
fn hurt_dragon(
|
||||
&mut self,
|
||||
part: usize,
|
||||
@@ -1025,13 +1077,25 @@ impl Game<'_> {
|
||||
by: Option<FightWho>,
|
||||
kind: DamageKind,
|
||||
) -> Hurt {
|
||||
let arrow = kind == DamageKind::Arrow;
|
||||
self.hurt_dragon_part(part, damage, counts, by, kind, None)
|
||||
}
|
||||
/// `EnderDragonPart.hurtServer` on the dragon in the level. `by` is the
|
||||
/// player behind the hit, remembered for the kill credit.
|
||||
fn hurt_dragon_part(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: bool,
|
||||
by: Option<FightWho>,
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
) -> Hurt {
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return Hurt::Refused;
|
||||
};
|
||||
let was_down = d.down();
|
||||
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
|
||||
let hurt = d.hurt(part, damage, counts, arrow);
|
||||
let hurt = d.hurt_part(part, damage, counts, kind, direct);
|
||||
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
|
||||
// (100 ticks); a hit the damage cooldown absorbs returns before that.
|
||||
if counts
|
||||
@@ -1167,7 +1231,7 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|(part, _)| *part == id)
|
||||
.map(|(_, b)| b);
|
||||
let hurt = self.hit_special(id, amount, true, DamageKind::PlayerAttack);
|
||||
let hurt = self.hit_special(id, amount, true, DamageKind::PlayerAttack, None);
|
||||
// The sweep and the sounds read the charge, which the hit then spends.
|
||||
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
|
||||
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
|
||||
@@ -1369,19 +1433,12 @@ impl Game<'_> {
|
||||
fn tick_phase(&mut self) {
|
||||
let players = self.fight_players();
|
||||
let peaceful = self.ui.difficulty == 0;
|
||||
// A crystal destroyed since: strafe whoever it names, if the dragon
|
||||
// can attack them (`canAttack`).
|
||||
// A crystal destroyed since: strafe the player it named.
|
||||
if let Some(d) = self.dragon.as_mut()
|
||||
&& let Some(request) = d.strafe_request.take()
|
||||
&& let Some(who) = d.strafe_request.take()
|
||||
&& matches!(d.phase, Phase::Holding { .. })
|
||||
{
|
||||
let target = match request {
|
||||
StrafeRequest::Player(who) => players
|
||||
.iter()
|
||||
.find(|p| p.who == who && p.enemy() && !peaceful)
|
||||
.copied(),
|
||||
StrafeRequest::Nearest(at) => nearest_enemy(&players, peaceful, at, None, |_| true),
|
||||
};
|
||||
let target = players.iter().find(|p| p.who == who).copied();
|
||||
if let Some(t) = target {
|
||||
d.set_phase(Phase::Strafe {
|
||||
charge: 0,
|
||||
@@ -2633,8 +2690,13 @@ impl Game<'_> {
|
||||
0,
|
||||
&mut eye_quads,
|
||||
);
|
||||
// `RenderTypes.eyes` has an `OitPipelineSet`: improved transparency
|
||||
// moves the tagged quads into its stages (the HDR tier is the eyes'
|
||||
// shine, `mobs::render::EYES_TIER`).
|
||||
for v in &mut eye_quads {
|
||||
v.layer = 2;
|
||||
v.layer = 2
|
||||
| horizon_client::gfx::OIT_ENTITY
|
||||
| u32::from(horizon_client::mesher::BRIGHT) << 16;
|
||||
}
|
||||
additive.extend(eye_quads);
|
||||
if death > 0.0 {
|
||||
@@ -2872,10 +2934,12 @@ impl Game<'_> {
|
||||
.filter(|c| !c.invulnerable && in_reach(c.position))
|
||||
.map(|c| c.position)
|
||||
.collect();
|
||||
// The blast's source carries the player who set it off, if any.
|
||||
let by = self.fight.blast_by;
|
||||
for at in crystals {
|
||||
// Look up the current index after each removal.
|
||||
if let Some(i) = self.crystals.iter().position(|c| c.position == at) {
|
||||
self.destroy_crystal(i, None, CrystalDamage::Explosion);
|
||||
self.destroy_crystal(i, by, CrystalDamage::Explosion);
|
||||
}
|
||||
}
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
@@ -2885,7 +2949,6 @@ impl Game<'_> {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
});
|
||||
let by = self.fight.blast_by;
|
||||
for (part, amount) in hits {
|
||||
self.hurt_dragon(part, amount, true, by, DamageKind::Explosion);
|
||||
}
|
||||
@@ -3751,6 +3814,44 @@ mod tests {
|
||||
assert_eq!(d.health, MAX_HEALTH - 9.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_refuses_fire() {
|
||||
// EnderDragonPart.hurtServer: the dragon is fire immune, so a
|
||||
// fireball's hit is refused on the head and on the other parts.
|
||||
let large = ProjectileKind::LargeFireball { power: 1 };
|
||||
for part in [0, 2] {
|
||||
let mut d = dragon(flying());
|
||||
let hurt = d.hurt_part(part, 6.0, true, DamageKind::Fireball, Some(&large));
|
||||
assert_eq!(hurt, Hurt::Refused);
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
}
|
||||
let small = ProjectileKind::SmallFireball;
|
||||
let mut d = dragon(flying());
|
||||
let hurt = d.hurt_part(0, 5.0, true, DamageKind::Fireball, Some(&small));
|
||||
assert_eq!(hurt, Hurt::Refused);
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_perched_dragon_shrugs_a_players_wind_charge_off() {
|
||||
// AbstractDragonSittingPhase.onHurt: a direct WindCharge does
|
||||
// nothing, a breeze's BreezeWindCharge is not one.
|
||||
let charge = ProjectileKind::WindCharge;
|
||||
let mut d = dragon(Phase::Scanning { time: 0 });
|
||||
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&charge));
|
||||
assert_eq!(hurt, Hurt::Shrugged);
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
let breeze = ProjectileKind::BreezeWindCharge;
|
||||
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&breeze));
|
||||
assert_eq!(hurt, Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 1.0);
|
||||
// In flight the player's charge lands too.
|
||||
let mut d = dragon(flying());
|
||||
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&charge));
|
||||
assert_eq!(hurt, Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cooldown_lets_only_the_excess_through() {
|
||||
// LivingEntity.hurtServer: damageCooldownTime > 10 keeps lastHurt.
|
||||
@@ -3832,6 +3933,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_crystal_destroyed_in_a_tick_names_the_strafed_player() {
|
||||
// DragonHoldingPatternPhase.onCrystalDestroyed strafes at once, and
|
||||
// the strafing phase ignores the next crystal.
|
||||
let (host, guest) = (
|
||||
player(FightWho::Host, 1.0, false, false),
|
||||
player(FightWho::Guest(8), 2.0, false, false),
|
||||
);
|
||||
let mut d = dragon(flying());
|
||||
d.strafe_after_crystal(Some(&host));
|
||||
d.strafe_after_crystal(Some(&guest));
|
||||
assert_eq!(d.strafe_request, Some(FightWho::Host));
|
||||
// Nobody, or a player it can't attack, leaves the next crystal free.
|
||||
let creative = player(FightWho::Host, 1.0, true, false);
|
||||
let mut d = dragon(flying());
|
||||
d.strafe_after_crystal(None);
|
||||
d.strafe_after_crystal(Some(&creative));
|
||||
d.strafe_after_crystal(Some(&guest));
|
||||
assert_eq!(d.strafe_request, Some(FightWho::Guest(8)));
|
||||
// Only while it holds its pattern.
|
||||
let mut d = dragon(Phase::Scanning { time: 0 });
|
||||
d.strafe_after_crystal(Some(&guest));
|
||||
assert_eq!(d.strafe_request, None);
|
||||
}
|
||||
|
||||
/// #2893: a player under a wing is pushed only when the wing's hurt
|
||||
/// lands, not on every tick they stay there, and never under a perched
|
||||
/// dragon's wing.
|
||||
|
||||
@@ -18,6 +18,13 @@ impl ExecutionRole {
|
||||
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
|
||||
self.is_client() && server_thread
|
||||
}
|
||||
|
||||
/// Single-player worlds (in-frame or on the integrated server thread) keep
|
||||
/// the owner's state in `Level` and write `players/data/<singleplayer_uuid>.dat`
|
||||
/// with the world; a dedicated server saves each connected player separately.
|
||||
pub fn writes_singleplayer_player(self) -> bool {
|
||||
self != Self::DedicatedServer
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -26,14 +33,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
|
||||
for (role, client, threaded) in [
|
||||
(Client, true, true),
|
||||
(IntegratedServer, false, false),
|
||||
(DedicatedServer, false, false),
|
||||
for (role, client, threaded, writes_player) in [
|
||||
(Client, true, true, true),
|
||||
(IntegratedServer, false, false, true),
|
||||
(DedicatedServer, false, false, false),
|
||||
] {
|
||||
assert_eq!(role.is_client(), client);
|
||||
assert_eq!(role.starts_integrated_server(true), threaded);
|
||||
assert!(!role.starts_integrated_server(false));
|
||||
assert_eq!(role.writes_singleplayer_player(), writes_player);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,30 @@ pub(super) fn blast_spares_decorations(
|
||||
!mob_griefing && (interaction == BlockInteraction::Keep || by_mob)
|
||||
}
|
||||
|
||||
/// A mob in a blast's reach: `hurtServer` when its team allows the damage
|
||||
/// (`hurtable`), then `pushFromExplosion` whatever that returned, so a mob
|
||||
/// that refuses the hit (invulnerable, in its hurt cooldown, a wither and an
|
||||
/// undead `cause`) is still pushed. `Entity.push` sets `needsSync`.
|
||||
fn hurt_and_push_mob(
|
||||
m: &mut horizon_client::mobs::Mob,
|
||||
hurtable: bool,
|
||||
cause: Option<horizon_client::mobs::Species>,
|
||||
(amount, source): (f32, horizon_client::mobs::Damage),
|
||||
push: [f64; 3],
|
||||
) {
|
||||
let shielded =
|
||||
cause.is_some_and(|c| horizon_client::mobs::wither::shielded_from(m.species(), c));
|
||||
if hurtable && !shielded {
|
||||
m.damage(amount, source);
|
||||
}
|
||||
m.velocity = Vec3::new(
|
||||
m.velocity.x + push[0],
|
||||
m.velocity.y + push[1],
|
||||
m.velocity.z + push[2],
|
||||
);
|
||||
m.needs_sync = true;
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Client-side randomness (explosion sound pitch), separate from the level random.
|
||||
static CLIENT_RANDOM: std::cell::RefCell<LegacyRandom> = std::cell::RefCell::new(LegacyRandom::new(0x5eed));
|
||||
@@ -433,13 +457,16 @@ impl Game<'_> {
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.species());
|
||||
// Mobs, but not the one exploding (`getEntities(source, box)`): a
|
||||
// wither's spawn blast neither hurts nor pushes it.
|
||||
// wither's spawn blast neither hurts nor pushes it. `Warden.ignoreExplosion`:
|
||||
// nor does any blast hurt or push a digging or emerging warden.
|
||||
let spared = exploding.filter(|_| self.combat.explosion_source_named);
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
for m in mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.alive() && spared != Some(m.id))
|
||||
{
|
||||
for m in mobs.iter_mut().filter(|m| {
|
||||
m.alive()
|
||||
&& spared != Some(m.id)
|
||||
&& !(m.species() == horizon_client::mobs::Species::Warden
|
||||
&& horizon_client::mobs::warden::digging_or_emerging(m))
|
||||
}) {
|
||||
let hurtable = teams_allow(self, self.mob_team(m.id));
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
@@ -467,19 +494,16 @@ impl Game<'_> {
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
let shielded =
|
||||
cause.is_some_and(|c| horizon_client::mobs::wither::shielded_from(m.species(), c));
|
||||
if hurtable && (shielded || !m.damage(damage(dist, exposure), source)) {
|
||||
continue;
|
||||
}
|
||||
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`: its
|
||||
// base is 0 for every mob type, so iron golems and ravagers are pushed.
|
||||
let dir = direction(eye);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
m.velocity = Vec3::new(
|
||||
m.velocity.x + dir[0] * power,
|
||||
m.velocity.y + dir[1] * power,
|
||||
m.velocity.z + dir[2] * power,
|
||||
hurt_and_push_mob(
|
||||
m,
|
||||
hurtable,
|
||||
cause,
|
||||
(damage(dist, exposure), source),
|
||||
[dir[0] * power, dir[1] * power, dir[2] * power],
|
||||
);
|
||||
}
|
||||
self.mobs = mobs;
|
||||
@@ -873,6 +897,98 @@ mod tests {
|
||||
assert!(!blast_reaches(center, 8.0, [8.6, 64.0, 0.5]));
|
||||
}
|
||||
|
||||
/// A zombie's type with only what a hurt and a push read.
|
||||
fn zombie_type() -> std::sync::Arc<horizon_client::mobs::MobType> {
|
||||
use horizon_client::mobs::{Anim, Brain, MobType, Part, Species};
|
||||
std::sync::Arc::new(MobType {
|
||||
id: "minecraft:zombie".into(),
|
||||
species: Species::Zombie,
|
||||
width: 0.6,
|
||||
height: 1.95,
|
||||
eye_height: 1.74,
|
||||
category: "monster".into(),
|
||||
max_health: 20.0,
|
||||
speed: 0.23,
|
||||
flying_speed: 0.02,
|
||||
attack_damage: 3.0,
|
||||
attack_knockback: 0.0,
|
||||
follow_range: 35.0,
|
||||
step_height: 0.6,
|
||||
armor: 2.0,
|
||||
armor_toughness: 0.0,
|
||||
knockback_resistance: 0.0,
|
||||
jump_strength: 0.42,
|
||||
attributes: Default::default(),
|
||||
gravity: 0.08,
|
||||
safe_fall_distance: 3.0,
|
||||
fall_damage_multiplier: 1.0,
|
||||
water_efficiency: 0.0,
|
||||
water_slowdown: 0.8,
|
||||
air_speed: 0.02,
|
||||
baby_scale: 0.5,
|
||||
head_rot_speed: 10.0,
|
||||
max_head_x_rot: 40.0,
|
||||
max_head_y_rot: 75.0,
|
||||
ambient_interval: 80,
|
||||
flags: Default::default(),
|
||||
model: std::sync::Arc::new(Part {
|
||||
name: "root".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}),
|
||||
layers: Default::default(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Humanoid { zombie_arms: true },
|
||||
sounds: ["", "", ""],
|
||||
step_sound: None,
|
||||
food: Default::default(),
|
||||
tempt: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mob_that_refuses_the_hit_is_still_pushed() {
|
||||
use horizon_client::mobs::{Damage, DamageKind, Mob, Species};
|
||||
let hit = (10.0, Damage::of(DamageKind::Explosion));
|
||||
let push = [0.5, 0.25, -0.5];
|
||||
let mob = |id, species| {
|
||||
let mut kind = (*zombie_type()).clone();
|
||||
kind.species = species;
|
||||
Mob::new(id, std::sync::Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
};
|
||||
let pushed = |m: &Mob| {
|
||||
assert_eq!(m.velocity, Vec3::new(0.5, 0.25, -0.5));
|
||||
assert!(m.needs_sync);
|
||||
};
|
||||
// An ordinary mob is hurt and pushed.
|
||||
let mut m = mob(1, Species::Zombie);
|
||||
let health = m.health;
|
||||
hurt_and_push_mob(&mut m, true, None, hit, push);
|
||||
assert!(m.health < health);
|
||||
pushed(&m);
|
||||
// An invulnerable one refuses the hit, and is pushed all the same.
|
||||
let mut m = mob(2, Species::Zombie);
|
||||
m.invulnerable_flag = true;
|
||||
let health = m.health;
|
||||
hurt_and_push_mob(&mut m, true, None, hit, push);
|
||||
assert_eq!(m.health, health);
|
||||
pushed(&m);
|
||||
// A team mate of the exploding mob is not hurt, but pushed.
|
||||
let mut m = mob(3, Species::Zombie);
|
||||
let health = m.health;
|
||||
hurt_and_push_mob(&mut m, false, None, hit, push);
|
||||
assert_eq!(m.health, health);
|
||||
pushed(&m);
|
||||
// A wither shrugs off a blast an undead caused, and is pushed.
|
||||
let mut m = mob(4, Species::Wither);
|
||||
let health = m.health;
|
||||
hurt_and_push_mob(&mut m, true, Some(Species::Skeleton), hit, push);
|
||||
assert_eq!(m.health, health);
|
||||
pushed(&m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mob_griefing_off_spares_decorations_from_kept_and_mob_blasts() {
|
||||
use BlockInteraction::{Destroy, DestroyWithDecay, Keep};
|
||||
|
||||
@@ -160,6 +160,7 @@ impl Game<'_> {
|
||||
mob.previous_head_yaw = yaw;
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
Some(id)
|
||||
}
|
||||
/// `CarvedPumpkinBlock.clearPatternBlocks`: every cell of the match
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources, WorldIcon,
|
||||
execution_role::ExecutionRole,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
lan::{LanSettings, Owner, SyncPreload, preload_sync_data},
|
||||
world_loading::{WorldLoader, WorldRequest},
|
||||
};
|
||||
use horizon_client::level_loading::{STATUS_RADIUS, Stage};
|
||||
use horizon_client::{
|
||||
@@ -490,8 +490,9 @@ impl Game<'_> {
|
||||
flags: Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
opening: Opening,
|
||||
) -> Result<()> {
|
||||
let result = self.run_integrated_inner(launch, flags, tasks, ready);
|
||||
let result = self.run_integrated_inner(launch, flags, tasks, ready, opening);
|
||||
if let Err(error) = &result {
|
||||
self.native_lifecycle_close_failed(&error.to_string());
|
||||
}
|
||||
@@ -504,10 +505,11 @@ impl Game<'_> {
|
||||
flags: Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
opening: Opening,
|
||||
) -> Result<()> {
|
||||
// The open segment runs as one unit (`integrated_open`) so its
|
||||
// failures fire `WorldOpenFailed` with the engine's own error text.
|
||||
if let Err(e) = self.integrated_open(&launch, &flags, tasks, ready) {
|
||||
if let Err(e) = self.integrated_open(&launch, &flags, tasks, ready, opening) {
|
||||
self.native_lifecycle_world_failed(&e.to_string());
|
||||
return Err(e);
|
||||
}
|
||||
@@ -573,15 +575,16 @@ impl Game<'_> {
|
||||
flags: &Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
opening: Opening,
|
||||
) -> Result<()> {
|
||||
self.native_lifecycle_open_started();
|
||||
self.load_world(WorldRequest::new(
|
||||
&launch.folder,
|
||||
launch.name.as_deref(),
|
||||
launch.seed,
|
||||
launch.creative,
|
||||
launch.create.as_ref(),
|
||||
)?)?;
|
||||
// `load_world`, with the world `serve` started preparing.
|
||||
self.join_world_rest()?;
|
||||
self.perf.world_opening();
|
||||
self.finish_open_world(opening.world?.finish()?)?;
|
||||
if let Some(sync) = opening.sync {
|
||||
sync.adopt(&self.pack_files());
|
||||
}
|
||||
let saved = Box::new(self.level.clone());
|
||||
// IntegratedServer runs the server mods; the window the client mods.
|
||||
self.mods.dev = self.options.dev_mods.clone();
|
||||
@@ -985,8 +988,46 @@ fn serve(
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
let mut server = server_game(res, pack, options, settings)?;
|
||||
server.run_integrated(launch, flags, tasks, ready)
|
||||
// The world prepares, and the registries the server sends are read, on
|
||||
// helper threads while this one builds the server's own Game: none of
|
||||
// them reads what another makes (`integrated_open` takes them up).
|
||||
let world = WorldRequest::new(
|
||||
&launch.folder,
|
||||
launch.name.as_deref(),
|
||||
launch.seed,
|
||||
launch.creative,
|
||||
launch.create.as_ref(),
|
||||
)
|
||||
.and_then(|request| {
|
||||
WorldLoader::start(
|
||||
request,
|
||||
res.clone(),
|
||||
pack.clone(),
|
||||
options.clone(),
|
||||
settings.clone(),
|
||||
)
|
||||
});
|
||||
let opening = Opening {
|
||||
world,
|
||||
sync: preload_sync_data(&options.resources),
|
||||
};
|
||||
let mut server = match server_game(res, pack, options, settings) {
|
||||
Ok(server) => server,
|
||||
Err(e) => {
|
||||
// The world's session lock is let go before the failure is told.
|
||||
if let Ok(world) = opening.world {
|
||||
drop(world.finish());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
server.run_integrated(launch, flags, tasks, ready, opening)
|
||||
}
|
||||
|
||||
/// What `serve` starts beside the server's own Game.
|
||||
struct Opening {
|
||||
world: Result<WorldLoader>,
|
||||
sync: Option<SyncPreload>,
|
||||
}
|
||||
|
||||
/// Component oracles supply their own synthetic levels and entity phases.
|
||||
|
||||
@@ -49,6 +49,40 @@ pub(super) fn forget_sync_data() {
|
||||
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// The registries and tags a server sends without data packs over them, read
|
||||
/// on a helper thread while a server's world prepares: most worlds have no
|
||||
/// packs, so it is what `sync_data` would load once the world is open.
|
||||
pub(super) struct SyncPreload(std::thread::JoinHandle<std::result::Result<SyncData, String>>);
|
||||
|
||||
/// Start reading the server's data beside the world's preparation; `None`
|
||||
/// when it is read already, or being read (`preload_world_data`).
|
||||
pub(super) fn preload_sync_data(root: &std::path::Path) -> Option<SyncPreload> {
|
||||
if !SYNC.try_lock().is_ok_and(|sync| sync.is_none()) {
|
||||
return None;
|
||||
}
|
||||
let root = root.to_owned();
|
||||
std::thread::Builder::new()
|
||||
.name("Server data preload".into())
|
||||
.spawn(move || SyncData::load(&root, &horizon_world::loot::PackOverlay::NONE))
|
||||
.map(SyncPreload)
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl SyncPreload {
|
||||
/// The world is open: without packs of its own (`overlay`), what was read
|
||||
/// becomes `sync_data`'s; with them, `sync_data` loads them as before.
|
||||
pub(super) fn adopt(self, overlay: &horizon_world::loot::PackOverlay) {
|
||||
if !overlay.files.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(read) = self.0.join() {
|
||||
SYNC.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get_or_insert_with(|| read.map(Arc::new));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings of the open world (WorldOptionsScreen's multiplayer section).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LanSettings {
|
||||
@@ -98,7 +132,7 @@ pub(super) struct LanHost {
|
||||
pub native_commands: Option<HashMap<String, u8>>,
|
||||
/// The entities the guests track, described again every tick
|
||||
/// (`lan_entities`) over last tick's states.
|
||||
states: lan::entity::States,
|
||||
pub(super) states: lan::entity::States,
|
||||
}
|
||||
|
||||
impl LanHost {
|
||||
@@ -1479,6 +1513,8 @@ impl Game<'_> {
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
self.save_guest_records(host, g);
|
||||
} else if let Some(saved) = host.owner.as_ref().and_then(|o| o.saved.as_ref()) {
|
||||
self.level.position = saved.position;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1715,6 +1751,7 @@ impl Game<'_> {
|
||||
for (id, event, at) in gone {
|
||||
host.hub.entity_event(id, event, at);
|
||||
}
|
||||
host.hub.far_level(&self.save);
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
@@ -1880,6 +1917,14 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the quiet commands");
|
||||
}
|
||||
if self.native_net_waiting() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_net();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the mod messages");
|
||||
}
|
||||
if !host.hub.native_menu_events.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_menu_events();
|
||||
@@ -1901,7 +1946,9 @@ impl Game<'_> {
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
self.lan_sync_scoreboard(&mut host);
|
||||
self.lan_sync_border(&mut host);
|
||||
host.hub.anticheat_ping();
|
||||
drop(holding);
|
||||
self.flush_native_net(&mut host);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
// A blast from a guest's attack, and a mace's wind burst.
|
||||
@@ -1913,6 +1960,7 @@ impl Game<'_> {
|
||||
self.flush_hook_pulls();
|
||||
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
|
||||
self.flush_lan_particles();
|
||||
self.flush_native_sounds();
|
||||
}
|
||||
|
||||
fn lan_guest_joined(
|
||||
@@ -2187,8 +2235,7 @@ impl Game<'_> {
|
||||
&& let Some((source, _)) = m.last_damage
|
||||
{
|
||||
use horizon_client::mobs::{DamageKind as K, Who};
|
||||
let player = source.attacker == Some(Who::Player);
|
||||
let kind = super::creatures::damage_type(source.kind, player);
|
||||
let kind = super::creatures::source_damage_type(source);
|
||||
// The player a mob sees is the guest acting, else the host.
|
||||
let cause = source.attacker.map(|who| match who {
|
||||
Who::Player => host
|
||||
@@ -2399,6 +2446,7 @@ impl Game<'_> {
|
||||
m.extra.fall_flying = body.gliding;
|
||||
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
|
||||
m.extra.auto_spin_attack = body.spin_attack;
|
||||
m.extra.previous_velocity = m.velocity;
|
||||
(m.extra.arrow_count, m.extra.stinger_count) = g.stuck;
|
||||
m.extra.shoulder_parrots = g.shoulders;
|
||||
m.velocity = g
|
||||
@@ -2575,7 +2623,24 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `LivingEntity.DATA_LIVING_ENTITY_FLAGS` for an item in use: 1, and 2 when
|
||||
/// it is the off hand's.
|
||||
/// it is the off hand's. A stop is 0 here; vanilla leaves bit 1 set, so its
|
||||
/// stop of an off-hand use is 2. Both mean "not using" (bit 0 is clear), and
|
||||
/// nothing reads bit 1 without bit 0.
|
||||
pub(super) fn living_flags(using: Option<(usize, u32)>) -> i8 {
|
||||
using.map_or(0, |(slot, _)| if slot == OFFHAND_SLOT { 3 } else { 1 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{OFFHAND_SLOT, living_flags};
|
||||
|
||||
/// `LivingEntity.startUsingItem` sets flag 1 for the use and flag 2 for
|
||||
/// the off hand, so an off-hand use is 3, not 2. A reader takes bit 0 as
|
||||
/// `isUsingItem` and bit 1 as `getUsedItemHand`.
|
||||
#[test]
|
||||
fn an_off_hand_use_flags_both_the_use_and_the_hand() {
|
||||
assert_eq!(living_flags(None), 0);
|
||||
assert_eq!(living_flags(Some((0, 12))), 1);
|
||||
assert_eq!(living_flags(Some((OFFHAND_SLOT, 12))), 3);
|
||||
}
|
||||
}
|
||||
@@ -1201,6 +1201,18 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let face = direction(i32::from(a.direction));
|
||||
// Native mods, once the dig may start: a canceled click
|
||||
// attacks, digs and breaks nothing (the ack puts the
|
||||
// block back on the client).
|
||||
let canceled = self.as_guest(host, conn, |g| {
|
||||
let state = g.world.block(a.pos[0], a.pos[1], a.pos[2]);
|
||||
let mut canceled = false;
|
||||
g.native_left_click_block(a.pos, face, state, &mut canceled);
|
||||
canceled
|
||||
});
|
||||
if canceled == Some(true) {
|
||||
return;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = Some((a.pos, face));
|
||||
play.dig_start = host.hub.ticks;
|
||||
@@ -1317,12 +1329,22 @@ impl Game<'_> {
|
||||
8 if !spectator => self.guest_jab(host, conn),
|
||||
_ => {}
|
||||
},
|
||||
// handleSetCarriedItem: a new slot stops a main-hand use.
|
||||
S::SetCarriedItem(p) => {
|
||||
if (0..9).contains(&p.slot) {
|
||||
host.hub.guests[gi].selected = p.slot as usize;
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.using = None;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !(0..9).contains(&p.slot) {
|
||||
eprintln!("{} tried to set an invalid carried item", g.name);
|
||||
return;
|
||||
}
|
||||
let changed = g.selected != p.slot as usize;
|
||||
g.selected = p.slot as usize;
|
||||
if let Some(play) = host.play.get_mut(&conn)
|
||||
&& changed
|
||||
&& play
|
||||
.using
|
||||
.is_some_and(|(slot, _)| slot != super::shield::OFFHAND_SLOT)
|
||||
{
|
||||
play.using = None;
|
||||
}
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handlePunch: the main hand swings
|
||||
@@ -1467,12 +1489,13 @@ impl Game<'_> {
|
||||
}
|
||||
S::Attack(p) => self.guest_attack(host, conn, p.id),
|
||||
S::Interact(p) => {
|
||||
// handleInteract: within the entity interaction range plus 3
|
||||
// of the target's box, or nothing happens.
|
||||
// handleInteract: a target inside the world border, within the entity
|
||||
// interaction range plus 3 of its box, or nothing happens.
|
||||
if let Some(target) = self.guest_target_box(host, gi, p.id)
|
||||
&& self.as_guest(host, conn, |g| {
|
||||
g.within_entity_interaction_range(&target, 3.0)
|
||||
}) != Some(true)
|
||||
&& (!self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| {
|
||||
g.within_entity_interaction_range(&target, 3.0)
|
||||
}) != Some(true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1645,6 +1668,9 @@ impl Game<'_> {
|
||||
S::CustomPayload(p) if p.channel == horizon_protocol::quiet::CHANNEL => {
|
||||
self.guest_quiet_commands(host, conn, &p.data)
|
||||
}
|
||||
S::CustomPayload(p) if p.channel == horizon_protocol::mod_net::CHANNEL => {
|
||||
self.guest_native_net(host, conn, &p.data)
|
||||
}
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
|
||||
@@ -1744,6 +1770,22 @@ impl Game<'_> {
|
||||
self.send_admin_feedback(host, Some(conn));
|
||||
}
|
||||
}
|
||||
S::ChangeGameMode(p) => {
|
||||
if let Some(mode) = usize::try_from(p.mode)
|
||||
.ok()
|
||||
.and_then(|index| horizon_client::command::args::GameMode::ALL.get(index))
|
||||
{
|
||||
let name = horizon_client::text::Text::translate(
|
||||
&format!("gameMode.{}", mode.name()),
|
||||
&[],
|
||||
);
|
||||
eprintln!(
|
||||
"Player {} tried to change game mode to {} without required permissions",
|
||||
host.hub.guests[gi].name,
|
||||
name.plain()
|
||||
);
|
||||
}
|
||||
}
|
||||
// handleEntityTagQuery: a game master's F3+I on an entity of their
|
||||
// level gets its Entity.saveWithoutId.
|
||||
S::EntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
@@ -1800,6 +1842,10 @@ impl Game<'_> {
|
||||
// data only with infinite materials.
|
||||
S::PickItemFromBlock(p) => {
|
||||
self.guest_pick_item(host, conn, |g| {
|
||||
// handlePickItemFromBlock: within reach and loaded.
|
||||
if !g.world.loaded(p.pos[0], p.pos[2]) {
|
||||
return None;
|
||||
}
|
||||
let eye = g.eye_position()?;
|
||||
let d2: f64 = (0..3)
|
||||
.map(|a| {
|
||||
@@ -2099,6 +2145,13 @@ impl Game<'_> {
|
||||
/// interaction: the host, another player, a mob, a vehicle or object, a
|
||||
/// dragon part or crystal, a projectile. None for an id the level doesn't
|
||||
/// have (the handlers then find nothing either).
|
||||
/// `WorldBorder.isWithinBounds(target.blockPosition())` for an entity of this box:
|
||||
/// the block its feet are in (the box's bottom centre).
|
||||
fn inside_border(&self, target: &Aabb) -> bool {
|
||||
let feet = target.bottom_center();
|
||||
self.border.contains(feet.x.floor(), feet.z.floor())
|
||||
}
|
||||
|
||||
pub(super) fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
if id == host.host_entity {
|
||||
@@ -2138,10 +2191,12 @@ impl Game<'_> {
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return;
|
||||
}
|
||||
// handleAttack: `isWithinAttackRange(mainHandItem, box, 3.0)` before
|
||||
// anything else, players, mobs, objects and fireballs alike.
|
||||
// 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.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true)
|
||||
&& (!self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -2727,9 +2782,12 @@ impl Game<'_> {
|
||||
g.tick_fishing();
|
||||
g.tick_maps();
|
||||
g.tick_lodestone_compasses();
|
||||
g.native_inventory_ticks();
|
||||
g.count_tick_stats(sleeping);
|
||||
g.tick_advancements();
|
||||
g.update_player_attributes();
|
||||
// Native mods, at the end of ServerPlayer.tick.
|
||||
g.native_player_tick();
|
||||
});
|
||||
self.lan_map_updates(host, conn);
|
||||
host.hub.flush_progress(conn);
|
||||
@@ -2768,6 +2826,7 @@ impl Game<'_> {
|
||||
let drops =
|
||||
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
self.native_player_died(uuid, cause.as_deref());
|
||||
// ServerPlayer.die: the neutral mobs near a guest in this level
|
||||
// forget their anger at them.
|
||||
if here {
|
||||
@@ -2970,6 +3029,7 @@ impl Game<'_> {
|
||||
/// regeneration and the food tick (ServerPlayer.doTick). Their hurt
|
||||
/// sounds are heard at the guest.
|
||||
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.guest_replay(host, conn);
|
||||
self.guest_vehicle_tick(host, conn);
|
||||
// The last move's fall stands in for the server's delta movement.
|
||||
let fell = host
|
||||
@@ -3533,6 +3593,10 @@ impl Game<'_> {
|
||||
let mut taken = Vec::new();
|
||||
for i in near.into_iter().rev() {
|
||||
let (stack, item_id) = (self.dropped[i].stack.clone(), self.dropped[i].id);
|
||||
if self.as_guest(host, conn, |g| g.native_pickup_refused(&stack)) == Some(true) {
|
||||
self.dropped[i].pickup_delay = 10;
|
||||
continue;
|
||||
}
|
||||
let before = stack.count;
|
||||
let thrower = self.dropped[i].thrower;
|
||||
let rest = self.as_guest(host, conn, |g| g.give(stack)).flatten();
|
||||
|
||||
@@ -22,6 +22,22 @@ pub(super) struct QueuedParticle {
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
fn send_native_nearby(
|
||||
host: &LanHost,
|
||||
dimension: &str,
|
||||
packet: Vec<u8>,
|
||||
within_range: impl Fn([f64; 3]) -> bool,
|
||||
) {
|
||||
for guest in host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.dimension == dimension && within_range(g.position))
|
||||
{
|
||||
guest.sender.send(packet.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// An ARGB colour as the options' RGBA.
|
||||
fn rgba(argb: i32) -> [f32; 4] {
|
||||
let c = |shift: i32| ((argb >> shift) & 255) as f32 / 255.0;
|
||||
@@ -411,20 +427,43 @@ impl Game<'_> {
|
||||
randomization: 0,
|
||||
})
|
||||
.encode();
|
||||
for g in host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.dimension == dimension)
|
||||
{
|
||||
// BlockPos.closerToCenterThan(at, 32): from the centre of the
|
||||
// player's block.
|
||||
// BlockPos.closerToCenterThan(at, 32): from the centre of the
|
||||
// player's block.
|
||||
send_native_nearby(host, dimension, body, |position| {
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.map(|k| (position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if d < 32.0 * 32.0 {
|
||||
g.sender.send(body.clone());
|
||||
d < 32.0 * 32.0
|
||||
});
|
||||
}
|
||||
|
||||
/// Native sounds wait until the server host is available.
|
||||
pub(super) fn flush_native_sounds(&mut self) {
|
||||
let Some(host) = &self.lan else { return };
|
||||
for sound in std::mem::take(&mut self.creatures.lan_sounds) {
|
||||
if host
|
||||
.hub
|
||||
.registry_id("minecraft:sound_event", &sound.sound)
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let packet = host
|
||||
.hub
|
||||
.sound_packet(
|
||||
&sound.sound,
|
||||
sound.at,
|
||||
sound.volume,
|
||||
sound.pitch,
|
||||
sound.source,
|
||||
host.hub.next_sound_seed(),
|
||||
)
|
||||
.encode();
|
||||
let range = f64::from(sound.volume.max(1.0)) * 16.0;
|
||||
send_native_nearby(host, sound.dimension, packet, |position| {
|
||||
let distance: f64 = (0..3).map(|k| (position[k] - sound.at[k]).powi(2)).sum();
|
||||
distance <= range * range
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,8 +296,24 @@ impl Game<'_> {
|
||||
}
|
||||
// Player.attack: the hurt (with its knockback away from the
|
||||
// attacker), then causeExtraKnockback along the attacker's yaw.
|
||||
// CombatTracker: the attacking player is the death message's killer.
|
||||
// Without it the message is built with one argument and a template
|
||||
// that names a killer ("%1$s was slain by %2$s") shows as written.
|
||||
let attacker_note = match s.attacker {
|
||||
Some(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid))),
|
||||
None => Some((
|
||||
host.host_entity,
|
||||
self.player_text(&host.host_name, host.host_uuid),
|
||||
)),
|
||||
};
|
||||
let land = |g: &mut Self| {
|
||||
g.combat.player_attacker = Some((s.from, s.disable_blocking));
|
||||
if let Some((id, name)) = attacker_note.clone() {
|
||||
g.note_named_attacker("minecraft:player_attack", id as u32, name);
|
||||
}
|
||||
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
|
||||
g.combat.player_attacker = None;
|
||||
let (strength, xd, zd) = s.knockback;
|
||||
|
||||
@@ -564,13 +564,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let data = self.beacon_data(host, pos);
|
||||
let mut updated = false;
|
||||
let mut updated = None;
|
||||
self.lan_station_op(host, conn, None, |menu, env| {
|
||||
menu.data.copy_from_slice(&data);
|
||||
let power = |k: usize| powers[k].as_ref().map(|(id, n)| (*id, n.as_str()));
|
||||
updated = menu.beacon_update(env, power(0), power(1));
|
||||
updated = Some(menu.beacon_update(env, power(0), power(1)));
|
||||
});
|
||||
if !updated {
|
||||
// handleSetBeaconPacket: effects the beacon can't give, or no payment, disconnect.
|
||||
if updated == Some(false) {
|
||||
let name = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| g.name.clone());
|
||||
eprintln!(
|
||||
"Player {} tried to set invalid beacon effects",
|
||||
name.unwrap_or_default()
|
||||
);
|
||||
host.hub.kick(
|
||||
conn,
|
||||
horizon_protocol::text::translatable("multiplayer.disconnect.generic", &[]),
|
||||
);
|
||||
}
|
||||
if updated != Some(true) {
|
||||
return;
|
||||
}
|
||||
let [primary, secondary] = powers.map(|p| p.map(|(_, n)| n));
|
||||
|
||||
@@ -381,6 +381,8 @@ impl Game<'_> {
|
||||
{
|
||||
self.native_lifecycle_server_tick_start(running);
|
||||
}
|
||||
let observe_level =
|
||||
running.is_some() && (self.serving.is_some() || self.options.dedicated.is_some());
|
||||
let dims = self.levels_to_tick();
|
||||
let n = dims.len();
|
||||
let mut result = Ok(());
|
||||
@@ -394,6 +396,9 @@ impl Game<'_> {
|
||||
last: k + 1 == n,
|
||||
running,
|
||||
};
|
||||
if observe_level {
|
||||
self.native_level_tick_pre(dim.id());
|
||||
}
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
@@ -411,6 +416,9 @@ impl Game<'_> {
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
if observe_level {
|
||||
self.native_level_tick_post(dim.id());
|
||||
}
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
if result.is_err() {
|
||||
|
||||
@@ -203,6 +203,7 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
|
||||
self.native_hurt_enemy_event(&stack, index);
|
||||
let smash = self.smash_attack();
|
||||
if smash {
|
||||
self.mace_hurt_enemy(index);
|
||||
@@ -238,7 +239,8 @@ impl Game<'_> {
|
||||
fn mace_hurt_enemy(&mut self, index: usize) {
|
||||
let target_on_ground = self.mobs[index].on_ground;
|
||||
if let Some(fall) = self.mace_smash_self(target_on_ground) {
|
||||
self.smash_knockback(index, fall);
|
||||
let target = &self.mobs[index];
|
||||
self.smash_knockback(target.position, Spared::Mob(target.id), fall);
|
||||
}
|
||||
}
|
||||
/// The attacker's side of `MaceItem.hurtEnemy` for a smash: the fall
|
||||
@@ -299,6 +301,37 @@ impl Game<'_> {
|
||||
}
|
||||
self.weapon_wear(&stack);
|
||||
}
|
||||
/// `Player.itemAttackInteraction` after the player's hit hurt the armor
|
||||
/// stand `id` (a living target) at `center`: as `weapon_hit` does for a
|
||||
/// mob, a smash stops the fall and knocks the creatures around the stand
|
||||
/// away, and the Wind Burst (a `post_attack` explosion on the attacker)
|
||||
/// launches the attacker.
|
||||
pub(super) fn weapon_hit_stand(&mut self, id: u32, center: Vec3, on_ground: bool) {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
if self.smash_attack()
|
||||
&& let Some(fall) = self.mace_smash_self(on_ground)
|
||||
{
|
||||
self.smash_knockback(center, Spared::Stand(id), fall);
|
||||
}
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.weapon_damage.is_some())
|
||||
{
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack_bursts(&stack, "minecraft:armor_stand");
|
||||
// MaceItem.postHurtEnemy
|
||||
if self.smash_attack()
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.weapon_wear(&stack);
|
||||
}
|
||||
/// `MaceItem.knockback`: the dust pillar (level event 2013) under the
|
||||
/// target, then every living entity within 3.5 blocks of it but the
|
||||
/// player and the target pushed away and up, harder after a fall of more
|
||||
@@ -306,9 +339,7 @@ impl Game<'_> {
|
||||
/// pets and the allays that like it), marker armor stands and creative
|
||||
/// flyers are spared; the predicate's own tamed-animal test asks whether
|
||||
/// the target owns the animal, which a mob never does.
|
||||
fn smash_knockback(&mut self, index: usize, fall: f64) {
|
||||
let target = &self.mobs[index];
|
||||
let (id, center) = (target.id, target.position);
|
||||
fn smash_knockback(&mut self, center: Vec3, target: Spared, fall: f64) {
|
||||
// Entity.getOnPos without a supporting block.
|
||||
let on_pos = [
|
||||
center.x.floor() as i32,
|
||||
@@ -318,11 +349,9 @@ impl Game<'_> {
|
||||
self.level_event_at(2013, on_pos, 750);
|
||||
let heavy = if fall > HEAVY_THRESHOLD { 2.0 } else { 1.0 };
|
||||
let player = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.id != id && m.alive() && !m.removed && !ally_of_player(m, player))
|
||||
{
|
||||
for m in self.mobs.iter_mut().filter(|m| {
|
||||
target != Spared::Mob(m.id) && m.alive() && !m.removed && !ally_of_player(m, player)
|
||||
}) {
|
||||
smash_push(
|
||||
center,
|
||||
heavy,
|
||||
@@ -331,7 +360,11 @@ impl Game<'_> {
|
||||
m.attrs.knockback_resistance,
|
||||
);
|
||||
}
|
||||
for s in self.armor_stands.iter_mut().filter(|s| !s.marker) {
|
||||
for s in self
|
||||
.armor_stands
|
||||
.iter_mut()
|
||||
.filter(|s| !s.marker && target != Spared::Stand(s.id))
|
||||
{
|
||||
smash_push(center, heavy, s.position, &mut s.velocity, 0.0);
|
||||
}
|
||||
// The host and the LAN guests are living entities too. A guest's turn
|
||||
@@ -501,7 +534,11 @@ impl Game<'_> {
|
||||
let source = self.attack_source(from);
|
||||
// hurtServer first; the knockback and post-attack effects follow
|
||||
// only when it landed.
|
||||
let old_health = self.mobs[i].health;
|
||||
if self.hurt_by_player(i, amount, source) {
|
||||
if old_health > 0.0 && !self.mobs[i].alive() {
|
||||
self.native_kill_event(i);
|
||||
}
|
||||
self.mobs[i].knockback(0.4, xd, zd);
|
||||
self.last_hurt_by_player
|
||||
.insert(self.mobs[i].id, self.lan_acting);
|
||||
|
||||
@@ -284,15 +284,24 @@ impl MapData {
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Maps {
|
||||
pub data: HashMap<i32, MapData>,
|
||||
/// The highest map id handed out (`maps/last_id.dat`), loaded on first use.
|
||||
last_id: Option<i32>,
|
||||
/// (map id, atlas slot), most recently shown last.
|
||||
slots: Vec<(i32, usize)>,
|
||||
/// The atlas slots holding map textures.
|
||||
pub slots: super::atlas_slots::AtlasSlots<i32>,
|
||||
missing: std::collections::HashSet<i32>,
|
||||
}
|
||||
impl Default for Maps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data: HashMap::new(),
|
||||
last_id: None,
|
||||
slots: super::atlas_slots::AtlasSlots::new(MAP_SLOTS),
|
||||
missing: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MapColor.getColorFromPackedId` as RGBA bytes (NONE is transparent).
|
||||
fn packed_rgba(packed: u8) -> [u8; 4] {
|
||||
let id = usize::from(packed >> 2);
|
||||
@@ -1191,23 +1200,16 @@ impl Game<'_> {
|
||||
/// The atlas slot showing map `id`, its texture uploaded when it changed.
|
||||
pub(super) fn map_texture(&mut self, id: i32) -> Option<Rect> {
|
||||
self.map(id)?;
|
||||
let slots = &mut self.maps.slots;
|
||||
if let Some(i) = slots.iter().position(|e| e.0 == id) {
|
||||
let e = slots.remove(i);
|
||||
slots.push(e);
|
||||
} else {
|
||||
// A free slot, else the least recently shown one.
|
||||
let slot = if slots.len() < MAP_SLOTS {
|
||||
slots.len()
|
||||
} else {
|
||||
slots.remove(0).1
|
||||
};
|
||||
slots.push((id, slot));
|
||||
if let Some(m) = self.maps.data.get_mut(&id) {
|
||||
m.texture_dirty = true;
|
||||
let slot = match self.maps.slots.show(&id) {
|
||||
Some(slot) => slot,
|
||||
None => {
|
||||
let (slot, _) = self.maps.slots.claim(id);
|
||||
if let Some(m) = self.maps.data.get_mut(&id) {
|
||||
m.texture_dirty = true;
|
||||
}
|
||||
slot
|
||||
}
|
||||
}
|
||||
let slot = self.maps.slots.last()?.1;
|
||||
};
|
||||
let rect = self
|
||||
.gui
|
||||
.atlas
|
||||
|
||||
@@ -72,15 +72,84 @@ pub(super) fn content_root(
|
||||
out.display()
|
||||
);
|
||||
}
|
||||
register_mob_effects(&mods);
|
||||
out
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: content mods not loaded: {e}");
|
||||
register_mob_effects(&[]);
|
||||
root.to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mods' mob effects join vanilla's (`brewing::effect`), once the root
|
||||
/// holding their network ids is built. The startup root's effects, none
|
||||
/// included, stay until restart: a pack reload after a Mods switch builds a
|
||||
/// root with others.
|
||||
fn register_mob_effects(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
use horizon_client::brewing::{self, Category};
|
||||
use horizon_mods::content::EffectCategory;
|
||||
let effects = horizon_client::mod_content::mob_effects(mods);
|
||||
let ids: Vec<String> = effects.iter().map(|e| e.id.clone()).collect();
|
||||
let set = brewing::register_mod_effects(effects.into_iter().map(|e| {
|
||||
let category = match e.category {
|
||||
EffectCategory::Beneficial => Category::Beneficial,
|
||||
EffectCategory::Harmful => Category::Harmful,
|
||||
EffectCategory::Neutral => Category::Neutral,
|
||||
};
|
||||
(e.id.clone(), e.color, category)
|
||||
}));
|
||||
if !set
|
||||
&& brewing::mod_effects()
|
||||
.iter()
|
||||
.map(|e| e.id)
|
||||
.ne(ids.iter().map(String::as_str))
|
||||
{
|
||||
log::warn!("Horizon: the mods' mob effects changed; restart the game to use them");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
|
||||
const BUILD_INFO: &str = "native-mods.json";
|
||||
|
||||
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
|
||||
/// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row
|
||||
/// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770).
|
||||
/// Its toggle is the `mod.<id>` line the launcher's next build follows.
|
||||
fn not_built(dir: &std::path::Path, built: &str, rows: &[Installed]) -> Vec<Installed> {
|
||||
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
|
||||
let in_build = |id: &str| {
|
||||
info["mods"]
|
||||
.as_array()
|
||||
.is_some_and(|mods| mods.iter().any(|m| m["id"] == id))
|
||||
};
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.map(|d| d.flatten().map(|e| e.path()).collect())
|
||||
.unwrap_or_default();
|
||||
paths.retain(|p| p.extension().is_some_and(|e| e == "hnmod"));
|
||||
paths.sort();
|
||||
let mut out: Vec<Installed> = Vec::new();
|
||||
for path in paths {
|
||||
let Ok(bundle) = horizon_mods::index::native::Bundle::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let m = bundle.manifest;
|
||||
let listed = rows.iter().chain(&out).any(|row| row.id == m.id);
|
||||
if !listed && !in_build(&m.id) {
|
||||
out.push(Installed {
|
||||
title: format!("{} {}", m.id, m.version),
|
||||
id: m.id,
|
||||
description: String::new(),
|
||||
problem: Some(mod_list::NOT_BUILT.to_owned()),
|
||||
dev: false,
|
||||
native: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A WebAssembly code mod's row on the Mods page.
|
||||
const STOPPED: &str = "Doesn't run any more: needs a native port";
|
||||
/// The player was told once about the WebAssembly mods that stopped (`horizon-mods/<this>`).
|
||||
@@ -127,6 +196,13 @@ impl Game<'_> {
|
||||
.collect();
|
||||
// A mod with a native version built in says so instead, and isn't news.
|
||||
self.list_native_mods();
|
||||
let built = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
|
||||
.unwrap_or_default();
|
||||
let native_dir = Self::options_path().with_file_name(mod_list::NATIVE_DIR);
|
||||
let unbuilt = not_built(&native_dir, &built, &self.settings.mods.installed);
|
||||
self.settings.mods.installed.extend(unbuilt);
|
||||
let installed = &self.settings.mods.installed;
|
||||
self.mods.stopped = installed
|
||||
.iter()
|
||||
@@ -152,3 +228,62 @@ impl Game<'_> {
|
||||
let _ = std::fs::write(&seen, "");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// A minimal `.hnmod` the registry accepts: its manifest and its mixin's.
|
||||
fn hnmod(dir: &std::path::Path, id: &str) {
|
||||
let horizon = "a".repeat(40);
|
||||
let manifest = serde_json::json!({
|
||||
"schema": 1, "id": id, "version": "0.3.0", "game": "26.3",
|
||||
"horizon": horizon, "mixins": "b".repeat(40),
|
||||
});
|
||||
let mixin = format!(
|
||||
"[mod]\nid = \"{id}\"\nversion = \"0.3.0\"\n[compatibility]\nhorizon_revision = \"{horizon}\"\n"
|
||||
);
|
||||
let file = std::fs::File::create(dir.join(format!("{id}.hnmod"))).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file("horizon-native.json", options).unwrap();
|
||||
zip.write_all(manifest.to_string().as_bytes()).unwrap();
|
||||
zip.start_file(format!("mixins/{id}/horizon-mixin.toml"), options)
|
||||
.unwrap();
|
||||
zip.write_all(mixin.as_bytes()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_mods_installed_but_not_built_get_a_row_saying_so() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-not-built-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for id in ["veinminer", "zoom", "minimap"] {
|
||||
hnmod(&dir, id);
|
||||
}
|
||||
std::fs::write(dir.join("notes.txt"), "not a mod").unwrap();
|
||||
std::fs::write(dir.join("broken.hnmod"), "not a zip").unwrap();
|
||||
// A release: none is built in; the page lists them, sorted, instead of "No mods".
|
||||
let rows = not_built(&dir, "", &[]);
|
||||
let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, ["minimap", "veinminer", "zoom"]);
|
||||
assert_eq!(rows[1].title, "veinminer 0.3.0");
|
||||
assert_eq!(rows[1].problem.as_deref(), Some(mod_list::NOT_BUILT));
|
||||
assert!(rows.iter().all(|r| r.native.is_none() && !r.dev));
|
||||
// A native build with zoom, and minimap's row already listed by its own hook.
|
||||
let built =
|
||||
r#"{"base":"nightly-2026-10-30-1da354b","mods":[{"id":"zoom","version":"0.3.0"}]}"#;
|
||||
let listed = Installed {
|
||||
id: "minimap".into(),
|
||||
..rows[0].clone()
|
||||
};
|
||||
let rows = not_built(&dir, built, &[listed]);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, "veinminer");
|
||||
// No folder: no rows.
|
||||
assert!(not_built(&dir.join("missing"), "", &[]).is_empty());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,61 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 40 adds mod messages between a mod's server and client halves
|
||||
//! (`native_net_from_client`, `native_net_from_server`, and
|
||||
//! `native_net_send_to_player`, `native_net_send_to_server` in
|
||||
//! `native_net.rs`), and hearing players and mobs: `native_player_died`,
|
||||
//! `native_mob_spawned`, `native_mob_killed`, `native_item_dropped`,
|
||||
//! `native_item_picked_up`.
|
||||
//!
|
||||
//! API 39 adds item behaviour: a held item's hit on a mob
|
||||
//! (`native_hurt_enemy`), a carried stack's tick (`native_inventory_tick`) and
|
||||
//! an equipment slot taking a different item (`native_on_equip`).
|
||||
//!
|
||||
//! API 38 adds redstone from a mod's block (`native_block_signal`,
|
||||
//! `native_is_signal_source`, and the helper `native_update_signal_neighbors`).
|
||||
//!
|
||||
//! API 37 adds mobs for server mods (`native_spawn_mob`, `native_mob`,
|
||||
//! `native_mobs`, `native_teleport_mob`, `native_push_mob`,
|
||||
//! `native_set_mob_health`, `native_kill_mob`, `native_remove_mob`... in
|
||||
//! `native_mobs.rs`).
|
||||
//!
|
||||
//! API 36 adds recipes of a mod's own types (`native_recipes_of_type`,
|
||||
//! `native_recipe_ingredient`, `native_recipe_result`).
|
||||
//!
|
||||
//! API 35 adds the commands a mod owns on the client (`native_commands`), so
|
||||
//! that the chat box colours, completes and lists them like the game's own.
|
||||
//!
|
||||
//! API 34 adds scheduled block ticks a mod can handle (`native_tick_block`).
|
||||
//!
|
||||
//! API 33 adds random ticks a mod can handle (`native_random_tick`), and
|
||||
//! `native_set_block_property` on the level, which takes `Level.setBlock`'s
|
||||
//! `flags` as `Game::native_set_block_property_with` does.
|
||||
//!
|
||||
//! API 32 adds shape updates a mod can answer (`native_update_shape`), and
|
||||
//! `native_schedule_block_tick` and `native_schedule_fluid_tick` on the
|
||||
//! level for the ticks a block's shape update schedules.
|
||||
//!
|
||||
//! API 31 adds a player's server tick (`native_player_tick`).
|
||||
//!
|
||||
//! API 30 adds left-clicking a block (`native_left_click_block`).
|
||||
//!
|
||||
//! API 29 adds right-clicking an entity (`native_interact_entity`).
|
||||
//!
|
||||
//! API 28 adds block and item tag reads for server tick hooks (`native_block_in_tag`,
|
||||
//! `native_item_in_tag`).
|
||||
//!
|
||||
//! API 27 adds the player-health HUD rows (`native_health_hud`).
|
||||
//!
|
||||
//! API 26 adds the game's shared config folder (`native_config_dir`).
|
||||
//!
|
||||
//! API 25 adds server sound playback (`native_play_sound`).
|
||||
//!
|
||||
//! API 24 adds a server block setter (`native_set_block`).
|
||||
//!
|
||||
//! API 23 adds per-level server tick hooks (`native_level_tick_pre`,
|
||||
//! `native_level_tick_post`).
|
||||
//!
|
||||
//! API 22 adds what a mod needs to keep the world from changing blocks
|
||||
//! it guards: fluid and fire spreading (`native_block_spread`), pistons
|
||||
//! (`native_piston_move`), explosions (`native_explosion_detonate`),
|
||||
@@ -92,9 +147,11 @@ use super::{
|
||||
native_server::{NativeCommand, NativeServerPlayer},
|
||||
};
|
||||
use horizon_client::{
|
||||
atlas_sources::qualified,
|
||||
block_entities::Pos,
|
||||
chat::ChatSource,
|
||||
containers::{BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer},
|
||||
crafting::{CustomRecipe, Ingredient, recipe_result},
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
fast_hash::FxHashMap,
|
||||
@@ -105,19 +162,36 @@ use horizon_client::{
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{geometry::Direction, position::BlockPos};
|
||||
use horizon_core::{
|
||||
geometry::Direction,
|
||||
position::BlockPos,
|
||||
ticks::{ScheduleResult, TickPriority},
|
||||
};
|
||||
use horizon_world::{
|
||||
level::{BlockId, flags},
|
||||
fluid::FluidKind,
|
||||
level::{BlockId, LevelAccess, Ticks},
|
||||
nbt::Compound,
|
||||
registry::BlockStateId,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// `Level.setBlock`'s flags, for the `native_set_block_property` helpers:
|
||||
/// `flags::UPDATE_CLIENTS` as a vanilla crop grows, `flags::UPDATE_ALL` as a
|
||||
/// furnace sets its `lit`. Horizon sends every write to players, so
|
||||
/// `UPDATE_CLIENTS`, `UPDATE_INVISIBLE` and `UPDATE_IMMEDIATE` change nothing
|
||||
/// yet.
|
||||
pub(super) use horizon_world::level::flags;
|
||||
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 22;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 40;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// An authoritative server committed a changed state through `Level.setBlock`.
|
||||
@@ -183,6 +257,112 @@ impl GameLevel<'_> {
|
||||
) {
|
||||
let _ = (dimension, piston, direction, push, destroy, canceled);
|
||||
}
|
||||
/// The redstone signal the block `state` at `pos` gives on its `face`, the
|
||||
/// side that touches the block reading it: `BlockState.getDirectSignal`
|
||||
/// when `direct` (what passes through a block it touches, as a lever's
|
||||
/// does), else `getSignal`. `signal` holds vanilla's answer: change it when
|
||||
/// the block is yours (0 to 15; anything else is clamped). Asked very often,
|
||||
/// from inside the level's update, so answer from your own state: check the
|
||||
/// block first, and don't read or change the world here. When the answer
|
||||
/// changes, and once the block is placed, call
|
||||
/// `native_update_signal_neighbors`. Authoritative servers only. API 38.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_block_signal(
|
||||
&self,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
face: Direction,
|
||||
direct: bool,
|
||||
signal: &mut i32,
|
||||
) {
|
||||
let _ = (dimension, pos, state, face, direct, signal);
|
||||
}
|
||||
|
||||
/// `BlockState.isSignalSource`: set `source` for a block of yours that
|
||||
/// gives a signal, so redstone wire beside it connects to it. Authoritative
|
||||
/// servers only. API 38.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_is_signal_source(&self, state: BlockStateId, source: &mut bool) {
|
||||
let _ = (state, source);
|
||||
}
|
||||
|
||||
/// An authoritative server's `ServerLevel.tickChunk` is about to
|
||||
/// random-tick the block `state` at `pos` (`BlockState.randomTick`):
|
||||
/// only blocks that tick randomly, a mod's as its vanilla template block
|
||||
/// does. Bone meal's random ticks (`StemBlock.performBonemeal`) don't
|
||||
/// fire it. Change the block with `native_set_block_property` and set
|
||||
/// `handled` to skip the block's own random tick (a mod crop growing its
|
||||
/// own way); a block changed without `handled` takes its own random tick
|
||||
/// as it is now, if it still ticks randomly. Lava's fluid random tick
|
||||
/// runs either way.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_random_tick(
|
||||
&mut self,
|
||||
ticks: &mut Ticks,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
handled: &mut bool,
|
||||
) {
|
||||
let _ = (ticks, dimension, pos, state, handled);
|
||||
}
|
||||
|
||||
/// An authoritative server is about to compute the state the block
|
||||
/// `state` at `pos` takes now that its neighbour in `direction` is
|
||||
/// `neighbour` (`BlockBehaviour.updateShape`). `state` may not be in the
|
||||
/// world yet (a piston landing it): decide from `state`, not from the
|
||||
/// block at `pos`. Answer only for your own blocks: put the new state in
|
||||
/// `new` (`state` keeps the block as it is; air breaks it, with its drops,
|
||||
/// on a neighbour update) and the block's own `updateShape` doesn't run,
|
||||
/// nor the ticks it would schedule. The level is read-only, as vanilla's
|
||||
/// `LevelReader`; ticks go through `native_schedule_block_tick` and
|
||||
/// `native_schedule_fluid_tick` with `ticks` (`ScheduledTickAccess`). It
|
||||
/// runs often, at least six times per `setBlock` that updates shapes
|
||||
/// (more for redstone wire's diagonals and knock-on updates): keep it
|
||||
/// short.
|
||||
#[inline(always)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn native_update_shape(
|
||||
&self,
|
||||
ticks: &mut Ticks,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
direction: Direction,
|
||||
neighbour_pos: BlockPos,
|
||||
neighbour: BlockStateId,
|
||||
new: &mut Option<BlockStateId>,
|
||||
) {
|
||||
let _ = (
|
||||
ticks,
|
||||
dimension,
|
||||
pos,
|
||||
state,
|
||||
direction,
|
||||
neighbour_pos,
|
||||
neighbour,
|
||||
new,
|
||||
);
|
||||
}
|
||||
|
||||
/// An authoritative server's `ServerLevel.tickBlock` is about to run a
|
||||
/// due scheduled tick of the block `state` at `pos` (`BlockState.tick`):
|
||||
/// the block is still the one the tick was scheduled for. Change the
|
||||
/// block with `native_set_block_property` and set `handled` to skip the
|
||||
/// block's own tick; a block changed without `handled` takes its own
|
||||
/// tick as it is now, if it is still the tick's block.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_tick_block(
|
||||
&mut self,
|
||||
ticks: &mut Ticks,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
handled: &mut bool,
|
||||
) {
|
||||
let _ = (ticks, dimension, pos, state, handled);
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none),
|
||||
@@ -491,6 +671,14 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (context, phase);
|
||||
}
|
||||
/// Before an authoritative server level's tick pass. `dimension` is its ID.
|
||||
pub(super) fn native_level_tick_pre(&mut self, dimension: &str) {
|
||||
let _ = dimension;
|
||||
}
|
||||
/// After a successful authoritative server level tick pass.
|
||||
pub(super) fn native_level_tick_post(&mut self, dimension: &str) {
|
||||
let _ = dimension;
|
||||
}
|
||||
/// The HUD (not drawn under F1), after the minimap: place a box with
|
||||
/// `corners.place` (boxes stack away from their edge) and draw it with
|
||||
/// `native_fill` and `native_text`. Skip a corner `native_corner_shown`
|
||||
@@ -498,6 +686,60 @@ impl Game<'_> {
|
||||
pub(super) fn native_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
let _ = (batch, corners);
|
||||
}
|
||||
/// A player right-clicks the entity `entity` (`Entity.getId`) of type
|
||||
/// `kind` (`minecraft:cow`) with `self.hand`, holding `stack` (none in an
|
||||
/// empty hand): where `Player.interactOn` starts, before the entity's own
|
||||
/// `interact` and the item's `interactLivingEntity`. Set `claimed` to take
|
||||
/// the click: the hand swings and neither runs. Asked once per hand, as
|
||||
/// vanilla tries the main hand then the off hand. Server-side, for every
|
||||
/// player of the world (the helpers read the acting one). API 29.
|
||||
pub(super) fn native_interact_entity(
|
||||
&mut self,
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
stack: Option<&ItemStack>,
|
||||
claimed: &mut bool,
|
||||
) {
|
||||
let _ = (entity, kind, stack, claimed);
|
||||
}
|
||||
/// A player left-clicks the block `state` at `pos`, `face` the face hit
|
||||
/// (`[0, 1, 0]` is the top): where `ServerPlayerGameMode.handleBlockBreakAction`
|
||||
/// takes a dig's start, after its reach, spawn protection and world border
|
||||
/// checks, before `Block.attack` (a note block plays) or a creative break.
|
||||
/// Set `canceled` to ignore the click: the block isn't attacked, dug or
|
||||
/// broken on the server, and the player's client gets it back (its own
|
||||
/// predicted crack still shows to the clicking player). Server-side, for
|
||||
/// every player of the world (the helpers read the acting one). API 30.
|
||||
pub(super) fn native_left_click_block(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
state: BlockStateId,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (pos, face, state, canceled);
|
||||
}
|
||||
/// A living player's server tick ends (`ServerPlayer.tick`): once a tick
|
||||
/// for every player in the world, after their digging, item use,
|
||||
/// cooldowns, statistics and advancements. The helpers read that player.
|
||||
/// API 31.
|
||||
pub(super) fn native_player_tick(&mut self) {}
|
||||
/// The player-health HUD rows, once a frame the HUD draws them
|
||||
/// (`HealthHud::layout`, not under F1): `inputs` carries the health,
|
||||
/// food, air and vehicle state the layout reads, `wi`/`hi` the screen's
|
||||
/// GUI size. A mod that sets `draws` replaces every row — armour,
|
||||
/// hearts, food, air and the heights (`left_height`/`right_height`)
|
||||
/// above the hotbar are the answer's to give; `bubble_pop_sound` too.
|
||||
/// Nothing set draws Horizon's own.
|
||||
pub(super) fn native_health_hud(
|
||||
&mut self,
|
||||
inputs: &horizon_client::hud_layout::HealthInputs,
|
||||
wi: i32,
|
||||
hi: i32,
|
||||
draws: &mut Option<Vec<horizon_client::hud_layout::GuiDraw>>,
|
||||
) {
|
||||
let _ = (inputs, wi, hi, draws);
|
||||
}
|
||||
/// A key or mouse button went down or up, by its vanilla name
|
||||
/// (`key.keyboard.z`, `key.mouse.left`): keys on any screen (check
|
||||
/// `native_playing`), mouse buttons while playing. Repeats are left out.
|
||||
@@ -513,6 +755,14 @@ impl Game<'_> {
|
||||
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
|
||||
let _ = (command, ran);
|
||||
}
|
||||
/// The commands `native_command` runs, by name and without their slash:
|
||||
/// push the ones that are yours. Asked when a connection starts and
|
||||
/// again when the server sends its own tree, so the chat box colours,
|
||||
/// completes and lists them as the game's own; a name the game already
|
||||
/// has keeps that command's behaviour. API 35.
|
||||
pub(super) fn native_commands(&mut self, commands: &mut Vec<&'static str>) {
|
||||
let _ = commands;
|
||||
}
|
||||
/// The zoom a native mod asks for, applied while playing (the spyglass's
|
||||
/// own view wins): set `zoom` when it is none yet.
|
||||
pub(super) fn native_zoom(&self, zoom: &mut Option<NativeZoom>) {
|
||||
@@ -805,11 +1055,143 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (entity, kind, pos, canceled);
|
||||
}
|
||||
/// A message from `player`'s client on `channel` (`mod_id:name`), within
|
||||
/// the caps (`native_net.rs`): check `channel`, and treat `data` as the
|
||||
/// player's own bytes, checked by nothing. Runs in the tick after it
|
||||
/// arrives, with no player's state swapped in. Server-side. API 40.
|
||||
pub(super) fn native_net_from_client(&mut self, player: u128, channel: &str, data: &[u8]) {
|
||||
let _ = (player, channel, data);
|
||||
}
|
||||
/// A message from the server on `channel` (`mod_id:name`), whose `data`
|
||||
/// is the sender's own bytes. The window's game only. API 40.
|
||||
pub(super) fn native_net_from_server(&mut self, channel: &str, data: &[u8]) {
|
||||
let _ = (channel, data);
|
||||
}
|
||||
/// `player` died: where `ServerPlayer.die` starts, with the death
|
||||
/// message's key when the game knows it (`death.attack.lava`; none for a
|
||||
/// message it builds from the combat). It only hears. Server-side, for
|
||||
/// every player of the world, whether death messages are shown or not.
|
||||
/// API 40.
|
||||
pub(super) fn native_player_died(&mut self, player: u128, cause: Option<&str>) {
|
||||
let _ = (player, cause);
|
||||
}
|
||||
/// A new mob of `kind` joined `dimension` at `pos`: `natural` when the
|
||||
/// world's spawning made it, false when something else did (a breed, a
|
||||
/// raid, a spawner, a conversion, `/summon`, and the second mob of a
|
||||
/// jockey the world spawned). One call a mob: a `/summon` with passengers
|
||||
/// tells each. Not told of a mob loaded with its chunk, leaving a hive or
|
||||
/// changing level. It only hears; the mob is already among the game's
|
||||
/// mobs. Server-side. API 40.
|
||||
pub(super) fn native_mob_spawned(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
kind: &str,
|
||||
pos: [f64; 3],
|
||||
natural: bool,
|
||||
) {
|
||||
let _ = (dimension, kind, pos, natural);
|
||||
}
|
||||
/// `player`'s melee blow killed the mob `entity` of `kind` (other deaths
|
||||
/// aren't told yet). It only hears, after the blow landed. Server-side.
|
||||
/// API 40.
|
||||
pub(super) fn native_mob_killed(&mut self, player: u128, entity: u32, kind: &str) {
|
||||
let _ = (player, entity, kind);
|
||||
}
|
||||
/// `player` threw `count` of `item` (`Player.drop`: the drop key, a
|
||||
/// menu's throw...). It only hears. Server-side. API 40.
|
||||
pub(super) fn native_item_dropped(&mut self, player: u128, item: &str, count: u32) {
|
||||
let _ = (player, item, count);
|
||||
}
|
||||
/// `player` is about to take `count` of `item` from the ground
|
||||
/// (`ItemEntity.playerTouch`). Set `canceled` to leave it there: the
|
||||
/// stack isn't offered again for 10 ticks. Server-side. API 40.
|
||||
pub(super) fn native_item_picked_up(
|
||||
&mut self,
|
||||
player: u128,
|
||||
item: &str,
|
||||
count: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (player, item, count, canceled);
|
||||
}
|
||||
/// A player's hit landed on the mob `entity` of type `kind`
|
||||
/// (`minecraft:zombie`), holding `stack`: where `Item.hurtEnemy` is called,
|
||||
/// before the post-attack enchantments and the weapon's wear. It only
|
||||
/// hears. Another player as the target isn't told yet. Server-side, for
|
||||
/// every player of the world (the helpers read the acting one). API 39.
|
||||
pub(super) fn native_hurt_enemy(&mut self, stack: &ItemStack, entity: u32, kind: &str) {
|
||||
let _ = (stack, entity, kind);
|
||||
}
|
||||
/// A server tick of the stack a player carries in `slot`: where
|
||||
/// `Inventory.tick` calls `Item.inventoryTick`, for every stack, each tick,
|
||||
/// `/tick freeze` included (it stops the world, not the player). `slot` is
|
||||
/// as `Inventory` saves it (0 to 35; the armour 100 feet to 103 head; -106
|
||||
/// the off hand), and `selected` says it is the held slot.
|
||||
/// `native_carried_stack` reads and changes the stack, which stays in its
|
||||
/// slot, so the item helpers see it: a count of 0 empties the slot, and the
|
||||
/// player's client gets the change. This runs often, so check the item
|
||||
/// first. Server-side, for every living player of the world (the helpers
|
||||
/// read the acting one). API 39.
|
||||
pub(super) fn native_inventory_tick(&mut self, slot: i32, selected: bool) {
|
||||
let _ = (slot, selected);
|
||||
}
|
||||
/// A player's equipment slot `slot` (`head`, `chest`, `legs`, `feet`,
|
||||
/// `offhand`, and `mainhand` on a swap of the hands) took a different
|
||||
/// item, `new` (none when it was emptied): `LivingEntity.onEquipItem`,
|
||||
/// before the equip sound. It only hears. Never for a spectator.
|
||||
/// Server-side, for every player of the world (the helpers read the
|
||||
/// acting one). API 39.
|
||||
pub(super) fn native_on_equip(&mut self, slot: &str, new: Option<&ItemStack>) {
|
||||
let _ = (slot, new);
|
||||
}
|
||||
}
|
||||
|
||||
fn dimension_id(dimension: LevelDimension) -> &'static str {
|
||||
match dimension {
|
||||
LevelDimension::Overworld => "minecraft:overworld",
|
||||
LevelDimension::Nether => "minecraft:the_nether",
|
||||
LevelDimension::End => "minecraft:the_end",
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-level helpers for native mods.
|
||||
#[allow(dead_code)]
|
||||
impl GameLevel<'_> {
|
||||
/// Play a registered vanilla sound for nearby players in this server level.
|
||||
/// The return value means queued, not heard. Unknown sound events are
|
||||
/// rejected at dispatch when the server registry is available.
|
||||
pub(super) fn native_play_sound(
|
||||
&mut self,
|
||||
sound: &str,
|
||||
at: [f64; 3],
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
) -> bool {
|
||||
if !self.native_authoritative || !sound.starts_with("minecraft:") {
|
||||
return false;
|
||||
}
|
||||
if source >= horizon_client::settings::SOUND_SOURCES.len() {
|
||||
return false;
|
||||
}
|
||||
let position_ok = at
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
|
||||
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
|
||||
if !position_ok || !gain_ok {
|
||||
return false;
|
||||
}
|
||||
self.native_sounds.push(super::creatures::NativeSound {
|
||||
dimension: dimension_id(self.dimension),
|
||||
sound: sound.to_owned(),
|
||||
at,
|
||||
source: source as i32,
|
||||
volume,
|
||||
pitch,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Queue particles in this authoritative level. A zero count sends one
|
||||
/// directional particle. The return value means accepted, not delivered.
|
||||
/// Block particle types require their block state; item, trail and
|
||||
@@ -848,14 +1230,9 @@ impl GameLevel<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let dimension = match self.dimension {
|
||||
LevelDimension::Overworld => "minecraft:overworld",
|
||||
LevelDimension::Nether => "minecraft:the_nether",
|
||||
LevelDimension::End => "minecraft:the_end",
|
||||
};
|
||||
self.native_particles
|
||||
.push(super::lan_particles::QueuedParticle {
|
||||
dimension,
|
||||
dimension: dimension_id(self.dimension),
|
||||
options: options.clone(),
|
||||
state,
|
||||
at,
|
||||
@@ -865,18 +1242,139 @@ impl GameLevel<'_> {
|
||||
});
|
||||
true
|
||||
}
|
||||
/// Sets property `key` of the block at `pos` to `value` with
|
||||
/// `Level.setBlock(pos, state, flags)`, from a hook that gets the level's
|
||||
/// `ticks` (`native_random_tick`, `native_tick_block`). A crop's random
|
||||
/// tick grows it with `flags::UPDATE_CLIENTS`: neighbours' shapes update,
|
||||
/// but no neighbour gets `neighborChanged` and comparators aren't told;
|
||||
/// `flags::UPDATE_ALL` does both. Horizon sends every write to players
|
||||
/// whatever the flags: `UPDATE_CLIENTS`, `UPDATE_INVISIBLE` and
|
||||
/// `UPDATE_IMMEDIATE` change nothing yet. Any loaded position, not only
|
||||
/// the hook's; false off an authoritative server, when the block has no
|
||||
/// such property or value, or `pos` is not loaded; true without a write
|
||||
/// when the property already has `value`. Elsewhere,
|
||||
/// `Game::native_set_block_property_with` does the same. API 33.
|
||||
pub(super) fn native_set_block_property(
|
||||
&mut self,
|
||||
ticks: &mut Ticks,
|
||||
pos: [i32; 3],
|
||||
key: &str,
|
||||
value: &str,
|
||||
flags: i32,
|
||||
) -> bool {
|
||||
if !self.native_authoritative {
|
||||
return false;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let state = self.block_state(at);
|
||||
let Some(id) = with_property(self.registry, state, key, value) else {
|
||||
return false;
|
||||
};
|
||||
if id == state {
|
||||
return true;
|
||||
}
|
||||
let b = self.behaviours;
|
||||
b.set_block(self, ticks, at, id, flags)
|
||||
}
|
||||
/// Schedules a tick of `block` (a block name) at `pos` in `delay` game
|
||||
/// ticks, as a falling block's shape update schedules its fall
|
||||
/// (`ScheduledTickAccess.scheduleTick(pos, block, delay)`, normal
|
||||
/// priority); false for an unknown block or a position that isn't
|
||||
/// loaded. A tick of that block already queued there is kept and counts
|
||||
/// as scheduled. `ticks` is the hook's. API 32.
|
||||
pub(super) fn native_schedule_block_tick(
|
||||
&self,
|
||||
ticks: &mut Ticks,
|
||||
pos: [i32; 3],
|
||||
block: &str,
|
||||
delay: i32,
|
||||
) -> bool {
|
||||
let Some(state) = self.registry.default_state(block) else {
|
||||
return false;
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let block = self.behaviours.block(state);
|
||||
let time = self.game_time();
|
||||
ticks.schedule_block(time, at, block, delay, TickPriority::Normal)
|
||||
!= ScheduleResult::MissingChunk
|
||||
}
|
||||
/// Schedules a tick of `fluid` (`minecraft:water`, `minecraft:lava`,
|
||||
/// `minecraft:flowing_water`...) at `pos` in `delay` game ticks, as a
|
||||
/// waterlogged block's shape update schedules its water in 5
|
||||
/// (`ScheduledTickAccess.scheduleTick(pos, fluid, delay)`); false for
|
||||
/// an unknown fluid or a position that isn't loaded. A tick of that
|
||||
/// fluid already queued there is kept and counts as scheduled. `ticks` is
|
||||
/// the hook's. API 32.
|
||||
pub(super) fn native_schedule_fluid_tick(
|
||||
&self,
|
||||
ticks: &mut Ticks,
|
||||
pos: [i32; 3],
|
||||
fluid: &str,
|
||||
delay: i32,
|
||||
) -> bool {
|
||||
let Some(kind) = FluidKind::from_name(fluid) else {
|
||||
return false;
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let time = self.game_time();
|
||||
ticks.schedule_fluid(time, at, kind, delay) != ScheduleResult::MissingChunk
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The stack the acting player carries in `slot`, by
|
||||
/// `native_inventory_tick`'s numbers: none for an empty slot or another
|
||||
/// number. Change it as you like: a count of 0 empties the slot at the end
|
||||
/// of `native_inventory_tick`, and the player's client gets the change.
|
||||
/// API 39.
|
||||
pub(super) fn native_carried_stack(&mut self, slot: i32) -> Option<&mut ItemStack> {
|
||||
self.native_carried_slot(slot)?.as_mut()
|
||||
}
|
||||
/// Whether the player left mod `mod_id` on in Options > Mods. A mod
|
||||
/// turned off does nothing (check this first in each hook) until the
|
||||
/// launcher's next build leaves it out.
|
||||
pub(super) fn native_on(&self, mod_id: &str) -> bool {
|
||||
!self.settings.mods.disabled.contains(mod_id)
|
||||
}
|
||||
/// Sets a block in the current authoritative level through `Level.setBlock`.
|
||||
/// Returns false for another dimension, an invalid state, or no change.
|
||||
/// `flags` selects vanilla's neighbour and shape updates; Horizon sends
|
||||
/// every write to players whatever the flags. A block entity block gets
|
||||
/// its block entity. API 24.
|
||||
pub(super) fn native_set_block(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
flags: i32,
|
||||
) -> bool {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
|| self.pack.assets.registry.state(state).is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let previous = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let changed =
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, state, flags));
|
||||
let new = self.res.facts().get(state).name.clone();
|
||||
let landed = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if changed
|
||||
&& self.res.facts().get(previous).name != new
|
||||
&& self.res.facts().get(landed).name == new
|
||||
&& let Some(entity) = self.new_block_entity(&new)
|
||||
{
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((pos[0], pos[1], pos[2]), entity);
|
||||
}
|
||||
let _ = self.process_level_events();
|
||||
changed
|
||||
}
|
||||
/// Sets property `key` of the block at `pos` to `value`, as a furnace's
|
||||
/// tick sets its `lit` (`Level.setBlock` with `UPDATE_ALL`: neighbours
|
||||
/// and clients are told); false when the block has no such property or
|
||||
@@ -887,22 +1385,66 @@ impl Game<'_> {
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> bool {
|
||||
let registry = self.pack.assets.registry;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(mut s) = registry.state(state).cloned() else {
|
||||
self.native_set_block_property_with(pos, key, value, flags::UPDATE_ALL)
|
||||
}
|
||||
/// `native_set_block_property` with `Level.setBlock`'s `flags`, as a
|
||||
/// brewing stand's tick sets its bottles with `flags::UPDATE_CLIENTS`.
|
||||
/// Players get the write whatever the flags (`flags`); false when this
|
||||
/// window isn't a server. API 33.
|
||||
pub(super) fn native_set_block_property_with(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
key: &str,
|
||||
value: &str,
|
||||
flags: i32,
|
||||
) -> bool {
|
||||
// As `native_set_block`: a window that isn't a server holds a copy.
|
||||
if self.serving.is_none() && self.options.dedicated.is_none() {
|
||||
return false;
|
||||
};
|
||||
match s.properties.get(key) {
|
||||
Some(v) if v == value => return true,
|
||||
Some(_) => {}
|
||||
None => return false,
|
||||
}
|
||||
s.properties.insert(key.into(), value.into());
|
||||
let Ok(id) = registry.resolve(&s) else {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(id) = with_property(self.pack.assets.registry, state, key, value) else {
|
||||
return false;
|
||||
};
|
||||
if id == state {
|
||||
return true;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, id, flags::UPDATE_ALL))
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, id, flags))
|
||||
}
|
||||
/// The block at `pos` changed the signal it gives: its neighbours, and
|
||||
/// theirs (a block it powers passes the signal on), read it again, as
|
||||
/// around a lever (`Level.updateNeighborsAt`). Call it when the answer
|
||||
/// changes and once the block is placed. Acts on the level in place:
|
||||
/// false (and nothing happens) when `dimension` is another, or this
|
||||
/// window isn't a server. API 38.
|
||||
pub(super) fn native_update_signal_neighbors(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
) -> bool {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
let block = b.block(state);
|
||||
b.update_neighbors_at(level, ticks, at, block);
|
||||
for [x, y, z] in [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
] {
|
||||
b.update_neighbors_at(level, ticks, at.offset(x, y, z), block);
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
/// Runs `change` on the mod block entity at `pos` (a generator charging
|
||||
/// the machine beside it) and returns what it returns; none for any
|
||||
@@ -1135,6 +1677,16 @@ impl Game<'_> {
|
||||
let dir = self.native_data_dir(mod_id);
|
||||
let _ = fs::create_dir_all(&dir).and_then(|()| fs::write(dir.join(key), value));
|
||||
}
|
||||
/// The game's `config/` folder beside `options.txt`, shared by every mod
|
||||
/// as on Fabric and NeoForge: a mod names its own files in it
|
||||
/// (`config/<id>.json`, or a folder of its own). The same folder on the
|
||||
/// window, its server thread and a LAN host; a dedicated server's is in
|
||||
/// its own folder, beside `server.properties`. Made when missing. No
|
||||
/// `self`, so it works from any hook (`Game::native_config_dir()`), the
|
||||
/// level's too. API 26.
|
||||
pub(super) fn native_config_dir() -> Result<PathBuf, String> {
|
||||
config_dir(&Self::options_path())
|
||||
}
|
||||
/// Shows the panel beside container screens with these items (at most
|
||||
/// 8192), as the game's own slots in pages, with a search box; the
|
||||
/// player's clicks, searches and keys over items come back as
|
||||
@@ -1159,6 +1711,62 @@ impl Game<'_> {
|
||||
let page = RecipePage::new(title, tabs, tab, views);
|
||||
self.panel.show_recipes(mod_id, page);
|
||||
}
|
||||
/// The recipes of type `kind` (`mymod:crusher`), a type outside `minecraft`,
|
||||
/// from `data/<namespace>/recipe/*.json` of the content mods (`.hmod`) and
|
||||
/// the world's data packs, in file order: each one's id and its file as
|
||||
/// JSON, for the mod to read as it likes. A data pack changes them on
|
||||
/// `/reload`, so look them up again when you need them. Empty for a type
|
||||
/// nobody uses and outside a world. These are the server's table on the
|
||||
/// server thread, a dedicated server or a LAN host; a client, the
|
||||
/// single-player window included, sees only the content mods' recipes, not
|
||||
/// the world's data packs'. API 36.
|
||||
pub(super) fn native_recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
|
||||
if self.player.is_none() {
|
||||
return &[];
|
||||
}
|
||||
self.recipes().custom.get(kind).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
/// An ingredient as a recipe's JSON names it: an item, a `#tag`, or a list
|
||||
/// of them, a bare id as `minecraft:` (as vanilla reads it); `test` says
|
||||
/// whether a stack fits. Items the game doesn't know are left out; None
|
||||
/// when nothing is left (an unknown or empty tag, for one) and outside a
|
||||
/// world. API 36.
|
||||
pub(super) fn native_recipe_ingredient(&self, json: &serde_json::Value) -> Option<Ingredient> {
|
||||
self.player.as_ref()?;
|
||||
let qualify = |s: &str| match s.strip_prefix('#') {
|
||||
Some(tag) => String::from("#") + &qualified(tag),
|
||||
None => qualified(s),
|
||||
};
|
||||
let json = match json {
|
||||
serde_json::Value::String(s) => serde_json::Value::String(qualify(s)),
|
||||
serde_json::Value::Array(a) => serde_json::Value::Array(
|
||||
a.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(|s| serde_json::Value::String(qualify(s)))
|
||||
.collect(),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
let items = self.items();
|
||||
let known: Vec<String> = Ingredient::parse(&json, items)?
|
||||
.1
|
||||
.into_iter()
|
||||
.filter(|item| items.get(item).is_some())
|
||||
.collect();
|
||||
(!known.is_empty()).then(|| Ingredient::of(known))
|
||||
}
|
||||
/// A stack as a recipe's `result` names it: `id` (a bare one as
|
||||
/// `minecraft:`), `count` (1 by default, up to 99) and `components`. None
|
||||
/// without an `id`, for an item the game doesn't know or a count outside
|
||||
/// that, and outside a world. API 36.
|
||||
pub(super) fn native_recipe_result(&self, json: &serde_json::Value) -> Option<ItemStack> {
|
||||
self.player.as_ref()?;
|
||||
let mut json = json.clone();
|
||||
if let Some(id) = json["id"].as_str() {
|
||||
json["id"] = qualified(id).into();
|
||||
}
|
||||
recipe_result(&json).filter(|s| self.items().get(&s.item).is_some())
|
||||
}
|
||||
/// Every item and recipe the game knows, made once (the same for every
|
||||
/// call); none outside a world. They are the window's own: the world's
|
||||
/// in single player, vanilla's on a server, which sends a client no
|
||||
@@ -1192,8 +1800,81 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob's kind as the hook points name it (`minecraft:zombie`).
|
||||
pub(super) fn mob_kind(mob: &horizon_client::mobs::Mob) -> String {
|
||||
["minecraft:", mob.kind.id.trim_start_matches("minecraft:")].concat()
|
||||
}
|
||||
|
||||
/// Where the game calls the hook points.
|
||||
impl Game<'_> {
|
||||
/// The acting player as the hook points name one: the guest whose turn
|
||||
/// it is, else the host.
|
||||
pub(super) fn native_acting_id(&mut self) -> u128 {
|
||||
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid())
|
||||
}
|
||||
/// Whether a native mod keeps the acting player from taking `stack`.
|
||||
pub(super) fn native_pickup_refused(&mut self, stack: &ItemStack) -> bool {
|
||||
let player = self.native_acting_id();
|
||||
let mut canceled = false;
|
||||
self.native_item_picked_up(player, &stack.item, stack.count, &mut canceled);
|
||||
canceled
|
||||
}
|
||||
/// The mobs from index `from` on are new in the world.
|
||||
pub(super) fn native_mobs_joined(&mut self, from: usize, natural: bool) {
|
||||
for i in from..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
let (kind, pos) = (mob_kind(m), [m.position.x, m.position.y, m.position.z]);
|
||||
self.native_mob_spawned(self.dim.id(), &kind, pos, natural);
|
||||
}
|
||||
}
|
||||
/// The acting player's blow killed mob `index`.
|
||||
pub(super) fn native_kill_event(&mut self, index: usize) {
|
||||
let (entity, kind) = (self.mobs[index].id, mob_kind(&self.mobs[index]));
|
||||
let player = self.native_acting_id();
|
||||
self.native_mob_killed(player, entity, &kind);
|
||||
}
|
||||
/// The acting player's hit landed on mob `index`, holding `stack`.
|
||||
pub(super) fn native_hurt_enemy_event(&mut self, stack: &ItemStack, index: usize) {
|
||||
let mob = &self.mobs[index];
|
||||
let kind = ["minecraft:", mob.kind.id.trim_start_matches("minecraft:")].concat();
|
||||
self.native_hurt_enemy(stack, mob.id, &kind);
|
||||
}
|
||||
/// `Inventory.tick` for the acting player: each carried stack goes through
|
||||
/// `native_inventory_tick`.
|
||||
pub(super) fn native_inventory_ticks(&mut self) {
|
||||
if self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
for i in 0..self.inventory.len() {
|
||||
self.native_slot_tick(i as i32, i == self.selected);
|
||||
}
|
||||
// Head first here; `Inventory` saves feet as 100 and head as 103.
|
||||
for i in 0..self.armor.len() {
|
||||
self.native_slot_tick(103 - i as i32, false);
|
||||
}
|
||||
self.native_slot_tick(-106, false);
|
||||
}
|
||||
fn native_slot_tick(&mut self, slot: i32, selected: bool) {
|
||||
if self.native_carried_slot(slot).is_none_or(|s| s.is_none()) {
|
||||
return;
|
||||
}
|
||||
self.native_inventory_tick(slot, selected);
|
||||
if let Some(stack) = self.native_carried_slot(slot)
|
||||
&& stack.as_ref().is_some_and(|s| s.count == 0)
|
||||
{
|
||||
*stack = None;
|
||||
}
|
||||
}
|
||||
/// The slot a player's carried stack is in, by `native_inventory_tick`'s
|
||||
/// numbers: none for another number.
|
||||
fn native_carried_slot(&mut self, slot: i32) -> Option<&mut Option<ItemStack>> {
|
||||
match slot {
|
||||
0.. if (slot as usize) < self.inventory.len() => self.inventory.get_mut(slot as usize),
|
||||
100..=103 => self.armor.get_mut(103 - slot as usize),
|
||||
-106 => Some(&mut self.offhand),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `EntityBlock.newBlockEntity` of block `name`: Horizon's own, else a
|
||||
/// native mod's.
|
||||
pub(super) fn new_block_entity(&mut self, name: &str) -> Option<BlockEntity> {
|
||||
@@ -1688,6 +2369,31 @@ fn mod_menu(name: &str) -> Option<(&'static str, usize)> {
|
||||
MOD_MENUS.into_iter().find(|(menu, _)| *menu == name)
|
||||
}
|
||||
|
||||
/// `state` with its property `key` set to `value`; none when the block has
|
||||
/// no such property or value.
|
||||
fn with_property(
|
||||
registry: &BlockRegistry,
|
||||
state: BlockStateId,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Option<BlockStateId> {
|
||||
let mut s = registry.state(state)?.clone();
|
||||
match s.properties.get(key) {
|
||||
Some(v) if v == value => return Some(state),
|
||||
Some(_) => {}
|
||||
None => return None,
|
||||
}
|
||||
s.properties.insert(key.into(), value.into());
|
||||
registry.resolve(&s).ok()
|
||||
}
|
||||
|
||||
/// `config/` beside `options`, made when missing.
|
||||
fn config_dir(options: &Path) -> Result<PathBuf, String> {
|
||||
let dir = options.with_file_name("config");
|
||||
fs::create_dir_all(&dir).map_err(|e| dir.display().to_string() + ": " + &e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// `v` within `lo..=hi`; `lo` when it is not a number.
|
||||
fn finite(v: f32, lo: f32, hi: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { lo }
|
||||
@@ -1695,6 +2401,30 @@ fn finite(v: f32, lo: f32, hi: f32) -> f32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// A mod's property write: none for a key or value the block lacks, the
|
||||
/// state itself when it already has the value, else the new state.
|
||||
#[test]
|
||||
fn with_property_finds_the_state() {
|
||||
let facts = br#"{"defaults":{"minecraft:air":0,"test:crop":1},"states":[
|
||||
["air",true,false,"","minecraft:air"],
|
||||
["crop0",false,false,"",{"id":"test:crop","properties":{"age":"0"}}],
|
||||
["crop1",false,false,"",{"id":"test:crop","properties":{"age":"1"}}]
|
||||
]}"#;
|
||||
let registry = horizon_world::registry::BlockRegistry::from_facts_json(&facts[..]).unwrap();
|
||||
use horizon_world::registry::BlockStateId;
|
||||
let (age0, age1) = (BlockStateId(1), BlockStateId(2));
|
||||
assert_eq!(
|
||||
super::with_property(®istry, age0, "age", "0"),
|
||||
Some(age0)
|
||||
);
|
||||
assert_eq!(
|
||||
super::with_property(®istry, age0, "age", "1"),
|
||||
Some(age1)
|
||||
);
|
||||
assert_eq!(super::with_property(®istry, age0, "age", "9"), None);
|
||||
assert_eq!(super::with_property(®istry, age0, "lit", "true"), None);
|
||||
}
|
||||
/// horizon-mixins refuses targets whose body has a `cfg` or a macro:
|
||||
/// the hook points and helpers must stay free of both. The launcher
|
||||
/// reads the API line as it is.
|
||||
@@ -1815,6 +2545,24 @@ mod tests {
|
||||
assert_eq!(mod_menu("hopper"), None);
|
||||
}
|
||||
|
||||
/// The config folder is `config/` beside `options.txt`, made when
|
||||
/// missing.
|
||||
#[test]
|
||||
fn config_dir_is_config_beside_options() {
|
||||
// Callable without a game, from any hook.
|
||||
let _: fn() -> Result<std::path::PathBuf, String> = super::Game::native_config_dir;
|
||||
use super::config_dir;
|
||||
let root = std::env::temp_dir().join(format!("horizon-config-dir-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let options = root.join("options.txt");
|
||||
assert!(!root.join("config").exists());
|
||||
let dir = config_dir(&options).unwrap();
|
||||
assert_eq!(dir, root.join("config"));
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(config_dir(&options).unwrap(), dir);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Vanilla's F3 draws a column in each top corner (`extractLines`, left
|
||||
/// and right aligned), and its charts along the bottom.
|
||||
#[test]
|
||||
|
||||
@@ -422,7 +422,7 @@ mod tests {
|
||||
.find("self.native_lifecycle_open_started();")
|
||||
.unwrap();
|
||||
let load = integrated
|
||||
.find("self.load_world(WorldRequest::new(")
|
||||
.find("self.finish_open_world(opening.world?.finish()?)?;")
|
||||
.unwrap();
|
||||
assert!(open < load);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Mobs for native server mods (docs/native-mods.md, "Mobs", API 37): spawn one,
|
||||
//! read the mobs of the level in place, and move, hurt or remove one by its id.
|
||||
//! They act where the world is simulated, never on a client's copy, and only on
|
||||
//! the level in place, which each names by its `dimension` (`minecraft:overworld`)
|
||||
//! as `native_set_block` does: another one is refused. While the game ticks its
|
||||
//! mobs they are out of the level: a mob a hook spawns then joins it after the
|
||||
//! tick, and the others read as gone.
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// A mob as a native mod reads it.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeMob {
|
||||
pub(crate) id: u32,
|
||||
/// The level it is in (`minecraft:overworld`).
|
||||
pub(crate) dimension: String,
|
||||
/// `minecraft:zombie`.
|
||||
pub(crate) kind: String,
|
||||
pub(crate) pos: [f64; 3],
|
||||
/// Degrees; 0 faces south.
|
||||
pub(crate) yaw: f32,
|
||||
pub(crate) health: f32,
|
||||
pub(crate) max_health: f32,
|
||||
}
|
||||
|
||||
/// What a mob starts with (`native_spawn_mob`); the default is a plain mob.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeSpawn {
|
||||
/// A name over it, shown to everyone.
|
||||
pub(crate) name: Option<String>,
|
||||
/// Cut to its maximum.
|
||||
pub(crate) health: Option<f32>,
|
||||
pub(crate) no_ai: bool,
|
||||
pub(crate) invisible: bool,
|
||||
pub(crate) silent: bool,
|
||||
pub(crate) invulnerable: bool,
|
||||
pub(crate) no_gravity: bool,
|
||||
}
|
||||
|
||||
fn finite(v: [f64; 3]) -> bool {
|
||||
v.into_iter().all(f64::is_finite)
|
||||
}
|
||||
|
||||
/// `/summon`'s world bounds (`Level.isInSpawnableBounds`).
|
||||
fn spawnable(pos: [f64; 3]) -> bool {
|
||||
finite(pos)
|
||||
&& (-30_000_000.0..30_000_000.0).contains(&pos[0])
|
||||
&& (-30_000_000.0..30_000_000.0).contains(&pos[2])
|
||||
&& (-20_000_000.0..20_000_000.0).contains(&pos[1].floor())
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Whether this is a level that simulates (not a client's copy) and the one
|
||||
/// in place is `dimension`.
|
||||
fn native_mob_level(&self, dimension: &str) -> bool {
|
||||
self.remote.is_none() && dimension == self.dim.id()
|
||||
}
|
||||
/// Spawns a mob of `kind` (`minecraft:zombie`) at `pos` in the level in
|
||||
/// place, `dimension`, finalized as a natural spawn is, and kept when the
|
||||
/// chunk unloads: its id. None for a kind the game doesn't know, another
|
||||
/// dimension, a position outside `/summon`'s bounds or in a chunk that isn't
|
||||
/// loaded (it would never tick or be saved), or a monster on peaceful (it
|
||||
/// would despawn at once). API 37.
|
||||
pub(super) fn native_spawn_mob(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
kind: &str,
|
||||
pos: [f64; 3],
|
||||
options: &NativeSpawn,
|
||||
) -> Option<u32> {
|
||||
if !self.native_mob_level(dimension) || !spawnable(pos) || !self.native_chunk_loaded(pos) {
|
||||
return None;
|
||||
}
|
||||
let qualified = if kind.contains(':') {
|
||||
kind.to_owned()
|
||||
} else {
|
||||
["minecraft:", kind].concat()
|
||||
};
|
||||
let monster = self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(&qualified)
|
||||
.is_some_and(|t| t.category == "monster");
|
||||
if monster && self.ui.difficulty == 0 {
|
||||
return None;
|
||||
}
|
||||
let id = self.spawn_mob(kind, pos, true)?;
|
||||
// A jockey's other mob comes after it: this one is found by its id.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.no_ai = options.no_ai;
|
||||
m.invisible = options.invisible;
|
||||
m.silent = options.silent;
|
||||
m.invulnerable_flag = options.invulnerable;
|
||||
m.no_gravity = options.no_gravity;
|
||||
if let Some(name) = &options.name {
|
||||
m.custom_name = Some(name.clone());
|
||||
m.name_visible = true;
|
||||
}
|
||||
if let Some(health) = options.health.filter(|h| h.is_finite() && *h > 0.0) {
|
||||
m.health = health.min(m.attrs.max_health);
|
||||
}
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// Whether the chunk holding `pos` is loaded in the level in place.
|
||||
fn native_chunk_loaded(&self, pos: [f64; 3]) -> bool {
|
||||
let (x, z) = (pos[0].floor() as i32, pos[2].floor() as i32);
|
||||
self.world.chunks.contains_key(&(x >> 4, z >> 4))
|
||||
}
|
||||
/// The living mob `id` in the level in place, `dimension`; none on a
|
||||
/// client's copy. API 37.
|
||||
pub(super) fn native_mob(&self, dimension: &str, id: u32) -> Option<NativeMob> {
|
||||
if !self.native_mob_level(dimension) {
|
||||
return None;
|
||||
}
|
||||
let mob = self.mobs.iter().find(|m| m.id == id && !m.removed)?;
|
||||
mob.alive().then(|| self.native_mob_of(mob))
|
||||
}
|
||||
/// The living mobs in the level in place, `dimension`; none on a client's
|
||||
/// copy. API 37.
|
||||
pub(super) fn native_mobs(&self, dimension: &str) -> Vec<NativeMob> {
|
||||
if !self.native_mob_level(dimension) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.alive())
|
||||
.map(|m| self.native_mob_of(m))
|
||||
.collect()
|
||||
}
|
||||
/// Moves mob `id` to `pos` at once, as `/tp` does: it dismounts, drops its
|
||||
/// path and resets its fall. False for no such mob, another dimension or a
|
||||
/// position outside `/summon`'s bounds. API 37.
|
||||
pub(super) fn native_teleport_mob(&mut self, dimension: &str, id: u32, pos: [f64; 3]) -> bool {
|
||||
if !self.native_mob_level(dimension) || !spawnable(pos) {
|
||||
return false;
|
||||
}
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id && !m.removed) else {
|
||||
return false;
|
||||
};
|
||||
let to = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
// `TeleportCommand`: stop the navigation, no vertical motion, and a
|
||||
// rider is put back on its vehicle unless it gets off first.
|
||||
m.ai.nav.stop();
|
||||
m.velocity.y = 0.0;
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
m.position = to;
|
||||
m.previous = to;
|
||||
m.fall_distance = 0.0;
|
||||
m.needs_sync = true;
|
||||
true
|
||||
}
|
||||
/// Adds `velocity` to mob `id`'s: false for no such mob, another dimension
|
||||
/// or a velocity that isn't finite. API 37.
|
||||
pub(super) fn native_push_mob(&mut self, dimension: &str, id: u32, velocity: [f64; 3]) -> bool {
|
||||
if !self.native_mob_level(dimension) || !finite(velocity) {
|
||||
return false;
|
||||
}
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id && !m.removed) else {
|
||||
return false;
|
||||
};
|
||||
m.velocity = Vec3::new(
|
||||
m.velocity.x + velocity[0],
|
||||
m.velocity.y + velocity[1],
|
||||
m.velocity.z + velocity[2],
|
||||
);
|
||||
m.needs_sync = true;
|
||||
true
|
||||
}
|
||||
/// Sets the living mob `id`'s health, cut to its maximum: false for no such
|
||||
/// mob, another dimension or a health that isn't above 0 (`native_kill_mob`
|
||||
/// kills). API 37.
|
||||
pub(super) fn native_set_mob_health(&mut self, dimension: &str, id: u32, health: f32) -> bool {
|
||||
if !self.native_mob_level(dimension) || !health.is_finite() || health <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let Some(m) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && !m.removed && m.alive())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
m.health = health.min(m.attrs.max_health);
|
||||
m.needs_sync = true;
|
||||
true
|
||||
}
|
||||
/// Kills mob `id` as `/kill` does: it falls and drops its loot. False for
|
||||
/// no such living mob or another dimension. API 37.
|
||||
pub(super) fn native_kill_mob(&mut self, dimension: &str, id: u32) -> bool {
|
||||
if self.native_mob(dimension, id).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.kill_target(Target::Mob(id));
|
||||
true
|
||||
}
|
||||
/// Takes mob `id` out of the world with no death and no loot: false for no
|
||||
/// such mob or another dimension. API 37.
|
||||
pub(super) fn native_remove_mob(&mut self, dimension: &str, id: u32) -> bool {
|
||||
if !self.native_mob_level(dimension) {
|
||||
return false;
|
||||
}
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id && !m.removed) else {
|
||||
return false;
|
||||
};
|
||||
m.removed = true;
|
||||
true
|
||||
}
|
||||
fn native_mob_of(&self, m: &horizon_client::mobs::Mob) -> NativeMob {
|
||||
NativeMob {
|
||||
id: m.id,
|
||||
dimension: self.dim.id().to_owned(),
|
||||
kind: horizon_client::command::args::normalize(&m.kind.id),
|
||||
pos: [m.position.x, m.position.y, m.position.z],
|
||||
yaw: m.yaw,
|
||||
health: m.health,
|
||||
max_health: m.attrs.max_health,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Mod messages for native mods (docs/native-mods.md, "Mod messages", API
|
||||
//! 23): a mod's server half and its client half send each other bytes
|
||||
//! (`horizon_protocol::mod_net`). The hub is out of the game while a guest's
|
||||
//! packet or turn runs, so both directions wait here: what a guest sent
|
||||
//! reaches `native_net_from_client` once the hub is back, and what a mod sends
|
||||
//! a player leaves at the tick's end, from whichever hook sent it.
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_protocol::{
|
||||
mod_net::{self, Message},
|
||||
packets::{
|
||||
configuration::CustomPayload,
|
||||
play::{Clientbound as C, Serverbound as S},
|
||||
},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Messages a tick at most: from one player, to one player, and from the
|
||||
/// window to its server.
|
||||
pub(super) const PER_TICK: u32 = 32;
|
||||
|
||||
/// The messages waiting, and what each player used of this tick's allowance.
|
||||
#[derive(Default)]
|
||||
pub(super) struct NativeNet {
|
||||
from_players: Vec<(u128, Message)>,
|
||||
from_counts: HashMap<u128, u32>,
|
||||
to_players: Vec<(u128, Vec<u8>)>,
|
||||
to_counts: HashMap<u128, u32>,
|
||||
pub(super) to_server: u32,
|
||||
}
|
||||
|
||||
/// A message's payload as `native_net_send_*` check it.
|
||||
fn payload(channel: &str, data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if !mod_net::channel_ok(channel) {
|
||||
return Err(format!("the channel {channel:?} is not mod_id:name"));
|
||||
}
|
||||
mod_net::encode(&Message {
|
||||
channel: channel.to_owned(),
|
||||
data: data.to_vec(),
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `horizon:mod_net` payload from `conn`: queued for
|
||||
/// `run_native_net`. Past the caps it is dropped in silence.
|
||||
pub(super) fn guest_native_net(&mut self, host: &LanHost, conn: u32, data: &[u8]) {
|
||||
let Some(player) = host.hub.guest(conn).map(|g| g.uuid) else {
|
||||
return;
|
||||
};
|
||||
let count = self.native_net.from_counts.entry(player).or_default();
|
||||
if *count >= PER_TICK {
|
||||
return;
|
||||
}
|
||||
*count += 1;
|
||||
if let Ok(message) = mod_net::decode(data) {
|
||||
self.native_net.from_players.push((player, message));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether messages from players wait for `run_native_net`.
|
||||
pub(super) fn native_net_waiting(&self) -> bool {
|
||||
!self.native_net.from_players.is_empty()
|
||||
}
|
||||
|
||||
/// The waiting messages reach the mods, with the hub back in the game.
|
||||
pub(super) fn run_native_net(&mut self) {
|
||||
for (player, m) in std::mem::take(&mut self.native_net.from_players) {
|
||||
self.native_net_from_client(player, &m.channel, &m.data);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the mods sent this tick leaves; the tick's allowances start over.
|
||||
pub(super) fn flush_native_net(&mut self, host: &mut LanHost) {
|
||||
for (player, data) in std::mem::take(&mut self.native_net.to_players) {
|
||||
if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.uuid == player && g.brand == "horizon")
|
||||
{
|
||||
host.hub.send_to(
|
||||
g.conn,
|
||||
&C::CustomPayload(CustomPayload {
|
||||
channel: mod_net::CHANNEL.into(),
|
||||
data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The allowance is a server tick's, not a level pass's: it starts over
|
||||
// after the last level.
|
||||
if self.level_pass.last {
|
||||
self.native_net.from_counts.clear();
|
||||
self.native_net.to_counts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A `horizon:mod_net` payload the server sent the window.
|
||||
pub(super) fn remote_native_net(&mut self, data: &[u8]) {
|
||||
match mod_net::decode(data) {
|
||||
Ok(m) => self.native_net_from_server(&m.channel, &m.data),
|
||||
Err(e) => log::debug!("Horizon: a bad mod message: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Sends `data` on `channel` (`mod_id:name`, at most 30 000 bytes, at
|
||||
/// most 32 a player a tick) to `player`'s client, at the tick's end, from
|
||||
/// any server hook. Ok means accepted: a player who left or isn't on a
|
||||
/// Horizon client doesn't get it. API 40.
|
||||
pub(super) fn native_net_send_to_player(
|
||||
&mut self,
|
||||
player: u128,
|
||||
channel: &str,
|
||||
data: &[u8],
|
||||
) -> Result<(), String> {
|
||||
if self.remote.is_some() {
|
||||
return Err("a client has no players to send to".into());
|
||||
}
|
||||
let payload = payload(channel, data)?;
|
||||
let count = self.native_net.to_counts.entry(player).or_default();
|
||||
if *count >= PER_TICK {
|
||||
return Err(format!("past {PER_TICK} messages to this player this tick"));
|
||||
}
|
||||
*count += 1;
|
||||
self.native_net.to_players.push((player, payload));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The client half: sends `data` on `channel` to the server (the same
|
||||
/// caps, 32 a tick). An error when the server doesn't carry mod messages
|
||||
/// (a vanilla server), and until the player has joined: a server says it
|
||||
/// carries them during configuration, before it can take one. API 40.
|
||||
pub(super) fn native_net_send_to_server(
|
||||
&mut self,
|
||||
channel: &str,
|
||||
data: &[u8],
|
||||
) -> Result<(), String> {
|
||||
// Not joined first: the announcement may still be on its way, and a
|
||||
// mod that gives up on "carries no mod messages" never tries again.
|
||||
let Some(remote) = self.remote.as_ref() else {
|
||||
return Err("the server carries no mod messages".into());
|
||||
};
|
||||
if !remote.joined {
|
||||
return Err("the player has not joined yet".into());
|
||||
}
|
||||
if !remote.mod_net {
|
||||
return Err("the server carries no mod messages".into());
|
||||
}
|
||||
let payload = payload(channel, data)?;
|
||||
if self.native_net.to_server >= PER_TICK {
|
||||
return Err(format!("past {PER_TICK} messages this tick"));
|
||||
}
|
||||
self.native_net.to_server += 1;
|
||||
self.send(S::CustomPayload(CustomPayload {
|
||||
channel: mod_net::CHANNEL.into(),
|
||||
data: payload,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Read-only block queries for a native server tick hook.
|
||||
use super::{Dim, Game, native_lifecycle::NativeLifecycleContext};
|
||||
use horizon_client::world::World;
|
||||
use horizon_client::{items::ItemStack, world::World};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry};
|
||||
|
||||
/// Why a server block cannot be read.
|
||||
@@ -17,6 +17,39 @@ pub(super) enum NativeBlockReadError {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Test a block state's name against the world's merged block tags. API 28.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_block_in_tag(
|
||||
&self,
|
||||
context: &NativeLifecycleContext,
|
||||
state: &BlockState,
|
||||
tag: &str,
|
||||
) -> Result<bool, NativeBlockReadError> {
|
||||
if !self.native_lifecycle.permits_read(context) {
|
||||
return Err(NativeBlockReadError::InactiveContext);
|
||||
}
|
||||
Ok(self.items().block_in_tag(tag, &state.name))
|
||||
}
|
||||
|
||||
/// Test an item stack against the world's merged item tags. API 28.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_item_in_tag(
|
||||
&self,
|
||||
context: &NativeLifecycleContext,
|
||||
stack: &ItemStack,
|
||||
tag: &str,
|
||||
) -> Result<bool, NativeBlockReadError> {
|
||||
if !self.native_lifecycle.permits_read(context) {
|
||||
return Err(NativeBlockReadError::InactiveContext);
|
||||
}
|
||||
let item = if stack.count == 0 {
|
||||
"minecraft:air"
|
||||
} else {
|
||||
&stack.item
|
||||
};
|
||||
Ok(self.items().item_in_tag(tag, item))
|
||||
}
|
||||
|
||||
/// Read a loaded block in a server tick hook, without loading a chunk.
|
||||
/// The returned name and properties are owned and remain valid after the hook.
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -434,6 +434,7 @@ impl<'a> Game<'a> {
|
||||
mob.persistent = persistent;
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, true);
|
||||
self.enchant_spawn_equipment(id);
|
||||
if !persistent {
|
||||
self.spawn_jockeys(id, true);
|
||||
|
||||
@@ -297,6 +297,10 @@ impl Game<'_> {
|
||||
let context = pack.context.clone();
|
||||
self.pack = pack;
|
||||
self.gui = gui;
|
||||
// The new atlas's skin and map slots are blank: players and maps
|
||||
// upload into it again instead of naming sprites it does not have.
|
||||
self.skin_slots.forget();
|
||||
self.maps.slots.forget();
|
||||
// The atlas's sprites moved: the particle engine re-reads every rect
|
||||
// and the packs' sprite sets, or every particle draws a stale region.
|
||||
self.particles
|
||||
@@ -350,13 +354,21 @@ pub(super) fn stage_pack(
|
||||
let root = startup_root(vanilla_root, selected, server)?;
|
||||
let root = super::mods::content_root(&root, mods_enabled, dev_mods);
|
||||
progress.set(1);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let mut atlas = HudAtlas::build_over(&root, vanilla_root)?;
|
||||
// The world's models and sprites and the GUI atlas read the same root and nothing of
|
||||
// each other: about a second each, side by side.
|
||||
let (render, atlas, panorama) = std::thread::scope(|s| {
|
||||
let atlas =
|
||||
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
|
||||
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root));
|
||||
(assets.reload(&root), atlas.join(), panorama.join())
|
||||
});
|
||||
let render = Arc::new(render?);
|
||||
let mut atlas = atlas.unwrap_or_else(|p| std::panic::resume_unwind(p))?;
|
||||
atlas.font.uniform = force_unicode;
|
||||
atlas.font.japanese = japanese;
|
||||
let gui = UiResources {
|
||||
atlas,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)),
|
||||
panorama: Arc::new(panorama.unwrap_or_else(|p| std::panic::resume_unwind(p))),
|
||||
};
|
||||
progress.set(2);
|
||||
let pack = PackResources::load(
|
||||
|
||||
@@ -50,6 +50,8 @@ impl Game<'_> {
|
||||
let Some(v) = self.mobs[i].extra.mount else {
|
||||
continue;
|
||||
};
|
||||
// Not a text display: a server's display mounts come through
|
||||
// `remote_set_passengers`, which seats them in `position_riders`.
|
||||
let valid = self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
&& (boats.contains(&v)
|
||||
@@ -81,12 +83,34 @@ impl Game<'_> {
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// Riders of riders settle over a few passes.
|
||||
for _ in 0..3 {
|
||||
for pass in 0..3 {
|
||||
for i in 0..self.mobs.len() {
|
||||
let Some(v) = self.mobs[i].extra.mount else {
|
||||
continue;
|
||||
};
|
||||
let Some(&j) = index.get(&v) else { continue };
|
||||
let Some(&j) = index.get(&v) else {
|
||||
// The display doesn't move between passes.
|
||||
if pass > 0 {
|
||||
continue;
|
||||
}
|
||||
// A mob on a text display: NPC plugins seat their sitting
|
||||
// NPCs on one. The server's position for a passenger
|
||||
// gives way to its seat, as `rideTick` places it.
|
||||
let Some((at, before)) = self.text_displays.get(&(v as i32)).map(|d| d.seat())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
let offset = riders::vehicle_attachment(m);
|
||||
m.position = Vec3::new(at.x, at.y - offset, at.z);
|
||||
m.previous = Vec3::new(before.x, before.y - offset, before.z);
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.fall_distance = 0.0;
|
||||
m.on_ground = false;
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
continue;
|
||||
};
|
||||
let vehicle = &self.mobs[j];
|
||||
let attach = riders::passenger_attachment(vehicle);
|
||||
let (pos, prev) = (vehicle.position, vehicle.previous);
|
||||
@@ -109,7 +133,7 @@ impl Game<'_> {
|
||||
// Displays and interaction boxes riding anything the client tracks,
|
||||
// as NPC plugins put a name over a head or a model's bones on a
|
||||
// pivot: `Entity.rideTick` -> `positionRider`, the vehicle's passenger
|
||||
// attachment (the top of its box in its pose) less the rider's own,
|
||||
// attachment (the top of its box in its pose and scale) less the rider's own,
|
||||
// which is 0 for both (Display and Interaction are 0 by 0). On the
|
||||
// local player, the top of its box in its pose and scale
|
||||
// (`Avatar.POSES`).
|
||||
|
||||
@@ -894,6 +894,7 @@ impl Game<'_> {
|
||||
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
|
||||
&& keys.contains(&chunk_of(self.mobs[i].position))
|
||||
&& self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
})
|
||||
.collect();
|
||||
for i in mobs {
|
||||
|
||||
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user