Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 cf550b69fd WIP (session wrap-up 2026-10-15): unfinished, not reviewed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:36:19 +02:00
586 changed files with 5178 additions and 81143 deletions

No files matched your search

+2 -13
View File
@@ -27,13 +27,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Keep game artifacts out of Git
run: |
python tools/check_public_tree.py
@@ -41,23 +34,19 @@ jobs:
- 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 --target wasm32-unknown-unknown
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting and lint
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: cargo +stable test --workspace
parity:
if: >-
@@ -83,7 +72,7 @@ jobs:
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
run: python3 tools/parity.py --changed ${{ github.event.pull_request.base.sha }} --timeout 2400 # world_spawn takes ~1480 s on a busy runner
run: python3 tools/parity.py --changed ${{ github.event.pull_request.base.sha }} --timeout 1500
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
+1 -3
View File
@@ -1,7 +1,6 @@
name: launcher
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
# push to main that changes the launcher or what it builds in (crates/horizon-prepare, crates/horizon-theme,
# crates/horizon-telemetry),
# push to main that changes the launcher or what it builds in (crates/horizon-prepare, crates/horizon-theme),
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
# for the game.
# A release is launcher-vX.Y.Z: the patch after the last launcher tag, or the next minor when a merged pull
@@ -17,7 +16,6 @@ on:
- 'launcher/**'
- 'crates/horizon-prepare/**'
- 'crates/horizon-theme/**'
- 'crates/horizon-telemetry/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
+5 -6
View File
@@ -109,18 +109,18 @@ jobs:
- name: ALSA and udev headers
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
- name: Build the client, horizon-ctl and horizon-prepare
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
for binary in horizon-client horizon-ctl horizon-prepare; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
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
for binary in horizon-client horizon-ctl horizon-prepare; do
file="target/release/$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)
@@ -137,7 +137,6 @@ jobs:
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --version
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
"$bin/horizon-server${{ matrix.exe }}" --version
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
@@ -155,7 +154,7 @@ jobs:
client="$PWD/target/release/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 \
--world smoke --seed 1 --render-distance 4 --frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
-3
View File
@@ -24,10 +24,7 @@
/horizon.txt
/horizon-graphics.txt
/horizon-controller.txt
/horizon-extras.txt
/horizon-player.txt
/servers.dat
/screenshots/
/.horizon-cache/
/crash-reports/
/debug/
-3
View File
@@ -4,9 +4,6 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Generated
+10 -1434
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "xtask"]
resolver = "2"
[workspace.package]
-2
View File
@@ -199,8 +199,6 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
[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).
## Author
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
-31
View File
@@ -1,31 +0,0 @@
# Third-party notices
## CullLeaves
The optional leaf and mangrove-root face-culling behavior is adapted from
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
Its MIT license is reproduced below.
```text
MIT License
Copyright (c) 2020 MidnightDust
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
@@ -1,379 +0,0 @@
{
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 10,
"ordering": [
[
"released",
"retained"
],
[
"retained",
"released"
],
[
"released",
"retained"
],
[
"retained",
"released"
],
[
"released",
"retained"
],
[
"retained",
"released"
],
[
"released",
"retained"
],
[
"retained",
"released"
],
[
"released",
"retained"
],
[
"retained",
"released"
]
],
"load_average_before": [
8.2421875,
6.56396484375,
6.39208984375
],
"load_average_after": [
7.98095703125,
6.6455078125,
6.4248046875
],
"executables": {
"released": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
"mode": "deferred",
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
},
"retained": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
"mode": "deferred-held",
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
}
},
"metrics": {
"released": {
"phase_ms": {
"median": 482.591,
"p25": 444.276,
"p75": 586.92,
"min": 430.128,
"max": 736.543,
"samples": [
529.244,
606.146,
449.23,
442.624,
669.108,
511.519,
736.543,
432.616,
430.128,
453.663
]
},
"total_ms": {
"median": 987.004,
"p25": 932.885,
"p75": 1134.876,
"min": 914.242,
"max": 1201.759,
"samples": [
1055.119,
1161.462,
968.629,
914.242,
1201.759,
1005.379,
1200.01,
928.249,
935.775,
931.921
]
},
"cpu_ms": {
"median": 1004.675,
"p25": 936.465,
"p75": 1116.791,
"min": 908.327,
"max": 1241.168,
"samples": [
1074.425,
1130.913,
984.832,
931.205,
1186.169,
1024.519,
1241.168,
908.327,
952.245,
927.15
]
},
"peak_rss_kib": {
"median": 268124.0,
"p25": 267753.0,
"p75": 268455.0,
"min": 267708,
"max": 268700,
"samples": [
268268,
268500,
267744,
267724,
267708,
267780,
268700,
268496,
268332,
267980
]
},
"behaviour_json_ms": {
"median": 24.371,
"p25": 22.469,
"p75": 28.486,
"min": 20.662,
"max": 35.297,
"samples": [
33.387,
35.297,
24.732,
24.011,
27.699,
28.748,
23.623,
22.084,
20.662,
21.266
]
},
"facts_ms": {
"median": 292.77,
"p25": 270.649,
"p75": 383.193,
"min": 264.691,
"max": 443.291,
"samples": [
346.613,
398.468,
274.673,
264.691,
443.291,
292.926,
395.387,
267.232,
269.308,
292.615
]
},
"behaviours_ms": {
"median": 148.635,
"p25": 141.329,
"p75": 181.741,
"min": 136.361,
"max": 307.004,
"samples": [
145.009,
168.982,
146.535,
150.736,
194.604,
185.994,
307.004,
140.102,
136.785,
136.361
]
},
"post_rss_kib": {
"median": 268668.0,
"p25": 268246.0,
"p75": 268922.0,
"min": 268048.0,
"max": 269012.0,
"samples": [
268668.0,
268936.0,
268108.0,
268088.0,
268048.0,
268668.0,
268880.0,
269012.0,
268960.0,
268660.0
]
}
},
"retained": {
"phase_ms": {
"median": 487.954,
"p25": 471.456,
"p75": 531.586,
"min": 401.516,
"max": 834.154,
"samples": [
486.974,
613.755,
540.535,
470.333,
488.934,
474.825,
834.154,
504.74,
401.516,
440.381
]
},
"total_ms": {
"median": 1038.875,
"p25": 989.724,
"p75": 1079.197,
"min": 880.933,
"max": 1746.386,
"samples": [
996.407,
1067.467,
1082.69,
1103.131,
1010.283,
1068.719,
1746.386,
987.496,
890.46,
880.933
]
},
"cpu_ms": {
"median": 1035.537,
"p25": 982.276,
"p75": 1082.698,
"min": 908.774,
"max": 1660.068,
"samples": [
1011.712,
1087.688,
1076.523,
1084.757,
998.654,
1059.363,
1660.068,
976.817,
912.849,
908.774
]
},
"peak_rss_kib": {
"median": 268150.0,
"p25": 267537.0,
"p75": 268614.0,
"min": 267268,
"max": 268848,
"samples": [
268748,
267968,
267268,
268332,
267540,
268560,
267492,
268632,
267536,
268848
]
},
"behaviour_json_ms": {
"median": 23.208,
"p25": 22.305,
"p75": 26.544,
"min": 20.059,
"max": 32.669,
"samples": [
32.669,
22.62,
22.2,
27.331,
25.088,
27.029,
23.488,
20.977,
22.928,
20.059
]
},
"facts_ms": {
"median": 304.44,
"p25": 291.562,
"p75": 330.991,
"min": 244.758,
"max": 384.634,
"samples": [
307.936,
374.145,
336.931,
300.944,
290.81,
293.817,
384.634,
313.171,
244.758,
279.303
]
},
"behaviours_ms": {
"median": 162.285,
"p25": 143.135,
"p75": 179.31,
"min": 133.83,
"max": 426.031,
"samples": [
146.367,
216.988,
181.402,
142.058,
173.036,
153.978,
426.031,
170.592,
133.83,
141.017
]
},
"post_rss_kib": {
"median": 268694.0,
"p25": 268116.0,
"p75": 269056.0,
"min": 267988.0,
"max": 269212.0,
"samples": [
269212.0,
268564.0,
268096.0,
268824.0,
268176.0,
269032.0,
267988.0,
269064.0,
268044.0,
269120.0
]
}
}
}
}
@@ -1,176 +0,0 @@
{
"scope": "Isolated gameplay-facts.json validation/typed decoding; includes JSON file read",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 10,
"load_averages_before_after": [
[
14.7451171875,
8.8994140625,
6.89599609375
],
[
14.7451171875,
8.8994140625,
6.89599609375
]
],
"binary_sha256": "c2be9140c792720499d70c5806b8def0472734264ad72486d01365447e0cc2d7",
"metrics": {
"validate": {
"phase_ms": {
"median": 117.417,
"p25": 114.101,
"p75": 158.855,
"min": 109.199,
"max": 173.408,
"samples": [
109.199,
113.958,
110.195,
118.987,
171.275,
124.701,
170.24,
173.408,
115.848,
114.531
]
},
"total_ms": {
"median": 139.933,
"p25": 133.863,
"p75": 193.565,
"min": 129.75,
"max": 276.311,
"samples": [
276.311,
135.448,
129.75,
138.452,
195.127,
141.413,
188.88,
199.802,
133.335,
132.094
]
},
"cpu_ms": {
"median": 138.849,
"p25": 134.285,
"p75": 168.881,
"min": 129.089,
"max": 190.82,
"samples": [
140.962,
136.06,
129.089,
139.059,
189.478,
138.639,
178.187,
190.82,
133.693,
132.716
]
},
"peak_rss_kib": {
"median": 56654.0,
"p25": 56642.0,
"p75": 56704.0,
"min": 56616,
"max": 56724,
"samples": [
56620,
56656,
56640,
56720,
56616,
56724,
56652,
56720,
56648,
56656
]
}
},
"typed": {
"phase_ms": {
"median": 115.873,
"p25": 109.422,
"p75": 129.67,
"min": 100.872,
"max": 183.923,
"samples": [
116.965,
132.071,
100.872,
105.098,
122.468,
170.777,
107.997,
183.923,
113.699,
114.781
]
},
"total_ms": {
"median": 133.883,
"p25": 126.939,
"p75": 155.913,
"min": 117.54,
"max": 210.164,
"samples": [
135.04,
159.38,
117.54,
122.391,
145.512,
189.132,
125.846,
210.164,
130.217,
132.727
]
},
"cpu_ms": {
"median": 134.375,
"p25": 126.231,
"p75": 154.675,
"min": 118.132,
"max": 204.242,
"samples": [
135.633,
157.961,
118.132,
122.77,
144.816,
178.293,
124.755,
204.242,
130.661,
133.117
]
},
"peak_rss_kib": {
"median": 83974.0,
"p25": 82744.0,
"p75": 84490.0,
"min": 82332,
"max": 84596,
"samples": [
83596,
84596,
82332,
82424,
84288,
82460,
84532,
83660,
84364,
84536
]
}
}
}
}
@@ -1,453 +0,0 @@
{
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 8,
"ordering": [
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
]
],
"load_average_before": [
10.57958984375,
8.45947265625,
6.8134765625
],
"load_average_after": [
7.158203125,
7.7998046875,
6.66455078125
],
"executables": {
"baseline": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-e422bd13",
"mode": "baseline",
"sha256": "bf61f2e6c92e001be3c3bc93cf8072cc6af6fd1d7bcfa7273045960948ec89e7"
},
"a01d47f2": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-a01d47f2",
"mode": "shared",
"sha256": "88be56f8dbd28d0b4abd6c672486ba758df96ba18f68da45b94db14231dd3eea"
},
"cf837118": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
"mode": "deferred",
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
},
"cd61e11b": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
"mode": "deferred",
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
},
"current": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-new",
"mode": "deferred",
"sha256": "84eb1baae8e138f52e300b0b44f1339fca38e70db6e06126e78db776ea3e5cd0"
}
},
"metrics": {
"baseline": {
"phase_ms": {
"median": 1277.781,
"p25": 1271.23,
"p75": 1296.157,
"min": 1223.341,
"max": 1338.745,
"samples": [
1265.934,
1223.341,
1274.931,
1289.177,
1338.745,
1317.096,
1272.995,
1280.631
]
},
"total_ms": {
"median": 1711.802,
"p25": 1695.745,
"p75": 1754.276,
"min": 1645.69,
"max": 2887.078,
"samples": [
2887.078,
1645.69,
1690.413,
1711.459,
1761.859,
1751.748,
1697.522,
1712.144
]
},
"cpu_ms": {
"median": 1769.996,
"p25": 1748.925,
"p75": 1807.254,
"min": 1705.167,
"max": 1950.463,
"samples": [
1950.463,
1705.167,
1749.023,
1768.05,
1804.543,
1815.386,
1771.942,
1748.632
]
},
"peak_rss_kib": {
"median": 820180.0,
"p25": 820030.0,
"p75": 820502.0,
"min": 819472,
"max": 820884,
"samples": [
819472,
820416,
820060,
820096,
820264,
820884,
819940,
820760
]
}
},
"a01d47f2": {
"phase_ms": {
"median": 430.783,
"p25": 427.542,
"p75": 451.763,
"min": 423.035,
"max": 550.345,
"samples": [
491.599,
423.035,
428.85,
427.694,
432.715,
427.084,
438.484,
550.345
]
},
"total_ms": {
"median": 868.457,
"p25": 863.709,
"p75": 932.066,
"min": 837.75,
"max": 997.779,
"samples": [
911.935,
837.75,
865.419,
868.626,
858.578,
997.779,
868.288,
992.46
]
},
"cpu_ms": {
"median": 897.639,
"p25": 887.661,
"p75": 944.363,
"min": 865.497,
"max": 1001.635,
"samples": [
925.321,
865.497,
899.505,
895.774,
876.929,
1001.635,
891.239,
1001.488
]
},
"peak_rss_kib": {
"median": 351906.0,
"p25": 351505.0,
"p75": 352181.0,
"min": 351176,
"max": 352440,
"samples": [
352008,
352120,
351352,
351556,
351804,
351176,
352440,
352364
]
}
},
"cf837118": {
"phase_ms": {
"median": 408.459,
"p25": 398.987,
"p75": 428.585,
"min": 387.999,
"max": 524.425,
"samples": [
433.521,
387.999,
412.195,
397.366,
404.724,
399.527,
426.939,
524.425
]
},
"total_ms": {
"median": 851.294,
"p25": 823.291,
"p75": 890.55,
"min": 801.356,
"max": 1030.737,
"samples": [
892.054,
801.356,
840.381,
818.285,
862.208,
824.96,
890.049,
1030.737
]
},
"cpu_ms": {
"median": 870.765,
"p25": 850.849,
"p75": 907.922,
"min": 825.718,
"max": 1041.996,
"samples": [
906.657,
825.718,
866.472,
843.591,
875.058,
853.268,
911.715,
1041.996
]
},
"peak_rss_kib": {
"median": 274172.0,
"p25": 273909.0,
"p75": 274438.0,
"min": 273668,
"max": 274964,
"samples": [
274372,
274964,
273668,
273972,
274552,
274400,
273840,
273932
]
}
},
"cd61e11b": {
"phase_ms": {
"median": 419.114,
"p25": 404.191,
"p75": 460.634,
"min": 388.557,
"max": 735.687,
"samples": [
457.971,
418.303,
388.557,
405.041,
419.925,
401.64,
468.624,
735.687
]
},
"total_ms": {
"median": 840.723,
"p25": 835.398,
"p75": 917.995,
"min": 811.43,
"max": 1565.471,
"samples": [
906.328,
837.706,
811.43,
835.727,
843.74,
834.412,
952.996,
1565.471
]
},
"cpu_ms": {
"median": 863.404,
"p25": 858.916,
"p75": 934.2,
"min": 833.955,
"max": 1550.132,
"samples": [
916.017,
864.82,
833.955,
859.057,
861.989,
858.494,
988.748,
1550.132
]
},
"peak_rss_kib": {
"median": 267508.0,
"p25": 267310.0,
"p75": 267890.0,
"min": 267172,
"max": 268412,
"samples": [
267304,
268292,
267312,
267756,
267340,
267676,
267172,
268412
]
}
},
"current": {
"phase_ms": {
"median": 406.779,
"p25": 397.839,
"p75": 413.957,
"min": 385.028,
"max": 552.051,
"samples": [
387.136,
385.028,
409.317,
404.242,
401.407,
411.451,
421.476,
552.051
]
},
"total_ms": {
"median": 819.787,
"p25": 814.211,
"p75": 855.69,
"min": 800.867,
"max": 1128.453,
"samples": [
808.685,
800.867,
816.053,
817.907,
821.667,
843.26,
892.982,
1128.453
]
},
"cpu_ms": {
"median": 847.078,
"p25": 836.598,
"p75": 877.524,
"min": 826.002,
"max": 1134.369,
"samples": [
834.665,
826.002,
837.243,
842.86,
851.296,
864.892,
915.419,
1134.369
]
},
"peak_rss_kib": {
"median": 267956.0,
"p25": 267612.0,
"p75": 268326.0,
"min": 267308,
"max": 268428,
"samples": [
268428,
267308,
268260,
267516,
267652,
268312,
268368,
267644
]
}
}
},
"experiment_note": "Experimental projection variant (reused sounds, Arc-keyed string pools and direct flags); reverted after mixed CPU/RSS evidence. Current in this file is not the final PR version."
}
@@ -1,582 +0,0 @@
{
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 10,
"ordering": [
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
]
],
"load_average_before": [
11.4921875,
7.46875,
6.69677734375
],
"load_average_after": [
17.763671875,
11.39013671875,
8.20556640625
],
"executables": {
"baseline": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-e422bd13",
"mode": "baseline",
"sha256": "bf61f2e6c92e001be3c3bc93cf8072cc6af6fd1d7bcfa7273045960948ec89e7"
},
"a01d47f2": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-a01d47f2",
"mode": "shared",
"sha256": "88be56f8dbd28d0b4abd6c672486ba758df96ba18f68da45b94db14231dd3eea"
},
"cf837118": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
"mode": "deferred",
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
},
"cd61e11b": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
"mode": "deferred",
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
},
"current": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
"mode": "deferred",
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
}
},
"metrics": {
"baseline": {
"phase_ms": {
"median": 1506.588,
"p25": 1388.314,
"p75": 2119.85,
"min": 1319.319,
"max": 3143.124,
"samples": [
3143.124,
2483.307,
2124.952,
2104.544,
1525.153,
1396.75,
1381.771,
1488.022,
1385.502,
1319.319
]
},
"total_ms": {
"median": 1962.122,
"p25": 1858.188,
"p75": 3152.608,
"min": 1804.713,
"max": 4136.912,
"samples": [
4136.912,
3250.285,
3134.13,
3158.767,
2001.229,
1883.076,
1849.892,
1923.016,
1818.34,
1804.713
]
},
"cpu_ms": {
"median": 1969.296,
"p25": 1893.348,
"p75": 2694.824,
"min": 1842.049,
"max": 3059.824,
"samples": [
3059.824,
2765.392,
2698.837,
2682.786,
2026.89,
1911.703,
1888.282,
1908.545,
1859.233,
1842.049
]
},
"peak_rss_kib": {
"median": 819952.0,
"p25": 819778.0,
"p75": 820164.0,
"min": 818944,
"max": 820304,
"samples": [
819956,
819744,
818944,
818984,
820128,
820176,
819948,
820236,
819880,
820304
]
}
},
"a01d47f2": {
"phase_ms": {
"median": 666.765,
"p25": 520.278,
"p75": 1156.022,
"min": 450.677,
"max": 1882.249,
"samples": [
1001.208,
1309.911,
1207.627,
1882.249,
747.233,
544.933,
512.06,
450.677,
466.596,
586.298
]
},
"total_ms": {
"median": 1141.968,
"p25": 974.723,
"p75": 2326.553,
"min": 922.496,
"max": 3158.114,
"samples": [
2374.699,
2275.18,
2343.677,
3158.114,
1267.594,
1006.411,
964.16,
922.496,
936.687,
1016.342
]
},
"cpu_ms": {
"median": 1106.901,
"p25": 973.629,
"p75": 1525.998,
"min": 929.573,
"max": 1568.573,
"samples": [
1545.278,
1568.573,
1468.158,
1563.377,
1196.236,
1017.565,
960.219,
929.573,
946.096,
1013.861
]
},
"peak_rss_kib": {
"median": 351564.0,
"p25": 351148.0,
"p75": 352106.0,
"min": 350416,
"max": 353368,
"samples": [
351572,
351268,
351108,
350416,
351656,
353368,
352256,
351556,
350964,
352308
]
}
},
"cf837118": {
"phase_ms": {
"median": 505.124,
"p25": 435.315,
"p75": 1103.87,
"min": 417.056,
"max": 1875.147,
"samples": [
1875.147,
1129.722,
1066.305,
1116.391,
461.019,
423.38,
417.056,
445.353,
549.229,
431.969
]
},
"total_ms": {
"median": 1209.375,
"p25": 895.862,
"p75": 2133.144,
"min": 872.434,
"max": 2936.234,
"samples": [
2936.234,
2215.538,
1885.964,
2695.131,
1378.495,
879.351,
897.968,
895.16,
1040.255,
872.434
]
},
"cpu_ms": {
"median": 1068.41,
"p25": 896.926,
"p75": 1488.578,
"min": 882.398,
"max": 1570.652,
"samples": [
1530.601,
1520.851,
1391.76,
1570.652,
1116.403,
900.779,
894.039,
895.642,
1020.417,
882.398
]
},
"peak_rss_kib": {
"median": 273832.0,
"p25": 273592.0,
"p75": 274131.0,
"min": 272732,
"max": 274976,
"samples": [
274080,
272732,
273560,
273468,
274200,
274148,
273728,
273936,
274976,
273688
]
}
},
"cd61e11b": {
"phase_ms": {
"median": 429.481,
"p25": 410.043,
"p75": 1178.091,
"min": 399.576,
"max": 1644.881,
"samples": [
1194.624,
1128.491,
1644.881,
1428.78,
405.544,
434.62,
424.341,
414.829,
408.447,
399.576
]
},
"total_ms": {
"median": 902.865,
"p25": 868.889,
"p75": 2443.447,
"min": 827.16,
"max": 2961.261,
"samples": [
2682.056,
2199.278,
2524.837,
2961.261,
884.838,
885.282,
848.79,
863.573,
920.448,
827.16
]
},
"cpu_ms": {
"median": 920.917,
"p25": 869.777,
"p75": 1475.629,
"min": 840.036,
"max": 1626.459,
"samples": [
1518.201,
1496.456,
1413.149,
1626.459,
893.762,
899.049,
861.782,
861.417,
942.785,
840.036
]
},
"peak_rss_kib": {
"median": 267762.0,
"p25": 267612.0,
"p75": 267966.0,
"min": 266300,
"max": 268636,
"samples": [
266672,
268636,
266300,
267596,
267660,
268280,
267968,
267680,
267960,
267844
]
}
},
"current": {
"phase_ms": {
"median": 460.489,
"p25": 429.004,
"p75": 986.935,
"min": 404.103,
"max": 1300.033,
"samples": [
992.891,
1126.098,
1300.033,
969.067,
443.604,
408.397,
472.429,
424.137,
448.549,
404.103
]
},
"total_ms": {
"median": 935.623,
"p25": 874.18,
"p75": 2282.679,
"min": 839.642,
"max": 2965.198,
"samples": [
2320.566,
2169.019,
2965.198,
2531.591,
924.347,
839.642,
946.899,
873.617,
875.87,
854.328
]
},
"cpu_ms": {
"median": 950.702,
"p25": 892.992,
"p75": 1540.918,
"min": 861.669,
"max": 1586.478,
"samples": [
1557.765,
1503.564,
1553.369,
1586.478,
931.391,
861.669,
970.013,
888.14,
907.549,
868.73
]
},
"peak_rss_kib": {
"median": 268010.0,
"p25": 267013.0,
"p75": 268385.0,
"min": 266144,
"max": 268876,
"samples": [
266824,
267580,
266516,
266144,
267976,
268304,
268044,
268480,
268412,
268876
]
},
"behaviour_json_ms": {
"median": 34.288,
"p25": 23.085,
"p75": 37.474,
"min": 20.137,
"max": 70.397,
"samples": [
37.07,
70.397,
37.609,
38.225,
34.568,
21.094,
23.469,
22.957,
34.008,
20.137
]
},
"facts_ms": {
"median": 272.839,
"p25": 256.553,
"p75": 618.472,
"min": 248.146,
"max": 780.388,
"samples": [
625.908,
693.735,
780.388,
596.165,
272.718,
249.954,
272.959,
264.913,
253.767,
248.146
]
},
"behaviours_ms": {
"median": 162.633,
"p25": 133.345,
"p75": 326.117,
"min": 132.828,
"max": 476.9,
"samples": [
325.198,
356.28,
476.9,
326.424,
132.828,
133.946,
167.929,
133.144,
157.337,
132.934
]
},
"post_rss_kib": {
"median": 268408.0,
"p25": 267369.0,
"p75": 268861.0,
"min": 267052.0,
"max": 269072.0,
"samples": [
267084.0,
268056.0,
267052.0,
267140.0,
268184.0,
268632.0,
268660.0,
269072.0,
268928.0,
268960.0
]
}
}
}
}
@@ -1,188 +0,0 @@
{
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
"resources": ".local/horizon-client/26.3",
"repeats": 5,
"comparison": "historical cf837118 release binary",
"reference_binary": "target/release/examples/shared_json_startup_profile-cf837118",
"metrics": {
"baseline": {
"phase_ms": {
"median": 1462.003,
"samples": [
1639.999,
1506.864,
1429.844,
1462.003,
1346.061
]
},
"total_ms": {
"median": 1922.795,
"samples": [
3383.516,
1990.554,
1912.354,
1922.795,
1777.912
]
},
"cpu_ms": {
"median": 1961.459,
"samples": [
2419.086,
2021.457,
1951.587,
1961.459,
1839.984
]
},
"peak_rss_kib": {
"median": 820912,
"samples": [
819956,
821052,
820912,
821792,
820424
]
}
},
"pr607": {
"phase_ms": {
"median": 486.715,
"samples": [
460.738,
442.013,
623.053,
486.715,
550.652
]
},
"total_ms": {
"median": 948.089,
"samples": [
923.057,
935.599,
1109.64,
948.089,
1052.546
]
},
"cpu_ms": {
"median": 968.424,
"samples": [
928.489,
946.932,
1128.431,
968.424,
1079.642
]
},
"peak_rss_kib": {
"median": 351796,
"samples": [
351796,
351844,
351536,
351532,
351988
]
}
},
"cf837118": {
"phase_ms": {
"median": 474.268,
"samples": [
443.122,
474.268,
520.497,
433.525,
532.058
]
},
"total_ms": {
"median": 945.87,
"samples": [
934.722,
945.87,
1006.539,
888.201,
1032.6
]
},
"cpu_ms": {
"median": 953.668,
"samples": [
941.957,
953.668,
1029.122,
903.574,
1065.025
]
},
"peak_rss_kib": {
"median": 274476,
"samples": [
273448,
274844,
274476,
274556,
274252
]
}
},
"current": {
"phase_ms": {
"median": 445.924,
"samples": [
472.165,
427.797,
419.288,
445.924,
524.211
]
},
"total_ms": {
"median": 999.669,
"samples": [
999.669,
892.616,
1051.427,
940.595,
1112.158
]
},
"cpu_ms": {
"median": 1001.24,
"samples": [
1001.24,
887.874,
1051.732,
951.495,
1132.208
]
},
"peak_rss_kib": {
"median": 267604,
"samples": [
267716,
267920,
267448,
267604,
267344
]
}
}
},
"builds": {
"current": "release example built at cf837118 + current uncommitted delta in PR #607 worktree",
"historical": "release example built at exact cf837118 commit, with same Cargo release profile and shared dependency target"
},
"mode_commits": {
"baseline": "independent loader algorithm at e422bd13, executed in current binary",
"pr607": "shared early construction algorithm at a01d47f2, executed in current binary",
"cf837118": "exact historical binary at cf837118, deferred mode",
"current": "current PR worktree, deferred mode with interned strings"
},
"limitations": "Loader-only (Assets + BlockFacts + Behaviours), no full client startup or first-frame timing. Same release profile but different compilation contexts for historical binary; hardware activity and cache warmth vary. CPU and elapsed differences between cf837118 and current are mixed and may be noise. Peak RSS is whole-process Linux ru_maxrss."
}
@@ -1,93 +0,0 @@
{
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
"resources": ".local/horizon-client/26.3",
"repeats": 5,
"metrics": {
"baseline": {
"phase_ms": {
"median": 4730.402,
"samples": [
3258.541,
6539.764,
4773.735,
3980.032,
4730.402
]
},
"total_ms": {
"median": 6325.026,
"samples": [
6596.141,
7627.893,
6296.443,
5435.408,
6325.026
]
},
"cpu_ms": {
"median": 3199.722,
"samples": [
3322.801,
3199.722,
3227.438,
3042.687,
3144.658
]
},
"peak_rss_kib": {
"median": 820000,
"samples": [
818540,
820400,
819352,
820000,
820128
]
}
},
"shared": {
"phase_ms": {
"median": 1180.588,
"samples": [
869.228,
842.213,
1180.588,
1560.087,
1728.575
]
},
"total_ms": {
"median": 2260.709,
"samples": [
1632.489,
1553.753,
2260.709,
2879.728,
3388.639
]
},
"cpu_ms": {
"median": 1603.393,
"samples": [
1568.288,
1432.106,
1603.393,
1604.423,
1619.02
]
},
"peak_rss_kib": {
"median": 351728,
"samples": [
353256,
353664,
351728,
350724,
351668
]
}
}
},
"build": "cargo build --release -p horizon-client --example shared_json_startup_profile",
"limitations": "Loader-only wall-clock timings under concurrent machine load; not full startup or time to first frame. RSS includes Assets::load."
}
@@ -1,227 +0,0 @@
{
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
"resources": ".local/horizon-client/26.3",
"repeats": 5,
"metrics": {
"baseline": {
"phase_ms": {
"median": 1996.921,
"samples": [
2213.335,
1453.207,
1637.901,
1996.921,
2010.335
],
"range": [
1453.207,
2213.335
]
},
"total_ms": {
"median": 4728.136,
"samples": [
5628.975,
2147.329,
2162.696,
4728.136,
6404.524
],
"range": [
2147.329,
6404.524
]
},
"cpu_ms": {
"median": 2514.776,
"samples": [
2919.908,
2088.239,
2212.892,
2716.104,
2514.776
],
"range": [
2088.239,
2919.908
]
},
"peak_rss_kib": {
"median": 819824,
"samples": [
819824,
820872,
820744,
818980,
818816
]
}
},
"pr607": {
"phase_ms": {
"median": 586.707,
"samples": [
464.424,
739.67,
586.707,
1373.307,
509.266
],
"range": [
464.424,
1373.307
]
},
"total_ms": {
"median": 1128.6,
"samples": [
954.73,
1764.614,
1128.6,
4312.528,
1030.735
],
"range": [
954.73,
4312.528
]
},
"cpu_ms": {
"median": 1145.826,
"samples": [
981.058,
1465.79,
1145.826,
1736.807,
1056.992
],
"range": [
981.058,
1736.807
]
},
"peak_rss_kib": {
"median": 351900,
"samples": [
352164,
351816,
351900,
352348,
350492
]
}
},
"shared": {
"phase_ms": {
"median": 560.117,
"samples": [
522.284,
1255.75,
560.117,
2151.064,
479.329
],
"range": [
479.329,
2151.064
]
},
"total_ms": {
"median": 1101.305,
"samples": [
1011.715,
2565.449,
1101.305,
2960.186,
1006.817
],
"range": [
1006.817,
2960.186
]
},
"cpu_ms": {
"median": 1128.214,
"samples": [
1035.19,
1565.383,
1128.214,
1494.36,
1031.198
],
"range": [
1031.198,
1565.383
]
},
"peak_rss_kib": {
"median": 351532,
"samples": [
351624,
350540,
351532,
351716,
350076
]
}
},
"deferred": {
"phase_ms": {
"median": 564.046,
"samples": [
433.35,
665.149,
587.456,
564.046,
497.422
],
"range": [
433.35,
665.149
]
},
"total_ms": {
"median": 1143.129,
"samples": [
885.053,
1143.129,
1195.869,
1074.27,
1162.103
],
"range": [
885.053,
1195.869
]
},
"cpu_ms": {
"median": 1114.261,
"samples": [
910.946,
1128.96,
1194.638,
1114.261,
1073.823
],
"range": [
910.946,
1194.638
]
},
"peak_rss_kib": {
"median": 273776,
"samples": [
274320,
273860,
272548,
273776,
272520
]
}
}
},
"baseline_commit": "e422bd13",
"pr607_commit": "a01d47f2",
"measurement": "same optimized binary, isolated processes in alternating forward/reverse four-mode runs",
"limitations": "Includes Assets::load, BlockFacts, Behaviours; excludes complete client startup, pack/mod root resolution, graphics and first frame. Different modes emulate the original independent, PR #607 at a01d47f2, paired and deferred startup sequences in the same release build. Machine was not idle. Elapsed times show high variance; no statistically established gain between PR, paired and deferred modes."
}
@@ -1,453 +0,0 @@
{
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 8,
"ordering": [
[
"cf837118",
"cd61e11b",
"arc-key",
"reused-sounds",
"current"
],
[
"current",
"reused-sounds",
"arc-key",
"cd61e11b",
"cf837118"
],
[
"cf837118",
"cd61e11b",
"arc-key",
"reused-sounds",
"current"
],
[
"current",
"reused-sounds",
"arc-key",
"cd61e11b",
"cf837118"
],
[
"cf837118",
"cd61e11b",
"arc-key",
"reused-sounds",
"current"
],
[
"current",
"reused-sounds",
"arc-key",
"cd61e11b",
"cf837118"
],
[
"cf837118",
"cd61e11b",
"arc-key",
"reused-sounds",
"current"
],
[
"current",
"reused-sounds",
"arc-key",
"cd61e11b",
"cf837118"
]
],
"load_average_before": [
10.9873046875,
8.37646484375,
6.91015625
],
"load_average_after": [
8.6962890625,
8.1591796875,
6.90087890625
],
"executables": {
"cf837118": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
"mode": "deferred",
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
},
"cd61e11b": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
"mode": "deferred",
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
},
"arc-key": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-arcpool",
"mode": "deferred",
"sha256": "8cbb53d01afedf5c7a5da0e36c9f8c3569f34def9bc51488fe8699e7f615a131"
},
"reused-sounds": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-reusesounds",
"mode": "deferred",
"sha256": "f45794bfbdbb3f9b5d34c375d397191ac67984b3ae91268aa3fdd02f2c1b7b98"
},
"current": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-new",
"mode": "deferred",
"sha256": "84eb1baae8e138f52e300b0b44f1339fca38e70db6e06126e78db776ea3e5cd0"
}
},
"metrics": {
"cf837118": {
"phase_ms": {
"median": 424.082,
"p25": 417.508,
"p75": 430.923,
"min": 395.439,
"max": 948.609,
"samples": [
948.609,
425.085,
425.571,
417.945,
395.439,
416.197,
423.078,
446.98
]
},
"total_ms": {
"median": 853.026,
"p25": 839.421,
"p75": 867.731,
"min": 813.144,
"max": 1906.081,
"samples": [
1906.081,
861.641,
864.233,
844.411,
813.144,
830.783,
842.3,
878.226
]
},
"cpu_ms": {
"median": 876.143,
"p25": 862.934,
"p75": 891.4,
"min": 837.769,
"max": 1517.372,
"samples": [
1517.372,
885.13,
887.888,
865.404,
837.769,
855.526,
867.157,
901.937
]
},
"peak_rss_kib": {
"median": 273904.0,
"p25": 273717.0,
"p75": 274142.0,
"min": 273648,
"max": 274836,
"samples": [
273648,
273936,
273872,
274836,
274064,
274376,
273740,
273648
]
}
},
"cd61e11b": {
"phase_ms": {
"median": 422.356,
"p25": 406.294,
"p75": 425.846,
"min": 394.111,
"max": 472.536,
"samples": [
425.832,
420.666,
472.536,
425.886,
400.588,
394.111,
424.047,
408.196
]
},
"total_ms": {
"median": 852.475,
"p25": 828.474,
"p75": 876.413,
"min": 817.3,
"max": 951.587,
"samples": [
951.587,
865.442,
899.564,
868.696,
823.74,
817.3,
839.508,
830.052
]
},
"cpu_ms": {
"median": 875.04,
"p25": 850.012,
"p75": 900.57,
"min": 837.672,
"max": 938.285,
"samples": [
938.285,
888.533,
926.846,
891.812,
845.956,
837.672,
861.548,
851.364
]
},
"peak_rss_kib": {
"median": 268086.0,
"p25": 267719.0,
"p75": 268486.0,
"min": 267444,
"max": 268920,
"samples": [
267776,
268660,
267752,
268920,
268428,
268396,
267444,
267620
]
}
},
"arc-key": {
"phase_ms": {
"median": 421.422,
"p25": 401.781,
"p75": 451.774,
"min": 394.041,
"max": 472.337,
"samples": [
446.155,
472.337,
468.632,
420.636,
400.91,
394.041,
422.207,
402.072
]
},
"total_ms": {
"median": 866.601,
"p25": 822.563,
"p75": 918.038,
"min": 817.092,
"max": 1001.287,
"samples": [
1001.287,
918.457,
917.898,
892.666,
817.092,
824.225,
840.536,
817.576
]
},
"cpu_ms": {
"median": 887.84,
"p25": 844.533,
"p75": 937.331,
"min": 838.687,
"max": 991.212,
"samples": [
991.212,
949.048,
933.425,
911.921,
838.687,
845.021,
863.759,
843.069
]
},
"peak_rss_kib": {
"median": 267728.0,
"p25": 267598.0,
"p75": 268039.0,
"min": 267452,
"max": 268636,
"samples": [
268264,
267616,
267840,
267964,
268636,
267452,
267580,
267604
]
}
},
"reused-sounds": {
"phase_ms": {
"median": 421.683,
"p25": 412.913,
"p75": 439.098,
"min": 395.338,
"max": 487.446,
"samples": [
487.446,
434.111,
402.815,
416.279,
424.524,
454.058,
395.338,
418.842
]
},
"total_ms": {
"median": 879.846,
"p25": 845.021,
"p75": 901.166,
"min": 823.721,
"max": 1013.539,
"samples": [
1013.539,
892.033,
928.565,
884.284,
847.853,
875.407,
823.721,
836.527
]
},
"cpu_ms": {
"median": 894.546,
"p25": 871.499,
"p75": 921.844,
"min": 844.891,
"max": 1028.688,
"samples": [
1028.688,
913.48,
946.935,
903.785,
868.684,
885.308,
844.891,
872.438
]
},
"peak_rss_kib": {
"median": 267960.0,
"p25": 267678.0,
"p75": 268133.0,
"min": 267212,
"max": 268404,
"samples": [
267212,
267888,
268404,
267692,
268096,
267636,
268244,
268032
]
}
},
"current": {
"phase_ms": {
"median": 411.403,
"p25": 406.814,
"p75": 452.869,
"min": 391.768,
"max": 532.402,
"samples": [
532.402,
442.563,
408.857,
410.202,
483.788,
391.768,
400.683,
412.604
]
},
"total_ms": {
"median": 905.017,
"p25": 851.488,
"p75": 937.934,
"min": 818.206,
"max": 1075.445,
"samples": [
1075.445,
878.155,
837.627,
955.655,
932.027,
818.206,
856.108,
931.88
]
},
"cpu_ms": {
"median": 922.9,
"p25": 874.849,
"p75": 956.282,
"min": 839.639,
"max": 1067.693,
"samples": [
1067.693,
900.18,
862.137,
970.356,
951.591,
839.639,
879.086,
945.62
]
},
"peak_rss_kib": {
"median": 268354.0,
"p25": 267889.0,
"p75": 268492.0,
"min": 267056,
"max": 268820,
"samples": [
268008,
268384,
268564,
268820,
267056,
267532,
268324,
268468
]
}
}
},
"experiment_note": "Exploratory variants (Arc-key pool, reused sounds, combined proposal) alongside cd61e11b; none gave a robust consistent improvement and all were reverted."
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "cargo-horizon-mod"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
serde_json = "1.0.151"
[lints]
workspace = true
-201
View File
@@ -1,201 +0,0 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
use horizon_mods::{
Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
};
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
let mut args: Vec<String> = std::env::args().skip(1).collect();
if args.first().is_some_and(|a| a == "horizon-mod") {
args.remove(0);
}
let result = match args.first().map(String::as_str) {
Some("new") => new(&args[1..]),
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// The value after `flag` in `args`.
fn flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
let i = args.iter().position(|a| a == flag)?;
args.get(i + 1).map(String::as_str)
}
fn new(args: &[String]) -> Result<(), String> {
let path = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("cargo horizon-mod new <path>")?;
let dir = Path::new(path);
if dir.exists() {
return Err(format!("{} already exists", dir.display()));
}
let base = dir
.file_name()
.and_then(|n| n.to_str())
.ok_or("the path needs a folder name")?;
let id: String = base
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
}
None => SDK_GIT.to_string(),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let files = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
];
for (file, text) in files {
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
}
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
println!(
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
dir.display()
);
Ok(())
}
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
fn build(release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(MANIFEST)
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.args([
"build",
"--lib",
"--target",
TARGET,
"--message-format=json-render-diagnostics",
]);
if release {
cmd.arg("--release");
}
let out = cmd
.stderr(Stdio::inherit())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!(
"cargo build failed (is the target installed? rustup target add {TARGET})"
));
}
let core = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|m| m["reason"] == "compiler-artifact")
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
.filter_map(|f| f.as_str().map(PathBuf::from))
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
fn run(args: &[String]) -> Result<(), String> {
let path = build(true)?;
let game = flag(args, "--game")
.map(str::to_string)
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
.unwrap_or_else(|| "horizon-client".into());
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
println!(
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
path.display()
);
let status = Command::new(&game)
.arg("--dev-mod")
.arg(&path)
.args(extra)
.status()
.map_err(|e| {
format!("{game}: {e} (pass --game <path to horizon-client> or set HORIZON_CLIENT)")
})?;
status
.success()
.then_some(())
.ok_or_else(|| format!("the game exited with {status}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
let path = dir.join("My-Mod");
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(
(manifest.id.as_str(), manifest.name.as_str()),
("my_mod", "My Mod")
);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
assert!(
new(&[path.display().to_string()]).is_err(),
"never overwrites"
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -1,14 +0,0 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-sdk = {sdk}
[profile.release]
opt-level = "s"
lto = true
@@ -1 +0,0 @@
/target
@@ -1,12 +0,0 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the SDK targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -1,19 +0,0 @@
use horizon_mod_sdk::{Mod, Registration, chat};
struct MyMod {
greeted: u32,
}
impl Mod for MyMod {
fn init() -> Result<(Self, Registration), String> {
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
}
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
self.greeted += 1;
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
Ok(None)
}
}
horizon_mod_sdk::export_mod!(MyMod);
+16 -34
View File
@@ -50,47 +50,29 @@ impl ModelLibrary {
/// A library that also knows `builtin/generated`, the parent of every
/// flat item model; the native client registers it as a special model.
pub fn with_item_generator(mut models: BTreeMap<String, CuboidModel>) -> Result<Self> {
models.insert(GENERATED_MODEL.to_owned(), Self::item_generator_model());
Self::new(models)
}
/// Register the item built-in while reusing already decoded resources.
pub fn with_shared_item_generator(
mut models: BTreeMap<String, Arc<CuboidModel>>,
) -> Result<Self> {
models.insert(
GENERATED_MODEL.to_owned(),
Arc::new(Self::item_generator_model()),
CuboidModel {
parent: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
"particle".to_owned(),
TextureSlot::Reference("layer0".to_owned()),
)]),
elements: None,
display: None,
},
);
Self::from_shared(models)
}
fn item_generator_model() -> CuboidModel {
CuboidModel {
parent: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
"particle".to_owned(),
TextureSlot::Reference("layer0".to_owned()),
)]),
elements: None,
display: None,
}
Self::new(models)
}
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
Self::from_models(
models
.into_iter()
.map(|(name, model)| (name, Arc::new(model))),
)
}
/// Build an immutable resolution view without copying model geometry.
pub fn from_shared(models: BTreeMap<String, Arc<CuboidModel>>) -> Result<Self> {
Self::from_models(models)
}
fn from_models(models: impl IntoIterator<Item = (String, Arc<CuboidModel>)>) -> Result<Self> {
let mut decoded = BTreeMap::new();
for (name, model) in models {
if decoded.insert(identifier(&name)?, model).is_some() {
if decoded
.insert(identifier(&name)?, Arc::new(model))
.is_some()
{
return Err(error("duplicate normalized model identifier"));
}
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "horizon-bots"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Load-test bots for Minecraft Java Edition 26.3 servers (docs/server.md, section 9)"
[dependencies]
horizon-protocol = { path = "../horizon-protocol" }
serde_json = "1.0.151"
[lints]
workspace = true
-589
View File
@@ -1,589 +0,0 @@
//! `horizon-bots`: load-test bots for any Minecraft Java Edition 26.3 server, vanilla, Paper or Horizon
//! (docs/server.md, section 9). Each bot logs in offline, goes through configuration as vanilla's client
//! does, answers keep-alives and pings, accepts teleports, acknowledges chunk batches, and moves at 20 Hz.
//!
//! Bots fly a little above the ground, so the server must give them creative mode (`gamemode=creative`
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
use horizon_protocol::{
PROTOCOL_VERSION,
buf::{Reader, Writer},
connection::{ConnWriter, Connection},
crypto::offline_uuid,
ids,
packets::{
Empty, configuration,
handshake::{self, Intention},
login::{self, ServerHello},
play,
},
};
use serde_json::json;
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
time::{Duration, Instant},
};
const HELP: &str = "\
horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline mode, creative)
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
--join-every SECONDS default 1
--seconds N how long to run after the first bot joins; default 60
--view-distance N what the bots ask for; default 10
--seed N default 12345
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--report FILE a JSON summary at the end
";
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Idle,
Walk,
Explore,
}
impl Behaviour {
fn name(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Walk => "walk",
Self::Explore => "explore",
}
}
}
struct Options {
host: String,
port: u16,
bots: usize,
behaviour: String,
join_every: f64,
seconds: u64,
view_distance: i8,
seed: u64,
name: String,
report: Option<String>,
}
fn options() -> Result<Options, String> {
let mut o = Options {
host: "127.0.0.1".into(),
port: 25565,
bots: 10,
behaviour: "mixed".into(),
join_every: 1.0,
seconds: 60,
view_distance: 10,
seed: 12345,
name: "Bot".into(),
report: None,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "-h" || arg == "--help" {
print!("{HELP}");
std::process::exit(0);
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
"--server" => {
let (host, port) = value.rsplit_once(':').unwrap_or((&value, "25565"));
o.host = host.into();
o.port = port.parse().map_err(|_| number("a port"))?;
}
"--bots" => o.bots = value.parse().map_err(|_| number("a number"))?,
"--behaviour" => o.behaviour = value,
"--join-every" => o.join_every = value.parse().map_err(|_| number("seconds"))?,
"--seconds" => o.seconds = value.parse().map_err(|_| number("seconds"))?,
"--view-distance" => o.view_distance = value.parse().map_err(|_| number("2 to 32"))?,
"--seed" => o.seed = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore or mixed",
o.behaviour
));
}
Ok(o)
}
/// What one bot is doing, shared between its reader and its ticks.
#[derive(Default)]
struct Bot {
online: AtomicBool,
chunks: AtomicU64,
/// Where the server last put the bot (a teleport), to start from.
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
struct Random(u64);
impl Random {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
}
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
let mut w = Writer::new();
w.varint(id);
write(&mut w);
w.data
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
/// Handshake, offline login and configuration; returns the connection in the play state.
fn join(o: &Options, name: &str) -> Result<Connection, String> {
let mut c = Connection::connect(&o.host, o.port, Duration::from_secs(10)).map_err(err)?;
c.send(
&handshake::Serverbound::Intention(Intention {
protocol: PROTOCOL_VERSION,
host: o.host.clone(),
port: o.port,
intent: handshake::LOGIN,
})
.encode(),
)
.map_err(err)?;
c.send(
&login::Serverbound::Hello(ServerHello {
name: name.into(),
uuid: offline_uuid(name),
})
.encode(),
)
.map_err(err)?;
loop {
let (id, body) = c.recv().map_err(err)?;
match login::Clientbound::decode(id, &body).map_err(err)? {
login::Clientbound::Compression(p) => c.set_compression(p.threshold),
login::Clientbound::LoginFinished(_) => {
c.send(&login::Serverbound::LoginAcknowledged(Empty).encode())
.map_err(err)?;
break;
}
login::Clientbound::Disconnect(p) => return Err(format!("refused: {}", p.reason_json)),
login::Clientbound::Hello(_) => {
return Err("the server is in online mode; bots need online-mode=false".into());
}
login::Clientbound::CustomQuery(p) => c
.send(
&login::Serverbound::CustomQueryAnswer(login::CustomQueryAnswer {
transaction: p.transaction,
payload: None,
})
.encode(),
)
.map_err(err)?,
login::Clientbound::CookieRequest(p) => c
.send(
&login::Serverbound::CookieResponse(login::CookieResponse {
key: p.key,
payload: None,
})
.encode(),
)
.map_err(err)?,
login::Clientbound::Unknown(..) => {}
}
}
use ids::configuration::clientbound as cb;
c.send(
&configuration::Serverbound::ClientInformation(configuration::ClientInformation::new(
o.view_distance,
))
.encode(),
)
.map_err(err)?;
loop {
let (id, body) = c.recv().map_err(err)?;
let reply = match id {
cb::SELECT_KNOWN_PACKS
| cb::KEEP_ALIVE
| cb::PING
| cb::CODE_OF_CONDUCT
| cb::COOKIE_REQUEST
| cb::DISCONNECT
| cb::FINISH_CONFIGURATION => {
match configuration::Clientbound::decode(id, &body).map_err(err)? {
configuration::Clientbound::SelectKnownPacks(p) => {
configuration::Serverbound::SelectKnownPacks(p)
}
configuration::Clientbound::KeepAlive(p) => {
configuration::Serverbound::KeepAlive(p)
}
configuration::Clientbound::Ping(p) => configuration::Serverbound::Pong(p),
configuration::Clientbound::CodeOfConduct(_) => {
configuration::Serverbound::AcceptCodeOfConduct(Empty)
}
configuration::Clientbound::CookieRequest(p) => {
configuration::Serverbound::CookieResponse(configuration::CookieResponse {
key: p.key,
payload: None,
})
}
configuration::Clientbound::Disconnect(_) => {
return Err("disconnected during configuration".into());
}
configuration::Clientbound::FinishConfiguration(_) => {
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
.map_err(err)?;
return Ok(c);
}
_ => continue,
}
}
_ => continue,
};
c.send(&reply.encode()).map_err(err)?;
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
loop {
let (id, body) = match reader.recv() {
Ok(packet) => packet,
Err(e) => {
if bot.online.swap(false, Ordering::SeqCst) {
*bot.error.lock().unwrap() = Some(e.to_string());
}
return;
}
};
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
Ok(v) => send(raw(sb::KEEP_ALIVE, |w| w.i64(v))),
Err(_) => Ok(()),
},
cb::PING => match r.i32() {
Ok(v) => send(raw(sb::PONG, |w| w.i32(v))),
Err(_) => Ok(()),
},
cb::LEVEL_CHUNK_WITH_LIGHT => {
bot.chunks.fetch_add(1, Ordering::Relaxed);
Ok(())
}
cb::CHUNK_BATCH_FINISHED => send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 25.0,
})
.encode(),
),
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
Ok(play::Clientbound::PlayerPosition(p)) => {
let mut at = bot.teleport.lock().unwrap();
let previous = at.unwrap_or([0.0; 3]);
// Relative flags: X 1, Y 2, Z 4.
let pos: [f64; 3] = std::array::from_fn(|i| {
if p.relatives & (1 << i) != 0 {
previous[i] + p.position[i]
} else {
p.position[i]
}
});
*at = Some(pos);
send(
play::Serverbound::AcceptTeleportation(play::AcceptTeleportation {
id: p.teleport,
x: pos[0],
y: pos[1],
z: pos[2],
yaw: p.yaw,
pitch: p.pitch,
})
.encode(),
)
}
_ => Ok(()),
},
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
format!(
"disconnected: {}",
String::from_utf8_lossy(&body)
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
)
} else {
"sent back to configuration".into()
});
return;
}
_ => Ok(()),
};
if sent.is_err() {
bot.online.store(false, Ordering::SeqCst);
return;
}
}
}
/// One bot: join, then move every 50 ms until `until`.
fn run_bot(
o: Arc<Options>,
index: usize,
behaviour: Behaviour,
bot: Arc<Bot>,
until: Arc<Mutex<Option<Instant>>>,
) {
let name = format!("{}{index}", o.name);
let asked = Instant::now();
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
*bot.error.lock().unwrap() = Some(e);
return;
}
};
let (reader, writer) = connection.split();
let writer = Arc::new(Mutex::new(writer));
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
thread::spawn(move || read(reader, writer, bot));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
let spawn = loop {
if let Some(at) = *bot.teleport.lock().unwrap() {
break at;
}
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
if bot.error.lock().unwrap().is_none() {
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
}
return;
}
thread::sleep(Duration::from_millis(20));
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + 24.0;
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
loop {
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|| !bot.online.load(Ordering::SeqCst)
{
break;
}
// A teleport from the server wins over where the bot thought it was.
if let Some(at) = bot.teleport.lock().unwrap().take() {
pos = at;
}
if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
match behaviour {
Behaviour::Idle => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
} else {
// Sprinting: 5.6 blocks a second.
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
}
}
Behaviour::Explore => {
// Flying fast in creative: about 22 blocks a second, which keeps new chunks coming.
pos[0] += heading.cos() * 1.1;
pos[2] += heading.sin() * 1.1;
}
}
}
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
if !ok {
bot.online.store(false, Ordering::SeqCst);
break;
}
next += Duration::from_millis(50);
let now = Instant::now();
if next > now {
thread::sleep(next - now);
} else if now - next > Duration::from_secs(1) {
next = now;
}
}
// Offline first, so the reader doesn't count the closed connection as a failure.
bot.online.store(false, Ordering::SeqCst);
writer.lock().unwrap().shutdown();
}
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
match mode {
"idle" => Behaviour::Idle,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
// mixed: of every five bots, three walk, one explores, one stands.
_ => match index % 5 {
0 | 2 | 4 => Behaviour::Walk,
1 => Behaviour::Explore,
_ => Behaviour::Idle,
},
}
}
fn main() {
let o = match options() {
Ok(o) => Arc::new(o),
Err(e) => {
eprintln!("horizon-bots: {e}");
std::process::exit(2);
}
};
let start = Instant::now();
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
let launcher = {
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
thread::spawn(move || {
let mut handles = Vec::new();
for (i, bot) in bots.iter().enumerate() {
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
let behaviour = behaviour_of(&o.behaviour, i);
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
thread::sleep(Duration::from_secs_f64(o.join_every));
}
handles
})
};
// The run lasts --seconds after the first bot is in.
let mut previous_chunks = 0;
let mut first = None;
loop {
thread::sleep(Duration::from_secs(1));
let online = bots
.iter()
.filter(|b| b.online.load(Ordering::SeqCst))
.count();
let chunks: u64 = bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum();
let failed = bots
.iter()
.filter(|b| b.error.lock().unwrap().is_some())
.count();
if first.is_none() && online > 0 {
first = Some(Instant::now());
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
}
println!(
"{}",
json!({
"t": start.elapsed().as_secs(),
"online": online,
"failed": failed,
"chunks": chunks,
"chunks_per_second": chunks - previous_chunks,
})
);
previous_chunks = chunks;
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
let all_failed = failed == o.bots;
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
*until.lock().unwrap() = Some(Instant::now());
break;
}
}
for handle in launcher.join().unwrap_or_default() {
let _ = handle.join();
}
let summary = json!({
"server": format!("{}:{}", o.host, o.port),
"bots": o.bots,
"behaviour": o.behaviour,
"seed": o.seed,
"view_distance": o.view_distance,
"seconds": o.seconds,
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
"chunks": bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum::<u64>(),
"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),
"chunks": b.chunks.load(Ordering::Relaxed),
"error": *b.error.lock().unwrap(),
})).collect::<Vec<_>>(),
});
if let Some(path) = &o.report {
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
eprintln!("horizon-bots: {path}: {e}");
}
}
let errors: Vec<String> = bots
.iter()
.enumerate()
.filter_map(|(i, b)| {
b.error
.lock()
.unwrap()
.clone()
.map(|e| format!("{}{i}: {e}", o.name))
})
.collect();
for e in errors.iter().take(10) {
eprintln!("horizon-bots: {e}");
}
std::process::exit(if errors.is_empty() { 0 } else { 1 });
}
+4 -11
View File
@@ -12,11 +12,7 @@ horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
horizon-mods = { path = "../horizon-mods" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
winit = "0.30.13"
pollster = "0.4"
@@ -26,11 +22,6 @@ png = "0.18"
sha2 = "0.10.9"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
toml = "1.1"
naga = { version = "30.0.1", features = ["wgsl-in"] }
# Java (OptiFine/Iris) shader packs, translated to WGSL.
horizon-shaderpack = { path = "../horizon-shaderpack" }
cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
@@ -46,8 +37,6 @@ arboard = { version = "3.6.1", default-features = false, features = ["wayland-da
zip = { version = "2", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input).
gilrs = "0.11"
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense" }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
@@ -61,3 +50,7 @@ rng-trace = ["horizon-core/rng-trace"]
[lints]
workspace = true
[dev-dependencies]
# Parses and validates the composed entity shader (already built for wgpu).
naga = { version = "30.0.1", features = ["wgsl-in"] }
@@ -131,7 +131,6 @@ fn main() {
let tick = TickContext {
player_underwater: ambience.was_underwater(),
player_removed: removed,
..TickContext::default()
};
engine.tick(false, &tick, &mut backend);
}
+5 -56
View File
@@ -4,8 +4,7 @@
use horizon_client::{
Result,
arrows::{
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, EntityHit, Pickup,
Touch,
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, Pickup, Touch,
},
assets::Assets,
blocks::BlockFacts,
@@ -13,11 +12,11 @@ use horizon_client::{
items::ItemStack,
mobs::Who,
motion::{LAVA, MotionResources, WATER},
projectiles::{Hit, Projectile, ProjectileKind},
projectiles::{Projectile, ProjectileKind},
world::World,
};
use horizon_core::{
geometry::{Aabb, Vec3},
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
use serde_json::Value;
@@ -90,16 +89,11 @@ fn main() -> Result<()> {
&ItemStack::new("minecraft:arrow", 1),
f["intangible"].as_bool().unwrap(),
));
// The entity in the way: its box and whether it takes the damage.
let mut target: Option<(Aabb, bool)> = None;
for op in f["ops"].as_array().unwrap() {
let mut events = Vec::new();
let mut touched = "-".to_string();
match op["mode"].as_str().unwrap() {
"tick" => {
// The game's part of an entity hit: the damage it deals
// (recorded after the tick's own events).
let mut hurt = None;
if !arrow.arrow.as_ref().unwrap().removed {
let no_rain = |_| false;
let view = ArrowScene {
@@ -112,35 +106,9 @@ fn main() -> Result<()> {
mob_griefing: true,
tnt_explodes: true,
};
let targets: Vec<_> = target.iter().map(|t| (Who::Mob(2), t.0)).collect();
let (_, hit) = arrows::tick(&mut arrow, &view, &targets);
if let (Hit::Entity { .. }, Some((_, takes))) = (hit, target) {
let mut st = arrow.arrow.take().unwrap();
let damage = arrow.arrow_damage(&mut st.random);
hurt = Some((st.events.len(), damage));
arrow.arrow = Some(st);
let outcome = if takes {
EntityHit::Hurt
} else {
EntityHit::Refused
};
arrows::finish_entity_hit(&mut arrow, &view, outcome);
}
arrows::tick(&mut arrow, &view, &[]);
}
let a = arrow.arrow.as_mut().unwrap();
let (drop_at, dropped) = (arrow.position, a.pickup_item.count);
let raised = std::mem::take(&mut a.events);
let count = raised.len();
for (i, event) in raised.into_iter().map(Some).chain([None]).enumerate() {
if let Some((at, damage)) = hurt
&& at == i
{
events.push(format!("hurt {:08x}", damage.to_bits()));
}
let Some(event) = event else {
debug_assert_eq!(i, count);
break;
};
for event in arrow.arrow.as_mut().unwrap().events.drain(..) {
match event {
ArrowEvent::Particle {
kind,
@@ -223,28 +191,9 @@ fn main() -> Result<()> {
},
ArrowEvent::SplashEvent => events.push("splash-event".into()),
ArrowEvent::ShootEvent => events.push("shoot-event".into()),
ArrowEvent::Drop => events.push(format!(
"drop {} {dropped}",
bits(Vec3::new(
drop_at.x,
drop_at.y + f64::from(0.1f32),
drop_at.z
))
)),
}
}
}
"target" => {
// EntityDimensions.scalable(0.6, 1.8).makeBoundingBox.
let at = vector(&op["position"]);
let w = f64::from(0.6f32) / 2.0;
let h = f64::from(1.8f32);
let b = Aabb::new(
Vec3::new(at.x - w, at.y, at.z - w),
Vec3::new(at.x + w, at.y + h, at.z + w),
);
target = Some((b, op["takes"].as_bool().unwrap()));
}
"block" => {
let p = &op["position"];
let mut state = assets
@@ -100,7 +100,6 @@ fn main() -> Result<()> {
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
@@ -1,104 +0,0 @@
//! Full production fact loading, including file reads, interning and temporary destruction.
//! cargo run --profile fast -p horizon-client --example block_facts_bench -- ROOT [SAMPLES]
//! Use SAMPLES=1 with an OS peak-RSS wrapper; `digest` checks all facts
//! and the shape sharing partition (including sharing between outlines and edges).
use horizon_client::blocks::BlockFacts;
use horizon_world::{DATA_VERSION, nbt::Tag, registry::BlockRegistry};
use serde_json::Value;
use std::{
collections::{HashMap, hash_map::DefaultHasher},
fs,
hash::{Hash, Hasher},
hint::black_box,
path::Path,
time::Instant,
};
type Result<T> = horizon_client::Result<T>;
fn tag(value: &Value) -> Result<Tag> {
Ok(match value {
Value::String(s) => Tag::String(s.as_str().into()),
Value::Number(n) => Tag::Int(i32::try_from(n.as_i64().ok_or("registry number")?)?),
Value::Array(a) => Tag::List(a.iter().map(tag).collect::<Result<_>>()?),
Value::Object(o) => Tag::Compound(
o.iter()
.map(|(k, v)| Ok((k.as_str().into(), tag(v)?)))
.collect::<Result<_>>()?,
),
_ => return Err("registry value".into()),
})
}
fn registry(root: &Path) -> Result<BlockRegistry> {
let facts: Value = serde_json::from_slice(&fs::read(root.join("facts.json"))?)?;
Ok(BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(
facts["states"]
.as_array()
.ok_or("registry states")?
.iter()
.map(|s| tag(&s[4]))
.collect::<Result<_>>()?,
),
),
("defaults", tag(&facts["defaults"])?),
]))?)
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let root = Path::new(
args.first()
.ok_or("usage: block_facts_bench ROOT [SAMPLES|digest]")?,
);
let setup = Instant::now();
let registry = registry(root)?;
println!(
"registry_setup_ms={:.3}",
setup.elapsed().as_secs_f64() * 1000.0
);
if args.get(1).is_some_and(|s| s == "digest") {
let facts = BlockFacts::load(root, &registry)?;
let mut hash = DefaultHasher::new();
let mut shapes = HashMap::new();
facts.air.hash(&mut hash);
for state in &facts.states {
format!("{state:?}").hash(&mut hash);
for shape in [&state.outline, &state.outline_edges] {
let next = shapes.len();
shapes
.entry((shape.as_ptr(), shape.len()))
.or_insert(next)
.hash(&mut hash);
}
}
println!(
"states={} shared_shapes={} digest={:016x}",
facts.states.len(),
shapes.len(),
hash.finish()
);
return Ok(());
}
let samples: usize = args.get(1).map(|s| s.parse()).transpose()?.unwrap_or(9);
assert!(samples > 0);
// First load is reported separately; OS file caches are not forcibly evicted.
let load = || -> Result<f64> {
let start = Instant::now();
let facts = black_box(BlockFacts::load(black_box(root), black_box(&registry))?);
let ms = start.elapsed().as_secs_f64() * 1000.0;
drop(facts);
Ok(ms)
};
println!("first_load_ms={:.3}", load()?);
if samples > 1 {
load()?;
load()?;
let mut times = (0..samples).map(|_| load()).collect::<Result<Vec<_>>>()?;
println!("samples_ms={times:.3?}");
times.sort_by(f64::total_cmp);
println!("median_ms={:.3}", times[times.len() / 2]);
}
Ok(())
}
@@ -139,7 +139,6 @@ fn main() -> Result<()> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
cull_leaves: false,
};
let mut best = f64::MAX;
let mut quads = 0usize;
@@ -1,33 +0,0 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
#[allow(dead_code)]
#[path = "../src/blocks/decode.rs"]
mod decode;
use std::{hint::black_box, path::Path, time::Instant};
// The included private decoder expects the parent crate Result alias.
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let root = args.next().ok_or("expected resource directory")?;
let mode = args.next().ok_or("expected validate or typed")?;
if mode != "validate" && mode != "typed" {
return Err("expected validate or typed".into());
}
let total = Instant::now();
let bytes = std::fs::read(Path::new(&root).join("gameplay-facts.json"))?;
let started = Instant::now();
if mode == "validate" {
let parsed: decode::Validate = serde_json::from_slice(&bytes)?;
black_box(parsed);
} else {
let parsed: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
black_box(parsed);
}
println!("phase_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0);
println!("total_ms={:.3}", total.elapsed().as_secs_f64() * 1000.0);
Ok(())
}
@@ -88,7 +88,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
seed: 0,
max_players: 8,
dedicated: false,
integrated: false,
},
);
let mut next_entity = 100u32;
+2 -11
View File
@@ -103,17 +103,8 @@ fn main() -> horizon_client::Result<()> {
}
_ => {}
}
let clicked = if op[2] == "QUICK_MOVE_ALL" {
// A Shift double click: lastQuickMoved is the op's fourth field.
let moved = parse_stack(op[3].as_str().unwrap_or("-"))?;
match moved {
Some(moved) => menu.quick_move_all(&env, slot as usize, &moved),
None => Ok(()),
}
} else {
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
menu.click(&env, slot, button, kind)
};
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
let clicked = menu.click(&env, slot, button, kind);
if let Some(f) = furnace.as_mut() {
let before = f.items[0].clone();
f.items = [0, 1, 2].map(|i| menu.slot(i).cloned());
@@ -31,7 +31,14 @@ fn main() -> horizon_client::Result<()> {
};
let s = sprites.mips[layer as usize];
writeln!(out, "== {name}")?;
for mip in horizon_client::mipmap::generate(base, size.trailing_zeros(), s) {
for mip in horizon_client::mipmap::generate(
base,
size.trailing_zeros(),
s.strategy,
s.alpha_cutoff_bias,
s.has_transparent,
s.item,
) {
write!(out, "{}x{}", mip.width, mip.height)?;
for row in mip.pixels.chunks(mip.width as usize) {
out.push(' ');
+5 -16
View File
@@ -19,7 +19,7 @@ use horizon_client::{
},
mobs::{
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
control::Operation, seen_grid::SeenGrid, spawning::LocalDifficulty,
control::Operation, spawning::LocalDifficulty,
},
world::World,
};
@@ -902,10 +902,6 @@ fn main() -> horizon_client::Result<()> {
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
"ridden" => {
m.extra.head = Some("minecraft:red_harness".into());
m.rider = Some(horizon_client::mobs::equine::Rider::default());
}
"noai" => m.no_ai = true,
// Warden.finalizeSpawn's own part.
"digcooldown" | "emerge" => horizon_client::mobs::warden::finalize(
@@ -1011,9 +1007,6 @@ fn main() -> horizon_client::Result<()> {
// The MobWorld every step sees, over a snapshot of the mobs.
macro_rules! mob_world {
($snapshot:expr) => {
mob_world!($snapshot, None)
};
($snapshot:expr, $grid:expr) => {
MobWorld {
teams: Default::default(),
world: &world,
@@ -1024,7 +1017,6 @@ fn main() -> horizon_client::Result<()> {
data: &data,
player: player.clone(),
others: $snapshot,
others_grid: $grid,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
game_time: world.game_time,
day_time: world.day_time,
@@ -1069,8 +1061,6 @@ fn main() -> horizon_client::Result<()> {
p.yaw = yaw;
}
let mut snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
// The snapshot by position, kept as the game keeps it.
let mut grid = SeenGrid::new(snapshot.iter().map(|e| e.bounds));
let w = mob_world!(&snapshot);
let mut events = Vec::new();
let mut heart_events = Vec::new();
@@ -1136,7 +1126,7 @@ fn main() -> horizon_client::Result<()> {
if mobs[i].removed {
continue;
}
let w = mob_world!(&snapshot, Some(&grid));
let w = mob_world!(&snapshot);
let m = &mut mobs[i];
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
@@ -1252,10 +1242,9 @@ fn main() -> horizon_client::Result<()> {
}
// ServerLevel ticks entities one after another: the ones
// after this mob see it as it is now.
if let Some(k) = snapshot.iter().position(|e| e.id == mobs[i].id) {
snapshot[k] = seen(&mobs[i]);
snapshot[k].alive &= !mobs[i].removed;
grid.update(k, snapshot[k].bounds);
if let Some(e) = snapshot.iter_mut().find(|e| e.id == mobs[i].id) {
*e = seen(&mobs[i]);
e.alive &= !mobs[i].removed;
}
}
for m in &mut mobs {
@@ -1,271 +0,0 @@
//! The Horizon side of a mod port's harness (tools/mod_port_harness.py,
//! docs/modpacks.md): builds the content root for carried-over mods and
//! writes what Horizon makes of every block state, drop, recipe and tag the
//! NeoForge oracle exported.
//!
//! cargo run -p horizon-client --example mod_port_probe -- PREPARED WORK OUT.json [MOD...]
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
use horizon_client::{
Result,
assets::Assets,
blocks::BlockFacts,
crafting::{Recipe, Recipes},
items::{ItemStack, Items},
mod_content::{self, ContentMod},
stations::StationRecipes,
};
use horizon_core::{geometry::Axis, random::LegacyRandom};
use horizon_world::chunk::BlockState;
use serde_json::{Map, Value, json};
use std::{collections::BTreeMap, fs, path::Path};
fn boxes(list: impl IntoIterator<Item = [f64; 6]>) -> Value {
let mut v: Vec<[f64; 6]> = list.into_iter().collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
json!(v)
}
fn sound(s: &horizon_client::blocks::Sounds) -> Value {
json!({"volume": s.volume, "pitch": s.pitch, "break": &*s.break_sound, "step": &*s.step,
"place": &*s.place, "hit": &*s.hit, "fall": &*s.fall})
}
fn tool(name: &str) -> Option<ItemStack> {
match name {
"hand" => None,
"silk_touch_pickaxe" => {
let mut pick = ItemStack::new("minecraft:diamond_pickaxe", 1);
pick.enchant("minecraft:silk_touch", 1);
Some(pick)
}
other => Some(ItemStack::new(&format!("minecraft:{other}"), 1)),
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [base, work, out, wanted @ ..] = args.as_slice() else {
return Err("usage: mod_port_probe PREPARED WORK OUT.json [MOD...]".into());
};
let (base, work) = (Path::new(base), Path::new(work));
let oracle: Value = serde_json::from_slice(&fs::read(work.join("oracle/facts.json"))?)?;
let mut mods = Vec::new();
for entry in fs::read_dir(work.join("mods"))?.flatten() {
let id = entry.file_name().to_string_lossy().into_owned();
let dir = entry.path().join("carry");
if !(wanted.is_empty() || wanted.contains(&id)) || !dir.is_dir() {
continue;
}
let package = horizon_mods::Package::read(&dir)?;
let content = horizon_mods::Content::read(&dir, &package.manifest)?;
mods.push(ContentMod {
id: package.manifest.id.clone(),
content,
});
}
mods.sort_by(|a, b| a.id.cmp(&b.id));
let root = work.join("horizon-root");
let began = std::time::Instant::now();
let step = |what: &str| {
let what = what.to_owned();
move |e: Box<dyn std::error::Error>| format!("{what}: {e}")
};
mod_content::build(base, &mods, &root).map_err(step("content root"))?;
let built = began.elapsed().as_secs_f64();
let assets = Assets::load(&root).map_err(step("assets"))?;
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).map_err(step("block facts"))?;
let items = Items::load(&root, registry, &facts).map_err(step("items"))?;
let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?;
let stations = StationRecipes::load(&root, &items);
let collisions =
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
let loaded = began.elapsed().as_secs_f64() - built;
let ours = |id: &str| {
id.split_once(':')
.is_some_and(|(ns, _)| mods.iter().any(|m| m.id == ns))
};
let tools: Vec<String> = serde_json::from_value(oracle["tools"].clone())?;
let mut blocks = Map::new();
for (name, block) in oracle["blocks"].as_object().ok_or("oracle: no blocks")? {
if !ours(name) {
continue;
}
let Some(default) = registry.default_state(name) else {
blocks.insert(name.clone(), Value::Null);
continue;
};
let mut states = Map::new();
for (key, o) in block["states"].as_object().into_iter().flatten() {
let properties: BTreeMap<String, String> = key
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
let descriptor = BlockState {
name: name.clone(),
properties,
};
let Some(id) = registry.id(&descriptor) else {
states.insert(key.clone(), Value::Null);
continue;
};
let f = facts.get(id);
let collision = collisions
.cached_shapes()
.cached_shape(id)
.ok()
.flatten()
.map(|s| {
boxes(s.boxes().iter().map(|b| {
[
b.min(Axis::X),
b.min(Axis::Y),
b.min(Axis::Z),
b.max(Axis::X),
b.max(Axis::Y),
b.max(Axis::Z),
]
}))
});
let mut row = json!({
"hardness": f.hardness,
"explosion_resistance": f.explosion_resistance,
"emission": f.emission,
"requires_tool": f.requires_tool,
"friction": f.friction,
"map_color_id": f.map_color,
"can_occlude": f.can_occlude,
"sound": sound(&f.sounds),
"outline": boxes(f.outline.iter().copied()),
"collision": collision,
"classes": f.classes.iter().map(|c| &**c).collect::<Vec<_>>(),
});
if o.get("drops").is_some() {
let mut drops = Map::new();
for t in &tools {
let mut random = LegacyRandom::new(0);
let held = tool(t);
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
for s in items.block_drops(registry, id, held.as_ref(), &mut random) {
*counts.entry(s.item.to_string()).or_default() += s.count;
}
drops.insert(t.clone(), json!(counts));
}
row["drops"] = Value::Object(drops);
}
states.insert(key.clone(), row);
}
let item = facts.get(default).item.to_string();
blocks.insert(
name.clone(),
json!({"states": states, "item": item, "item_known": items.items.contains_key(&*item)}),
);
}
// Vanilla's sounds, by block: the harness maps the oracle's version's
// sound events to this one's through them.
let mut vanilla_sounds = Map::new();
for name in oracle["blocks"]
.as_object()
.into_iter()
.flatten()
.map(|(k, _)| k)
{
if let Some(id) = name
.starts_with("minecraft:")
.then(|| registry.default_state(name))
.flatten()
{
vanilla_sounds.insert(name.clone(), sound(&facts.get(id).sounds));
}
}
let mut item_rows = Map::new();
for name in oracle["items"]
.as_object()
.into_iter()
.flatten()
.map(|(k, _)| k)
{
item_rows.insert(
name.clone(),
match items.items.get(name.as_str()) {
Some(i) => json!({"max_stack_size": i.max_stack, "block": i.block.map(|b| b.0)}),
None => Value::Null,
},
);
}
let sorted = |list: &[String]| {
let mut v = list.to_vec();
v.sort();
v.dedup();
v
};
let mut recipe_rows = Map::new();
for (name, recipe) in &recipes.crafting {
if !ours(name) {
continue;
}
let row = match recipe {
Recipe::Shaped { cells, result, .. } => json!({
"result": result.item.as_deref(), "count": result.count,
"ingredients": cells.iter().flatten().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
Recipe::Shapeless {
ingredients,
result,
} => json!({
"result": result.item.as_deref(), "count": result.count,
"ingredients": ingredients.iter().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
Recipe::Special(_) => json!({"special": true}),
};
recipe_rows.insert(name.clone(), row);
}
for c in recipes.cooking.iter().filter(|c| ours(&c.id)) {
recipe_rows.insert(
c.id.clone(),
json!({"result": &*c.result.item, "count": c.result.count,
"ingredients": [sorted(&c.ingredient.1)]}),
);
}
for s in stations.stonecutting.iter().filter(|s| ours(&s.id)) {
recipe_rows.insert(
s.id.clone(),
json!({"result": &*s.result.item, "count": s.result.count,
"ingredients": [sorted(&s.input.1)]}),
);
}
let mut block_tags = Map::new();
for (tag, members) in &items.block_tags {
let mut ids: Vec<&String> = members.iter().filter(|m| ours(m)).collect();
if !ids.is_empty() {
ids.sort();
block_tags.insert(tag.clone(), json!(ids));
}
}
let report = json!({
"states": registry.len(),
"build_seconds": built,
"load_seconds": loaded,
"blocks": blocks,
"items": item_rows,
"recipes": recipe_rows,
"block_tags": block_tags,
"vanilla_sounds": vanilla_sounds,
});
fs::write(out, serde_json::to_vec(&report)?)?;
println!(
"{out}: {} blocks, {} items, {} recipes; root built in {built:.2} s, loaded in {loaded:.2} s",
blocks.len(),
item_rows.len(),
recipe_rows.len()
);
Ok(())
}
@@ -1,129 +0,0 @@
//! Full block/assets + item startup. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
hash::{Hash, Hasher},
path::Path,
sync::atomic::{AtomicUsize, Ordering::Relaxed},
time::Instant,
};
struct CountingAllocator;
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
static LIVE: AtomicUsize = AtomicUsize::new(0);
// The allocator delegates every operation and layout unchanged to System.
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc(layout) };
if TRACK && !ptr.is_null() {
LIVE.fetch_add(layout.size(), Relaxed);
}
ptr
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc_zeroed(layout) };
if TRACK && !ptr.is_null() {
LIVE.fetch_add(layout.size(), Relaxed);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
if TRACK {
LIVE.fetch_sub(layout.size(), Relaxed);
}
unsafe { System.dealloc(ptr, layout) };
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
let out = unsafe { System.realloc(ptr, layout, size) };
if TRACK && !out.is_null() {
LIVE.fetch_sub(layout.size(), Relaxed);
LIVE.fetch_add(size, Relaxed);
}
out
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("usage: model_library_bench ROOT [--parity]")?;
let root = Path::new(&root);
let initial = LIVE.load(Relaxed);
let start = Instant::now();
let assets = Assets::load(root)?;
let block_ms = start.elapsed().as_secs_f64() * 1000.;
let block_retained = LIVE.load(Relaxed) - initial;
let start_items = Instant::now();
let items = ItemRenderer::load_with_library(root, assets.model_library.clone())?;
let item_ms = start_items.elapsed().as_secs_f64() * 1000.;
let total_ms = start.elapsed().as_secs_f64() * 1000.;
let retained = LIVE.load(Relaxed) - initial;
println!(
"block_ms={block_ms:.3} item_ms={item_ms:.3} total_ms={total_ms:.3} retained_bytes={retained} block_retained_bytes={block_retained}"
);
if std::env::args().any(|a| a == "--parity") {
let mut hash = std::collections::hash_map::DefaultHasher::new();
for id in 0..assets.registry.len() {
let id = horizon_world::registry::BlockStateId(id as u32);
for pos in [
horizon_core::position::BlockPos::new(0, 0, 0),
horizon_core::position::BlockPos::new(-17, 65, 31),
] {
if let Ok(selected) = assets.model_catalog.collect(id, pos) {
for (variant, model) in selected {
format!("{variant:?}").hash(&mut hash);
model.ambient_occlusion.hash(&mut hash);
for face in &model.faces {
format!("{:?}", face.quad).hash(&mut hash);
face.layer.hash(&mut hash);
face.opaque.hash(&mut hash);
face.translucent.hash(&mut hash);
}
}
}
}
}
let mut names = Vec::new();
for ns in std::fs::read_dir(root.join("assets"))? {
let ns = ns?;
let base = ns.path().join("models");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries {
let path = entry?.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "json") {
names.push(format!(
"{}:{}",
ns.file_name().to_string_lossy(),
path.strip_prefix(&base)?
.with_extension("")
.to_string_lossy()
.replace('\\', "/")
));
}
}
}
}
names.sort();
for name in &names {
format!("{:?}", items.bake(name)).hash(&mut hash);
}
println!(
"parity_models={} fingerprint={:016x}",
names.len(),
hash.finish()
);
}
std::hint::black_box((&assets, &items));
Ok(())
}
@@ -17,16 +17,13 @@
//! a `mobs::trial_spawner::TrialSpawner` with the game time advancing and
//! print its states, events, spawns and ejected items.
//!
//! Vault cases (`mode=vault`, tools/test_vault_parity.py) tick a
//! `vault::Vault` the same way, with players moving and using keys on it.
//!
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
assets::Assets,
biome::{Biomes, SpawnEntry},
blocks::BlockFacts,
collisions::{self, LookupCollisions, no_collision_empty},
items::{ItemStack, Items},
items::Items,
mobs::{
MobTypes, Species,
data::MobData,
@@ -38,12 +35,8 @@ use horizon_client::{
},
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
species::{self, Baby},
trial_spawner::{
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
TrialSpawnerLevel, TrialState,
},
trial_spawner::{TrialEvent, TrialPlayer, TrialSpawner, TrialSpawnerLevel, TrialState},
},
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
};
use horizon_core::{
geometry::Aabb,
@@ -122,14 +115,8 @@ struct World<'a> {
placed: RefCell<Vec<([i32; 3], BlockStateId)>>,
/// The game time (trial cases advance it).
time: Cell<i64>,
/// The mobs a trial spawner added: UUID, position, height and box.
trial_mobs: RefCell<Vec<([i32; 4], [f64; 3], f32, Aabb)>>,
/// How many mobs a trial spawner added (their UUIDs count up).
trial_count: Cell<i32>,
/// Each player's omen and its amplifier (`/trial_omen:A`, `/bad_omen:A`).
omens: RefCell<Vec<Option<(Omen, i32)>>>,
/// Loot tables (trial cases).
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
/// The mobs a trial spawner added: UUID, position and box.
trial_mobs: RefCell<Vec<([i32; 4], [f64; 3], Aabb)>>,
}
impl World<'_> {
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
@@ -391,16 +378,6 @@ impl TrialSpawnerLevel for World<'_> {
eye: [p[0], p[1] + f64::from(1.62f32), p[2]],
creative: mode == "creative",
spectator: mode == "spectator",
position: *p,
height: 1.8,
alive: true,
omen: self
.omens
.borrow()
.get(i)
.copied()
.flatten()
.map(|(o, _)| o),
})
.collect()
}
@@ -423,38 +400,9 @@ impl TrialSpawnerLevel for World<'_> {
}
fn entity_block(&self, uuid: [i32; 4]) -> Option<[i32; 3]> {
let mobs = self.trial_mobs.borrow();
let (_, p, ..) = mobs.iter().find(|(u, ..)| *u == uuid)?;
let (_, p, _) = mobs.iter().find(|(u, ..)| *u == uuid)?;
Some(p.map(|v| v.floor() as i32))
}
fn entity_box(&self, uuid: [i32; 4]) -> Option<([f64; 3], f32)> {
let mobs = self.trial_mobs.borrow();
let (_, p, h, _) = mobs.iter().find(|(u, ..)| *u == uuid)?;
Some((*p, *h))
}
fn no_block_collision(&self, block: [i32; 3]) -> bool {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
let mut scene = LookupCollisions {
block: &lookup,
binding: self.s.collision,
};
let state = self.state(block[0], block[1], block[2]);
self.s
.collision
.collision_shape(&mut scene, state, block)
.map_or(true, |shape| shape.is_empty())
}
fn loot_items(&self, table: &str, seed: i64) -> Vec<horizon_client::items::ItemStack> {
let Some(loot) = &self.loot else {
return Vec::new();
};
loot.roll_split(table, &mut LegacyRandom::new(seed))
.iter()
.map(horizon_client::structure_blocks::loot_item_with_components)
.collect()
}
fn seed(&self) -> i64 {
self.seed
}
}
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
@@ -524,7 +472,7 @@ fn main() -> horizon_client::Result<()> {
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
// Trial spawner rewards: the loot tables and the configs.
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
let loot = if input.contains("mode=trial") {
horizon_client::mobs::trial_spawner::load_registry(root);
horizon_client::structure_blocks::loot_data(root)
} else {
@@ -633,19 +581,6 @@ fn main() -> horizon_client::Result<()> {
bounds: Bx(b),
});
}
// A player's third field: an omen and its amplifier.
let omens = indexed("p")
.iter()
.map(|p| {
let (effect, amplifier) = p.split('/').nth(2)?.split_once(':')?;
let omen = if effect == "trial_omen" {
Omen::Trial
} else {
Omen::Bad
};
Some((omen, amplifier.parse().ok()?))
})
.collect();
let players = indexed("p")
.into_iter()
.map(|p| {
@@ -732,9 +667,6 @@ fn main() -> horizon_client::Result<()> {
placed: RefCell::new(Vec::new()),
time: Cell::new(0),
trial_mobs: RefCell::new(Vec::new()),
trial_count: Cell::new(0),
omens: RefCell::new(omens),
loot: loot.clone(),
entities: RefCell::new(
case_entities
.iter()
@@ -774,9 +706,6 @@ fn main() -> horizon_client::Result<()> {
} else if mode == "trial" {
let loot = loot.as_ref().ok_or("no loot tables")?;
trial_case(&world, registry, loot, &get, &mut random, &mut log)?;
} else if mode == "vault" {
let loot = loot.as_ref().ok_or("no loot tables")?;
vault_case(world.players.clone(), loot, &get, &mut random, &mut log)?;
} else if mode == "gen" {
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
// The level random of the spawn predicates: no creature's predicate
@@ -1043,22 +972,8 @@ fn trial_case(
let scale = size as f32;
let bounds = spawn_box(x, y, z, t.width as f32 * scale, t.height as f32 * scale);
l.entities.borrow_mut().push((r.id.to_owned(), bounds));
let uuid = [1, 0, 0, l.trial_count.get()];
l.trial_count.set(l.trial_count.get() + 1);
// Zombie.readAdditionalSaveData: IsBaby halves the box
// (getAgeScale); finalizeSpawn's fixed random never makes one.
let baby = match r
.entity
.get(&horizon_world::nbt::JavaString::from("IsBaby"))
{
Some(Tag::Byte(v)) => *v != 0,
Some(Tag::Int(v)) => *v != 0,
_ => false,
};
let height = t.height as f32 * scale * if baby { 0.5 } else { 1.0 };
l.trial_mobs
.borrow_mut()
.push((uuid, r.position, height, bounds));
let uuid = [1, 0, 0, l.trial_mobs.borrow().len() as i32];
l.trial_mobs.borrow_mut().push((uuid, r.position, bounds));
spawned.push_back(format!(
"{tick} spawn {} {} {} {} {}",
r.id,
@@ -1113,7 +1028,7 @@ fn trial_case(
for e in events {
match e {
TrialEvent::BlockUpdated => log.push(format!("{tick} update")),
TrialEvent::LevelEvent(id, p, data) => {
TrialEvent::LevelEvent(id, _, data) => {
// A spawn's events follow its tryAddFreshEntityWithPassengers,
// a reward's its items.
match id {
@@ -1121,13 +1036,7 @@ fn trial_case(
3014 => log.append(&mut items),
_ => {}
}
match id {
3020 | 3012 => log.push(format!(
"{tick} levent {id} {data} at {},{},{}",
p[0], p[1], p[2]
)),
_ => log.push(format!("{tick} levent {id} {data}")),
}
log.push(format!("{tick} levent {id} {data}"));
}
TrialEvent::Sound(name) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} 1.0 1.0",
@@ -1139,44 +1048,6 @@ fn trial_case(
"{tick} gevent minecraft:entity_place {} {} {}",
p[0], p[1], p[2]
)),
TrialEvent::TrialOmen(uuid) => {
let i = (uuid[3] - 1) as usize;
let mut omens = world.omens.borrow_mut();
let amplifier = omens.get(i).copied().flatten().map_or(0, |(_, a)| a);
log.push(format!("{tick} effect remove minecraft:bad_omen"));
log.push(format!(
"{tick} effect add minecraft:trial_omen {} 0",
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1)
));
if let Some(o) = omens.get_mut(i) {
*o = Some((Omen::Trial, 0));
}
}
TrialEvent::Discard(uuid) => {
let mut mobs = world.trial_mobs.borrow_mut();
if let Some(i) = mobs.iter().position(|(u, ..)| *u == uuid) {
let (.., b) = mobs.remove(i);
world.entities.borrow_mut().retain(|(_, e)| *e != b);
}
}
TrialEvent::SoundAt(name, b, pitch) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} 1.0 {pitch:?}",
bits(f64::from(b[0]) + 0.5),
bits(f64::from(b[1]) + 0.5),
bits(f64::from(b[2]) + 0.5)
)),
TrialEvent::ItemSpawner {
item,
position,
delay,
} => log.push(format!(
"{tick} ispawner {} {} {} {} {} {delay}",
item.item,
item.count,
bits(position[0]),
bits(position[1]),
bits(position[2])
)),
TrialEvent::OminousSet(set) => {
ominous = set;
world
@@ -1230,195 +1101,3 @@ fn trial_case(
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
Ok(())
}
/// What a vault reads: the game time and the case's players, moved by `move`.
struct VaultWorld {
time: i64,
players: Vec<([f64; 3], String)>,
}
impl VaultLevel for VaultWorld {
fn game_time(&self) -> i64 {
self.time
}
fn players(&self) -> Vec<VaultPlayer> {
self.players
.iter()
.enumerate()
.map(|(i, (p, mode))| VaultPlayer {
uuid: [0, 0, 0, i as i32 + 1],
block: p.map(|v| v.floor() as i32),
spectator: mode == "spectator",
})
.collect()
}
}
/// A UUID's low 64 bits, as the oracle prints players.
fn uuid_low(u: &[i32; 4]) -> String {
((i64::from(u[2]) << 32) | i64::from(u[3] as u32)).to_string()
}
/// The display item and the connected players as the oracle prints them.
fn vault_shared(v: &Vault) -> String {
let mut connected: Vec<String> = v.connected_players.iter().map(uuid_low).collect();
connected.sort();
format!(
"{} [{}]",
v.display_item
.as_ref()
.map_or_else(|| "-".to_owned(), |s| format!("{}*{}", s.item, s.count)),
connected.join(",")
)
}
/// `VaultBlockEntity.Server.tick` `ticks` times from game time `t0` at `spos`
/// on a vault block in state `state` (ominous with `ominous=1`) loaded from
/// `snbt` (base64 SNBT), with the case's key uses (`use`) and player moves
/// (`move`) before each tick, as the oracle logs it.
fn vault_case(
players: Vec<([f64; 3], String)>,
loot: &horizon_world::loot::LootData,
get: &dyn Fn(&str, &'static str) -> String,
random: &mut LegacyRandom,
log: &mut Vec<String>,
) -> horizon_client::Result<()> {
let v: Vec<i32> = get("spos", "0,0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
let pos = [v[0], v[1], v[2]];
let mut state = VaultState::from_name(&get("state", "inactive")).ok_or("bad state")?;
let ominous = get("ominous", "0") == "1";
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
let mut vault = Vault::load(&tag);
let schedule = |key: &str| -> Vec<Vec<String>> {
get(key, "")
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.split('/').map(str::to_owned).collect())
.collect()
};
let (uses, moves) = (schedule("use"), schedule("move"));
let ticks: i64 = get("ticks", "1").parse()?;
let t0: i64 = get("t0", "0").parse()?;
let seed: i64 = get("wseed", "0").parse()?;
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
let mut world = VaultWorld { time: t0, players };
let center = pos.map(|v| f64::from(v) + 0.5);
for tick in 0..ticks {
world.time = t0 + tick;
let at = |s: &[String]| s[0].parse() == Ok(tick);
for m in moves.iter().filter(|m| at(m)) {
let v: Vec<f64> = m[2].split(',').map(|n| n.parse().unwrap_or(0.0)).collect();
world.players[m[1].parse::<usize>()?].0 = [v[0], v[1], v[2]];
}
let mut events = Vec::new();
let mut roll = |table: &str, random: &mut LegacyRandom, sequence: bool| {
let drops = match loot.random_sequence(table).filter(|_| sequence) {
Some(s) => loot.roll_split(table, sequences.get(s, seed)),
None => loot.roll_split(table, random),
};
drops
.iter()
.map(horizon_client::structure_blocks::loot_item_with_components)
.collect()
};
for u in uses.iter().filter(|u| at(u)) {
let player: usize = u[1].parse()?;
let mut stack = ItemStack::new(&u[2], u[3].parse()?);
let used = vault.use_item_on(
&world,
random,
pos,
state,
ominous,
[0, 0, 0, player as i32 + 1],
Some(&stack),
&mut roll,
&mut events,
);
if world.players[player].1 != "creative" {
stack.count -= used.unwrap_or(0);
}
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
log.push(format!(
"{tick} use {} {}",
if used.is_some() { "ok" } else { "pass" },
stack.count
));
}
vault.server_tick(&world, random, pos, state, ominous, &mut roll, &mut events);
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
}
let saved = vault.save();
let config = saved
.iter()
.find(|(k, _)| *k == "config")
.and_then(|(_, t)| match t {
Tag::Compound(c) => {
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
keys.sort();
Some(keys.join(","))
}
_ => None,
})
.unwrap_or_default();
let eject: Vec<String> = vault
.items_to_eject
.iter()
.map(|s| format!("{}*{}", s.item, s.count))
.collect();
log.push(format!(
"end state={} rewarded={} resumes={} eject={} total={} shared={} config={config}",
state.name(),
vault
.rewarded_players
.iter()
.map(uuid_low)
.collect::<Vec<_>>()
.join(","),
vault.state_updating_resumes_at,
eject.join(","),
vault.total_ejections_needed,
vault_shared(&vault),
));
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
Ok(())
}
/// Logs a vault's events and applies its state changes.
fn vault_events(
events: &mut Vec<VaultEvent>,
state: &mut VaultState,
ominous: bool,
tick: i64,
center: [f64; 3],
vault: &Vault,
log: &mut Vec<String>,
) {
for e in events.drain(..) {
match e {
VaultEvent::State(s) => {
*state = s;
log.push(format!("{tick} state {} {ominous}", s.name()));
}
VaultEvent::BlockUpdated => log.push(format!("{tick} update {}", vault_shared(vault))),
VaultEvent::LevelEvent(id, data) => log.push(format!("{tick} levent {id} {data}")),
VaultEvent::Sound(name, volume, pitch) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} {volume:?} {pitch:?}",
bits(center[0]),
bits(center[1]),
bits(center[2])
)),
VaultEvent::Eject(stack, v) => log.push(format!(
"{tick} item {} {} {} {} {} {} {} {}",
stack.item,
stack.count,
bits(center[0]),
bits(center[1] + 0.7 - 0.125),
bits(center[2]),
bits(v[0]),
bits(v[1]),
bits(v[2])
)),
}
}
}
@@ -1,10 +1,7 @@
#![forbid(unsafe_code)]
//! Loads and saves option files like `Options.load`/`save`, for the
//! differential harness (tools/test_options_parity.py).
use horizon_client::{
Result, keybinds,
options_file::{OptionsFile, V},
};
use horizon_client::{Result, keybinds, options_file::OptionsFile};
use std::{fs, io::Write};
fn main() -> Result<()> {
@@ -17,16 +14,11 @@ fn main() -> Result<()> {
let mut first = true;
for path in names {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let mut file = if name == "absent.txt" {
let file = if name == "absent.txt" {
OptionsFile::default()
} else {
OptionsFile::parse(&String::from_utf8_lossy(&fs::read(&path)?))
};
// Horizon extends the slider to 64; vanilla rejects 33..64 to its default.
// Compare that deliberate extension as vanilla would serialize it.
if file.int("renderDistance") > 32 {
file.set("renderDistance", V::Int(12));
}
write!(out, "== {name}\n{}", file.serialize())?;
if first {
first = false;
@@ -1,96 +0,0 @@
//! Block entity data on items for tools/test_pick_block_data_parity.py: reads
//! one JSON case per line (see VanillaPickBlockDataOracle) and prints the
//! picked item's component patch, a tab, then the placed block entity.
//!
//! usage: pick_block_data_fixture INPUT OUTPUT
use horizon_client::block_entity_item::{pick, place};
use horizon_client::block_entity_nbt::canonical;
use horizon_client::java_text::{double_to_string, float_to_string};
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::fs;
/// ParityData.canon: sorted keys, typed numbers, Java's float text.
fn canon(tag: &Tag, out: &mut String) {
let join = |out: &mut String, prefix: &str, items: Vec<String>| {
out.push_str(prefix);
out.push_str(&items.join(","));
out.push(']');
};
match tag {
Tag::Float(v) => out.push_str(&format!("{}f", float_to_string(f32::from_bits(*v)))),
Tag::Double(v) => out.push_str(&format!("{}d", double_to_string(f64::from_bits(*v)))),
Tag::List(items) => join(
out,
"[",
items
.iter()
.map(|t| {
let mut s = String::new();
canon(t, &mut s);
s
})
.collect(),
),
Tag::Compound(map) => {
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
keys.sort_by(|a, b| a.0.cmp(&b.0));
out.push('{');
for (i, (k, v)) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&k);
out.push(':');
canon(v, out);
}
out.push('}');
}
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
}
}
fn snbt(value: &Value) -> Option<Tag> {
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let saved = canonical(&snbt(&case["nbt"])?)?;
let picked = pick(&saved);
let patch = match case.get("patch") {
Some(p) => match snbt(p)? {
Tag::Compound(c) => c,
_ => return None,
},
None => picked.clone(),
};
let target = canonical(&snbt(&case["target"])?)?;
let Tag::Compound(mut placed) = place(&target, &patch, case["op"].as_bool()?) else {
return None;
};
// keepPacked is the chunk's, not saveWithFullMetadata's.
placed.remove(&"keepPacked".into());
let mut out = String::new();
canon(&Tag::Compound(picked), &mut out);
out.push('\t');
canon(&Tag::Compound(placed), &mut out);
Some(out)
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: pick_block_data_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -1,149 +0,0 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
fast_hash::FxHashMap,
random_tick_sections::RandomTickSections,
};
use horizon_world::registry::BlockStateId;
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
fn select_baseline(chunks: &FxHashMap<(i32, i32), Arc<Chunk>>, facts: &BlockFacts) -> usize {
let mut ticking = Vec::new();
for (&(x, z), chunk) in chunks {
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
continue;
}
let sections: Vec<i32> = chunk
.sections
.iter()
.enumerate()
.filter(|(_, s)| {
s.blocks
.maybe_has(|id| facts.get(BlockStateId(id)).random_ticks)
})
.map(|(i, _)| MIN_Y + i as i32 * 16)
.collect();
ticking.push((x, z, sections));
}
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
}
fn select_cached(
chunks: &FxHashMap<(i32, i32), Arc<Chunk>>,
facts: &BlockFacts,
cache: &mut RandomTickSections,
) -> usize {
let mut ticking = Vec::new();
for (&(x, z), chunk) in chunks {
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
continue;
}
ticking.push((x, z, cache.get((x, z), chunk, facts)));
}
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
}
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("expected prepared client resource directory")?;
let start = Instant::now();
let registry = horizon_world::level::data::load_registry(Path::new(&root))?;
let facts = BlockFacts::load(Path::new(&root), &registry)?;
println!(
"resource_load_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
let inactive: Vec<u16> = facts
.states
.iter()
.enumerate()
.filter(|(_, f)| !f.random_ticks)
.take(128)
.map(|(i, _)| i as u16)
.collect();
let active = registry.default_state("minecraft:grass_block").unwrap().0 as u16;
assert!(facts.get(BlockStateId(u32::from(active))).random_ticks);
for (name, palette) in [("sparse", 1), ("dense", 128)] {
let start = Instant::now();
let mut chunks = FxHashMap::default();
for z in -8..=8 {
for x in -8..=8 {
let mut chunk = Chunk::empty(x, z);
chunk.lit = true;
for (i, section) in chunk.sections.iter_mut().enumerate() {
let mut values = std::array::from_fn(|j| inactive[j % palette]);
if i % 3 == 0 {
values[4095] = active;
}
Arc::make_mut(section).blocks = Section::from_blocks(&values);
}
chunks.insert((x, z), Arc::new(chunk));
}
}
println!(
"{name} fixture_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
for (workload, changed) in [
("steady", 0),
("cold", 0),
("reload", 0),
("edit_1pct", 3),
("edit_all", 289),
] {
let keys: Vec<_> = chunks.keys().copied().collect();
let mut baseline = Vec::new();
let mut candidate = Vec::new();
let mut cache = RandomTickSections::default();
for sample in 0..8 {
// Alternate order; first pair warms both paths and is discarded.
for cached in if sample % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let start = Instant::now();
for tick in 0..300 {
if cached && workload == "cold" {
cache = RandomTickSections::default();
}
if cached && workload == "reload" {
cache.clear();
}
for key in keys.iter().take(changed) {
Arc::make_mut(chunks.get_mut(key).unwrap()).set(
0,
MIN_Y,
0,
inactive[tick % 2],
);
}
black_box(if cached {
select_cached(&chunks, &facts, &mut cache)
} else {
select_baseline(&chunks, &facts)
});
}
// Include teardown, and remove weak handles before baseline edits.
if cached {
cache = RandomTickSections::default();
}
if sample > 0 {
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
if cached {
candidate.push(us);
} else {
baseline.push(us);
}
}
}
}
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
}
}
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
Ok(())
}
@@ -2,11 +2,6 @@
#![forbid(unsafe_code)]
use horizon_client::{assets, collisions, world};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::{
collision_context::CollisionContext,
entity_obstruction::EntityIdentity,
world_movement::{self, CollisionCheckRequest, MovementRequest},
};
use serde_json::Value;
use std::{
fs,
@@ -62,55 +57,21 @@ fn main() -> Result<()> {
f["step"].as_str().ok_or("expected step bits")?,
16,
)?);
let entities = f["entities"]
.as_array()
.into_iter()
.flatten()
.map(|v| {
let a = v.as_array().ok_or("expected entity box")?;
Ok(Aabb::new(
vector(&Value::Array(a[..3].to_vec()))?,
vector(&Value::Array(a[3..].to_vec()))?,
))
})
.collect::<Result<Vec<_>>>()?;
let mut selected = collisions::EntityCollisions {
blocks: scene,
entities: &entities,
};
let movement = world_movement::resolve_world_movement(
&mut selected,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
bounds,
movement: delta,
max_step: step,
on_ground: f["grounded"].as_bool().ok_or("expected grounded flag")?,
candidate_budget: 1_000_000,
},
let movement = scene.resolve(
bounds,
delta,
step,
f["grounded"].as_bool().ok_or("expected grounded flag")?,
)?;
let mut unobstructed = |bounds: Aabb| {
world_movement::no_world_collision(
&mut selected,
CollisionCheckRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
bounds,
candidate_budget: 1_000_000,
},
)
};
writeln!(
output,
"move {:016x} {:016x} {:016x} clear {} {}",
movement.x.to_bits(),
movement.y.to_bits(),
movement.z.to_bits(),
unobstructed(bounds)?,
unobstructed(bounds.move_by(movement))?
scene.unobstructed(bounds)?,
scene.unobstructed(bounds.move_by(movement))?
)?;
scene = selected.blocks;
}
Ok(())
}
@@ -298,7 +298,6 @@ fn main() -> Result<()> {
let mut travel: Option<horizon_core::position::BlockPos> = None;
for op in f["ops"].as_array().unwrap() {
let mut world = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
@@ -134,7 +134,6 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
cull_leaves: false,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
@@ -1,74 +0,0 @@
//! Profile the fact and behaviour table phase of client startup.
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
fn resident_kib() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let line = status.lines().find(|line| line.starts_with("VmRSS:"))?;
line.split_whitespace().nth(1)?.parse().ok()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let root = args.next().ok_or("expected resource directory")?;
let mode = args
.next()
.ok_or("expected baseline, pr607, shared, deferred or deferred-held")?;
if !["baseline", "pr607", "shared", "deferred", "deferred-held"].contains(&mode.as_str()) {
return Err("expected baseline, pr607, shared, deferred or deferred-held".into());
}
let root = Path::new(&root);
let total = Instant::now();
let assets = Assets::load(root)?;
let started = Instant::now();
let mut retained_inputs = None;
let mut subphases = None;
let (facts, behaviours) = if mode == "baseline" {
(
BlockFacts::load(root, assets.registry)?,
Behaviours::load(assets.registry, root)?,
)
} else if mode == "pr607" {
// Original PR #607 sequence at a01d47f2, retained as a same-build comparator.
let json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))?,
))?;
BlockFacts::load_with_behaviours(root, assets.registry, &json)?
} else if mode == "shared" {
BlockFacts::load_pair(root, assets.registry)?
} else {
let parse_started = Instant::now();
let json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))?,
))?;
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
let facts_started = Instant::now();
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
let behaviours_started = Instant::now();
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
let behaviours_ms = behaviours_started.elapsed().as_secs_f64() * 1000.0;
subphases = Some((parse_ms, facts_ms, behaviours_ms));
if mode == "deferred-held" {
retained_inputs = Some((json, gameplay));
}
(facts, behaviours)
};
println!("phase_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0);
println!("total_ms={:.3}", total.elapsed().as_secs_f64() * 1000.0);
println!("states={}", facts.states.len());
if let Some((parse, facts, behaviours)) = subphases {
println!("behaviour_json_ms={parse:.3}");
println!("facts_ms={facts:.3}");
println!("behaviours_ms={behaviours:.3}");
}
if let Some(rss) = resident_kib() {
println!("post_rss_kib={rss}");
}
std::hint::black_box((facts, behaviours, retained_inputs));
Ok(())
}
@@ -5,7 +5,7 @@
mod common;
use common::{Fake, d, describe, f, flush, result_name};
use horizon_client::sound_engine::{
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
};
use horizon_client::sound_events::SoundRegistry;
use horizon_core::random::LegacyRandom;
@@ -19,9 +19,6 @@ fn main() {
let mut backend = Fake::default();
let mut ctx = TickContext::default();
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
// Script cart names to entity ids (a removed cart leaves the level).
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
@@ -52,21 +49,6 @@ fn main() {
"loop" => SoundInstance::biome_loop(&event, random),
"uloop" => SoundInstance::under_liquid_loop(&event, random),
"usub" => SoundInstance::under_liquid_sub(&event, random),
"cart" | "rcart" | "rcartu" => {
let id = cart_ids[a[3]];
let cart = ctx
.carts
.iter()
.find(|c| c.id == id)
.or_else(|| removed.get(&id))
.copied()
.expect("cart");
if a[2] == "cart" {
SoundInstance::minecart(&cart, random)
} else {
SoundInstance::riding_minecart(&cart, a[2] == "rcartu", random)
}
}
_ => panic!("{line}"),
};
if let Some(old) = instances.remove(a[1]) {
@@ -110,26 +92,6 @@ fn main() {
ctx.player_underwater = a[1] == "1";
ctx.player_removed = a[2] == "1";
}
"cart" => {
let next = cart_ids.len() as u32 + 1;
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
ctx.carts.retain(|c| c.id != id);
let cart = CartSound {
id,
position: [d(a[2]), d(a[3]), d(a[4])],
// Vec3.horizontalDistance.
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
};
if a[8] == "1" {
// Out of the level, still there for a later play.
removed.insert(id, cart);
} else {
removed.remove(&id);
ctx.carts.push(cart);
}
}
"ride" => ctx.vehicle = cart_ids.get(a[1]).copied(),
"freeze" => ctx.frozen = a[1] == "1",
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
"forget" => {
if let Some(id) = instances.remove(a[1]) {
@@ -1,227 +0,0 @@
//! The chunk pipeline (generation, decoration, lighting, meshing) on its worker
//! pool, without a window: how fast a render distance fills with N workers, and
//! what the render thread pays for it in `Streaming::update`.
//!
//! streaming_bench RESOURCES [--threads N] [--radius R] [--seed S] [--fly CHUNKS_PER_S] [--seconds T] [--hz F]
//!
//! First the area around (0, 0) fills; then, with `--fly`, the centre moves along +x
//! at that speed for T seconds while frames run at F per second. Each phase reports
//! its wall time, chunks generated and sections meshed, and the time `update`
//! took on the calling thread (the render thread in the game): total, median,
//! p99 and slowest. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1 to count the
//! allocations made on the calling thread during updates and by everything.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
light::LightTables,
mesher::MeshTables,
models,
streaming::{Streaming, WorkerContext},
vanillagen::VanillaTerrain,
world::World,
};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
path::PathBuf,
sync::{
Arc,
atomic::{AtomicU64, Ordering::Relaxed},
},
time::{Duration, Instant},
};
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
static BYTES: AtomicU64 = AtomicU64::new(0);
thread_local! {
static LOCAL: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
}
fn count(size: usize) {
if TRACK {
ALLOCATIONS.fetch_add(1, Relaxed);
BYTES.fetch_add(size as u64, Relaxed);
let _ = LOCAL.try_with(|c| {
let (n, b) = c.get();
c.set((n + 1, b + size as u64));
});
}
}
struct CountingAllocator;
// Delegates every operation and layout unchanged to System.
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
count(layout.size());
unsafe { System.alloc(layout) }
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
count(layout.size());
unsafe { System.alloc_zeroed(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) };
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
count(size);
unsafe { System.realloc(ptr, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[derive(Default)]
struct Phase {
updates: Vec<f64>,
local: (u64, u64),
uploads: usize,
}
impl Phase {
fn update(&mut self, streaming: &mut Streaming, world: &mut World) {
let before = LOCAL.with(Cell::get);
let started = Instant::now();
let (uploads, _) = streaming.update(world);
self.updates.push(started.elapsed().as_secs_f64() * 1e3);
let after = LOCAL.with(Cell::get);
self.local.0 += after.0 - before.0;
self.local.1 += after.1 - before.1;
self.uploads += uploads.len();
drop(uploads);
}
fn report(&self, name: &str, wall: Duration, streaming: &Streaming, generated: u64) {
let mut sorted = self.updates.clone();
sorted.sort_by(f64::total_cmp);
let at = |p: f64| sorted[((sorted.len() as f64 * p) as usize).min(sorted.len() - 1)];
let total: f64 = sorted.iter().sum();
println!(
"{name}: {:.2} s, {} chunks generated, {} sections meshed; update {:.1} ms total over {} calls, p50 {:.3} p99 {:.3} max {:.2} ms",
wall.as_secs_f64(),
streaming.generated_total - generated,
self.uploads,
total,
sorted.len(),
at(0.5),
at(0.99),
sorted.last().copied().unwrap_or(0.0),
);
if TRACK {
println!(
" update allocations: {} ({:.1} MiB)",
self.local.0,
self.local.1 as f64 / 1048576.0
);
}
}
}
/// Nothing queued or in flight. Sections at the edge of the area stay dirty
/// (their outer neighbours are never lit), so they don't count.
fn settled(streaming: &Streaming) -> bool {
streaming
.stages()
.iter()
.all(|&(name, n)| name == "protos" || n == 0)
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let root = PathBuf::from(args.first().ok_or("usage: streaming_bench RESOURCES")?);
let flag = |name: &str, default: f64| -> f64 {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let auto = std::thread::available_parallelism().map_or(2, |n| n.get() - 1);
let threads = flag("--threads", auto as f64) as usize;
let radius = flag("--radius", 8.0) as i32;
let seed = flag("--seed", 1234.0) as i64;
let fly = flag("--fly", 0.0);
let seconds = flag("--seconds", 10.0);
let hz = flag("--hz", 240.0);
let assets = Arc::new(Assets::load(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let tables = Arc::new(MeshTables::new(
&assets,
&facts,
models::load_visibility(&root, assets.registry)?,
)?);
let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?);
let terrain = Arc::new(VanillaTerrain::new(
&root.join("worldgen"),
assets.registry,
&biomes,
seed,
)?);
let context = Arc::new(WorkerContext {
assets,
tables,
biomes,
light,
});
let mut world = World::new(facts.air, seed);
let mut streaming = Streaming::new(terrain, context, threads);
streaming.radius = radius;
streaming.center = (0, 0);
println!("{threads} workers, radius {radius}, seed {seed}");
let (allocations, bytes) = (ALLOCATIONS.load(Relaxed), BYTES.load(Relaxed));
// Fill: update as often as the game would uncapped, until settled.
let mut phase = Phase::default();
let started = Instant::now();
let mut quiet = 0;
while quiet < 20 {
let uploads = phase.uploads;
phase.update(&mut streaming, &mut world);
quiet = if settled(&streaming) && phase.uploads == uploads {
quiet + 1
} else {
0
};
std::thread::sleep(Duration::from_micros(500));
}
phase.report("fill", started.elapsed(), &streaming, 0);
if fly > 0.0 {
let mut phase = Phase::default();
let generated = streaming.generated_total;
let frame = Duration::from_secs_f64(1.0 / hz);
let started = Instant::now();
let mut behind = Vec::new();
while started.elapsed().as_secs_f64() < seconds {
let next = Instant::now() + frame;
let x = (started.elapsed().as_secs_f64() * fly) as i32;
streaming.center = (x, 0);
phase.update(&mut streaming, &mut world);
// Columns within 2 chunks of the player still missing their meshes.
let near = (-2..=2)
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, dz)))
.filter(|&(cx, cz)| !streaming.is_lit(cx, cz))
.count();
behind.push(near);
if let Some(wait) = next.checked_duration_since(Instant::now()) {
std::thread::sleep(wait);
}
}
phase.report("fly", started.elapsed(), &streaming, generated);
let unlit = behind.iter().filter(|&&n| n > 0).count();
println!(
" frames with an unlit column within 2 chunks of the player: {unlit} of {} ({:.1}%)",
behind.len(),
unlit as f64 * 100.0 / behind.len().max(1) as f64
);
}
if TRACK {
println!(
"all threads: {} allocations ({:.1} MiB)",
ALLOCATIONS.load(Relaxed) - allocations,
(BYTES.load(Relaxed) - bytes) as f64 / 1048576.0
);
}
Ok(())
}
@@ -1,47 +0,0 @@
//! Transport for the vanilla quad sorting / viewpoint parity gate.
use horizon_client::gfx::translucency::{SortState, Sorter, quad_center};
use serde::Deserialize;
use serde_json::json;
use std::{
fs,
io::{BufWriter, Write},
};
#[derive(Deserialize)]
struct Case {
origin: [i32; 3],
quads: Vec<[[f32; 3]; 4]>,
cameras: Vec<[f64; 3]>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let [input, output] = args.as_slice() else {
return Err("usage: translucency_fixture INPUT OUTPUT".into());
};
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(input)?.lines() {
let case: Case = serde_json::from_str(line)?;
let centers: Vec<_> = case.quads.iter().map(|q| quad_center(q[0], q[2])).collect();
let mut state = SortState::new(centers.clone());
let mut sorter = Sorter::default();
let mut cache_sorter = Sorter::default();
let frames: Vec<_> = case
.cameras
.iter()
.map(|&camera| {
let update = state
.update(camera, case.origin, &mut cache_sorter)
.is_some();
let local = std::array::from_fn(|i| (camera[i] - f64::from(case.origin[i])) as f32);
json!({"indices": sorter.sort(&centers, local), "update": update})
})
.collect();
let bits: Vec<_> = centers
.iter()
.map(|c| c.map(|f| f.to_bits() as i32))
.collect();
writeln!(out, "{}", json!({"centers": bits, "frames": frames}))?;
}
Ok(())
}
@@ -1,40 +0,0 @@
//! Packet payload oracle companion for tools/test_world_options_parity.py.
use horizon_protocol::{buf::Reader, packets::play::*};
use std::{error::Error, fmt::Write};
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
let fields: Vec<&str> = line.split('\t').collect();
let entries = if fields.len() == 3 {
vec![GameRuleEntry {
name: format!("minecraft:{}", fields[1]),
value: fields[2].into(),
}]
} else {
vec![]
};
let bytes = match fields[0] {
"difficulty" => Serverbound::ChangeDifficulty(ChangeDifficultyRequest {
difficulty: fields[1].parse()?,
})
.encode(),
"lock" => Serverbound::LockDifficulty(LockDifficulty {
locked: fields[1].parse()?,
})
.encode(),
"set" => Serverbound::SetGameRule(SetGameRule { entries }).encode(),
"values" => Clientbound::GameRuleValues(GameRuleValues { entries }).encode(),
_ => return Err("unknown case".into()),
};
let mut reader = Reader::new(&bytes);
reader.varint()?;
for byte in reader.rest() {
write!(output, "{byte:02x}")?;
}
output.push('\n');
}
std::fs::write(&args[2], output)?;
Ok(())
}
-58
View File
@@ -175,53 +175,6 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
})
}
/// `CriterionProgress`'s date as saved, "yyyy-MM-dd HH:mm:ss Z", in UTC.
pub fn date_text(unix_secs: i64) -> String {
let days = unix_secs.div_euclid(86400);
let rem = unix_secs.rem_euclid(86400);
// Civil from days (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} +0000",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
/// A saved date back to milliseconds since the epoch (the network form of
/// `CriterionProgress`), the zone offset applied.
pub fn date_millis(text: &str) -> Option<i64> {
let (date, rest) = text.trim().split_once(' ')?;
let (time, zone) = rest.split_once(' ').unwrap_or((rest, "+0000"));
let mut d = date.split('-').map(|v| v.parse::<i64>().ok());
let (y, m, day) = (d.next()??, d.next()??, d.next()??);
let mut t = time.split(':').map(|v| v.parse::<i64>().ok());
let (h, min, sec) = (t.next()??, t.next()??, t.next()??);
let sign = if zone.starts_with('-') { -1 } else { 1 };
let zone = zone.trim_start_matches(['+', '-']);
let offset =
zone.get(0..2)?.parse::<i64>().ok()? * 3600 + zone.get(2..4)?.parse::<i64>().ok()? * 60;
// Days from civil (Howard Hinnant's algorithm).
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some((days * 86400 + h * 3600 + min * 60 + sec - sign * offset) * 1000)
}
/// The player's progress: advancement -> criterion -> obtained date.
#[derive(Default, Clone)]
pub struct Progress {
@@ -1106,15 +1059,4 @@ mod tests {
assert_eq!(l[4], Some((1, 2.0)));
assert_eq!(l[1], Some((2, 2.0)));
}
#[test]
fn saved_dates_read_back_to_their_instant() {
for secs in [0, 951_782_400, 1_792_216_800, 4_107_542_399] {
assert_eq!(date_millis(&date_text(secs)), Some(secs * 1000));
}
assert_eq!(
date_millis("2026-10-17 07:30:00 +0200"),
date_millis("2026-10-17 05:30:00 +0000")
);
assert_eq!(date_millis("not a date"), None);
}
}
+18 -111
View File
@@ -1,9 +1,8 @@
//! `AbstractArrow.tick` for arrows, tipped and spectral arrows: the flight with
//! its water inertia and bubbles, the base tick's fluids (currents, splash),
//! bubble columns and water along the path, sticking in blocks, falling out
//! when the block goes, and despawning. The tick stops at an entity it hits:
//! the game hurts the entity (`projectile_hit`), then `finish_entity_hit`
//! spends the arrow or turns it back and ends the tick.
//! when the block goes, and despawning. Entity hits and their effects stay
//! with the game (`projectile_hit`).
use crate::{
blocks::BlockFacts,
collisions::SceneCollisions,
@@ -114,9 +113,6 @@ pub enum ArrowEvent {
/// The SPLASH and PROJECTILE_SHOOT game events.
SplashEvent,
ShootEvent,
/// `spawnAtLocation(pickupItem, 0.1)`: a turned-back arrow too slow to fly
/// on drops what picking it up gives.
Drop,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArrowParticle {
@@ -128,7 +124,7 @@ pub enum ArrowParticle {
pub enum ArrowSound {
/// `entity.generic.splash` (an arrow's splash and high-speed splash).
Splash,
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
/// The arrow's hit-ground sound (`entity.arrow.hit`).
HitGround,
/// `entity.generic.extinguish_fire`.
Extinguish,
@@ -386,17 +382,14 @@ fn signum(v: f64) -> f64 {
v
}
}
/// One arrow tick: whether to keep it and what it hit. An arrow that hit an
/// entity stops there and is not kept: the game hands it to
/// `finish_entity_hit` once it has hurt the entity.
/// One arrow tick: whether to keep it and what it hit.
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
let Some(mut st) = p.arrow.take() else {
return (false, Hit::None);
};
// Entity.setOldPosAndRot: the renderer interpolates from here, in flight
// and stuck alike; ServerLevel.tickNonPassenger counts the tick.
// and stuck alike.
p.previous = p.position;
p.age += 1;
let hit = Ticker {
p,
st: &mut st,
@@ -404,43 +397,9 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
}
.tick(targets);
p.stuck = st.in_ground;
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
p.arrow = Some(st);
(keep, hit)
}
/// What became of the entity an arrow hit (`AbstractArrow.onHitEntity`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntityHit {
/// `hurtOrSimulate` succeeded: the arrow is spent, with its hit sound.
Hurt,
/// It failed (a raised shield, invulnerability): the arrow turns back.
Refused,
/// The entity turned it first (`Entity.deflection`: breezes).
Deflected,
}
/// The rest of the tick of an arrow that hit an entity: whether to keep it.
pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: EntityHit) -> bool {
let Some(mut st) = p.arrow.take() else {
return false;
};
let mut ticker = Ticker {
p,
st: &mut st,
scene,
};
match outcome {
EntityHit::Hurt => {
ticker.hit_sound();
ticker.st.removed = true;
}
EntityHit::Refused => ticker.turn_back(),
EntityHit::Deflected => {}
}
ticker.finish();
p.stuck = st.in_ground;
let keep = !st.removed;
p.arrow = Some(st);
keep
(keep, hit)
}
struct Ticker<'p, 's, 'a, 'r> {
p: &'p mut Projectile,
@@ -558,20 +517,10 @@ impl Ticker<'_, '_, '_, '_> {
);
if physics {
hit = self.step_move_and_hit(original, end, targets);
if matches!(hit, Hit::Entity { .. }) {
return hit;
}
} else {
self.p.position = end;
self.apply_effects_from_blocks(original, end);
}
self.finish();
hit
}
/// The end of a tick in flight: drag and gravity, then `Projectile.tick`
/// and `Entity.baseTick`'s fluid and fire phases.
fn finish(&mut self) {
let physics = !self.st.no_physics;
if !self.st.was_touching_water {
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
}
@@ -598,6 +547,7 @@ impl Ticker<'_, '_, '_, '_> {
self.st.removed = true;
}
self.st.first_tick = false;
hit
}
/// `getDeltaMovement().add(0, y, 0)`: the zeros added to x and z also
/// turn a negative zero positive.
@@ -639,23 +589,16 @@ impl Ticker<'_, '_, '_, '_> {
self.st.life = 0;
}
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
/// block (piercing is not modelled).
/// block (not piercing: the game removes an arrow that hits an entity).
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
let block = self.scene.clip(from, to);
let end = block.map_or(to, |b| b.0);
// findHitEntities: ProjectileUtil.computeMargin and the search box.
let margin = f64::from(((self.p.age as i32 - 2) as f32 / 20.0).clamp(0.0, 0.3));
let area = bounds(from)
.expand_towards(self.p.velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
let mut best: Option<(Who, Vec3, f64)> = None;
for (who, b) in targets {
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
if Some(*who) == self.p.owner && !self.p.left_owner {
continue;
}
if let Some(at) = self.entity_hit(b, from, end, margin) {
// Sorted by the distance to the entity's position (the
// stable sort keeps the first of equals).
if let Some(at) = b.inflate(Vec3::new(0.3, 0.3, 0.3)).clip(from, end) {
let c = b.center();
let d = from.distance_squared(Vec3::new(c.x, b.min_position().y, c.z));
if best.is_none_or(|bb| d < bb.2) {
@@ -667,6 +610,8 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position = next;
self.apply_effects_from_blocks(from, next);
if let Some((who, at, _)) = best {
// The game hurts it; the arrow is spent (piercing is not modelled).
self.st.removed = true;
return Hit::Entity { who, at };
}
let Some((at, pos, face)) = block else {
@@ -686,49 +631,6 @@ impl Ticker<'_, '_, '_, '_> {
face,
}
}
/// `ProjectileUtil.getManyEntityHitResult` for one entity box: where the
/// path enters it, else where it enters the box grown by `margin`, if a
/// line from there towards the centre (stopped by blocks) reaches the box.
fn entity_hit(&self, b: &Aabb, from: Vec3, to: Vec3, margin: f64) -> Option<Vec3> {
if let Some(at) = b.clip(from, to) {
return Some(at);
}
if margin <= 0.0 {
return None;
}
let outside = b
.inflate(Vec3::new(margin, margin, margin))
.clip(from, to)?;
let centre = b.center();
let towards = self.scene.clip(outside, centre).map_or(centre, |h| h.0);
b.clip(outside, towards).map(|_| outside)
}
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::HitGround,
volume: 1.0,
pitch,
});
}
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
/// a half turn give or take ten degrees at a tenth of the speed; one
/// left almost still drops its pickup (ALLOWED) and is discarded.
fn turn_back(&mut self) {
let power = 0.2;
let rotation = 170.0 + self.st.random.next_float() * 20.0;
let v = self.p.velocity;
let k = -power * 0.5;
self.p.velocity = Vec3::new(v.x * k, v.y * k, v.z * k);
self.p.yaw += rotation;
if self.p.velocity.length_squared() < 1.0e-7 {
if self.st.pickup == Pickup::Allowed {
self.st.events.push(ArrowEvent::Drop);
}
self.st.removed = true;
}
}
/// `AbstractArrow.onHitBlock`: back off the face by 0.05 along each axis
/// it moved on, stop, and stick.
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
@@ -747,7 +649,12 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position.z - signum(v.z) * offset,
);
self.p.velocity = Vec3::ZERO;
self.hit_sound();
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::HitGround,
volume: 1.0,
pitch,
});
self.st.in_ground = true;
self.st.shake = 7;
self.p.set_critical(false);
+2 -7
View File
@@ -59,8 +59,6 @@ pub struct Assets {
pub pixels: Vec<u8>,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
/// Shared parsed resources; item-only validation errors surface at item preparation.
pub model_library: std::result::Result<Arc<horizon_assets::resolve::ModelLibrary>, String>,
pub model_placements: Vec<(horizon_core::position::BlockPos, BlockStateId)>,
pub model_gallery: Vec<Value>,
pub animations: Vec<crate::sprites::SpriteAnimation>,
@@ -221,9 +219,7 @@ impl Assets {
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
let model_library = parsed.into_item_library();
let models = crate::models::prepare(root, registry, &mut sprites)?;
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
for name in [
@@ -245,10 +241,9 @@ impl Assets {
material_states,
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.count())?,
texture_layers: u32::try_from(sprites.layers.len())?,
pixels: sprites.pixels,
model_catalog: models.catalog,
model_library,
model_placements: models.placements,
model_gallery: models.states,
animations: sprites.animations,
+2 -52
View File
@@ -44,7 +44,6 @@ struct Mixer {
channels: HashMap<ChannelId, Channel>,
listener: [f64; 3],
listener_yaw: f32,
limiter_peak: f32,
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
@@ -398,17 +397,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.position += step;
}
}
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 50 ms.
let peak = data.iter().fold(1.0f32, |peak, s| peak.max(s.abs()));
mixer.limiter_peak = mixer.limiter_peak.max(peak);
let release = (-1.0 / (0.05 * rate as f32)).exp();
for frame in data.chunks_mut(channels) {
let gain = mixer.limiter_peak.recip();
for s in frame {
*s *= gain;
}
mixer.limiter_peak = (mixer.limiter_peak * release).max(peak);
for s in data.iter_mut() {
*s = s.clamp(-1.0, 1.0);
}
}
/// Why the game should not open an output, if it should not:
@@ -872,46 +862,6 @@ mod tests {
assert_eq!(distance_gain(Some(16.0), 40.0), 0.0);
}
#[test]
fn overlapping_voices_are_limited_without_flattening_or_changing_stereo_balance() {
let mut m = Mixer::default();
let wave = vec![0.25, 0.5, 0.75, 1.0, -1.0, -0.75, -0.5, -0.25, 0.0];
for id in 1..=4 {
m.channels
.insert(id, channel(wave.clone(), None, [-1.0, 0.0, 0.0], false));
}
let mut data = [0.0; 16];
mix(&mut m, &mut data, 2, 48_000);
for (frame, sample) in data.as_chunks::<2>().0.iter().zip(wave) {
assert!((frame[0] - sample * 0.4).abs() < 1e-6, "{frame:?}");
assert!((frame[1] - sample).abs() < 1e-6, "{frame:?}");
}
assert!(data.iter().all(|s| s.abs() <= 1.0));
}
#[test]
fn limiter_leaves_quiet_audio_alone_and_recovers_across_callbacks() {
let mut m = Mixer::default();
m.channels
.insert(1, channel(vec![0.25; 2], None, [0.0; 3], true));
let mut data = [0.0; 16];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.25; 16]);
for id in 2..=5 {
m.channels
.insert(id, channel(vec![1.0; 2], None, [0.0; 3], true));
}
mix(&mut m, &mut data, 2, 48_000);
assert!(data.iter().all(|s| s.abs() <= 1.0));
m.channels.retain(|&id, _| id == 1);
let mut recovery = vec![0.0; 2 * 48_000];
mix(&mut m, &mut recovery, 2, 48_000);
assert!(recovery[0] > 0.0 && recovery[0] < 0.1);
assert!(recovery.windows(2).all(|w| w[1] >= w[0]));
assert_eq!(*recovery.last().unwrap(), 0.25);
}
#[test]
fn sources_play_attenuated_then_stop_or_loop() {
let mut m = Mixer::default();
-95
View File
@@ -232,87 +232,6 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
}
}
/// What the session service says about a player joining this server
/// (MinecraftServicesSessionService.hasJoinedServer).
#[derive(Clone, Debug, PartialEq)]
pub struct JoinedProfile {
pub uuid: u128,
pub properties: Vec<horizon_protocol::packets::login::Property>,
}
/// HttpDiscoveryService.buildQuery: `URLEncoder.encode(value, UTF-8)`.
fn url_encode(text: &str) -> String {
let mut out = String::new();
for b in text.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'*' | b'_' => {
out.push(char::from(b));
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// The session service's verify endpoint, asked by an online-mode server
/// whether `name` joined with `server_hash` (and from `ip`, when the server
/// prevents proxy connections). `Ok(None)`: it didn't, or the service said
/// no; `Err(Unavailable)`: no answer.
pub fn has_joined(
name: &str,
server_hash: &str,
ip: Option<std::net::IpAddr>,
) -> Result<Option<JoinedProfile>, JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "verify").ok_or(JoinError::Unavailable)?;
let mut query = format!(
"username={}&serverId={}",
url_encode(name),
url_encode(server_hash)
);
if let Some(ip) = ip {
query.push_str(&format!("&ip={}", url_encode(&ip.to_string())));
}
let separator = if url.contains('?') { '&' } else { '?' };
let body = match agent().get(&format!("{url}{separator}{query}")).call() {
Ok(response) => response.into_string().unwrap_or_default(),
Err(ureq::Error::Status(status, response)) => {
let error =
JoinError::from_response(status, &response.into_string().unwrap_or_default());
return match error {
JoinError::Unavailable => Err(error),
_ => Ok(None),
};
}
Err(ureq::Error::Transport(_)) => return Err(JoinError::Unavailable),
};
Ok(parse_joined(&body))
}
/// HasJoinedMinecraftServerResponse: `id` and `properties`; no id is no profile.
pub fn parse_joined(body: &str) -> Option<JoinedProfile> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let uuid = parse_uuid(v.get("id")?.as_str()?)?;
let properties = v
.get("properties")
.and_then(|p| p.as_array())
.into_iter()
.flatten()
.filter_map(|p| {
Some(horizon_protocol::packets::login::Property {
name: p.get("name")?.as_str()?.to_owned(),
value: p.get("value")?.as_str()?.to_owned(),
signature: p
.get("signature")
.and_then(|s| s.as_str())
.map(str::to_owned),
})
})
.collect();
Some(JoinedProfile { uuid, properties })
}
#[cfg(test)]
mod tests {
use super::*;
@@ -331,20 +250,6 @@ mod tests {
path
}
#[test]
fn verify_answers() {
assert_eq!(url_encode("Tester_1"), "Tester_1");
assert_eq!(url_encode("-5a f"), "-5a+f");
assert_eq!(url_encode("::1"), "%3A%3A1");
let body = r#"{"id":"069a79f444e94726a5befca90e38aaf5","name":"Notch","properties":[{"name":"textures","value":"e30=","signature":"c2ln"}]}"#;
let p = parse_joined(body).unwrap();
assert_eq!(p.uuid, UUID);
assert_eq!(p.properties.len(), 1);
assert_eq!(p.properties[0].signature.as_deref(), Some("c2ln"));
assert_eq!(parse_joined(""), None);
assert_eq!(parse_joined(r#"{"name":"Notch"}"#), None);
}
#[test]
fn session_files_are_read_once_and_must_be_private() {
let dir = std::env::temp_dir().join(format!("horizon-auth-test-{}", std::process::id()));
-26
View File
@@ -199,14 +199,6 @@ pub fn dye_rgb(name: &str) -> u32 {
pub fn layers(stack: &ItemStack) -> &[BannerLayer] {
stack.data().map_or(&[], |d| &d.banner_patterns)
}
/// The ominous banner's component patch hides its pattern tooltip.
pub fn tooltip_layers(stack: &ItemStack) -> &[BannerLayer] {
if stack.data().is_some_and(|d| d.ominous_banner) {
&[]
} else {
layers(stack)
}
}
/// `LoomMenu.setupResultSlot`: one banner with the pattern added in the dye colour.
pub fn loom_result(banner: &ItemStack, dye: &str, pattern: &str) -> ItemStack {
let mut result = ItemStack {
@@ -310,24 +302,6 @@ mod tests {
}
assert!(has_max_layers(&banner));
}
#[test]
fn ominous_banner_hides_tooltip_layers_but_keeps_its_patterns() {
let mut banner = crate::mobs::raider::ominous_banner();
(banner.damage, banner.extra) = crate::item_data::decode_components(
crate::item_data::encode_components(&banner).as_ref(),
);
assert_eq!(
layers(&banner).len(),
crate::item_data::OMINOUS_LAYERS.len()
);
assert!(tooltip_layers(&banner).is_empty());
banner.data_mut().custom_name = Some("Captain's Standard".into());
assert!(tooltip_layers(&banner).is_empty());
banner.data_mut().ominous_banner = false;
assert_eq!(tooltip_layers(&banner), layers(&banner));
assert!(!tooltip_layers(&banner).is_empty());
}
#[test]
fn layers_round_trip_through_nbt() {
let l = vec![
@@ -145,16 +145,8 @@ pub const COMMANDS: &[Cmd] = &[
["horizon-ctl launch --world Survival --seed 42 --wait", "horizon-ctl launch --world Flat --creative --render-distance 8"],
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)")],
mcp: true, local: true),
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
["horizon-ctl wait-for screen inventory", "horizon-ctl wait-for loaded --timeout 120", "horizon-ctl wait-for chat joined the game", "horizon-ctl wait-for event death --timeout 600"],
&[flag("timeout", Ty::Num, "seconds (default 30)"), flag("since", Ty::Int, "chat and event: count from this event seq"), p("condition", Ty::Enum(&["screen", "widget", "loaded", "chat", "event"]), "what to wait for"), opt("value", Ty::Rest, "the screen name, widget id, chat text or event kind")], mcp: true, local: false),
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
@@ -256,9 +248,8 @@ pub const COMMANDS: &[Cmd] = &[
&[opt("all", Ty::Bool, "drop the whole stack")], mcp: true, local: false),
cmd!("hold", Act, "hold KEY SECONDS", "press a key, wait, release it", "Keys as for key.", ["horizon-ctl hold w 2"],
&[p("key", Ty::Str, "key name"), p("seconds", Ty::Num, "how long")], mcp: false, local: true),
cmd!("key", Act, "key KEY down|up|tap|repeat", "press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
cmd!("key", Act, "key KEY down|up|tap", "press or release a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)", "",
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap"]), "what to do")], mcp: true, local: false),
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
@@ -275,34 +266,12 @@ pub const COMMANDS: &[Cmd] = &[
["horizon-ctl click-slot 36", "horizon-ctl click-slot 100 right", "horizon-ctl click-slot 200 shift"],
&[p("slot", Ty::Int, "slot id"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click")], mcp: true, local: false),
cmd!("close", Screens, "close", "close the open menu and return to the game", "", ["horizon-ctl close"], &[], mcp: true, local: false),
cmd!("ui", Screens, "ui", "every widget of the open screen: id, kind, label, bounds, value",
"Kinds: button, slider (value 0..1), field, row (worlds, servers, packs, languages), tab (selected), slot (slot-ID, with its stack). Ids come from labels (\"FOV: 70\" is fov, a repeat gets -2) or kind-N without one, and stay put when a value changes. Reflects the last drawn frame (frame counts them).",
["horizon-ctl ui"], &[], mcp: true, local: false),
cmd!("click", Screens, "click ID [right] [shift]", "click a widget of ui by its id, as the mouse would",
"A field takes focus (then type); slot-ID clicks a slot like click-slot.",
["horizon-ctl click done", "horizon-ctl click video-settings", "horizon-ctl click row-0", "horizon-ctl click slot-0 shift"],
&[p("id", Ty::Str, "widget id from ui"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click (slots)")], mcp: true, local: false),
cmd!("set", Screens, "set ID VALUE", "move a slider of ui to VALUE (0..1), as a click on its track",
"The option behind it rounds to its own steps; read the new label with ui.",
["horizon-ctl set fov 0.5", "horizon-ctl set render-distance 0"],
&[p("id", Ty::Str, "slider id from ui"), p("value", Ty::Num, "0..1 along the track")], mcp: true, local: false),
cmd!("options-get", Screens, "options-get [KEY...]", "the game's options (options.txt) by key, with their type and range or choices",
"Keys are vanilla's: fov, renderDistance, guiScale, gamma, maxFps, lang, resourcePacks, soundCategory_music, key_key.jump... Enums are given and set by name, key bindings by key name (key.keyboard.w).",
["horizon-ctl options-get", "horizon-ctl options-get fov renderDistance"], &[opt("keys", Ty::Rest, "option keys (default: all)")], mcp: true, local: false),
cmd!("options-set", Screens, "options-set KEY VALUE", "set one option, apply it at once and save options.txt, as the options screen does",
"A value the option refuses is an error saying what it accepts. Lists are JSON: options-set resourcePacks [\"vanilla\"].",
["horizon-ctl options-set fov 90", "horizon-ctl options-set renderClouds fast", "horizon-ctl options-set key_key.jump key.keyboard.j"],
&[p("key", Ty::Str, "option key from options-get"), p("value", Ty::Rest, "the new value")], mcp: true, local: false),
cmd!("click-button", Screens, "click-button LABEL", "click a button of the open screen or title menu by its label",
"Case-insensitive; a unique part of the label is enough. screen lists the buttons.",
["horizon-ctl click-button Singleplayer", "horizon-ctl click-button \"Create New World\"", "horizon-ctl click-button respawn"],
&[p("label", Ty::Rest, "button label")], mcp: true, local: false),
cmd!("click-at", Screens, "click-at X Y [right]", "click at GUI pixel X Y of the window (screen gives button rectangles)", "",
["horizon-ctl click-at 200 120"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), opt("right", Ty::Bool, "right-click")], mcp: true, local: false),
cmd!("drag-at", Screens, "drag-at [right] X Y X Y...", "press at the first GUI point, move through the others, release at the last", "",
["horizon-ctl drag-at 130 100 148 100 166 100", "horizon-ctl drag-at right 130 100 148 100"], &[p("points", Ty::Rest, "right, then GUI x y pairs")], mcp: true, local: false),
cmd!("scroll-at", Screens, "scroll-at X Y STEPS", "turn the mouse wheel at GUI pixel X Y (positive scrolls up)", "",
["horizon-ctl scroll-at 130 100 -1"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), p("steps", Ty::Num, "wheel steps")], mcp: true, local: false),
cmd!("type", Screens, "type [--field name|seed] [--replace] TEXT", "type into the focused text field (chat box, world name, anvil, sign...)",
"On the title's create-world screen, --field name|seed picks the field and --replace clears it first; type \\b deletes one character.",
["horizon-ctl type hello everyone", "horizon-ctl type --field name --replace Agent World", "horizon-ctl type --field seed 12345"], &[flag("field", Ty::Enum(&["name", "seed"]), "create-world field to type into"), flag("replace", Ty::Bool, "clear the field first"), p("text", Ty::Rest, "text to type")], mcp: true, local: false),
@@ -324,9 +293,7 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)", "", ["horizon-ctl wait 40"],
&[p("ticks", Ty::Int, "game ticks")], mcp: true, local: false),
// Scripting
cmd!("script", Scripting, "script [FILE] [--keep-going]", "run commands from FILE (or stdin), one per line",
@@ -1,485 +0,0 @@
//! Block entities carried by items, vanilla 26.3: Ctrl + Pick Block in creative
//! (`ServerGamePacketListenerImpl.addBlockDataToItem`) and placing such an item
//! (`BlockItem.updateCustomBlockEntityTag`, then
//! `BlockEntity.applyComponentsFromItemStack`). Both work on the block entity's
//! saved NBT (`block_entity_nbt::canonical`) and the item's component patch.
//!
//! What a type turns into item components (`removeComponentsFromTag`,
//! `collectImplicitComponents`) leaves its NBT; the rest goes to
//! `minecraft:block_entity_data`, tagged with the type's id.
use crate::block_entity_nbt::{self, canonical};
use horizon_world::nbt::{Compound, JavaString, Tag};
pub const BLOCK_ENTITY_DATA: &str = "minecraft:block_entity_data";
/// `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`: the types whose data only a player
/// who can use game master blocks places (`BlockEntityType.onlyOpCanSetNbt`).
pub const OP_ONLY: [&str; 6] = [
"minecraft:command_block",
"minecraft:lectern",
"minecraft:sign",
"minecraft:hanging_sign",
"minecraft:mob_spawner",
"minecraft:trial_spawner",
];
/// The `minecraft:block_entity_data` of `stack`: its type id and its data.
pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
let data = stack.data()?.other(BLOCK_ENTITY_DATA)?;
Some((block_entity_nbt::id(data)?, data))
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
}
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
/// spawners alike): the entity type a spawner item spawns.
pub fn spawner_entity(stack: &crate::items::ItemStack) -> Option<String> {
let (_, data) = data(stack)?;
match data.get("SpawnData")?.get("entity")?.get("id")? {
Tag::String(id) => Some(id.to_string_lossy()),
_ => None,
}
}
/// A part of a block entity's NBT that its item holds as a component.
#[derive(Clone, Copy)]
enum Part {
/// The NBT key and the component hold the same value.
Same(&'static str, &'static str),
/// `Items` as `minecraft:container`, in a container of this many slots.
Items(usize),
/// `LootTable` and `LootTableSeed` as `minecraft:container_loot`.
Loot,
/// `is_waxed` as `minecraft:waxed`.
Waxed,
/// A decorated pot's `item` as `minecraft:container`.
PotItem,
/// Left out of the item's data, with no component.
Drop(&'static str),
}
const NAME: Part = Part::Same("CustomName", "minecraft:custom_name");
const LOCK: Part = Part::Same("lock", "minecraft:lock");
const CONTAINER: &str = "minecraft:container";
/// Each type's `removeComponentsFromTag`, `collectImplicitComponents` and
/// `applyImplicitComponents`.
fn parts(id: &str) -> &'static [Part] {
match id {
"minecraft:chest"
| "minecraft:trapped_chest"
| "minecraft:barrel"
| "minecraft:shulker_box" => &[NAME, LOCK, Part::Items(27), Part::Loot],
"minecraft:dispenser" | "minecraft:dropper" | "minecraft:crafter" => {
&[NAME, LOCK, Part::Items(9), Part::Loot]
}
"minecraft:hopper" => &[NAME, LOCK, Part::Items(5), Part::Loot],
"minecraft:furnace" | "minecraft:smoker" | "minecraft:blast_furnace" => {
&[NAME, LOCK, Part::Items(3)]
}
"minecraft:brewing_stand" => &[NAME, LOCK, Part::Items(5)],
"minecraft:beacon" => &[NAME, LOCK],
"minecraft:enchanting_table" => &[NAME],
"minecraft:command_block" => &[NAME, Part::Drop("conditionMet"), Part::Drop("powered")],
"minecraft:campfire" => &[Part::Items(4)],
"minecraft:chiseled_bookshelf" => &[Part::Items(6)],
"minecraft:shelf" => &[Part::Items(3)],
"minecraft:beehive" => &[Part::Same("bees", "minecraft:bees")],
"minecraft:banner" => &[Part::Same("patterns", "minecraft:banner_patterns"), NAME],
"minecraft:decorated_pot" => &[
Part::Same("sherds", "minecraft:pot_decorations"),
Part::PotItem,
],
"minecraft:sign" | "minecraft:hanging_sign" => &[
Part::Same("front_text", "minecraft:sign_text_front"),
Part::Same("back_text", "minecraft:sign_text_back"),
Part::Waxed,
],
"minecraft:skull" => &[
Part::Same("profile", "minecraft:profile"),
Part::Same("note_block_sound", "minecraft:note_block_sound"),
Part::Same("custom_name", "minecraft:custom_name"),
],
_ => &[],
}
}
/// The component a part reads, and what it holds for `tag` (None: unset).
fn collect(part: Part, tag: &Tag) -> Option<(&'static str, Option<Tag>)> {
Some(match part {
Part::Same(key, component) => (
component,
tag.get(key).cloned().or_else(|| unset(component)),
),
Part::Items(_) => {
let slots = match tag.get("Items") {
Some(Tag::List(items)) => items.iter().filter_map(slot_entry).collect(),
_ => Vec::new(),
};
(CONTAINER, Some(Tag::List(slots)))
}
Part::Loot => {
let table = tag.get("LootTable").cloned();
let loot = table.map(|t| {
let mut entries = vec![("loot_table", t)];
if let Some(seed) = tag.get("LootTableSeed") {
entries.push(("seed", seed.clone()));
}
Tag::compound(entries)
});
("minecraft:container_loot", loot)
}
Part::Waxed => (
"minecraft:waxed",
matches!(tag.get("is_waxed"), Some(Tag::Byte(1)))
.then(|| Tag::Compound(Compound::new())),
),
Part::PotItem => {
let slots = match tag.get("item") {
Some(Tag::Compound(item)) => Some(container_slot(0, item.clone())),
_ => None,
};
(CONTAINER, Some(Tag::List(slots.into_iter().collect())))
}
Part::Drop(_) => return None,
})
}
/// What a type that always collects `component` gives when its NBT leaves the
/// value out (its empty value, which the NBT omits).
fn unset(component: &str) -> Option<Tag> {
Some(match component {
"minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
"minecraft:pot_decorations" => Tag::Compound(Compound::new()),
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
block_entity_nbt::sign_side(None)
}
_ => return None,
})
}
/// An `Items` entry as a `minecraft:container` slot.
fn slot_entry(entry: &Tag) -> Option<Tag> {
let Tag::Compound(entry) = entry else {
return None;
};
let mut item = entry.clone();
let slot = match item.remove(&JavaString::from("Slot"))? {
Tag::Byte(s) => i32::from(s),
_ => return None,
};
Some(container_slot(slot, item))
}
/// An `ItemContainerContents` slot: `ItemStackTemplate` leaves out a count of 1.
fn container_slot(slot: i32, mut item: Compound) -> Tag {
if item.get(&JavaString::from("count")) == Some(&Tag::Int(1)) {
item.remove(&JavaString::from("count"));
}
Tag::compound([("slot", Tag::Int(slot)), ("item", Tag::Compound(item))])
}
/// The value of `component` on the clone item of a type that collects it: a
/// picked item leaves it out of its patch, and a placed item without it in its
/// patch gives it to the block entity.
fn item_default(component: &str) -> Option<Tag> {
Some(match component {
CONTAINER | "minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
"minecraft:pot_decorations" => {
let brick = || Tag::compound([("id", Tag::String("minecraft:brick".into()))]);
Tag::compound([
("back", brick()),
("front", brick()),
("left", brick()),
("right", brick()),
])
}
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
block_entity_nbt::sign_side(None)
}
_ => return None,
})
}
const METADATA: [&str; 6] = ["id", "x", "y", "z", "keepPacked", "components"];
/// `saveCustomOnly`: the saved NBT without its id, position and components.
fn custom_only(saved: &Tag) -> Compound {
match saved {
Tag::Compound(c) => c
.iter()
.filter(|(k, _)| !METADATA.contains(&k.to_string_lossy().as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
_ => Compound::new(),
}
}
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
/// item of the block entity saved as `saved` (a full, canonical compound).
pub fn pick(saved: &Tag) -> Compound {
let id = block_entity_nbt::id(saved).unwrap_or_default();
let mut patch = Compound::new();
// BlockItem.setBlockEntityData after removeComponentsFromTag.
let mut data = custom_only(saved);
for part in parts(&id) {
let key = match *part {
Part::Same(key, _) | Part::Drop(key) => key,
Part::Items(_) => "Items",
Part::Loot => {
data.remove(&JavaString::from("LootTableSeed"));
"LootTable"
}
Part::Waxed => "is_waxed",
Part::PotItem => "item",
};
data.remove(&JavaString::from(key));
}
if !data.is_empty() {
data.insert("id".into(), Tag::String(id.as_str().into()));
patch.insert(BLOCK_ENTITY_DATA.into(), Tag::Compound(data));
}
// ItemStack.applyComponents(collectComponents()): the block entity's own
// components, then the implicit ones; a value equal to the item's own is
// no change.
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
for (component, value) in collected {
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
patch.remove(&component);
} else {
patch.insert(component, value);
}
}
patch
}
/// The block entity saved as `saved` after an item with component patch
/// `components` is placed as it: `BlockItem.updateCustomBlockEntityTag` (the
/// item's data of the same type, op-only types only when `game_master`, the
/// player can use game master blocks), then `applyComponentsFromItemStack`.
pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag {
let Some(id) = block_entity_nbt::id(saved) else {
return saved.clone();
};
let Tag::Compound(mut tag) = saved.clone() else {
return saved.clone();
};
// TypedEntityData.loadInto: saveCustomOnly, merge, loadCustomOnly when changed.
if let Some(Tag::Compound(data)) = components.get(&JavaString::from(BLOCK_ENTITY_DATA))
&& data
.get(&JavaString::from("id"))
.is_some_and(|t| matches!(t, Tag::String(s) if s.to_string_lossy() == id))
&& (game_master || !OP_ONLY.contains(&id.as_str()))
{
let custom = custom_only(saved);
let mut merged = custom.clone();
let mut data = data.clone();
data.remove(&JavaString::from("id"));
crate::data_cmd::merge_compound(&mut merged, &data);
if merged != custom {
for key in METADATA {
if let Some(v) = tag.get(&JavaString::from(key)) {
merged.insert(key.into(), v.clone());
}
}
if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) {
tag = loaded;
}
}
}
// applyImplicitComponents, then the rest of the patch as the block
// entity's own components.
let mut read = vec![BLOCK_ENTITY_DATA, "minecraft:block_state"];
// The item's components: its patch over its own defaults.
let get = |c: &str| match components.get(&JavaString::from(c)) {
Some(v) => Some(v.clone()),
None if components.contains_key(&JavaString::from(format!("!{c}").as_str())) => None,
None => item_default(c),
};
let mut set = |key: &str, value: Option<Tag>| match value {
Some(v) => tag.insert(key.into(), v),
None => tag.remove(&JavaString::from(key)),
};
for part in parts(&id) {
match *part {
Part::Same(key, component) => {
read.push(component);
set(key, get(component));
}
Part::Items(size) => {
read.push(CONTAINER);
let items = container_slots(get(CONTAINER).as_ref())
.filter(|(slot, _)| (*slot as usize) < size)
.map(|(slot, mut item)| {
item.insert("Slot".into(), Tag::Byte(slot as i8));
Tag::Compound(item)
})
.collect();
set("Items", Some(Tag::List(items)));
}
Part::Loot => {
read.push("minecraft:container_loot");
if let Some(loot) = get("minecraft:container_loot") {
set("LootTable", loot.get("loot_table").cloned());
set("LootTableSeed", loot.get("seed").cloned());
}
}
Part::Waxed => {
read.push("minecraft:waxed");
set(
"is_waxed",
Some(Tag::Byte(i8::from(get("minecraft:waxed").is_some()))),
);
}
Part::PotItem => {
read.push(CONTAINER);
let first = container_slots(get(CONTAINER).as_ref()).find(|(slot, _)| *slot == 0);
set("item", first.map(|(_, item)| Tag::Compound(item)));
}
Part::Drop(_) => {}
}
}
let own: Compound = components
.iter()
.filter(|(k, _)| {
let k = k.to_string_lossy();
!k.starts_with('!') && !read.contains(&k.as_str())
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
tag.insert("components".into(), Tag::Compound(own));
let tag = Tag::Compound(tag);
canonical(&tag).unwrap_or(tag)
}
/// The slots of a `minecraft:container` component.
fn container_slots(container: Option<&Tag>) -> impl Iterator<Item = (i32, Compound)> + '_ {
let list = match container {
Some(Tag::List(list)) => list.as_slice(),
_ => &[],
};
list.iter().filter_map(|entry| {
let slot = match entry.get("slot")? {
Tag::Int(s) => *s,
_ => return None,
};
match entry.get("item")? {
Tag::Compound(item) => Some((slot, item.clone())),
_ => None,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn snbt(text: &str) -> Tag {
horizon_world::nbt::parse_snbt(text).expect("snbt").0
}
fn saved(text: &str) -> Tag {
canonical(&snbt(text)).expect("known type")
}
fn canon(patch: &Compound) -> String {
crate::item_data::canon_tag(&Tag::Compound(patch.clone()))
}
#[test]
fn a_filled_named_chest_gives_its_items_and_name_as_components() {
let chest = saved(
"{id:\"minecraft:chest\",x:1,y:2,z:3,CustomName:\"Loot\",\
Items:[{Slot:4b,id:\"minecraft:stone\",count:5}]}",
);
let patch = pick(&chest);
assert_eq!(
canon(&patch),
crate::item_data::canon_tag(&snbt(
"{\"minecraft:container\":[{slot:4,item:{id:\"minecraft:stone\",count:5}}],\
\"minecraft:custom_name\":\"Loot\"}"
))
);
// Placing it back gives the same chest.
let empty = saved("{id:\"minecraft:chest\",x:1,y:2,z:3}");
assert_eq!(place(&empty, &patch, false), chest);
// An empty chest has nothing to carry.
assert!(pick(&empty).is_empty());
}
#[test]
fn a_command_block_keeps_its_command_in_block_entity_data() {
let block = saved(
"{id:\"minecraft:command_block\",x:0,y:0,z:0,Command:\"say hi\",auto:1b,powered:1b}",
);
let patch = pick(&block);
let data = patch
.get(&JavaString::from(BLOCK_ENTITY_DATA))
.expect("data");
assert_eq!(data.get("Command"), Some(&Tag::String("say hi".into())));
assert_eq!(data.get("powered"), None);
assert_eq!(
data.get("id"),
Some(&Tag::String("minecraft:command_block".into()))
);
// Only a player who can use game master blocks places it.
let fresh = saved("{id:\"minecraft:command_block\",x:0,y:0,z:0}");
assert_eq!(place(&fresh, &patch, false), fresh);
assert_eq!(
place(&fresh, &patch, true).get("Command"),
Some(&Tag::String("say hi".into()))
);
// Data of another type is ignored.
let chest = saved("{id:\"minecraft:chest\",x:0,y:0,z:0}");
assert_eq!(place(&chest, &patch, true), chest);
}
#[test]
fn item_data_names_its_spawn_and_op_only_types() {
let item = |text: &str| {
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
let (damage, extra) = crate::item_data::decode_components(Some(&components));
crate::items::ItemStack {
damage,
extra,
..crate::items::ItemStack::new("minecraft:spawner", 1)
}
};
let spawner =
item("{id:\"minecraft:mob_spawner\",SpawnData:{entity:{id:\"minecraft:zombie\"}}}");
assert_eq!(
spawner_entity(&spawner).as_deref(),
Some("minecraft:zombie")
);
assert!(op_warning(&spawner));
let chest = item("{id:\"minecraft:chest\",LootTable:\"x\"}");
assert_eq!(spawner_entity(&chest), None);
assert!(!op_warning(&chest));
}
#[test]
fn a_written_sign_gives_its_text_and_wax() {
let sign = saved(
"{id:\"minecraft:sign\",x:0,y:0,z:0,is_waxed:1b,\
front_text:{messages:[\"a\",\"b\",\"\",\"\"]}}",
);
let patch = pick(&sign);
let keys: Vec<String> = patch.keys().map(|k| k.to_string_lossy()).collect();
assert_eq!(keys, ["minecraft:sign_text_front", "minecraft:waxed"]);
let fresh = saved("{id:\"minecraft:sign\",x:0,y:0,z:0}");
assert_eq!(place(&fresh, &patch, false), sign);
}
}
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The loaded `SignText` of one side, or the empty text.
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
@@ -876,7 +876,7 @@ impl BlockEntityRenderer {
.as_vec3();
let base = Mat4::from_translation(origin);
let packed = scene.world.light(pos[0], pos[1], pos[2]);
self.render_one(scene, *pos, kind, base, packed, lights, false, out);
self.render_one(scene, *pos, kind, base, packed, lights, out);
}
}
/// One block entity at `pos` drawn under `base` with the given packed light
@@ -892,33 +892,12 @@ impl BlockEntityRenderer {
) {
let kind = self.kind(state);
let lights = diffuse_lights(scene.nether);
self.render_one(scene, pos, kind, base, packed, lights, false, out);
self.render_one(scene, pos, kind, base, packed, lights, out);
}
/// Whether this block state has a block-entity renderer here.
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
}
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
pub fn render_breaking(&self, scene: &Scene, pos: [i32; 3], state: u32, out: &mut Vec<Vertex>) {
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
return;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let start = out.len();
self.render_one(
scene,
pos,
self.kind(state),
Mat4::from_translation(origin),
0xff,
diffuse_lights(scene.nether),
true,
out,
);
crate::breaking::entity_geometry(&mut out[start..], origin);
}
/// `ChestRenderer.extractRenderState`'s openness for the chest of `state`
/// at `pos`: `ChestBlock.combine` (`DoubleBlockCombiner.combineWithNeigbour`)
/// pairs a double half with its neighbour only when that is the same
@@ -991,7 +970,7 @@ impl BlockEntityRenderer {
let kind = self.kind(state);
if !matches!(kind, Kind::None) {
let lights = diffuse_lights(scene.nether);
self.render_one(scene, [i32::MAX; 3], kind, base, packed, lights, false, out);
self.render_one(scene, [i32::MAX; 3], kind, base, packed, lights, out);
}
}
#[allow(clippy::too_many_arguments)]
@@ -1003,24 +982,11 @@ impl BlockEntityRenderer {
base: Mat4,
packed: u8,
lights: (Vec3, Vec3),
crumbling: bool,
out: &mut Vec<Vertex>,
) {
let sprite = |name: &str| scene.sprites.get(name).copied();
let paint = |name: &str, tint: u32, packed: u8| {
let rect = if crumbling {
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
})
} else {
sprite(name)
};
rect.map(|s| Paint {
sprite(name).map(|s| Paint {
sprite: s,
tint,
light: vertex_light(packed),
@@ -1092,7 +1058,7 @@ impl BlockEntityRenderer {
ChestPart::Left => "double_chest_left",
ChestPart::Right => "double_chest_right",
};
let Some(model) = self.models.get(model) else {
let (Some(model), Some(_)) = (self.models.get(model), sprite(texture)) else {
return;
};
let (open, other) = self.chest_openness(
@@ -1179,10 +1145,6 @@ impl BlockEntityRenderer {
draw_part(main, "root", m, &none, paint, out);
draw_part(flag, "root", m, &wave, paint, out);
}
// Pattern passes must not multiply the crumbling decal.
if crumbling {
return;
}
// BannerRenderer.submitPatterns: the base pattern layer in the dye colour,
// then up to 16 pattern layers in order.
if let Some(paint) = paint("entity/banner/base", *color, packed) {
@@ -1711,138 +1673,6 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn entity_block_particles_and_breaking_use_exported_models() -> crate::Result<()> {
use crate::{models::catalog::StateModels, sprites::Sprites};
use horizon_world::{DATA_VERSION, chunk::BlockState, nbt::Tag};
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let names = [
"white_banner",
"white_wall_banner",
"chest",
"trapped_chest",
"ender_chest",
"copper_chest",
];
let mut facts: std::collections::BTreeMap<String, Value> =
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
let states = names.map(|name| {
let name = format!("minecraft:{name}");
let mut first = facts[&name]["states"][0].clone();
if first.get("type").is_some() {
first["type"] = serde_json::json!("single");
}
facts.get_mut(&name).unwrap()["states"] = serde_json::json!([first.clone()]);
BlockState {
name,
properties: serde_json::from_value(first).unwrap(),
}
.to_nbt()
});
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("states", Tag::List(states.into())),
(
"defaults",
Tag::compound(names.iter().enumerate().map(|(id, name)| {
(
horizon_world::nbt::JavaString::from(format!("minecraft:{name}").as_str()),
Tag::Int(id as i32),
)
})),
),
]))?;
let mut sprites = Sprites::default();
let catalog = StateModels::from_facts(&root, &registry, &names, &mut sprites, &facts)?;
for (id, name) in names.iter().enumerate() {
let models = catalog.collect(
BlockStateId(id as u32),
horizon_core::position::BlockPos::default(),
)?;
assert!(models.iter().all(|(_, model)| model.faces.is_empty()));
let particle = match *name {
"ender_chest" => "minecraft:block/obsidian",
"copper_chest" => "minecraft:block/copper_block",
_ => "minecraft:block/oak_planks",
};
assert_eq!(models[0].1.particle, Some(sprites.layers[particle]));
}
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
let renderer = BlockEntityRenderer::new(
MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
.collect(),
&registry,
);
let world = World::new(BlockStateId(0), 1);
let base_sprites = [
"entity/banner/banner_base",
"entity/chest/normal",
"entity/chest/trapped",
"entity/chest/ender",
"entity/chest/copper",
]
.into_iter()
.map(|name| {
(
name.to_owned(),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
)
})
.collect();
let mut scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.5,
game_time: 0,
max_y: 320,
world: &world,
sprites: &base_sprites,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
glyph: &|_| (None, 0.0),
};
for state in 0..names.len() as u32 {
let mut base = Vec::new();
renderer.render_block(&scene, [0; 3], state, Mat4::IDENTITY, 0xff, &mut base);
let mut cracks = Vec::new();
renderer.render_breaking(&scene, [0; 3], state, &mut cracks);
assert!(!cracks.is_empty());
assert_eq!(
base.iter().map(|v| v.pos).collect::<Vec<_>>(),
cracks.iter().map(|v| v.pos).collect::<Vec<_>>()
);
assert!(cracks.iter().all(|v| v.layer == 11
&& v.color == u32::MAX
&& v.uv.iter().all(|u| u.is_finite())));
scene.game_time = 20;
if state >= 2 {
scene.lid = &|_| 1.0;
}
let mut animated = Vec::new();
renderer.render_breaking(&scene, [0; 3], state, &mut animated);
assert!(cracks.iter().zip(&animated).any(|(a, b)| a.pos != b.pos));
scene.game_time = 0;
scene.lid = &|_| 0.0;
}
Ok(())
}
#[test]
fn classifies_vanilla_block_names() {
let props = |pairs: &'static [(&'static str, &'static str)]| {
+70 -278
View File
@@ -1,11 +1,7 @@
//! Per-state gameplay facts exported from vanilla's own block-state accessors
//! (`gameplay-facts.json`), indexed by registry state ID.
mod decode;
use crate::Result;
use horizon_world::{
level::{Behaviours, GameplayStateSource},
registry::{BlockRegistry, BlockStateId},
};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::Value;
use std::{
collections::HashMap,
@@ -110,190 +106,23 @@ impl StateFacts {
self.classes.iter().any(|c| &**c == name)
}
}
/// Compact, owned behaviour inputs retained until the normal behaviour startup stage.
/// No borrowed gameplay JSON or process-wide cache survives the fact loader.
pub struct GameplayProjection {
flags: u16,
emission: u64,
dampening: u64,
hardness: f64,
push: Arc<str>,
volume: f64,
pitch: f64,
place: Arc<str>,
break_sound: Arc<str>,
fall: Arc<str>,
}
impl GameplayProjection {
fn from_entry(entry: &decode::Entry<'_>, strings: &mut HashMap<String, Arc<str>>) -> Self {
fn intern(strings: &mut HashMap<String, Arc<str>>, value: &str) -> Arc<str> {
if let Some(existing) = strings.get(value) {
return Arc::clone(existing);
}
let value_arc: Arc<str> = Arc::from(value);
strings.insert(value.to_owned(), Arc::clone(&value_arc));
value_arc
}
const FLAGS: [&str; 11] = [
"replaceable",
"random_ticks",
"solid_render",
"liquid",
"block_entity",
"ignited_by_lava",
"can_occlude",
"full_collision",
"skylight_down",
"requires_tool",
"shape_occlusion",
];
Self {
flags: FLAGS.iter().enumerate().fold(0, |flags, (i, name)| {
flags | ((u16::from(entry.flag(name))) << i)
}),
emission: entry.unsigned("emission"),
dampening: entry.unsigned("dampening"),
hardness: entry.number("hardness"),
push: intern(strings, entry.text("push")),
volume: entry.sound_number("volume"),
pitch: entry.sound_number("pitch"),
place: intern(strings, entry.sound_id("place")),
break_sound: intern(strings, entry.sound_id("break")),
fall: intern(strings, entry.sound_id("fall")),
}
}
}
impl GameplayStateSource for GameplayProjection {
fn flag(&self, name: &str) -> bool {
const FLAGS: [&str; 11] = [
"replaceable",
"random_ticks",
"solid_render",
"liquid",
"block_entity",
"ignited_by_lava",
"can_occlude",
"full_collision",
"skylight_down",
"requires_tool",
"shape_occlusion",
];
FLAGS
.iter()
.position(|field| *field == name)
.is_some_and(|index| self.flags & (1 << index) != 0)
}
fn unsigned(&self, name: &str) -> u64 {
match name {
"emission" => self.emission,
"dampening" => self.dampening,
_ => 0,
}
}
fn number(&self, name: &str) -> f64 {
if name == "hardness" {
self.hardness
} else {
0.0
}
}
fn text(&self, name: &str) -> &str {
if name == "push" { &self.push } else { "" }
}
fn sound_number(&self, name: &str) -> f64 {
match name {
"volume" => self.volume,
"pitch" => self.pitch,
_ => 1.0,
}
}
fn sound_id(&self, name: &str) -> &str {
match name {
"place" => &self.place,
"break" => &self.break_sound,
"fall" => &self.fall,
_ => "",
}
}
}
pub struct BlockFacts {
pub states: Vec<StateFacts>,
pub air: BlockStateId,
}
impl BlockFacts {
pub fn load(root: &Path, registry: &BlockRegistry) -> Result<Self> {
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("block-behaviours.json"),
)?))?;
Self::load_with_behaviour_json(root, registry, &behaviours)
}
/// Load both tables from one resource root, sharing each JSON decode.
/// No data is cached across calls: changes to prepared packs or mods are read again.
pub fn load_pair(root: &Path, registry: &BlockRegistry) -> Result<(Self, Behaviours)> {
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("block-behaviours.json"),
)?))?;
Self::load_with_behaviours(root, registry, &behaviours)
}
/// Decode state facts once, retaining only the small owned fields needed later.
pub fn load_with_projection(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<(Self, Vec<GameplayProjection>)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
let mut strings = HashMap::new();
Ok(entries
.iter()
.map(|entry| GameplayProjection::from_entry(entry, &mut strings))
.collect())
})
}
pub fn load_with_behaviour_json(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<Self> {
Ok(Self::load_with_callback(root, registry, behaviours, |_| Ok(()))?.0)
}
/// Build both tables from the same behaviour document and gameplay decoder.
pub fn load_with_behaviours(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<(Self, Behaviours)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
Ok(Behaviours::load_with_gameplay(
registry, root, behaviours, entries,
)?)
})
}
fn load_with_callback<T>(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
callback: impl FnOnce(&[decode::Entry<'_>]) -> Result<T>,
) -> Result<(Self, T)> {
let bytes = fs::read(root.join("gameplay-facts.json"))?;
serde_json::from_slice::<decode::Validate>(&bytes)?;
let raw: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
let entries = raw
.states
.0
.as_ref()
let raw: Value = serde_json::from_slice(&fs::read(root.join("gameplay-facts.json"))?)?;
let entries = raw["states"]
.as_array()
.ok_or("missing gameplay state facts")?;
if entries.len() != registry.len() {
return Err("gameplay facts do not match the block registry".into());
}
// Class chains and per-state behaviour flags exported from the actual block objects.
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("block-behaviours.json"),
)?))?;
let behaviour_states = behaviours["states"]
.as_array()
.filter(|s| s.len() == registry.len())
@@ -319,22 +148,40 @@ impl BlockFacts {
.collect();
let mut names: HashMap<String, Arc<str>> = HashMap::new();
let mut intern = |s: &str| -> Arc<str> {
if let Some(name) = names.get(s) {
return name.clone();
}
let name: Arc<str> = Arc::from(s);
names.insert(s.to_owned(), name.clone());
name
names
.entry(s.to_owned())
.or_insert_with(|| Arc::from(s))
.clone()
};
let mut shapes: HashMap<String, Arc<[[f64; 6]]>> = HashMap::new();
let mut shape = |v: &Value, fallback: &dyn Fn() -> Vec<[f64; 6]>| -> Arc<[[f64; 6]]> {
let Some(list) = v.as_array() else {
return fallback().into();
};
shapes
.entry(v.to_string())
.or_insert_with(|| {
list.iter()
.filter_map(|b| {
let b = b.as_array()?;
let n = |i: usize| b.get(i).and_then(Value::as_f64).unwrap_or(0.0);
Some([n(0), n(1), n(2), n(3), n(4), n(5)])
})
.collect()
})
.clone()
};
let mut shapes = decode::Shapes::default();
let mut states = Vec::with_capacity(entries.len());
for (index, entry) in entries.iter().enumerate() {
let id = BlockStateId(index as u32);
if entry.id != Some(index as u64) {
if entry["id"].as_u64() != Some(index as u64) {
return Err("gameplay facts are not in registry order".into());
}
let state = registry.state(id).ok_or("missing registry state")?;
let sound = &entry.sound;
let num = |k: &str| entry[k].as_f64().unwrap_or(0.0);
let flag = |k: &str| entry[k].as_bool().unwrap_or(false);
let sound = &entry["sound"];
let sound_id = |k: &str| sound[k]["id"].as_str().unwrap_or("").to_owned();
let prop = |k: &str| state.properties.get(k).map(String::as_str);
let (fluid, fluid_level, fluid_falling) = match state.name.as_str() {
"minecraft:water" | "minecraft:lava" => {
@@ -373,10 +220,10 @@ impl BlockFacts {
}
};
let edges = move || if air { Vec::new() } else { cube_edges() };
let outline = shapes.intern(entry.outline, cube)?;
let outline_edges = shapes.intern(entry.outline_edges, edges)?;
let outline = shape(&entry["outline"], &cube);
let outline_edges = shape(&entry["outline_edges"], &edges);
// The export holds MapColor.col; ids are positions in MAP_COLORS.
let col = entry.map_color.unwrap_or(0) as u32;
let col = entry["map_color"].as_u64().unwrap_or(0) as u32;
let map_color = if col == 0 {
0
} else {
@@ -387,41 +234,43 @@ impl BlockFacts {
outline,
outline_edges,
name: intern(&state.name),
emission: entry.emission.as_f64().unwrap_or(0.0) as u8,
dampening: entry.dampening.as_f64().unwrap_or(0.0) as u8,
skylight_down: entry.skylight_down,
shape_occlusion: entry.shape_occlusion,
can_occlude: entry.can_occlude,
solid_render: entry.solid_render,
hardness: entry.hardness.as_f64().unwrap_or(0.0) as f32,
requires_tool: entry.requires_tool,
replaceable: entry.replaceable,
random_ticks: entry.random_ticks,
block_entity: entry.block_entity,
render_shape: match entry.render_shape.as_deref() {
emission: num("emission") as u8,
dampening: num("dampening") as u8,
skylight_down: flag("skylight_down"),
shape_occlusion: flag("shape_occlusion"),
can_occlude: flag("can_occlude"),
solid_render: flag("solid_render"),
hardness: num("hardness") as f32,
requires_tool: flag("requires_tool"),
replaceable: flag("replaceable"),
random_ticks: flag("random_ticks"),
block_entity: flag("block_entity"),
render_shape: match entry["render_shape"].as_str() {
Some("INVISIBLE") => RenderShape::Invisible,
Some("ENTITYBLOCK_ANIMATED") => RenderShape::Animated,
_ => RenderShape::Model,
},
full_collision: entry.full_collision,
friction: entry.friction as f32,
explosion_resistance: entry.explosion_resistance as f32,
item: intern(entry.item.as_deref().unwrap_or("minecraft:air")),
full_collision: flag("full_collision"),
friction: num("friction") as f32,
explosion_resistance: num("explosion_resistance") as f32,
item: intern(entry["item"].as_str().unwrap_or("minecraft:air")),
sounds: Sounds {
volume: sound.volume as f32,
pitch: sound.pitch as f32,
break_sound: intern(sound.r#break.id.as_deref().unwrap_or("")),
step: intern(sound.step.id.as_deref().unwrap_or("")),
place: intern(sound.place.id.as_deref().unwrap_or("")),
hit: intern(sound.hit.id.as_deref().unwrap_or("")),
fall: intern(sound.fall.id.as_deref().unwrap_or("")),
volume: sound["volume"].as_f64().unwrap_or(1.0) as f32,
pitch: sound["pitch"].as_f64().unwrap_or(1.0) as f32,
break_sound: intern(&sound_id("break")),
step: intern(&sound_id("step")),
place: intern(&sound_id("place")),
hit: intern(&sound_id("hit")),
fall: intern(&sound_id("fall")),
},
fluid,
fluid_level,
fluid_falling,
waterlogged: prop("waterlogged") == Some("true"),
random_offset: entry.offset,
outline_offset: entry.outline_offset.unwrap_or(entry.offset),
random_offset: flag("offset"),
outline_offset: entry["outline_offset"]
.as_bool()
.unwrap_or_else(|| flag("offset")),
legacy_solid: behaviour_states[index][1].as_u64().unwrap_or(0) & 1 != 0,
sturdy_full: (behaviour_states[index][0].as_u64().unwrap_or(0) & 63) as u8,
classes: classes
@@ -430,16 +279,12 @@ impl BlockFacts {
.ok_or("missing block behaviour class chain")?,
});
}
let extra = callback(entries)?;
Ok((
Self {
states,
air: registry
.default_state("minecraft:air")
.ok_or("missing air")?,
},
extra,
))
Ok(Self {
states,
air: registry
.default_state("minecraft:air")
.ok_or("missing air")?,
})
}
pub fn get(&self, id: BlockStateId) -> &StateFacts {
&self.states[id.0 as usize]
@@ -451,56 +296,3 @@ impl BlockFacts {
)
}
}
impl GameplayStateSource for decode::Entry<'_> {
fn flag(&self, name: &str) -> bool {
match name {
"replaceable" => self.replaceable,
"random_ticks" => self.random_ticks,
"solid_render" => self.solid_render,
"liquid" => self.liquid,
"block_entity" => self.block_entity,
"ignited_by_lava" => self.ignited_by_lava,
"can_occlude" => self.can_occlude,
"full_collision" => self.full_collision,
"skylight_down" => self.skylight_down,
"requires_tool" => self.requires_tool,
"shape_occlusion" => self.shape_occlusion,
_ => false,
}
}
fn unsigned(&self, name: &str) -> u64 {
match name {
"emission" => self.emission.as_u64().unwrap_or(0),
"dampening" => self.dampening.as_u64().unwrap_or(0),
_ => 0,
}
}
fn number(&self, name: &str) -> f64 {
match name {
"hardness" => self.hardness.as_f64().unwrap_or(0.0),
_ => 0.0,
}
}
fn text(&self, name: &str) -> &str {
match name {
"push" => self.push.as_deref().unwrap_or(""),
_ => "",
}
}
fn sound_number(&self, name: &str) -> f64 {
match name {
"volume" => self.sound.volume,
"pitch" => self.sound.pitch,
_ => 1.0,
}
}
fn sound_id(&self, name: &str) -> &str {
match name {
"place" => self.sound.place.id.as_deref().unwrap_or(""),
"break" => self.sound.r#break.id.as_deref().unwrap_or(""),
"fall" => self.sound.fall.id.as_deref().unwrap_or(""),
_ => "",
}
}
}
-293
View File
@@ -1,293 +0,0 @@
//! Typed projection of gameplay facts. Scalar conversions deliberately retain Value's
//! permissive defaults; maps retain its last-key-wins behavior. Repeated geometry stays
//! borrowed until it is interned, rather than allocating a JSON tree for every state.
use serde::{
Deserialize, Deserializer,
de::{MapAccess, SeqAccess, Visitor},
};
use serde_json::{Value, value::RawValue};
use std::{
collections::{HashMap, hash_map::Entry as MapEntry},
fmt,
hash::{Hash, Hasher},
marker::PhantomData,
sync::Arc,
};
// RawValue skips numeric range and depth checks. A non-allocating validation pass
// keeps the original Value loader's JSON errors, even in overwritten/unused fields.
#[derive(Default)]
pub(super) struct Validate;
impl<'de> Container<'de> for Validate {}
impl<'de> Deserialize<'de> for Validate {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
container(d)
}
}
trait Container<'de>: Default {
fn map<A: MapAccess<'de>>(mut map: A) -> Result<Self, A::Error> {
while map.next_key::<serde::de::IgnoredAny>()?.is_some() {
map.next_value::<Validate>()?;
}
Ok(Self::default())
}
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
while seq.next_element::<Validate>()?.is_some() {}
Ok(Self::default())
}
}
struct ContainerVisitor<T>(PhantomData<T>);
impl<'de, T: Container<'de>> Visitor<'de> for ContainerVisitor<T> {
type Value = T;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("JSON facts")
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<T, A::Error> {
T::map(map)
}
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<T, A::Error> {
T::seq(seq)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<T, E> {
Ok(T::default())
}
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<T, E> {
Ok(T::default())
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<T, E> {
Ok(T::default())
}
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<T, E> {
Ok(T::default())
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<T, E> {
Ok(T::default())
}
fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<T, E> {
Ok(T::default())
}
}
fn container<'de, D: Deserializer<'de>, T: Container<'de>>(d: D) -> Result<T, D::Error> {
d.deserialize_any(ContainerVisitor(PhantomData))
}
macro_rules! read_field {
($map:ident, typed) => {
$map.next_value()?
};
($map:ident, value($convert:expr)) => {
($convert)($map.next_value::<Value>()?)
};
}
macro_rules! object {
($name:ident<$life:lifetime> { $($field:ident: $ty:ty = $default:expr => $kind:ident $(($convert:expr))?),* $(,)? }) => {
pub(super) struct $name<$life> {
$(pub $field: $ty,)*
marker: PhantomData<&$life ()>,
}
impl Default for $name<'_> {
fn default() -> Self { Self { $($field: $default,)* marker: PhantomData } }
}
impl<$life> Container<$life> for $name<$life> {
fn map<A: MapAccess<$life>>(mut map: A) -> Result<Self, A::Error> {
#[derive(Deserialize)]
#[allow(non_camel_case_types)]
#[serde(field_identifier)]
enum Field { $($field,)* #[serde(other)] Unknown }
let mut result = Self::default();
while let Some(field) = map.next_key::<Field>()? {
match field {
$(Field::$field => result.$field = read_field!(map, $kind $(($convert))?),)*
Field::Unknown => { map.next_value::<Validate>()?; }
}
}
Ok(result)
}
}
impl<$life> Deserialize<$life> for $name<$life> {
fn deserialize<D: Deserializer<$life>>(d: D) -> Result<Self, D::Error> { container(d) }
}
};
}
fn number(v: Value) -> f64 {
v.as_f64().unwrap_or(0.0)
}
fn flag(v: Value) -> bool {
v.as_bool().unwrap_or(false)
}
fn unsigned(v: Value) -> Option<u64> {
v.as_u64()
}
fn text(v: Value) -> Option<String> {
if let Value::String(s) = v {
Some(s)
} else {
None
}
}
#[derive(Default)]
pub(super) struct Entries<'a>(pub Option<Vec<Entry<'a>>>);
impl<'de> Container<'de> for Entries<'de> {
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
let mut entries = Vec::new();
while let Some(entry) = seq.next_element()? {
entries.push(entry);
}
Ok(Self(Some(entries)))
}
}
impl<'de> Deserialize<'de> for Entries<'de> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
container(d)
}
}
object!(Gameplay<'a> { states: Entries<'a> = Entries::default() => typed });
object!(SoundId<'a> { id: Option<String> = None => value(text) });
object!(Sound<'a> {
volume: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
pitch: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
r#break: SoundId<'a> = SoundId::default() => typed,
step: SoundId<'a> = SoundId::default() => typed,
place: SoundId<'a> = SoundId::default() => typed,
hit: SoundId<'a> = SoundId::default() => typed,
fall: SoundId<'a> = SoundId::default() => typed,
});
object!(Entry<'a> {
id: Option<u64> = None => value(unsigned),
emission: Value = Value::Null => typed,
dampening: Value = Value::Null => typed,
skylight_down: bool = false => value(flag),
shape_occlusion: bool = false => value(flag),
can_occlude: bool = false => value(flag),
solid_render: bool = false => value(flag),
hardness: Value = Value::Null => typed,
requires_tool: bool = false => value(flag),
replaceable: bool = false => value(flag),
random_ticks: bool = false => value(flag),
block_entity: bool = false => value(flag),
liquid: bool = false => value(flag),
ignited_by_lava: bool = false => value(flag),
push: Option<String> = None => value(text),
render_shape: Option<String> = None => value(text),
full_collision: bool = false => value(flag),
friction: f64 = 0.0 => value(number),
explosion_resistance: f64 = 0.0 => value(number),
item: Option<String> = None => value(text),
sound: Sound<'a> = Sound::default() => typed,
offset: bool = false => value(flag),
outline_offset: Option<bool> = None => value(|v: Value| v.as_bool()),
map_color: Option<u64> = None => value(unsigned),
outline: Option<&'a RawValue> = None => typed,
outline_edges: Option<&'a RawValue> = None => typed,
});
// Match Value::to_string identity without serializing: integer/float spelling is
// distinct, as is the sign of floating zero. Object keys are already sorted by Value.
struct ShapeKey(Value);
fn same(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(a), Value::Number(b)) => {
a == b && a.as_f64().map(f64::to_bits) == b.as_f64().map(f64::to_bits)
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same(a, b))
}
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|((ak, av), (bk, bv))| ak == bk && same(av, bv))
}
_ => a == b,
}
}
impl PartialEq for ShapeKey {
fn eq(&self, other: &Self) -> bool {
same(&self.0, &other.0)
}
}
impl Eq for ShapeKey {}
fn shape_hash<H: Hasher>(v: &Value, h: &mut H) {
std::mem::discriminant(v).hash(h);
match v {
Value::Null => {}
Value::Bool(b) => b.hash(h),
Value::String(s) => s.hash(h),
Value::Number(n) => {
n.is_f64().hash(h);
n.hash(h);
n.as_f64().map(f64::to_bits).hash(h);
}
Value::Array(a) => {
a.len().hash(h);
for v in a {
shape_hash(v, h);
}
}
Value::Object(o) => {
o.len().hash(h);
for (k, v) in o {
k.hash(h);
shape_hash(v, h);
}
}
}
}
impl Hash for ShapeKey {
fn hash<H: Hasher>(&self, h: &mut H) {
shape_hash(&self.0, h);
}
}
#[derive(Default)]
pub(super) struct Shapes<'a> {
raw: HashMap<&'a str, usize>,
canonical: HashMap<ShapeKey, usize>,
values: Vec<Arc<[[f64; 6]]>>,
}
impl<'a> Shapes<'a> {
pub fn intern(
&mut self,
raw: Option<&'a RawValue>,
fallback: impl FnOnce() -> Vec<[f64; 6]>,
) -> crate::Result<Arc<[[f64; 6]]>> {
let Some(raw) = raw else {
return Ok(fallback().into());
};
if let Some(&id) = self.raw.get(raw.get()) {
return Ok(self.values[id].clone());
}
let value: Value = serde_json::from_str(raw.get())?;
if !value.is_array() {
return Ok(fallback().into());
}
let id = match self.canonical.entry(ShapeKey(value)) {
MapEntry::Occupied(entry) => *entry.get(),
MapEntry::Vacant(entry) => {
let shape = entry
.key()
.0
.as_array()
.unwrap()
.iter()
.filter_map(|b| {
let b = b.as_array()?;
Some(std::array::from_fn(|i| {
b.get(i).and_then(Value::as_f64).unwrap_or(0.0)
}))
})
.collect::<Arc<[[f64; 6]]>>();
let id = self.values.len();
self.values.push(shape);
entry.insert(id);
id
}
};
self.raw.insert(raw.get(), id);
Ok(self.values[id].clone())
}
}
#[cfg(test)]
#[path = "decode_tests.rs"]
mod tests;
@@ -1,137 +0,0 @@
use super::*;
#[test]
fn shape_identity_and_geometry_match_the_value_loader() -> crate::Result<()> {
let inputs = [
"[]",
"[ ]",
"[[0,0,0,1,1,1]]",
"[ [0, 0, 0, 1, 1, 1] ]",
"[[0.0,0,0,1,1,1]]",
"[[0e0,0,0,1,1,1]]",
"[[-0.0,0,0,1,1,1]]",
"[[0,0,0,1,1,1,7]]",
"[[0,0,0,1,1,1,8]]",
"[[1,null,\"2\"],true,{}]",
"[[1,null,\"\\u0032\"],true,{}]",
"[{\"z\":0,\"a\":1}]",
"[{\"a\":1,\"z\":0}]",
"[[18446744073709551615,-9223372036854775808,1e20]]",
"[{\"a\":2,\"a\":1,\"z\":0}]",
];
let values = inputs
.iter()
.map(|s| serde_json::from_str::<Value>(s))
.collect::<Result<Vec<_>, _>>()?;
// Value equality merges signed zero; the legacy string keys do not.
let keys: Vec<_> = values.iter().map(Value::to_string).collect();
let mut shapes = Shapes::default();
let actual = inputs
.iter()
.map(|s| {
shapes.intern(Some(serde_json::from_str::<&RawValue>(s)?), || {
panic!("array fallback")
})
})
.collect::<crate::Result<Vec<_>>>()?;
for (i, value) in values.iter().enumerate() {
let expected: Vec<[f64; 6]> = value
.as_array()
.unwrap()
.iter()
.filter_map(|b| {
let b = b.as_array()?;
Some(std::array::from_fn(|i| {
b.get(i).and_then(Value::as_f64).unwrap_or(0.0)
}))
})
.collect();
// Bit comparisons include negative zero.
assert_eq!(
actual[i]
.iter()
.flatten()
.map(|v| v.to_bits())
.collect::<Vec<_>>(),
expected
.iter()
.flatten()
.map(|v| v.to_bits())
.collect::<Vec<_>>()
);
for j in 0..values.len() {
assert_eq!(
Arc::ptr_eq(&actual[i], &actual[j]),
keys[i] == keys[j],
"{i}, {j}"
);
}
}
for input in ["null", "false", "7", "{}", "\"shape\""] {
let raw = serde_json::from_str(input)?;
let a = shapes.intern(Some(raw), || vec![[7.0; 6]])?;
let b = shapes.intern(Some(raw), || vec![[7.0; 6]])?;
assert_eq!(&*a, &[[7.0; 6]]);
assert!(!Arc::ptr_eq(&a, &b)); // Fallbacks were never interned.
}
Ok(())
}
#[test]
fn typed_fields_keep_defaults_duplicates_and_wrong_type_behavior() -> crate::Result<()> {
let input = r#"{"states":[null,[],{"id":0,"id":2,"emission":4.5,"dampening":"15",
"hardness":-0.0,"skylight_down":true,"offset":true,"outline_offset":"true",
"item":42,"render_shape":false,"sound":{"volume":null,"pitch":2,"break":{"id":"a\u0062"},
"step":false,"hit":{"id":2}},"outline":[[0,1]],"unknown":{"nested":true}}]}"#;
let parsed: Gameplay<'_> = serde_json::from_str(input)?;
let entries = parsed.states.0.unwrap();
assert_eq!(entries[0].id, None);
assert_eq!(entries[1].id, None);
let e = &entries[2];
assert_eq!(e.id, Some(2));
assert_eq!(e.emission.as_f64(), Some(4.5));
assert_eq!(e.dampening.as_f64().unwrap_or(0.0), 0.0);
assert_eq!(e.hardness.as_f64().unwrap().to_bits(), (-0.0f64).to_bits());
assert!(e.skylight_down && e.outline_offset.unwrap_or(e.offset));
assert_eq!(e.item, None);
assert_eq!(e.render_shape, None);
assert_eq!((e.sound.volume, e.sound.pitch), (1.0, 2.0));
assert_eq!(e.sound.r#break.id.as_deref(), Some("ab"));
assert_eq!(e.sound.step.id, None);
assert_eq!(e.sound.hit.id, None);
assert_eq!(e.outline.unwrap().get(), "[[0,1]]");
for input in ["null", "[]", "{}", r#"{"states":null}"#, r#"{"states":{}}"#] {
let parsed: Gameplay<'_> = serde_json::from_str(input)?;
assert!(parsed.states.0.is_none());
}
for input in [
r#"{"states":["#,
r#"{"states":[{"id":1e400}]}"#,
"{} trailing",
r#"{"unknown":1e400}"#,
] {
let before = serde_json::from_str::<Value>(input).err().unwrap();
let after = serde_json::from_str::<Gameplay<'_>>(input).err().unwrap();
assert_eq!(before.classify(), after.classify());
}
Ok(())
}
#[test]
fn raw_shape_capture_keeps_original_json_errors() {
let deep = format!("{}0{}", "[".repeat(130), "]".repeat(130));
let inputs = [
br#"{"states":[{"outline":[[1e400]],"outline":[]}]}"#.to_vec(),
format!("{{\"states\":[{{\"outline\":{deep}}}]}}").into_bytes(),
br#"{"states":[{"outline":[[0]]}]} trailing"#.to_vec(),
b"{\"states\":[{\"outline\":[[\"\xff\"]]}]}".to_vec(),
];
for input in inputs {
let old = serde_json::from_slice::<Value>(&input).err().unwrap();
let new = serde_json::from_slice::<Validate>(&input).err().unwrap();
assert_eq!(
(old.classify(), old.line(), old.column()),
(new.classify(), new.line(), new.column())
);
}
}
-48
View File
@@ -41,35 +41,6 @@ pub fn geometry(model: &BakedModel, origin: glam::Vec3, translucent: bool, out:
}
}
/// Project a block entity's transformed quads onto the same destroy-stage pass.
pub fn entity_geometry(vertices: &mut [Vertex], origin: glam::Vec3) {
for quad in vertices.as_chunks_mut::<4>().0 {
let positions = [0, 1, 2].map(|i| glam::Vec3::from(quad[i].pos));
let normal = (positions[1] - positions[0]).cross(positions[2] - positions[0]);
let mut facing = Direction::North;
let mut best = f32::from_bits(1);
for (direction, dot) in [
(Direction::Down, -normal.y),
(Direction::Up, normal.y),
(Direction::North, -normal.z),
(Direction::South, normal.z),
(Direction::West, -normal.x),
(Direction::East, normal.x),
] {
if dot > best {
facing = direction;
best = dot;
}
}
for vertex in quad {
vertex.uv = decal_uv((glam::Vec3::from(vertex.pos) - origin).to_array(), facing);
vertex.layer = 11;
vertex.color = 0xffff_ffff;
vertex.light = 240 | (240 << 8);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -78,7 +49,6 @@ mod tests {
fn slab_top() -> BakedModel {
BakedModel {
ambient_occlusion: true,
particle: None,
cube: None,
faces: vec![BakedFace {
layer: 0,
@@ -116,24 +86,6 @@ mod tests {
assert_eq!(v.color, 0xffff_ffff);
}
}
#[test]
fn entity_overlay_uses_block_local_projected_uvs() {
let origin = glam::Vec3::new(12.0, -3.0, 8.0);
let mut expected = Vec::new();
geometry(&slab_top(), origin, false, &mut expected);
let mut actual = expected.clone();
for vertex in &mut actual {
vertex.uv = [0.0; 2];
vertex.layer = 0;
vertex.color = 0;
}
entity_geometry(&mut actual, origin);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&actual),
bytemuck::cast_slice::<_, u8>(&expected)
);
}
#[test]
fn small_faces_use_projected_not_stretched_uvs() {
let a = decal_uv([0.0, 0.0, 0.0], Direction::North);
+1 -6
View File
@@ -99,8 +99,6 @@ pub struct Chat {
pub options: ChatOptions,
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
pub log: bool,
/// Messages added since the start, for the control socket's events.
pub received: u64,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -132,7 +130,6 @@ impl Chat {
self.add_lines(atlas, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
let width = self.max_width();
@@ -518,9 +515,7 @@ impl ChatScreen {
}
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
let parse = self.parse.as_ref().expect("parsed above");
let (range, list) = c
.dispatcher
.client_suggestions(parse, cursor_b, c.registries);
let (range, list) = c.dispatcher.suggestions(parse, cursor_b, c.registries);
self.pending = Some((range.0, list));
self.update_usage_info(m, c);
}
-67
View File
@@ -163,70 +163,3 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
);
}
}
#[test]
fn history_arrows_recall_messages_and_restore_the_draft() {
use winit::keyboard::KeyCode::{ArrowDown, ArrowUp};
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let commands = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let history = vec!["first message".to_owned(), "latest message".to_owned()];
let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
for (key, expected) in [
(ArrowUp, "latest message"),
(ArrowUp, "first message"),
(ArrowUp, "first message"),
(ArrowDown, "latest message"),
(ArrowDown, "unfinished draft"),
(ArrowDown, "unfinished draft"),
] {
assert_eq!(
screen.key(&m, &commands, &history, key, false, false),
ChatAction::None
);
assert_eq!(screen.value(), expected);
}
}
#[test]
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
let font = font();
let m = Measure(&font);
// Allow Commands off: a mod's command needs no level.
let registries = Registries::default();
let vanilla = crate::command::vanilla::game_dispatcher();
let modded = vanilla.with_mod_commands(["where", "coords"]);
let red = |screen: &ChatScreen| {
screen
.format(screen.value(), 0)
.spans
.iter()
.any(|s| s.style.color == Some(text::RED))
};
let typed = |dispatcher: &crate::command::Dispatcher, line: &str| {
let c = Commands {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
screen.type_text(&m, &c, line);
screen
};
// Unknown without the mod's tree (#456).
assert!(red(&typed(vanilla, "/coords")));
for line in ["/coords", "/where", "/where me and you"] {
assert!(!red(&typed(&modded, line)), "{line}");
}
let c = Commands {
dispatcher: &modded,
registries: &registries,
};
let mut screen = typed(&modded, "/coo");
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
assert_eq!(screen.value(), "/coords");
}
-50
View File
@@ -158,19 +158,6 @@ impl Chunk {
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
BlockStateId(u32::from(self.get(x, y, z)))
}
/// The highest y where `matches`, scanning down. Sections holding only
/// `skip` are passed over whole: `matches` must be false for it.
pub fn highest(&self, skip: u16, mut matches: impl FnMut(i32) -> bool) -> Option<i32> {
self.sections
.iter()
.enumerate()
.rev()
.filter(|(_, section)| !section.is_uniform(skip))
.find_map(|(s, _)| {
let base = MIN_Y + s as i32 * 16;
(base..base + 16).rev().find(|&y| matches(y))
})
}
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u16) -> u16 {
let s = (y - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
@@ -211,40 +198,3 @@ impl Chunk {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highest_skips_only_sections_that_cannot_match() {
let (air, cave_air, stone) = (0, 7, 1);
let mut chunk = Chunk::empty(0, 0);
// Stone low down, cave air (uniform in its section) above it, and a
// section of air with one stone block higher up.
for y in MIN_Y..MIN_Y + 40 {
chunk.set(3, y, 4, stone);
}
for i in 0..4096 {
Arc::make_mut(&mut chunk.sections[5]).set(i, cave_air);
}
chunk.set(3, 150, 4, stone);
let full = |c: &Chunk, matches: &dyn Fn(u16) -> bool| {
(MIN_Y..MIN_Y + HEIGHT)
.rev()
.find(|&y| matches(c.get(3, y, 4)))
};
for matches in [
&(|v: u16| v != air) as &dyn Fn(u16) -> bool,
&|v: u16| v == stone,
&|v: u16| v == cave_air,
] {
let fast = chunk.highest(air, |y| matches(chunk.get(3, y, 4)));
assert_eq!(fast, full(&chunk, matches));
}
assert_eq!(
chunk.highest(air, |y| chunk.get(3, y, 4) == stone),
Some(150)
);
assert_eq!(Chunk::empty(0, 0).highest(air, |_| unreachable!()), None);
}
}
+1 -5
View File
@@ -223,10 +223,6 @@ impl AppendSelection {
pub fn covers(&self, id: FieldId, value: &str) -> bool {
self.0.as_ref().is_some_and(|(i, v)| *i == id && v == value)
}
/// Select the whole `value` of field `id`, as Select All does.
pub fn select(&mut self, id: FieldId, value: &str) {
self.0 = Some((id, value.to_owned()));
}
/// Before typing or a backspace: an all-selected value is cleared (true).
pub fn take(&mut self, id: FieldId, value: &mut String) -> bool {
let covered = self.covers(id, value);
@@ -251,7 +247,7 @@ impl AppendSelection {
let covered = self.covers(id, value);
match shortcut {
EditShortcut::SelectAll => {
self.select(id, value);
self.0 = Some((id, value.clone()));
false
}
EditShortcut::Copy => {
+1 -43
View File
@@ -298,9 +298,6 @@ pub struct FixedStep {
tick_times: [i64; SAMPLES],
aggregated: i64,
tick_count: i64,
/// A server's client: its level's tick rate only slows the timer down
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
client: bool,
}
impl Default for FixedStep {
@@ -315,20 +312,11 @@ impl Default for FixedStep {
tick_times: [0; SAMPLES],
aggregated: 0,
tick_count: 0,
client: false,
}
}
}
impl FixedStep {
/// The clock of a client connected to a server: the `ClientLevel`'s
/// `TickRateManager`, set by the server's ticking packets.
pub fn client() -> Self {
Self {
client: true,
..Self::default()
}
}
fn now_ms(&self) -> i64 {
self.now.as_millis() as i64
}
@@ -353,14 +341,9 @@ impl FixedStep {
}
let runs = self.manager.base.runs_normally();
let mspt = self.manager.base.milliseconds_per_tick();
let client = self.client;
let ticks = self
.timer
.advance_game_time(now_ms, |default| match (runs, client) {
(true, true) => default.max(mspt),
(true, false) => mspt,
(false, _) => default,
});
.advance_game_time(now_ms, |default| if runs { mspt } else { default });
ticks.clamp(0, 10) as u32
}
/// Restart pacing from now (after loading, so no backlog replays).
@@ -410,17 +393,9 @@ impl FixedStep {
pub fn frozen(&self) -> bool {
self.manager.base.is_frozen()
}
/// `runsNormally` as of the last `world_runs`.
pub fn runs_normally(&self) -> bool {
self.manager.base.runs_normally()
}
pub fn set_frozen(&mut self, frozen: bool) {
self.manager.base.set_frozen(frozen);
}
/// `TickRateManager.setFrozenTicksToRun` (ClientboundTickingStepPacket).
pub fn set_frozen_ticks_to_run(&mut self, ticks: i32) {
self.manager.base.set_frozen_ticks_to_run(ticks);
}
pub fn sprinting(&self) -> bool {
self.manager.is_sprinting()
}
@@ -505,21 +480,4 @@ mod tests {
m.base.tick();
assert!(!m.base.runs_normally());
}
#[test]
fn a_servers_tick_rate_only_slows_its_client_down() {
let quarter = std::time::Duration::from_millis(250);
let mut client = FixedStep::client();
client.set_rate(40.0);
assert_eq!(client.advance(quarter), 5, "still 20 a second");
let mut slow = FixedStep::client();
slow.set_rate(10.0);
assert_eq!(slow.advance(quarter * 2), 5, "10 a second");
let mut local = FixedStep::default();
local.set_rate(40.0);
assert_eq!(
local.advance(quarter),
10,
"the integrated loop runs at the rate"
);
}
}
-217
View File
@@ -68,226 +68,9 @@ pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
})
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
/// resource-pack reloads replace the renderer and its cache together.
pub struct Input<'a> {
pub texture: &'a (u32, u32, Vec<bool>),
pub uv: [f32; 2],
pub camera: glam::DVec3,
pub time: i64,
pub partial_tick: f64,
pub range: i32,
pub fast: bool,
pub color: [f32; 4],
}
#[derive(Debug, PartialEq, Eq)]
struct MeshKey {
cell_x: i64,
cell_z: i64,
radius: i32,
fast: bool,
// The two vertical face tests include both surfaces at the boundaries.
top: bool,
bottom: bool,
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct Uniform {
offset: [f32; 4],
colors: [[f32; 4]; 4],
}
impl Input<'_> {
fn state(&self) -> (MeshKey, Uniform) {
let (w, h, _) = self.texture;
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
let mut x = self.camera.x + offset * 0.030_000_001;
let mut z = self.camera.z + 3.96;
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
x -= (x / tw).floor() * tw;
z -= (z / th).floor() * th;
let cell_x = (x / 12.0).floor() as i64;
let cell_z = (z / 12.0).floor() as i64;
let bottom = (192.33 - self.camera.y) as f32;
let key = MeshKey {
cell_x,
cell_z,
radius: radius_cells(self.range),
fast: self.fast,
top: bottom <= 0.0,
bottom: bottom + 4.0 >= 0.0,
};
let colors = [1.0, 0.7, 0.8, 0.9].map(|shade| {
let [r, g, b, a] = self.color;
[r * shade, g * shade, b * shade, a]
.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8 as f32 / 255.0)
});
(
key,
Uniform {
offset: [
-(x - cell_x as f64 * 12.0) as f32,
bottom,
-(z - cell_z as f64 * 12.0) as f32,
0.0,
],
colors,
},
)
}
}
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
pub vertices: Vec<crate::mesher::Vertex>,
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
pub fn prepare(&mut self, input: &Input<'_>) -> (bool, Uniform) {
let (key, uniform) = input.state();
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
self.vertices.clear();
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let bottom = uniform.offset[1];
let uv = input.uv;
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
let mut quad = |p: [[f32; 3]; 4], shade: u32| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal: [0; 4],
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
});
}
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0);
}
continue;
}
let quads = [
([[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]], 1),
([[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]], 0),
([[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]], 2),
([[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]], 2),
([[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]], 3),
([[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]], 3),
];
let mask = face_mask(
dx as i32,
dz as i32,
bottom,
[
!filled(cx, cz - 1),
!filled(cx, cz + 1),
!filled(cx - 1, cz),
!filled(cx + 1, cz),
],
);
for (direction, inside) in faces(mask) {
let (p, shade) = quads[usize::from(direction)];
quad(if inside { [p[3], p[2], p[1], p[0]] } else { p }, shade);
}
}
self.key = Some(key);
(true, uniform)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cached_mesh_tracks_geometry_but_not_subcell_motion_or_color() {
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
let mut input = Input {
texture: &texture,
uv: [0.5; 2],
camera: glam::DVec3::new(1.0, 100.0, 1.0),
time: 0,
partial_tick: 0.0,
range: 2,
fast: false,
color: [1.0; 4],
};
let mut mesh = Mesh::default();
assert!(mesh.prepare(&input).0);
let vertices = bytemuck::cast_slice::<_, u8>(&mesh.vertices).to_vec();
let pointer = mesh.vertices.as_ptr();
for tick in 0..100 {
input.time = tick;
input.partial_tick = 0.5;
input.camera.x = 2.0;
input.camera.y = 101.0;
input.color = [0.8, 0.7, 0.6, 0.5];
let (rebuilt, uniform) = mesh.prepare(&input);
assert!(!rebuilt);
assert_eq!(mesh.vertices.as_ptr(), pointer);
assert_eq!(bytemuck::cast_slice::<_, u8>(&mesh.vertices), vertices);
assert_eq!(uniform.offset[1], (192.33 - input.camera.y) as f32);
assert_eq!(
uniform.colors[1],
[0.8 * 0.7, 0.7 * 0.7, 0.6 * 0.7, 0.5].map(|v| (v * 255.0) as u8 as f32 / 255.0)
);
for color in uniform.colors {
assert_eq!(color[3], 127.0 / 255.0);
}
}
for (x, y, z, range, fast) in [
(13.0, 101.0, 1.0, 2, false),
(13.0, 101.0, -5.0, 2, false),
(13.0, 192.33, -5.0, 2, false),
(13.0, 196.34, -5.0, 2, false),
(13.0, 196.34, -5.0, 3, false),
(13.0, 196.34, -5.0, 3, true),
] {
input.camera = glam::DVec3::new(x, y, z);
input.range = range;
input.fast = fast;
assert!(mesh.prepare(&input).0);
let mut fresh = Mesh::default();
fresh.prepare(&input);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&mesh.vertices),
bytemuck::cast_slice::<_, u8>(&fresh.vertices)
);
assert!(!mesh.prepare(&input).0);
}
// Drift alone crosses a cell, even with a stationary camera.
input.time += 400;
assert!(mesh.prepare(&input).0);
// Resource reload constructs a new renderer and cache, including empty packs.
let mut reloaded = Mesh::default();
let empty = (1, 1, vec![false]);
input.texture = &empty;
assert!(reloaded.prepare(&input).0);
assert!(reloaded.vertices.is_empty());
assert!(!reloaded.prepare(&input).0);
}
#[test]
fn settings_and_packed_alpha_gate_clouds() {
for mode in ["false", "fast", "true"] {
+12 -75
View File
@@ -28,7 +28,7 @@ use std::{fs, io, path::Path, sync::Arc};
pub fn load<'r>(root: &Path, registry: &'r BlockRegistry) -> Result<BlockCollisionRegistry<'r>> {
let data = nbt::read_named(
&fs::read(root.join("cached-collision.nbt"))?,
Limits::tables(),
Limits::default(),
)?
.0
.tag;
@@ -65,68 +65,6 @@ pub struct SceneCollisions<'w, 'r> {
/// The entity colliding, for powder snow; None: no fall, no boots.
pub subject: Option<PowderSnowSubject>,
}
/// Already selected entity boxes alongside the resident block collision world.
pub struct EntityCollisions<'a, W> {
pub blocks: W,
pub entities: &'a [Aabb],
}
impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
type Error = W::Error;
type Chunk = W::Chunk;
fn collision_chunk(
&mut self,
p: ChunkPos,
) -> std::result::Result<Option<Self::Chunk>, Self::Error> {
self.blocks.collision_chunk(p)
}
fn collision_block(
&mut self,
chunk: Self::Chunk,
p: BlockPos,
) -> std::result::Result<CollisionBlock, Self::Error> {
self.blocks.collision_block(chunk, p)
}
fn is_suffocating(
&mut self,
chunk: Self::Chunk,
block: CollisionBlock,
p: BlockPos,
) -> std::result::Result<bool, Self::Error> {
self.blocks.is_suffocating(chunk, block, p)
}
fn context_shape(
&mut self,
block: CollisionBlock,
p: BlockPos,
context: &CollisionContext<'_>,
) -> std::result::Result<VoxelShape, Self::Error> {
self.blocks.context_shape(block, p, context)
}
}
impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCollisions<'_, W> {
type Error = io::Error;
fn entity_collisions(
&mut self,
entity: EntityIdentity,
bounds: Aabb,
) -> io::Result<Vec<VoxelShape>> {
let mut shapes = self.blocks.entity_collisions(entity, bounds)?;
for &aabb in self.entities {
// EntityGetter.getEntityCollisions expands its entity query by 1e-7.
if aabb.intersects(bounds.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))) {
shapes.push(VoxelShape::from_box(aabb).map_err(io::Error::other)?);
}
}
Ok(shapes)
}
fn movement_border(
&mut self,
entity: EntityIdentity,
bounds: Aabb,
) -> io::Result<Option<VoxelShape>> {
self.blocks.movement_border(entity, bounds)
}
}
impl SceneCollisions<'_, '_> {
/// Position contexts permit geometric movement probes without inventing a
/// player, inventory, effects or fluid-standing behavior; a lava walker
@@ -209,22 +147,21 @@ where
)
.unwrap_or(false)
}
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
impl BlockCollisionWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
/// The resident column, looked up once per chunk the scan enters.
type Chunk = &'w crate::chunk::Chunk;
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<Self::Chunk>> {
Ok(self.world.chunks.get(&(p.x, p.z)).map(|chunk| &**chunk))
type Chunk = ChunkPos;
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(self.world.chunks.contains_key(&(p.x, p.z)).then_some(p))
}
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result<CollisionBlock> {
fn collision_block(&mut self, chunk: ChunkPos, p: BlockPos) -> io::Result<CollisionBlock> {
let chunk = self
.world
.chunks
.get(&(chunk.x, chunk.z))
.ok_or_else(|| io::Error::other("resident collision chunk disappeared"))?;
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
}
fn is_suffocating(
&mut self,
_: Self::Chunk,
_: CollisionBlock,
_: BlockPos,
) -> io::Result<bool> {
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
Ok(false)
}
fn context_shape(
-50
View File
@@ -61,33 +61,6 @@ pub fn decode_component(id: &str, t: &Tag) -> Result<(), String> {
_ => Err(not_map(t)),
},
"minecraft:custom_name" | "minecraft:item_name" => super::component::decode(t),
// TypedEntityData.codec: the type is the compound's id.
"minecraft:block_entity_data" => match t {
Tag::Compound(c) => match c.get(&"id".into()) {
None => Err(format!("Expected 'id' field in {}", snbt(t))),
Some(Tag::String(id)) => {
let id = id.to_string_lossy();
let id = if id.contains(':') {
id
} else {
format!("minecraft:{id}")
};
let known = crate::block_entity_nbt::TYPES
.iter()
.chain(&crate::block_entity_nbt::NEWEST)
.any(|k| *k == id);
if known {
Ok(())
} else {
Err(format!(
"Unknown registry key in ResourceKey[minecraft:root / minecraft:block_entity_type]: {id}"
))
}
}
Some(_) => Ok(()),
},
_ => Ok(()),
},
_ => Ok(()),
}
}
@@ -636,26 +609,3 @@ pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
fn _unused(t: &Tag) -> String {
snbt(t)
}
#[cfg(test)]
mod tests {
use super::decode_component;
fn check(snbt: &str) -> Result<(), String> {
let tag = horizon_world::nbt::parse_snbt(snbt).expect("snbt").0;
decode_component("minecraft:block_entity_data", &tag)
}
#[test]
fn block_entity_data_needs_a_known_type() {
assert_eq!(check("{id:\"chest\",Lock:1}"), Ok(()));
assert_eq!(
check("{Command:\"say\"}"),
Err("Expected 'id' field in {Command:\"say\"}".into())
);
assert_eq!(
check("{id:\"minecraft:stone\"}"),
Err("Unknown registry key in ResourceKey[minecraft:root / minecraft:block_entity_type]: minecraft:stone".into())
);
}
}
+2 -41
View File
@@ -8,7 +8,6 @@ pub mod block;
pub mod codec;
pub mod component;
pub mod item;
pub mod network;
pub mod packrat;
pub mod selector;
pub mod snbt;
@@ -24,9 +23,6 @@ mod vanilla_order;
use crate::text::Text;
pub use args::{Arg, ArgType, Registries};
/// The command id of a mod's command (`Dispatcher::with_mod_commands`).
pub const MOD_COMMAND: &str = "mod";
/// A command error (CommandSyntaxException): the message and, for parse
/// errors, the input and cursor it occurred at.
#[derive(Clone, Debug, PartialEq)]
@@ -774,19 +770,6 @@ impl Dispatcher {
self.nodes[node].permission = level;
node
}
/// This tree plus the commands mods own (`docs/mods.md`), so the chat
/// colours and completes them like the others. Every player may use one,
/// and the text after its name goes to the mod as it was typed.
pub fn with_mod_commands<'a>(&self, names: impl IntoIterator<Item = &'a str>) -> Dispatcher {
let mut d = self.clone();
for name in names {
let command = d.literal(0, name);
d.executes(command, MOD_COMMAND);
let args = d.argument(command, "args", ArgType::Greedy);
d.executes(args, MOD_COMMAND);
}
d
}
/// A root literal by name.
pub fn find(&self, name: &str) -> Option<usize> {
self.nodes[0]
@@ -947,41 +930,19 @@ impl Dispatcher {
let context = Context::new(0, start);
self.parse_nodes(0, input, start, &context, reg, reg.permission)
}
/// CommandDispatcher.getCompletionSuggestions at `cursor`: brigadier
/// lists every child, whatever its requirement.
/// CommandDispatcher.getCompletionSuggestions at `cursor`.
pub fn suggestions(
&self,
parse: &ParseResults,
cursor: usize,
reg: &Registries,
) -> (Range, Vec<Suggestion>) {
self.suggestions_within(parse, cursor, reg, u8::MAX)
}
/// The suggestions the player's chat shows: the client's tree only holds
/// the nodes the player may use (Commands.sendCommands prunes by canUse).
pub fn client_suggestions(
&self,
parse: &ParseResults,
cursor: usize,
reg: &Registries,
) -> (Range, Vec<Suggestion>) {
self.suggestions_within(parse, cursor, reg, reg.permission)
}
fn suggestions_within(
&self,
parse: &ParseResults,
cursor: usize,
reg: &Registries,
permission: u8,
) -> (Range, Vec<Suggestion>) {
let (parent, start, context) = parse.context.suggestion_context(cursor);
let start = start.min(cursor);
let truncated = &parse.input[..cursor.min(parse.input.len())];
let mut all = Vec::new();
// Brigadier lists every child, whatever its requirement.
for &child in &self.nodes[parent].children {
if self.nodes[child].permission > permission {
continue;
}
let mut builder = SuggestionsBuilder::new(truncated, start);
match &self.nodes[child].kind {
NodeKind::Literal(l) => {
@@ -1,404 +0,0 @@
//! The command tree a server sends (ClientboundCommandsPacket) as a
//! dispatcher the chat parses, colours and completes with
//! (ClientPacketListener.handleCommands).
//!
//! The packet holds each argument's parser and its network properties. A
//! node the client's own vanilla tree has at the same path takes that node's
//! argument type and suggestion provider (the parsers' suggestion lists and
//! the custom providers are the same code on both sides); any other node
//! (another server's or a mod's) gets the type its parser and properties
//! describe.
use super::{ArgType, Dispatcher, Node, NodeKind, Suggests};
use horizon_protocol::packets::play::{ARGUMENT_TYPES, ArgumentProperties, Commands};
use std::{
collections::{HashSet, VecDeque},
sync::{Mutex, OnceLock},
};
/// The command id of every executable node of a server's tree: the chat
/// sends the command, the server runs it.
pub const SERVER_COMMAND: &str = "server";
/// The dispatcher for a server's tree. `local` is the client's own tree
/// (vanilla's), whose matching nodes lend their types and providers.
pub fn from_packet(packet: &Commands, local: &Dispatcher) -> Dispatcher {
let count = packet.nodes.len();
let root = usize::try_from(packet.root)
.ok()
.filter(|&r| r < count)
.unwrap_or(0);
// The packet's root becomes node 0, the others follow in packet order.
let index = |i: usize| -> usize {
if i == root {
0
} else if i < root {
i + 1
} else {
i
}
};
let valid = |c: &i32| usize::try_from(*c).ok().filter(|&c| c < count);
// The local node at the same path, found breadth first from the roots
// along children (redirects are not paths of their own).
let mut matched: Vec<Option<usize>> = vec![None; count];
matched[root] = Some(0);
let mut queue = VecDeque::from([root]);
let mut seen = HashSet::from([root]);
while let Some(n) = queue.pop_front() {
for c in packet.nodes[n].children.iter().filter_map(valid) {
if !seen.insert(c) {
continue;
}
let node = &packet.nodes[c];
matched[c] = matched[n].and_then(|l| {
local.nodes[l].children.iter().copied().find(|&lc| {
let ln = &local.nodes[lc];
ln.name() == node.name
&& matches!(
(&ln.kind, node.kind),
(NodeKind::Literal(_), 1) | (NodeKind::Argument { .. }, 2)
)
})
});
queue.push_back(c);
}
}
let mut nodes = vec![None; count];
for (i, node) in packet.nodes.iter().enumerate() {
let local_node = matched[i].map(|l| &local.nodes[l]);
let kind = match node.kind {
1 => NodeKind::Literal(node.name.clone()),
2 => {
let ty = match local_node.map(|l| &l.kind) {
Some(NodeKind::Argument { ty, .. }) => ty.clone(),
_ => node
.parser
.as_ref()
.map_or(ArgType::Word, |(id, props)| argument_type(*id, props)),
};
NodeKind::Argument {
name: node.name.clone(),
ty,
}
}
_ => NodeKind::Root,
};
let suggests = match local_node {
Some(l) => l.suggests.clone(),
None => node.suggestions.as_deref().and_then(provider),
};
nodes[index(i)] = Some(Node {
kind: if i == root { NodeKind::Root } else { kind },
children: node.children.iter().filter_map(valid).map(index).collect(),
command: node.executable.then_some(SERVER_COMMAND),
redirect: node.redirect.as_ref().and_then(valid).map(index),
// The server sent only what the player may use.
permission: 0,
suggests,
});
}
let mut d = Dispatcher::new();
if count > 0 {
d.nodes = nodes.into_iter().flatten().collect();
}
d
}
/// SuggestionProviders by id (`ask_server` asks the server; the client's
/// own argument suggestions stand in for it).
fn provider(id: &str) -> Option<Suggests> {
match id {
"minecraft:summonable_entities" => Some(Suggests::SummonableEntities),
_ => None,
}
}
/// A registry name kept for the life of the game (argument types hold
/// `&'static str`): each distinct name is stored once.
fn intern(name: &str) -> &'static str {
static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
let mut names = NAMES
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(&n) = names.get(name) {
return n;
}
let n: &'static str = Box::leak(name.to_owned().into_boxed_str());
names.insert(n);
n
}
/// The argument type a parser and its properties describe
/// (ArgumentTypeInfo.Template.instantiate).
pub fn argument_type(id: i32, props: &[u8]) -> ArgType {
let Some(name) = usize::try_from(id).ok().and_then(|i| ARGUMENT_TYPES.get(i)) else {
return ArgType::Word;
};
let p = ArgumentProperties::decode(id, props).unwrap_or(ArgumentProperties::None);
let registry = || match &p {
ArgumentProperties::Registry(r) => intern(r),
_ => "",
};
match *name {
"brigadier:bool" => ArgType::Bool,
"brigadier:integer" => match p {
ArgumentProperties::Integer { min, max } => ArgType::Integer {
min: min.unwrap_or(i32::MIN),
max: max.unwrap_or(i32::MAX),
},
_ => ArgType::Integer {
min: i32::MIN,
max: i32::MAX,
},
},
// No long type of its own: a double over the same bounds reads
// the same digits.
"brigadier:long" => match p {
ArgumentProperties::Long { min, max } => ArgType::Double {
min: min.map_or(f64::MIN, |v| v as f64),
max: max.map_or(f64::MAX, |v| v as f64),
},
_ => ArgType::Double {
min: f64::MIN,
max: f64::MAX,
},
},
"brigadier:float" => match p {
ArgumentProperties::Float { min, max } => ArgType::Float {
min: min.unwrap_or(f32::MIN),
max: max.unwrap_or(f32::MAX),
},
_ => ArgType::Float {
min: f32::MIN,
max: f32::MAX,
},
},
"brigadier:double" => match p {
ArgumentProperties::Double { min, max } => ArgType::Double {
min: min.unwrap_or(f64::MIN),
max: max.unwrap_or(f64::MAX),
},
_ => ArgType::Double {
min: f64::MIN,
max: f64::MAX,
},
},
"brigadier:string" => match p {
ArgumentProperties::String(1) => ArgType::Str,
ArgumentProperties::String(2) => ArgType::Greedy,
_ => ArgType::Word,
},
"minecraft:entity" => match p {
ArgumentProperties::Entity {
single,
players_only,
} => ArgType::Entity {
single,
players: players_only,
},
_ => ArgType::Entity {
single: false,
players: false,
},
},
"minecraft:game_profile" => ArgType::GameProfile,
"minecraft:block_pos" => ArgType::BlockPos,
"minecraft:column_pos" => ArgType::ColumnPos,
"minecraft:vec3" => ArgType::Vec3 { center: true },
"minecraft:vec2" => ArgType::Vec2,
"minecraft:block_state" => ArgType::BlockState,
"minecraft:block_predicate" => ArgType::BlockPredicate,
"minecraft:item_stack" => ArgType::Item,
"minecraft:item_predicate" => ArgType::ItemPredicate,
"minecraft:team_color" => ArgType::TeamColor,
"minecraft:hex_color" => ArgType::HexColor,
"minecraft:component" => ArgType::Component,
"minecraft:style" => ArgType::Style,
"minecraft:message" => ArgType::Message,
"minecraft:nbt_compound_tag" => ArgType::Nbt,
"minecraft:nbt_tag" => ArgType::NbtTag,
"minecraft:nbt_path" => ArgType::NbtPath,
"minecraft:objective" => ArgType::Objective,
"minecraft:objective_criteria" => ArgType::Criteria,
"minecraft:operation" => ArgType::Operation,
"minecraft:particle" => ArgType::Particle,
"minecraft:angle" => ArgType::Angle,
"minecraft:rotation" => ArgType::Rotation,
"minecraft:scoreboard_slot" => ArgType::ScoreboardSlot,
"minecraft:score_holder" => ArgType::ScoreHolder {
multiple: matches!(p, ArgumentProperties::ScoreHolder { multiple: true }),
},
"minecraft:swizzle" => ArgType::Swizzle,
"minecraft:team" => ArgType::Team,
"minecraft:item_slot" => ArgType::Slot,
"minecraft:item_slots" => ArgType::Slots,
"minecraft:resource_location" => ArgType::Id("none"),
"minecraft:function" => ArgType::Function,
"minecraft:entity_anchor" => ArgType::Anchor,
"minecraft:int_range" => ArgType::IntRange,
"minecraft:float_range" => ArgType::FloatRange,
"minecraft:dimension" => ArgType::Dimension,
"minecraft:gamemode" => ArgType::GameMode,
"minecraft:time" => ArgType::Time {
min: match p {
ArgumentProperties::Time { min } => min,
_ => 0,
},
},
"minecraft:resource_or_tag" => ArgType::ResOrTag(registry()),
"minecraft:resource_or_tag_key" => ArgType::ResOrTagKey(registry()),
"minecraft:resource" => ArgType::Res(registry()),
"minecraft:resource_key" => ArgType::ResKey(registry()),
"minecraft:resource_selector" => ArgType::ResourceSelector(registry()),
"minecraft:template_mirror" => ArgType::Enum(&["none", "front_back", "left_right"]),
"minecraft:template_rotation" => {
ArgType::Enum(&["none", "clockwise_90", "180", "counterclockwise_90"])
}
"minecraft:heightmap" => ArgType::Enum(&[
"world_surface",
"motion_blocking",
"motion_blocking_no_leaves",
"ocean_floor",
]),
"minecraft:swing_animation" => ArgType::Enum(&["none", "whack", "stab"]),
"minecraft:loot_table" => ArgType::ResourceOrId("minecraft:loot_table"),
"minecraft:loot_predicate" => ArgType::ResourceOrId("minecraft:predicate"),
"minecraft:loot_modifier" => ArgType::ResourceOrId("minecraft:item_modifier"),
"minecraft:context_float_provider" => {
ArgType::ResourceOrId("minecraft:context_float_provider")
}
"minecraft:context_int_provider" => ArgType::ResourceOrId("minecraft:context_int_provider"),
"minecraft:slot_source" => ArgType::SlotSource,
"minecraft:dialog" => ArgType::ResourceOrId("minecraft:dialog"),
"minecraft:feature" => ArgType::ResourceOrId("minecraft:worldgen/feature"),
"minecraft:uuid" => ArgType::Uuid,
_ => ArgType::Word,
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::play::CommandNode,
};
fn literal(name: &str, children: Vec<i32>, executable: bool) -> CommandNode {
CommandNode {
kind: 1,
executable,
restricted: false,
children,
redirect: None,
name: name.into(),
parser: None,
suggestions: None,
}
}
fn argument(name: &str, parser: &str, props: Vec<u8>, executable: bool) -> CommandNode {
let id = ARGUMENT_TYPES.iter().position(|p| *p == parser).unwrap() as i32;
CommandNode {
kind: 2,
executable,
restricted: false,
children: Vec::new(),
redirect: None,
name: name.into(),
parser: Some((id, props)),
suggestions: Some("minecraft:ask_server".into()),
}
}
/// A tree with the root last (as vanilla's numbering need not put it
/// first), a vanilla command and one the client does not know.
fn packet() -> Commands {
let mut range = Writer::new();
range.u8(3);
range.i32(1);
range.i32(64);
Commands {
nodes: vec![
literal("give", vec![2], false),
literal("warp", vec![3], false),
argument("targets", "minecraft:entity", vec![2], false),
argument("count", "brigadier:integer", range.data, true),
CommandNode {
kind: 0,
executable: false,
restricted: false,
children: vec![0, 1],
redirect: None,
name: String::new(),
parser: None,
suggestions: None,
},
],
root: 4,
}
}
#[test]
fn argument_nodes_round_trip() {
let p = packet();
let mut w = Writer::new();
Commands::write(&p, &mut w);
let back = Commands::read(&mut Reader::new(&w.data)).unwrap();
assert_eq!(back, p);
}
#[test]
fn server_tree_becomes_a_dispatcher() {
let local = super::super::vanilla::dispatcher();
let d = from_packet(&packet(), local);
assert!(matches!(d.nodes[0].kind, NodeKind::Root));
let warp = d.find("warp").expect("warp");
let count = d.nodes[warp].children[0];
match &d.nodes[count].kind {
NodeKind::Argument {
ty: ArgType::Integer { min, max },
..
} => assert_eq!((*min, *max), (1, 64)),
other => panic!("count is {other:?}"),
}
assert_eq!(d.nodes[count].command, Some(SERVER_COMMAND));
// `give <targets>` is vanilla's: it keeps the client's type.
let give = d.find("give").expect("give");
let targets = d.nodes[give].children[0];
assert!(matches!(
d.nodes[targets].kind,
NodeKind::Argument {
ty: ArgType::Entity {
single: false,
players: true
},
..
}
));
}
#[test]
fn every_argument_type_decodes() {
for (id, name) in ARGUMENT_TYPES.iter().enumerate() {
let mut w = Writer::new();
match *name {
"brigadier:integer" | "brigadier:long" | "brigadier:float" | "brigadier:double" => {
w.u8(0)
}
"brigadier:string" => w.varint(2),
"minecraft:entity" | "minecraft:score_holder" => w.u8(1),
"minecraft:time" => w.i32(0),
n if n.starts_with("minecraft:resource") && n != "minecraft:resource_location" => {
w.string("minecraft:item")
}
_ => {}
}
let mut r = Reader::new(&w.data);
ArgumentProperties::read(&mut r, id as i32).unwrap();
assert_eq!(r.remaining(), 0, "{name}");
let _ = argument_type(id as i32, &w.data);
}
}
}
@@ -185,42 +185,6 @@ fn vanilla_error_messages() {
assert_eq!(e.message.plain(), "argument.block.property.invalid");
}
#[test]
fn commands_need_allow_commands() {
// IntegratedServer.getProfilePermissions: the owner of a world without
// Allow Commands is at LevelBasedPermissionSet.ALL.
let reg = Registries {
permission: 0,
..reg()
};
let d = vanilla::game_dispatcher();
let e = d.parse("gamemode creative", &reg).executable().unwrap_err();
assert_eq!(e.message.plain(), "command.unknown.command");
assert!(d.parse("me waves", &reg).executable().is_ok());
let parse = d.parse("", &reg);
// Brigadier itself lists every literal; the chat's (client tree) only
// the ones the player may use.
assert!(
d.suggestions(&parse, 0, &reg)
.1
.iter()
.any(|s| s.text == "gamemode")
);
let root: Vec<String> = d
.client_suggestions(&parse, 0, &reg)
.1
.into_iter()
.map(|s| s.text)
.collect();
// Commands.java with CommandSelection.INTEGRATED: seed and version need no level.
assert_eq!(
root,
[
"help", "list", "me", "msg", "random", "seed", "teammsg", "tell", "tm", "trigger",
"version", "w"
]
);
}
#[test]
fn suggestions_like_vanilla() {
let root = suggestions("");
assert!(root.contains(&"gamemode".to_owned()) && root.contains(&"tp".to_owned()));
@@ -570,25 +534,3 @@ fn place_template_tree() {
assert!(run("place template minecraft:x ~ ~ ~ sideways").is_err());
assert!(run("place template minecraft:x ~ ~ ~ none none 2").is_err());
}
#[test]
fn mod_commands_join_the_tree_for_every_player() {
let reg = Registries {
permission: 0,
..reg()
};
let d = vanilla::game_dispatcher().with_mod_commands(["where"]);
for line in ["where", "where north 12"] {
let parse = d.parse(line, &reg);
assert_eq!(parse.executable().unwrap().1, MOD_COMMAND, "{line}");
}
let parse = d.parse("w", &reg);
let names: Vec<String> = d
.client_suggestions(&parse, 1, &reg)
.1
.into_iter()
.map(|s| s.text)
.collect();
assert!(names.contains(&"where".to_owned()), "{names:?}");
// The game's own tree is left as it was.
assert!(vanilla::game_dispatcher().find("where").is_none());
}
-256
View File
@@ -1,256 +0,0 @@
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
//! count comparators read, the output it keeps, and the powered, automatic
//! and condition flags `CommandBlock.tick` runs on.
use crate::{
save::{number, text},
text::Text,
};
use horizon_world::nbt::Tag;
/// `CommandBlockEntity.Mode`, in declaration order (the edit screen's mode
/// button cycles through it).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Sequence,
Auto,
Redstone,
}
impl Mode {
/// `CommandBlockEntity.getMode`: the block the entity sits in.
pub fn of_block(name: &str) -> Option<Self> {
match name {
"minecraft:command_block" => Some(Self::Redstone),
"minecraft:repeating_command_block" => Some(Self::Auto),
"minecraft:chain_command_block" => Some(Self::Sequence),
_ => None,
}
}
/// The block `ServerGamePacketListenerImpl.handleSetCommandBlock` puts
/// down for a mode.
pub fn block(self) -> &'static str {
match self {
Self::Sequence => "minecraft:chain_command_block",
Self::Auto => "minecraft:repeating_command_block",
Self::Redstone => "minecraft:command_block",
}
}
/// `CycleButton`: the next value, wrapping round.
pub fn next(self) -> Self {
match self {
Self::Sequence => Self::Auto,
Self::Auto => Self::Redstone,
Self::Redstone => Self::Sequence,
}
}
/// `CommandBlockEditScreen`'s label for the mode.
pub fn label(self) -> &'static str {
match self {
Self::Sequence => "advMode.mode.sequence",
Self::Auto => "advMode.mode.auto",
Self::Redstone => "advMode.mode.redstone",
}
}
}
/// The `automatic` flag `CommandBlock` gives its entity: chain command blocks
/// are always active when placed.
pub fn automatic_block(name: &str) -> bool {
name == "minecraft:chain_command_block"
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
impl Default for CommandBlock {
fn default() -> Self {
Self {
command: String::new(),
success_count: 0,
custom_name: None,
track_output: true,
last_output: None,
update_last_execution: true,
last_execution: -1,
powered: false,
auto: false,
condition_met: false,
}
}
}
impl CommandBlock {
/// `CommandBlockEntity.loadAdditional` with `BaseCommandBlock.load`.
pub fn load(tag: &Tag) -> Self {
let flag = |key: &str, default: bool| number(tag.get(key)).map_or(default, |v| v != 0);
let track_output = flag("TrackOutput", true);
let update_last_execution = flag("UpdateLastExecution", true);
Self {
command: text(tag.get("Command")).unwrap_or_default(),
success_count: number(tag.get("SuccessCount")).unwrap_or(0) as i32,
custom_name: tag.get("CustomName").cloned(),
track_output,
last_output: track_output
.then(|| tag.get("LastOutput"))
.flatten()
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
update_last_execution,
last_execution: if update_last_execution {
number(tag.get("LastExecution")).unwrap_or(-1)
} else {
-1
},
powered: flag("powered", false),
auto: flag("auto", false),
condition_met: flag("conditionMet", false),
}
}
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
/// plain string component.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![
("Command", Tag::String(self.command.as_str().into())),
("SuccessCount", Tag::Int(self.success_count)),
];
if let Some(name) = &self.custom_name {
out.push(("CustomName", name.clone()));
}
out.push(("TrackOutput", Tag::Byte(i8::from(self.track_output))));
if self.track_output
&& let Some(last) = &self.last_output
{
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
}
out.push((
"UpdateLastExecution",
Tag::Byte(i8::from(self.update_last_execution)),
));
if self.update_last_execution && self.last_execution != -1 {
out.push(("LastExecution", Tag::Long(self.last_execution)));
}
out.push(("powered", Tag::Byte(i8::from(self.powered))));
out.push(("conditionMet", Tag::Byte(i8::from(self.condition_met))));
out.push(("auto", Tag::Byte(i8::from(self.auto))));
out
}
/// `BaseCommandBlock.setCommand`: a new command starts at no successes.
pub fn set_command(&mut self, command: &str) {
self.command = command.to_owned();
self.success_count = 0;
}
/// `getLastOutput().getString()`: what the edit screen shows.
pub fn last_output_string(&self) -> String {
self.last_output
.as_ref()
.map(Text::plain)
.unwrap_or_default()
}
/// `getName`: the custom name, else `@`.
pub fn name(&self) -> Text {
self.custom_name.as_ref().map_or_else(
|| Text::literal("@"),
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
)
}
}
/// What `CommandBlock.tick` does with a block of `mode` whose condition was
/// `met` when last checked: run its command (`Some(true)`), clear the success
/// count of a conditional block (`Some(false)`), or nothing. Chain blocks
/// only run from the block before them (`executeChain`).
pub fn tick_action(mode: Mode, met: bool, conditional: bool) -> Option<bool> {
match mode {
Mode::Sequence => None,
_ if met => Some(true),
_ if conditional => Some(false),
_ => None,
}
}
/// `CommandBlock.tick`: a repeating block ticks again next tick while powered
/// or always active.
pub fn repeats(mode: Mode, powered: bool, auto: bool) -> bool {
mode == Mode::Auto && (powered || auto)
}
/// `CommandBlock.setPoweredAndUpdate`: a rising signal schedules the impulse
/// or repeating block's tick unless it is always active anyway.
pub fn power_schedules(mode: Mode, was_powered: bool, powered: bool, auto: bool) -> bool {
powered && !was_powered && !auto && mode != Mode::Sequence
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_cycle_and_map_to_blocks() {
for mode in [Mode::Sequence, Mode::Auto, Mode::Redstone] {
assert_eq!(Mode::of_block(mode.block()), Some(mode));
}
assert_eq!(Mode::Redstone.next(), Mode::Sequence);
assert_eq!(Mode::Sequence.next(), Mode::Auto);
assert!(automatic_block("minecraft:chain_command_block"));
assert!(!automatic_block("minecraft:repeating_command_block"));
}
#[test]
fn tick_follows_the_condition() {
assert_eq!(tick_action(Mode::Redstone, true, false), Some(true));
assert_eq!(tick_action(Mode::Redstone, false, true), Some(false));
assert_eq!(tick_action(Mode::Auto, false, false), None);
assert_eq!(tick_action(Mode::Sequence, true, false), None);
assert!(repeats(Mode::Auto, false, true));
assert!(!repeats(Mode::Auto, false, false));
assert!(!repeats(Mode::Redstone, true, true));
}
#[test]
fn only_a_rising_edge_schedules() {
assert!(power_schedules(Mode::Redstone, false, true, false));
assert!(!power_schedules(Mode::Redstone, true, true, false));
assert!(!power_schedules(Mode::Redstone, false, true, true));
assert!(!power_schedules(Mode::Sequence, false, true, false));
}
#[test]
fn saves_like_base_command_block() {
let block = CommandBlock {
command: "say hi".into(),
success_count: 1,
last_output: Some(Text::literal("[12:00:00] hi")),
last_execution: 42,
powered: true,
..CommandBlock::default()
};
let tag = Tag::compound(block.save());
let back = CommandBlock::load(&tag);
assert_eq!(back.command, "say hi");
assert_eq!(back.success_count, 1);
assert_eq!(back.last_output_string(), "[12:00:00] hi");
assert_eq!(back.last_execution, 42);
assert!(back.powered && !back.auto && back.track_output);
// Not tracking output drops it, and no execution time is kept at -1.
let quiet = CommandBlock {
track_output: false,
last_output: Some(Text::literal("x")),
..CommandBlock::default()
};
let tag = Tag::compound(quiet.save());
assert!(tag.get("LastOutput").is_none() && tag.get("LastExecution").is_none());
assert_eq!(CommandBlock::load(&tag).last_output, None);
}
}
+3 -29
View File
@@ -70,12 +70,8 @@ pub enum BlockEntity {
Spawner(Box<crate::mobs::spawner::Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<crate::vault::Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
@@ -132,8 +128,6 @@ impl Crafter {
pub struct Banner {
pub layers: Vec<crate::banner::BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
@@ -198,24 +192,12 @@ impl BlockEntity {
if name == "minecraft:trial_spawner" {
return Some(Self::TrialSpawner(Box::default()));
}
if name == "minecraft:vault" {
return Some(Self::Vault(Box::default()));
}
if name == "minecraft:beehive" || name == "minecraft:bee_nest" {
return Some(Self::Beehive(Box::default()));
}
if name == "minecraft:lectern" {
return Some(Self::Lectern(Lectern::default()));
}
if crate::command_block::Mode::of_block(name).is_some() {
// CommandBlock.newBlockEntity: setAutomatic(this.automatic).
return Some(Self::CommandBlock(Box::new(
crate::command_block::CommandBlock {
auto: crate::command_block::automatic_block(name),
..Default::default()
},
)));
}
if let Some(sculk) = crate::sculk::SculkEntity::for_block(name) {
return Some(Self::Sculk(Box::new(sculk)));
}
@@ -290,9 +272,7 @@ impl BlockEntity {
Self::CreakingHeart(_) => "minecraft:creaking_heart",
Self::Spawner(_) => "minecraft:mob_spawner",
Self::TrialSpawner(_) => "minecraft:trial_spawner",
Self::Vault(_) => "minecraft:vault",
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -306,9 +286,7 @@ impl BlockEntity {
| Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => Vec::new(),
| Self::Beehive(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
@@ -330,9 +308,7 @@ impl BlockEntity {
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &[],
| Self::Beehive(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
@@ -350,9 +326,7 @@ impl BlockEntity {
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &mut [],
| Self::Beehive(_) => &mut [],
}
}
}
+8 -12
View File
@@ -467,16 +467,13 @@ impl FrameStats {
}
let total: f64 = sorted.iter().map(|&m| f64::from(m)).sum();
let percentile = |p: f64| sorted[((n as f64 * p).ceil() as usize).clamp(1, n) - 1];
// Average frame time of the slowest 1% (0.1%) of frames, as frames per second.
let low = |per: usize| {
let slowest = n.div_ceil(per);
let slow: f64 = sorted[n - slowest..]
.iter()
.map(|&m| f64::from(m))
.sum::<f64>()
/ slowest as f64;
1000.0 / slow
};
// Average frame time of the slowest 1% of frames, as frames per second.
let slowest = n.div_ceil(100);
let slow: f64 = sorted[n - slowest..]
.iter()
.map(|&m| f64::from(m))
.sum::<f64>()
/ slowest as f64;
// Where the slowest 1% of frames by CPU time go.
let slow_phases = {
let mut cpu = self.cpu.clone();
@@ -490,8 +487,7 @@ impl FrameStats {
"frames": n,
"seconds": total / 1000.0,
"avg_fps": n as f64 * 1000.0 / total,
"low_1pct_fps": low(100),
"low_01pct_fps": low(1000),
"low_1pct_fps": 1000.0 / slow,
"p50_ms": percentile(0.5),
"p95_ms": percentile(0.95),
"p99_ms": percentile(0.99),
+12 -27
View File
@@ -14,7 +14,7 @@ use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
path::Path,
sync::Arc,
};
@@ -1182,33 +1182,18 @@ impl Recipes {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut meta = HashMap::new();
let json_files = |dir: &Path| -> Result<Vec<PathBuf>> {
let mut files: Vec<_> = fs::read_dir(dir)?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
Ok(files)
};
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut files: Vec<(String, PathBuf)> = json_files(&root.join("data/minecraft/recipe"))?
.into_iter()
.map(|p| (String::new(), p))
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe"))?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
for namespace in crate::mod_content::namespaces(root) {
let dir = root.join(format!("data/{namespace}/recipe"));
if dir.is_dir() {
files.extend(
json_files(&dir)?
.into_iter()
.map(|p| (format!("{namespace}:"), p)),
);
}
}
for (prefix, path) in files {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let name = format!("{prefix}{stem}");
files.sort();
for path in files {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
let text = |k: &str| v[k].as_str().unwrap_or("").to_owned();
meta.insert(name.clone(), (text("category"), text("group")));
+1 -2
View File
@@ -164,8 +164,7 @@ fn operating_system() -> String {
)
}
/// Logical processors and the CPU's name (`12x AMD Ryzen 5 5600X ...`).
pub fn cpu() -> String {
fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| {
+7 -213
View File
@@ -36,9 +36,6 @@ pub struct Creative {
/// The scrollbar is held (scrolling): dragging moves it.
pub scrolling: bool,
pub search: String,
/// When the search box took the focus (`EditBox.setFocused`): its
/// cursor blinks from then.
pub search_focused: std::time::Instant,
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
@@ -49,24 +46,17 @@ pub struct Names {
}
impl Names {
pub fn load(root: &Path) -> Self {
let read = |namespace: &str| {
fs::read(root.join(format!("assets/{namespace}/lang/en_us.json")))
.ok()
.and_then(|b| serde_json::from_slice::<HashMap<String, String>>(&b).ok())
.unwrap_or_default()
};
let mut map = read("minecraft");
// Content mods' names (docs/modpacks.md); none in a vanilla root.
for namespace in crate::mod_content::namespaces(root) {
map.extend(read(&namespace));
}
let map = fs::read(root.join("assets/minecraft/lang/en_us.json"))
.ok()
.and_then(|b| serde_json::from_slice::<HashMap<String, String>>(&b).ok())
.unwrap_or_default();
Self { map }
}
pub fn item(&self, id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
let path = id.trim_start_matches("minecraft:");
self.map
.get(&format!("item.{namespace}.{path}"))
.or_else(|| self.map.get(&format!("block.{namespace}.{path}")))
.get(&format!("item.minecraft.{path}"))
.or_else(|| self.map.get(&format!("block.minecraft.{path}")))
.cloned()
.unwrap_or_else(|| path.replace('_', " "))
}
@@ -103,26 +93,6 @@ impl Creative {
_ => None,
}
}
/// Game slot id of an item tab's first display slot (ItemPickerMenu's
/// `CustomCreativeSlot`s, 9 by 5); its hotbar row keeps ids 0..9.
pub const DISPLAY: usize = 900;
/// Visible slots of an item tab (game slot ids).
pub fn tab_slots() -> impl Iterator<Item = usize> {
(Self::DISPLAY..Self::DISPLAY + 45).chain(0..9)
}
/// ItemPickerMenu's slot positions.
pub fn tab_slot_position(slot: usize) -> Option<(f32, f32)> {
match slot {
0..9 => Some((9.0 + slot as f32 * 18.0, 112.0)),
_ if (Self::DISPLAY..Self::DISPLAY + 45).contains(&slot) => {
let i = slot - Self::DISPLAY;
Some((9.0 + (i % 9) as f32 * 18.0, 18.0 + (i / 9) as f32 * 18.0))
}
_ => None,
}
}
/// The scrollbar track (insideScrollbar), relative to the screen origin.
pub const SCROLL_X: f32 = 175.0;
pub const SCROLL_Y: f32 = 18.0;
@@ -242,21 +212,10 @@ impl Creative {
scroll: 0.0,
scrolling: false,
search: String::new(),
search_focused: std::time::Instant::now(),
search_items,
search_index,
}
}
/// Where a stack comes in the creative tabs (the search tab lists them
/// all, in tab order): the same stack's place, else its item's first.
/// None for items the tabs don't show.
pub fn order_of(&self, stack: &ItemStack) -> Option<usize> {
let items = &self.search_items;
items
.iter()
.position(|s| s.same_item_same_components(stack))
.or_else(|| items.iter().position(|s| s.item == stack.item))
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
}
@@ -267,9 +226,6 @@ impl Creative {
}
self.selected = tab;
self.scroll = 0.0;
if self.tab().search {
self.search_focused = std::time::Instant::now();
}
}
/// The search tab's stacks.
pub fn search_items(&self) -> &[ItemStack] {
@@ -424,172 +380,10 @@ fn split_id(id: &str) -> (String, String) {
None => ("minecraft".to_owned(), id.to_owned()),
}
}
/// What a click on an item tab's display slot does besides changing the
/// carried stack.
#[derive(Clone, PartialEq, Debug)]
pub enum DisplayClick {
Nothing,
/// SWAP: the hotbar slot gets a full stack of the item.
Hotbar(usize, ItemStack),
/// THROW: a copy of the item is dropped.
Drop(ItemStack),
}
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
/// the tab's end) and `max` gives an item's max stack size. The tab's items
/// are never changed: clicks make copies.
pub fn click_display(
carried: &mut Option<ItemStack>,
shown: Option<&ItemStack>,
max: impl Fn(&ItemStack) -> u32,
button: i32,
input: crate::menu::Input,
) -> DisplayClick {
use crate::menu::Input;
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
let quick_key = input == Input::QuickMove;
match input {
Input::Swap => {
return match shown {
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
None => DisplayClick::Nothing,
};
}
Input::Clone => {
if carried.is_none()
&& let Some(s) = shown
{
*carried = Some(copy(s, max(s)));
}
return DisplayClick::Nothing;
}
Input::Throw => {
return match shown {
Some(s) => DisplayClick::Drop(copy(s, if button == 0 { 1 } else { max(s) })),
None => DisplayClick::Nothing,
};
}
_ => {}
}
match (carried.as_mut(), shown) {
(Some(c), Some(s)) if c.item == s.item && c.extra == s.extra && c.damage == s.damage => {
// isSameItemSameComponents: grow, fill or shrink what is carried.
if button == 0 {
if quick_key {
c.count = max(c);
} else if c.count < max(c) {
c.count += 1;
}
} else {
c.count -= 1;
}
}
(None, Some(s)) => {
*carried = Some(copy(s, if quick_key { max(s) } else { s.count }));
}
(Some(c), _) => {
if button == 0 {
*carried = None;
} else {
c.count -= 1;
}
}
(None, None) => {}
}
if carried.as_ref().is_some_and(|c| c.count == 0) {
*carried = None;
}
DisplayClick::Nothing
}
#[cfg(test)]
mod tests {
use super::*;
fn stack(item: &str, count: u32) -> ItemStack {
ItemStack {
item: format!("minecraft:{item}").into(),
count,
damage: 0,
extra: None,
}
}
/// One click on a display slot showing one stone (max 64; pearls 16).
fn display(
carried: Option<ItemStack>,
button: i32,
input: crate::menu::Input,
) -> (Option<ItemStack>, DisplayClick) {
let mut carried = carried;
let max = |s: &ItemStack| if s.item.ends_with("pearl") { 16 } else { 64 };
let effect = click_display(&mut carried, Some(&stack("stone", 1)), max, button, input);
(carried, effect)
}
#[test]
fn display_slot_clicks_follow_slot_clicked() {
use crate::menu::Input::*;
// An empty hand takes the shown stack, or a full one with Shift.
assert_eq!(display(None, 0, Pickup).0, Some(stack("stone", 1)));
assert_eq!(display(None, 1, Pickup).0, Some(stack("stone", 1)));
assert_eq!(display(None, 0, QuickMove).0, Some(stack("stone", 64)));
// The same item grows by one (left), fills (Shift) or shrinks (right).
assert_eq!(
display(Some(stack("stone", 5)), 0, Pickup).0,
Some(stack("stone", 6))
);
assert_eq!(
display(Some(stack("stone", 64)), 0, Pickup).0,
Some(stack("stone", 64))
);
assert_eq!(
display(Some(stack("stone", 5)), 0, QuickMove).0,
Some(stack("stone", 64))
);
assert_eq!(
display(Some(stack("stone", 5)), 1, Pickup).0,
Some(stack("stone", 4))
);
assert_eq!(display(Some(stack("stone", 1)), 1, QuickMove).0, None);
// Another item is deleted (left) or loses one (right).
assert_eq!(display(Some(stack("ender_pearl", 9)), 0, Pickup).0, None);
assert_eq!(display(Some(stack("ender_pearl", 9)), 0, QuickMove).0, None);
assert_eq!(
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
Some(stack("ender_pearl", 8))
);
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
assert_eq!(
display(None, 3, Swap).1,
DisplayClick::Hotbar(3, stack("stone", 64))
);
assert_eq!(
display(None, 0, Throw).1,
DisplayClick::Drop(stack("stone", 1))
);
assert_eq!(
display(None, 1, Throw).1,
DisplayClick::Drop(stack("stone", 64))
);
assert_eq!(display(None, 2, Clone).0, Some(stack("stone", 64)));
assert_eq!(
display(Some(stack("dirt", 2)), 2, Clone).0,
Some(stack("dirt", 2))
);
}
#[test]
fn an_empty_display_slot_deletes_or_shrinks_the_carried_stack() {
use crate::menu::Input::Pickup;
let mut carried = Some(stack("dirt", 3));
click_display(&mut carried, None, |_| 64, 1, Pickup);
assert_eq!(carried, Some(stack("dirt", 2)));
click_display(&mut carried, None, |_| 64, 0, Pickup);
assert_eq!(carried, None);
}
fn creative(items: usize) -> Creative {
let mut stacks: Vec<String> = ["anvil", "crafting_table", "wooden_hoe"]
.iter()
+1 -62
View File
@@ -34,27 +34,6 @@ impl DataPack {
}
}
}
/// The files of `data/<namespace>/<kind>/` for each kind, in the order
/// `list("data/")` gives them. A folder pack walks only those folders:
/// the built-in pack holds about 10,000 other data files.
fn list_kinds(&self, kinds: &[&str]) -> Vec<String> {
debug_assert!(kinds.is_sorted(), "kinds in walk order");
let Source::Dir(root) = &self.source else {
return self.list("data/");
};
let Ok(entries) = std::fs::read_dir(root.join("data")) else {
return Vec::new();
};
let mut namespaces: Vec<_> = entries.flatten().map(|e| e.path()).collect();
namespaces.sort();
let mut out = Vec::new();
for ns in namespaces {
for kind in kinds {
walk(root, &ns.join(kind), &mut out);
}
}
out
}
fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
@@ -329,7 +308,7 @@ impl DataStore {
pub fn load(packs: &[&DataPack]) -> Self {
let mut s = Self::default();
for pack in packs {
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
for f in pack.list("data/") {
if let Some(id) = resource_id(&f, "function", ".mcfunction")
&& let Some(bytes) = pack.read(&f)
{
@@ -526,44 +505,4 @@ mod tests {
assert!(store.predicates.contains_key("mine:p"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn folder_packs_list_the_needed_kinds_in_walk_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-kinds-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let pack = dir.join("datapacks/p");
let files = [
"data/b/function/z.mcfunction",
"data/b/function/a/y.mcfunction",
"data/b/loot_table/l.json",
"data/b/tags/block/t.json",
"data/b/tags/function/load.json",
"data/b/predicate/p.json",
"data/a/item_modifier/m.json",
"data/a/function/f.mcfunction",
"data/a/functions/old.mcfunction",
"data/a/recipe/r.json",
"data/a/tags/function/tick.json",
"data/c/worldgen/noise/n.json",
];
for f in files {
let path = pack.join(f);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "{}").unwrap();
}
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
let p = DataPack::builtin(&pack);
let kinds = ["function", "item_modifier", "predicate", "tags/function"];
let wanted: Vec<String> = p
.list("data/")
.into_iter()
.filter(|f| {
let rest = f.splitn(3, '/').nth(2).unwrap_or_default();
kinds.iter().any(|k| rest.starts_with(&format!("{k}/")))
})
.collect();
assert_eq!(wanted.len(), 7);
assert_eq!(p.list_kinds(&kinds), wanted);
let _ = std::fs::remove_dir_all(&dir);
}
}
-43
View File
@@ -72,12 +72,6 @@ pub struct DebugCharts {
pub ping: SampleLogger,
/// `bandwidthLogger`: bytes received per tick.
pub bandwidth: SampleLogger,
/// `MinecraftServer.smoothedTickTimeMillis`.
pub smoothed_tick_ms: f32,
/// Connection's smoothed packets per second, sent then received.
pub packet_rates: [f32; 2],
packet_totals: [i64; 2],
packet_tick: u8,
previous_tick: Option<Instant>,
bytes_seen: Option<i64>,
}
@@ -116,10 +110,6 @@ impl DebugCharts {
self.bandwidth.reset();
self.previous_tick = None;
self.bytes_seen = None;
self.smoothed_tick_ms = 0.0;
self.packet_rates = [0.0; 2];
self.packet_totals = [0; 2];
self.packet_tick = 0;
}
/// One integrated server tick that started at `started` and ran its
/// tick method for `method` (`MinecraftServer.logFullTickTime` and
@@ -133,7 +123,6 @@ impl DebugCharts {
.max(method);
self.previous_tick = Some(started);
self.tick_times.log_full([full, method, 0, full - method]);
self.smoothed_tick_ms = self.smoothed_tick_ms * 0.8 + method as f32 / 1e6 * 0.199_999_99;
}
/// `BandwidthDebugMonitor.tick`: the bytes received since the last
/// tick, from the connection's running total.
@@ -143,17 +132,6 @@ impl DebugCharts {
}
self.bytes_seen = Some(total);
}
/// `Connection.tickSecond`: sampled on the first tick, then every 20 ticks.
pub fn log_packets(&mut self, totals: [i64; 2]) {
if self.packet_tick == 0 {
for (i, total) in totals.into_iter().enumerate() {
let count = (total - self.packet_totals[i]).max(0) as f32;
self.packet_rates[i] = count + 0.75 * (self.packet_rates[i] - count);
}
self.packet_totals = totals;
}
self.packet_tick = (self.packet_tick + 1) % 20;
}
/// The charts along the bottom of the screen (`extractRenderState`):
/// frame times left and tick times right, or bandwidth left (remote
/// servers only) and ping right. `mspt` is the level's milliseconds
@@ -542,27 +520,6 @@ mod tests {
assert!(draws.contains(&fill(361, 174, 365, 178, 0xFF0A_141E)));
}
#[test]
fn packet_rates_follow_connection_smoothing_and_reset_between_worlds() {
let mut charts = DebugCharts::default();
charts.log_packets([80, 400]);
assert_eq!(charts.packet_rates, [20.0, 100.0]);
for _ in 1..20 {
charts.log_packets([180, 800]);
}
assert_eq!(charts.packet_rates, [20.0, 100.0]);
charts.log_packets([180, 800]);
assert_eq!(charts.packet_rates, [40.0, 175.0]);
for _ in 0..20 {
charts.log_packets([180, 800]);
}
assert_eq!(charts.packet_rates, [30.0, 131.25]);
charts.reset();
assert_eq!(charts.packet_rates, [0.0, 0.0]);
charts.log_packets([4, 8]);
assert_eq!(charts.packet_rates, [1.0, 2.0]);
}
#[test]
fn ticks_and_bandwidth_log_their_differences() {
let mut c = DebugCharts::default();
-340
View File
@@ -1,340 +0,0 @@
//! `DebugScreenOverlay`: the F3 text. The entries of the default profile
//! (`DebugScreenEntries.PROFILES`) report priority lines, regular lines and
//! groups; the overlay splits them into a left and a right column and draws
//! them last, over any screen, at the debug GUI scale.
use crate::{
font::Font,
hud_layout::{GuiDraw, PoseOp, fill},
text::Text,
};
/// `extractLines`'s background: ARGB 0x90505050.
const BACKGROUND: u32 = 0x9050_5050;
/// `extractLines`'s text colour: ARGB 0xFFE0E0E0.
const TEXT: u32 = 0xFFE0_E0E0;
/// `Font.lineHeight`.
const LINE_HEIGHT: i32 = 9;
/// What the enabled entries reported (`DebugScreenDisplayer`).
#[derive(Default, Debug)]
pub struct Displayer {
left: Vec<String>,
right: Vec<String>,
lines: Vec<String>,
groups: Vec<(&'static str, Vec<String>)>,
}
impl Displayer {
/// `addPriorityLine`: to the shorter column, the left one first.
pub fn priority(&mut self, line: impl Into<String>) {
if self.left.len() > self.right.len() {
self.right.push(line.into());
} else {
self.left.push(line.into());
}
}
/// `addLine`: split between the columns after the priority lines.
pub fn line(&mut self, line: impl Into<String>) {
self.lines.push(line.into());
}
/// `addToGroup`: lines kept together, groups in the order first added.
pub fn group(&mut self, group: &'static str, lines: impl IntoIterator<Item = String>) {
let index = match self.groups.iter().position(|(g, _)| *g == group) {
Some(i) => i,
None => {
self.groups.push((group, Vec::new()));
self.groups.len() - 1
}
};
self.groups[index].1.extend(lines);
}
/// `extractRenderState`: the left and right columns, then `footer` (the
/// chart keys, shown while the overlay is open) under the left one.
pub fn columns(self, footer: &[String]) -> (Vec<String>, Vec<String>) {
let (mut left, mut right) = (self.left, self.right);
if !left.is_empty() {
left.push(String::new());
}
if !right.is_empty() {
right.push(String::new());
}
if !self.lines.is_empty() {
let mid = self.lines.len().div_ceil(2);
left.extend_from_slice(&self.lines[..mid]);
right.extend_from_slice(&self.lines[mid..]);
left.push(String::new());
if mid < self.lines.len() {
right.push(String::new());
}
}
let mid = self.groups.len().div_ceil(2);
for (i, (_, lines)) in self.groups.into_iter().enumerate() {
if lines.is_empty() {
continue;
}
let column = if i < mid { &mut left } else { &mut right };
column.extend(lines);
column.push(String::new());
}
if !footer.is_empty() {
left.push(String::new());
left.extend_from_slice(footer);
}
(left, right)
}
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
if wrapped >= 180.0 {
wrapped - 360.0
} else if wrapped < -180.0 {
wrapped + 360.0
} else {
wrapped
}
}
/// `DebugEntryPosition` and `DebugEntrySectionPosition` for a camera at
/// `pos` looking `yaw`/`pitch` in `dimension` with `forced` force-loaded chunks.
pub fn position(
pos: [f64; 3],
yaw: f32,
pitch: f32,
dimension: &str,
forced: usize,
) -> Vec<String> {
let block = pos.map(|v| v.floor() as i32);
let (cx, sy, cz) = (block[0] >> 4, block[1] >> 4, block[2] >> 4);
// Direction.fromYRot: floor(yRot / 90 + 0.5) & 3 as a 2D data value.
let (direction, towards) = match ((f64::from(yaw) / 90.0 + 0.5).floor() as i32) & 3 {
0 => ("south", "Towards positive Z"),
1 => ("west", "Towards negative X"),
2 => ("north", "Towards negative Z"),
_ => ("east", "Towards positive X"),
};
vec![
format!("XYZ: {:.3} / {:.5} / {:.3}", pos[0], pos[1], pos[2]),
format!("Block: {} {} {}", block[0], block[1], block[2]),
format!(
"Chunk: {cx} {sy} {cz} [{} {} in r.{}.{}.mca]",
cx & 31,
cz & 31,
cx >> 5,
cz >> 5
),
format!(
"Facing: {direction} ({towards}) ({:.1} / {:.1})",
wrap_degrees(yaw),
wrap_degrees(pitch)
),
format!("{dimension} FC: {forced}"),
format!(
"Section-relative: {:02} {:02} {:02}",
block[0] & 15,
block[1] & 15,
block[2] & 15
),
]
}
/// `VideoMode.refreshRateLabel`: whole rates without decimals.
pub fn refresh_rate_label(hz: f64) -> String {
if hz == hz.round() {
format!("{}", hz as i64)
} else {
format!("{hz:.2}")
}
}
/// `DeviceInfo.vendorName` for a PCI vendor id.
pub fn vendor_name(id: u32) -> String {
match id {
0x10de => "NVIDIA Corporation".into(),
0x1002 | 0x1022 => "AMD".into(),
0x8086 => "Intel".into(),
0x106b => "Apple".into(),
0x5143 => "Qualcomm".into(),
0x13b5 => "ARM".into(),
0x10005 => "Mesa".into(),
0 => "unknown".into(),
other => format!("{other:#06x}"),
}
}
/// The overlay's scale against the GUI scale and the scaled screen size it
/// lays out in. `debugGuiScale`: -1 keeps the GUI scale, 0 (the default) is
/// half the largest automatic scale, n is `calculateScale(n)`; only a scale
/// below the GUI scale applies.
pub fn scale(
framebuffer: [i32; 2],
gui_scale: i32,
option: i32,
unicode: bool,
gui_size: [i32; 2],
) -> (f32, [i32; 2]) {
let scale = match option {
-1 => gui_scale,
0 => crate::window::calculate_scale(framebuffer, 0, unicode) / 2,
n => crate::window::calculate_scale(framebuffer, n, unicode),
};
if scale < gui_scale && scale > 0 {
(
scale as f32 / gui_scale as f32,
[framebuffer[0] / scale, framebuffer[1] / scale],
)
} else {
(1.0, gui_size)
}
}
/// `extractLines` for both columns under the overlay's pose: each line's
/// background first, then the text, right-aligned in the right column.
pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec<GuiDraw> {
let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])];
for (lines, align_left) in [(columns[0], true), (columns[1], false)] {
let placed: Vec<(&String, i32, i32)> = lines
.iter()
.enumerate()
.filter(|(_, l)| !l.is_empty())
.map(|(i, l)| {
let w = font.width(l) as i32;
let left = if align_left { 2 } else { width - 2 - w };
(l, left, 2 + LINE_HEIGHT * i as i32)
})
.collect();
for (line, left, top) in &placed {
let w = font.width(line) as i32;
out.push(fill(
left - 1,
top - 1,
left + w + 1,
top + LINE_HEIGHT - 1,
BACKGROUND,
));
}
for (line, left, top) in placed {
out.push(GuiDraw::Text {
text: Text::literal(line.clone()),
x: left,
y: top,
color: TEXT,
shadow: false,
});
}
}
out.push(GuiDraw::Pop);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn columns_follow_the_vanilla_split() {
// The default profile's entries, sorted by identifier as
// DebugScreenEntryList.rebuildCurrentList does: fps, game_version,
// memory, player_position, player_section_position,
// simple_performance_impactors, system_specs, tps.
let mut d = Displayer::default();
d.priority("112 fps T: 120 (fifo) @165Hz");
d.priority("Minecraft 26.3 (26.3/vanilla)");
d.group("memory", lines(&["Mem: 25% 1046/4096MiB"]));
d.group("position", lines(&["XYZ: 1 / 2 / 3", "Block: 1 2 3"]));
d.group("position", lines(&["Section-relative: 01 02 03"]));
d.line("fancy-clouds B: 2");
d.line("Filtering: RGSS");
d.line("Terrain Rendering: multidrawindirect");
d.group(
"system",
lines(&["CPU: 12x Ryzen", "Display: 854x709 (NVIDIA)"]),
);
d.line("Integrated server @ 6.6/50.0 ms, 28 tx, 492 rx");
let footer = lines(&["Debug charts: ...", "To edit: press [F3+F6]"]);
let (left, right) = d.columns(&footer);
// As the issue's vanilla screenshot (#376) shows them.
assert_eq!(
left,
lines(&[
"112 fps T: 120 (fifo) @165Hz",
"",
"fancy-clouds B: 2",
"Filtering: RGSS",
"",
"Mem: 25% 1046/4096MiB",
"",
"XYZ: 1 / 2 / 3",
"Block: 1 2 3",
"Section-relative: 01 02 03",
"",
"",
"Debug charts: ...",
"To edit: press [F3+F6]",
])
);
assert_eq!(
right,
lines(&[
"Minecraft 26.3 (26.3/vanilla)",
"",
"Terrain Rendering: multidrawindirect",
"Integrated server @ 6.6/50.0 ms, 28 tx, 492 rx",
"",
"CPU: 12x Ryzen",
"Display: 854x709 (NVIDIA)",
"",
])
);
}
#[test]
fn position_lines_match_vanilla() {
// The issue's vanilla screenshot (#376).
assert_eq!(
position([108.5, 104.0, -51.5], -1.0, 7.1, "minecraft:overworld", 0),
lines(&[
"XYZ: 108.500 / 104.00000 / -51.500",
"Block: 108 104 -52",
"Chunk: 6 6 -4 [6 28 in r.0.-1.mca]",
"Facing: south (Towards positive Z) (-1.0 / 7.1)",
"minecraft:overworld FC: 0",
"Section-relative: 12 08 12",
])
);
assert_eq!(
position([0.0, 64.0, 0.0], 450.0, 0.0, "minecraft:the_nether", 2)[3],
"Facing: west (Towards negative X) (90.0 / 0.0)"
);
assert_eq!(refresh_rate_label(165.0), "165");
assert_eq!(refresh_rate_label(59.94), "59.94");
}
#[test]
fn debug_scale_is_half_the_automatic_scale() {
// The issue's window: 854x709 at GUI scale 2 draws F3 at scale 1.
assert_eq!(
scale([854, 709], 2, 0, false, [427, 355]),
(0.5, [854, 709])
);
// 1920x1080 at GUI scale 4: auto is 4, so F3 is at 2.
assert_eq!(
scale([1920, 1080], 4, 0, false, [480, 270]),
(0.5, [960, 540])
);
// A GUI scale already at or under the debug scale is kept.
assert_eq!(
scale([1920, 1080], 2, 0, false, [960, 540]),
(1.0, [960, 540])
);
assert_eq!(
scale([1920, 1080], 3, -1, false, [640, 360]),
(1.0, [640, 360])
);
}
}
-41
View File
@@ -68,14 +68,6 @@ impl EffectInstance {
}
changed
}
/// `MobEffectInstance.tickClient`: a client counts its effects down
/// (the server removes them).
pub fn tick_client(&mut self) {
if self.infinite() || self.duration > 0 {
self.tick_down();
self.downgrade();
}
}
fn tick_down(&mut self) {
if let Some(h) = self.hidden.as_mut() {
h.tick_down();
@@ -178,39 +170,6 @@ impl PlayerEffects {
pub fn clear(&mut self) {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, _) in saved {
if let Some(def) = brewing::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
effects.add(e);
}
}
effects
}
/// `Player.active_effects` for the save.
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
self.list
.iter()
.map(|e| {
(
format!("minecraft:{}", e.id),
e.duration,
e.amplifier,
e.ambient,
e.visible,
)
})
.collect()
}
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
/// particles visible and the icon shown (always, for these effects).
pub fn flags(e: &EffectInstance) -> i8 {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick). Returns the actions to apply, in order, and
-2
View File
@@ -185,8 +185,6 @@ impl Enchantments {
.into_iter()
.filter(|(k, _)| {
k.starts_with("enchantment.")
|| k.starts_with("attribute.")
|| k.starts_with("item.modifiers.")
|| k.starts_with("effect.")
|| k.starts_with("item.minecraft.potion")
|| k.starts_with("item.minecraft.splash_potion")
-45
View File
@@ -301,20 +301,6 @@ pub struct Experience {
pub total: i32,
}
impl Experience {
/// `Player.getBaseExperienceReward`: keep-inventory and spectators drop none.
pub fn death_reward(&self, keep: bool) -> u32 {
if keep {
0
} else {
self.level.wrapping_mul(7).clamp(0, 100) as u32
}
}
/// `ServerPlayer.restoreFrom`: only keep-inventory and spectators retain XP.
pub fn respawn(&mut self, keep: bool) {
if !keep {
*self = Self::default();
}
}
pub fn needed(level: i32) -> i32 {
if level >= 30 {
112 + (level - 30) * 9
@@ -554,37 +540,6 @@ impl ArmorStand {
}
}
#[cfg(test)]
mod experience_tests {
use super::*;
#[test]
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
let xp = Experience {
level,
progress: 0.75,
total: 10_000,
};
assert_eq!(xp.death_reward(false), reward);
assert_eq!(xp.death_reward(true), 0);
}
}
#[test]
fn respawn_clears_all_xp_unless_it_is_kept() {
let mut xp = Experience::default();
xp.give(155);
let before = (xp.level, xp.progress, xp.total);
xp.respawn(true);
assert_eq!((xp.level, xp.progress, xp.total), before);
xp.respawn(false);
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
xp.give(7);
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
}
}
#[cfg(test)]
mod fire_environment_tests {
use super::*;
+2 -15
View File
@@ -58,8 +58,8 @@ fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
return vec4(1.0);
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
var out: Out;
out.clip = globals.view_proj * vec4(position, 1.0);
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
@@ -90,19 +90,6 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
if overlay == 4u { out.spherical = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position, uv, overlay, color, light, normal);
}
struct CloudInfo {
offset: vec4<f32>,
colors: array<vec4<f32>, 4>,
}
@group(1) @binding(0) var<uniform> cloud: CloudInfo;
@vertex fn vs_cloud(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position + cloud.offset.xyz, uv, overlay, cloud.colors[light.x], light, normal);
}
// rendertype_end_portal.fsh: the layer colours.
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
-266
View File
@@ -1,266 +0,0 @@
//! Conservative, budgeted occlusion against full opaque block cross-sections.
//!
//! A covered projection rectangle proves that an entire bounding box is hidden.
//! Testing a few rays would miss openings between those rays.
use std::cell::Cell;
const EPSILON: f64 = 1.0e-5;
pub struct Culler {
pub camera: [f64; 3],
remaining: Cell<usize>,
}
impl Culler {
pub fn new(camera: [f64; 3], budget: usize) -> Self {
Self {
camera,
remaining: Cell::new(budget),
}
}
/// Remove only this entity's completed camera-relative mesh when hidden.
pub fn cull_vertices(
&self,
vertices: &mut Vec<crate::mesher::Vertex>,
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if start >= vertices.len() || self.remaining.get() == 0 {
return false;
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vertex in &vertices[start..] {
for a in 0..3 {
let value = f64::from(vertex.pos[a]) + self.camera[a];
if !value.is_finite() {
return false;
}
min[a] = min[a].min(value);
max[a] = max[a].max(value);
}
}
// Cover floating-point and rasterization edges as well as the vertices.
if self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque) {
vertices.truncate(start);
return true;
}
false
}
/// Returns true only when a complete opaque block plane covers the bounds.
/// Unknown blocks, invalid bounds and an exhausted lookup budget stay visible.
pub fn occluded(
&self,
min: [f64; 3],
max: [f64; 3],
mut opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if (0..3).any(|a| {
!self.camera[a].is_finite()
|| !min[a].is_finite()
|| !max[a].is_finite()
|| min[a] > max[a]
}) {
return false;
}
let mut best = (0, 0.0, 0.0);
for a in 0..3 {
let (gap, sign) = if self.camera[a] < min[a] {
(min[a] - self.camera[a], 1.0)
} else {
(self.camera[a] - max[a], -1.0)
};
if gap > best.1 {
best = (a, gap, sign);
}
}
let (axis, gap, sign) = best;
if !gap.is_finite() || gap <= EPSILON {
return false;
}
let across = [(axis + 1) % 3, (axis + 2) % 3];
let Some(mut cell) = floor_cell(self.camera[axis]) else {
return false;
};
let step = sign as i32;
if (f64::from(cell) + 0.5 - self.camera[axis]) * sign <= EPSILON {
let Some(next) = cell.checked_add(step) else {
return false;
};
cell = next;
}
loop {
let depth = (f64::from(cell) + 0.5 - self.camera[axis]) * sign;
if depth >= gap - EPSILON || self.remaining.get() == 0 {
return false;
}
let mut lo = [f64::INFINITY; 2];
let mut hi = [f64::NEG_INFINITY; 2];
for corner in 0..8 {
let point: [f64; 3] = std::array::from_fn(|a| {
if corner & (1 << a) == 0 {
min[a]
} else {
max[a]
}
});
let t = depth / ((point[axis] - self.camera[axis]) * sign);
for (i, &a) in across.iter().enumerate() {
let p = self.camera[a] + t * (point[a] - self.camera[a]);
if !p.is_finite() {
return false;
}
lo[i] = lo[i].min(p);
hi[i] = hi[i].max(p);
}
}
let bounds = [
floor_cell(lo[0] - EPSILON),
floor_cell(hi[0] + EPSILON),
floor_cell(lo[1] - EPSILON),
floor_cell(hi[1] + EPSILON),
];
let [Some(x0), Some(x1), Some(y0), Some(y1)] = bounds else {
return false;
};
let mut pos = [0; 3];
pos[axis] = cell;
pos[across[0]] = x0 + ((i64::from(x1) - i64::from(x0)) / 2) as i32;
pos[across[1]] = y0 + ((i64::from(y1) - i64::from(y0)) / 2) as i32;
self.remaining.set(self.remaining.get() - 1);
if opaque(pos) {
let area = (i64::from(x1) - i64::from(x0) + 1)
.checked_mul(i64::from(y1) - i64::from(y0) + 1)
.and_then(|n| usize::try_from(n).ok());
let Some(area) = area.filter(|&n| n <= self.remaining.get()) else {
return false;
};
// Reserve the worst-case lookups before walking the rectangle.
self.remaining.set(self.remaining.get() - area);
let covered = (x0..=x1).all(|x| {
(y0..=y1).all(|y| {
pos[across[0]] = x;
pos[across[1]] = y;
opaque(pos)
})
});
if covered {
return true;
}
}
let Some(next) = cell.checked_add(step) else {
return false;
};
cell = next;
}
}
}
fn floor_cell(value: f64) -> Option<i32> {
let n = value.floor();
(n.is_finite() && n >= f64::from(i32::MIN) && n <= f64::from(i32::MAX)).then_some(n as i32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completed_mesh_culling_preserves_prior_geometry_and_protruding_parts() {
let vertex = |pos| crate::mesher::Vertex {
pos,
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
};
let prior = vertex([0.0; 3]);
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
let wall = |p: [i32; 3]| p[0] == 4 && (-1..=0).contains(&p[1]);
let mut hidden = vec![prior];
hidden.extend(body);
assert!(Culler::new([0.0; 3], 1_000).cull_vertices(&mut hidden, 1, wall));
assert_eq!(hidden.len(), 1);
assert_eq!(hidden[0].pos, prior.pos);
// A held item or animated part beyond the wall keeps the whole model.
let mut exposed = vec![prior];
exposed.extend(body);
exposed.push(vertex([8.0, 5.0, 0.0]));
let before = exposed.clone();
assert!(!Culler::new([0.0; 3], 1_000).cull_vertices(&mut exposed, 1, wall));
assert_eq!(
bytemuck::cast_slice::<_, u8>(&exposed),
bytemuck::cast_slice::<_, u8>(&before)
);
assert!(!Culler::new([0.0; 3], 0).cull_vertices(&mut exposed, 1, |_| panic!()));
}
#[test]
fn opaque_walls_hide_bounds_in_all_directions() {
for axis in 0..3 {
for sign in [-1.0, 1.0] {
let mut min = [-2.0; 3];
let mut max = [2.0; 3];
min[axis] = if sign > 0.0 { 8.0 } else { -10.0 };
max[axis] = if sign > 0.0 { 10.0 } else { -8.0 };
let wall = if sign > 0.0 { 4 } else { -5 };
assert!(Culler::new([0.0; 3], 1_000).occluded(min, max, |p| p[axis] == wall));
}
}
}
#[test]
fn windows_slits_and_partial_walls_stay_visible() {
let min = [8.0, -4.0, -4.0];
let max = [10.0, 4.0, 4.0];
for opening in 0..3 {
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |p| {
p[0] == 4
&& match opening {
0 => !(p[1] == 1 && p[2] == 1),
1 => p[1] != 1,
_ => p[1] < 0,
}
}));
}
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |_| false));
}
#[test]
fn budgets_are_shared_and_exhaustion_fails_open() {
let culler = Culler::new([0.0; 3], 3);
let calls = Cell::new(0);
for _ in 0..2 {
assert!(!culler.occluded([8.0; 3], [10.0; 3], |_| {
calls.set(calls.get() + 1);
false
}));
}
assert_eq!(calls.get(), 3);
assert!(!Culler::new([0.0; 3], 0).occluded([8.0; 3], [10.0; 3], |_| panic!()));
assert!(!Culler::new([0.0; 3], 1).occluded([8.0; 3], [10.0; 3], |_| true));
}
#[test]
fn camera_inside_invalid_and_target_crossing_wall_stay_visible() {
let culler = Culler::new([0.0; 3], 1_000);
assert!(!culler.occluded([-1.0; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([f64::NAN; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([2.0; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([4.0, -1.0, -1.0], [8.0, 1.0, 1.0], |p| p[0] == 4));
assert!(!Culler::new([f64::INFINITY; 3], 1_000).occluded(
[8.0; 3],
[10.0; 3],
|_| panic!()
));
assert!(!Culler::new([f64::MAX; 3], 1_000).occluded(
[-f64::MAX; 3],
[-1.0; 3],
|_| panic!()
));
}
}
+8 -44
View File
@@ -435,8 +435,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
});
}
"minecraft:zombie" => {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
for (key, value) in [
("IsBaby", Tag::Byte(0)),
("CanBreakDoors", Tag::Byte(0)),
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
@@ -845,11 +845,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.health = health;
m.extra.flag2 = flag(tag, "wasOnGround");
}
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
// zombies of trial spawners).
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
crate::mobs::finalize::set_baby(&mut m, true);
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mob saved mid-death finishes dying; one saved dead is dropped.
@@ -1046,15 +1041,12 @@ pub fn arrow_from_nbt(
}
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
#[allow(clippy::too_many_arguments)]
pub fn falling_block_to_nbt(
state: Tag,
position: Vec3,
motion: Vec3,
time: i32,
drop_item: bool,
damage: crate::falling_block::Damage,
fall_distance: f64,
uuid: [i32; 4],
) -> Tag {
let base = Base {
@@ -1064,27 +1056,29 @@ pub fn falling_block_to_nbt(
motion,
rotation: [0.0, 0.0],
on_ground: false,
fall_distance,
fall_distance: 0.0,
};
let mut tag = base.tag(None);
for (key, value) in [
("BlockState", state),
("Time", Tag::Int(time)),
("DropItem", Tag::Byte(i8::from(drop_item))),
("HurtEntities", Tag::Byte(0)),
("CancelDrop", Tag::Byte(0)),
("FallHurtAmount", Tag::Float(0)),
("FallHurtMax", Tag::Int(40)),
] {
put(&mut tag, key, value);
}
damage.save(&mut tag);
tag
}
/// (block state tag, position, motion, time, fall distance)
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)> {
/// (block state tag, position, motion, time)
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> {
Some((
tag.get("BlockState")?.clone(),
position(tag)?,
motion(tag),
number(tag.get("Time")).unwrap_or(0) as i32,
float(tag.get("fall_distance")).unwrap_or(0.0),
))
}
@@ -1494,36 +1488,6 @@ mod tests {
use super::*;
use crate::items::ItemStack;
#[test]
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
let position = Vec3::new(3.5, 100.0, -7.5);
let velocity = Vec3::new(0.0, -1.25, 0.0);
let damage = crate::falling_block::Damage {
cancel_drop: true,
..crate::falling_block::Damage::anvil()
};
let tag = falling_block_to_nbt(
state.clone(),
position,
velocity,
30,
false,
damage,
19.75,
[1, 2, 3, 4],
);
let (loaded_state, pos, motion, age, distance) = falling_block_from_nbt(&tag).unwrap();
assert_eq!(loaded_state, state);
assert_eq!(
(pos, motion, age, distance),
(position, velocity, 30, 19.75)
);
assert_eq!(falling_block_flags(&tag), (false, false));
assert_eq!(crate::falling_block::Damage::from_nbt(&tag, true), damage);
assert_eq!(uuid(&tag), Some([1, 2, 3, 4]));
}
#[test]
fn item_entities_round_trip() {
let e = ItemEntity {
+4 -95
View File
@@ -7,8 +7,6 @@ use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the zoom key waits for a key.
pub const ZOOM_KEY: &str = "horizon.zoom";
/// The same for the sort key.
pub const SORT_KEY: &str = "horizon.sort";
/// Zoom levels the default cycles through, and the wheel's limits.
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
pub const ZOOM_MIN: f32 = 1.25;
@@ -68,7 +66,6 @@ impl Corner {
#[derive(Clone, Debug, PartialEq)]
pub struct Extras {
pub fullbright: bool,
pub zoom: bool,
/// A vanilla key name (`key.keyboard.z`).
pub zoom_key: String,
@@ -97,19 +94,10 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
pub mouse_tweaks_wheel: bool,
/// Sort buttons on the inventory and chest-like containers.
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
}
impl Default for Extras {
fn default() -> Self {
Self {
fullbright: false,
zoom: false,
zoom_key: "key.keyboard.z".into(),
zoom_level: 4,
@@ -128,10 +116,6 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
}
}
}
@@ -157,7 +141,6 @@ impl Extras {
_ => d,
};
match key.trim() {
"fullbright" => e.fullbright = flag(e.fullbright),
"zoom" => e.zoom = flag(e.zoom),
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
"zoomLevel" => {
@@ -190,10 +173,6 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
_ => {}
}
}
@@ -201,8 +180,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
bool_text(self.fullbright),
"zoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\n",
bool_text(self.zoom),
self.zoom_key,
self.zoom_level,
@@ -225,22 +203,8 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
ZOOM_KEY => self.zoom_key = value,
SORT_KEY => self.sort_key = value,
_ => return false,
}
true
}
/// Written only once something differs from the defaults, so a vanilla
/// setup gains no file.
pub fn save(&self, dir: &Path) {
@@ -422,7 +386,6 @@ impl Cps {
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Item {
Fullbright,
Zoom,
ZoomKey,
ZoomLevel,
@@ -441,17 +404,11 @@ pub enum Item {
MinimapZoom,
MinimapRotate,
AutoReconnect,
MouseTweaks,
MouseTweaksWheel,
Sort,
SortKey,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
Err("Lighting"),
Ok(Fullbright),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
@@ -473,11 +430,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(Keystrokes),
Ok(KeystrokesCorner),
Ok(KeystrokesCps),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Multiplayer"),
Ok(AutoReconnect),
]
@@ -500,7 +452,6 @@ fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
impl Item {
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
Item::Zoom => "Zoom",
Item::ZoomKey => "Zoom key",
Item::ZoomLevel => "Starting zoom",
@@ -514,10 +465,6 @@ impl Item {
Item::MinimapZoom => "Zoom",
Item::MinimapRotate => "Turn with the player",
Item::AutoReconnect => "Auto-reconnect",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::FpsLows => "Minimum and 1% low",
Item::Keystrokes => "Keystrokes",
Item::KeystrokesCps => "Clicks per second",
@@ -526,7 +473,6 @@ impl Item {
/// What the row says under its caption.
pub fn description(self) -> &'static str {
match self {
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
Item::ZoomLevel => "The zoom the key starts at.",
@@ -545,18 +491,6 @@ impl Item {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::FpsLows => "The slowest frame and the 1% low of the last 5 seconds.",
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
Item::MouseTweaksWheel => {
"Down sends one item of the slot to the other side, up brings one back."
}
Item::Sort => {
"Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks."
}
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
}
@@ -565,16 +499,15 @@ impl Item {
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
use crate::graphics_style::Cost;
match self {
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Minimap => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
Item::AutoReconnect => Some(Cost::None),
_ => None,
}
}
/// On/off items and their value.
pub fn switch(self, e: &Extras) -> Option<bool> {
Some(match self {
Item::Fullbright => e.fullbright,
Item::Zoom => e.zoom,
Item::ZoomSmooth => e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity,
@@ -585,9 +518,6 @@ impl Item {
Item::Minimap => e.minimap,
Item::MinimapRotate => e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
_ => return None,
})
}
@@ -595,7 +525,6 @@ impl Item {
pub fn value(self, e: &Extras) -> String {
match self {
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::ZoomLevel => format!("{}x", e.zoom_level),
Item::FpsCorner => e.fps_corner.label().into(),
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
@@ -616,7 +545,6 @@ impl Item {
/// A click (`back` for the right button). The zoom key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::Zoom => e.zoom = !e.zoom,
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
@@ -632,9 +560,6 @@ impl Item {
Item::Minimap => e.minimap = !e.minimap,
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back),
Item::MinimapSize => {
let sizes: Vec<u8> = MINIMAP_SIZES.iter().map(|s| s.0).collect();
@@ -642,15 +567,7 @@ impl Item {
}
Item::MinimapShape => e.minimap_round = !e.minimap_round,
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
Item::ZoomKey | Item::SortKey => {}
}
}
/// The key name the options screen waits on for a key item.
pub fn key_name(self) -> Option<&'static str> {
match self {
Item::ZoomKey => Some(ZOOM_KEY),
Item::SortKey => Some(SORT_KEY),
_ => None,
Item::ZoomKey => {}
}
}
}
@@ -662,14 +579,10 @@ mod tests {
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
assert!(!d.fullbright);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
Item::Fullbright.cycle(&mut e, false);
assert_eq!(Item::Fullbright.switch(&e), Some(true));
e.zoom = true;
e.zoom_key = "key.keyboard.c".into();
e.zoom_level = 6;
@@ -683,10 +596,6 @@ mod tests {
e.minimap_rotate = true;
e.minimap_corner = Corner::BottomLeft;
e.auto_reconnect = true;
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
assert_eq!(Extras::parse(&e.serialize()), e);
}
-26
View File
@@ -129,32 +129,6 @@ pub fn draw_fps(
);
}
/// A box of text lines (a mod's HUD panel): `(text, 0xRRGGBB)` each.
pub fn draw_lines(
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
corner: Corner,
lines: &[(&str, u32)],
) {
if lines.is_empty() {
return;
}
let w = lines
.iter()
.map(|(text, _)| HudBatch::text_width(atlas, text))
.fold(0.0, f32::max)
+ 4.0;
let h = 10.0 * lines.len() as f32 + 2.0;
let (x, y) = corners.place(corner, w, h);
batch.fill(atlas, x, y, w, h, BACKGROUND);
for (i, (text, color)) in lines.iter().enumerate() {
let [_, r, g, b] = color.to_be_bytes();
let y = y + 2.0 + 10.0 * i as f32;
batch.text(atlas, text, x + 2.0, y, rgba(r, g, b, 255), true);
}
}
/// What the keystroke overlay shows.
pub struct Keystrokes {
/// Forward, left, back, right: held, and the bound key's name.
-192
View File
@@ -1,192 +0,0 @@
//! FallingBlockEntity's impact damage, anvil wear and saved impact settings.
use crate::{
mobs::{Damage as MobDamage, DamageKind, Mob},
save::{float, number, put},
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::{chunk::BlockState, nbt::Tag};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Damage {
pub hurt_entities: bool,
pub cancel_drop: bool,
pub per_distance: f32,
pub max: i32,
}
impl Default for Damage {
fn default() -> Self {
Self {
hurt_entities: false,
cancel_drop: false,
per_distance: 0.0,
max: 40,
}
}
}
impl Damage {
pub fn anvil() -> Self {
Self {
hurt_entities: true,
per_distance: 2.0,
..Self::default()
}
}
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
Self {
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
cancel_drop: number(tag.get("CancelDrop")).is_some_and(|v| v != 0),
per_distance: float(tag.get("FallHurtAmount")).unwrap_or(0.0) as f32,
max: number(tag.get("FallHurtMax")).unwrap_or(40) as i32,
}
}
pub fn save(self, tag: &mut Tag) {
for (key, value) in [
("HurtEntities", Tag::Byte(i8::from(self.hurt_entities))),
("CancelDrop", Tag::Byte(i8::from(self.cancel_drop))),
("FallHurtAmount", Tag::Float(self.per_distance.to_bits())),
("FallHurtMax", Tag::Int(self.max)),
] {
put(tag, key, value);
}
}
pub fn impact(self, fall_distance: f64) -> Option<Impact> {
if !self.hurt_entities {
return None;
}
let steps = (fall_distance - 1.0).ceil() as i32;
if steps < 0 {
return None;
}
let amount = ((steps as f32 * self.per_distance).floor() as i32).min(self.max) as f32;
Some(Impact {
amount,
wear_chance: if amount > 0.0 {
0.05 + steps as f32 * 0.05
} else {
0.0
},
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct Impact {
pub amount: f32,
pub wear_chance: f32,
}
/// AnvilBlock.damage: advance wear without changing facing; false means broken.
pub fn wear_anvil(state: &mut BlockState) -> bool {
state.name = match state.name.as_str() {
"minecraft:anvil" => "minecraft:chipped_anvil",
"minecraft:chipped_anvil" => "minecraft:damaged_anvil",
_ => return false,
}
.into();
true
}
pub fn bounds(position: Vec3) -> Aabb {
let size = f64::from(0.98_f32);
Aabb::new(
Vec3::new(position.x - size / 2.0, position.y, position.z - size / 2.0),
Vec3::new(
position.x + size / 2.0,
position.y + size,
position.z + size / 2.0,
),
)
}
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
if amount <= 0.0 {
return;
}
let bounds = bounds(position);
let source = MobDamage {
kind: DamageKind::FallingBlock,
attacker: None,
from: Some([position.x, position.y, position.z]),
};
for mob in mobs
.iter_mut()
.filter(|m| m.alive() && bounds.intersects(m.bounds()))
{
mob.damage(amount, source);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
let damage = Damage::anvil();
assert!(Damage::default().impact(50.0).is_none());
assert!(damage.impact(0.0).is_none());
let short = damage.impact(1.0).unwrap();
assert_eq!((short.amount, short.wear_chance), (0.0, 0.0));
assert_eq!(damage.impact(1.01).unwrap().amount, 2.0);
assert_eq!(damage.impact(5.0).unwrap().amount, 8.0);
assert_eq!(damage.impact(100.0).unwrap().amount, 40.0);
assert!(damage.impact(21.0).unwrap().wear_chance > 1.0);
}
#[test]
fn damage_settings_round_trip_and_keep_vanilla_load_defaults() {
let empty = Tag::Compound(Default::default());
assert_eq!(Damage::from_nbt(&empty, false), Damage::default());
let loaded_anvil = Damage::from_nbt(&empty, true);
assert!(loaded_anvil.hurt_entities);
assert_eq!(loaded_anvil.per_distance, 0.0);
let damage = Damage {
hurt_entities: true,
cancel_drop: true,
per_distance: 3.5,
max: 17,
};
let mut tag = empty;
damage.save(&mut tag);
assert_eq!(Damage::from_nbt(&tag, false), damage);
}
#[test]
fn anvil_wears_twice_then_breaks_preserving_facing() {
for facing in ["north", "south", "east", "west"] {
let mut state = BlockState {
name: "minecraft:anvil".into(),
properties: [("facing".into(), facing.into())].into(),
};
for name in ["minecraft:chipped_anvil", "minecraft:damaged_anvil"] {
assert!(wear_anvil(&mut state));
assert_eq!(state.name, name);
assert_eq!(state.properties["facing"], facing);
}
assert!(!wear_anvil(&mut state));
}
}
#[test]
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
use crate::mobs::{Mob, MobTypes};
use std::{path::Path, sync::Arc};
let root = std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES");
let types = MobTypes::load(Path::new(&root)).unwrap();
let cow = Arc::new(types.types["minecraft:cow"].clone());
let mut mobs = vec![
Mob::new(1, cow.clone(), Vec3::ZERO, 0.0),
Mob::new(2, cow.clone(), Vec3::new(3.0, 0.0, 0.0), 0.0),
Mob::new(3, cow.clone(), Vec3::ZERO, 0.0),
Mob::new(4, cow, Vec3::ZERO, 0.0),
];
mobs[2].invulnerable_flag = true;
mobs[3].extra.head = Some("minecraft:carved_pumpkin".into());
let initial = mobs.iter().map(|m| m.health).collect::<Vec<_>>();
hurt_mobs(
&mut mobs,
Vec3::ZERO,
Damage::anvil().impact(5.0).unwrap().amount,
);
assert_eq!(mobs[0].health, initial[0] - 8.0);
assert_eq!(mobs[1].health, initial[1]);
assert_eq!(mobs[2].health, initial[2]);
assert_eq!(mobs[3].health, initial[3] - 6.0);
}
}
+1 -52
View File
@@ -31,9 +31,7 @@ pub enum ItemPose {
},
}
/// `submitArmWithItem`: the pose of an item in the hand on the `left` or
/// right arm, before the model transform. Only the main hand's charged
/// crossbow is turned aside.
/// `submitArmWithItem`: the main-hand item pose, before the model transform.
pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
@@ -129,25 +127,6 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
m
}
/// `FirstPersonHandsAndItems.evaluateWhichHandsToRender`: whether the main
/// and the off hand show, from what each holds (item and whether it is a
/// charged crossbow) and the item in use with its hand. A bow or crossbow
/// being drawn hides the other hand, and a charged crossbow in the main hand
/// hides the off hand unless the off hand is the one in use.
pub fn hands_to_render(held: [Option<(&str, bool)>; 2], using: Option<(Hand, &str)>) -> [bool; 2] {
let bow_like = |id: &str| matches!(id, "minecraft:bow" | "minecraft:crossbow");
let charged_crossbow =
|h: Option<(&str, bool)>| h.is_some_and(|(id, c)| c && id == "minecraft:crossbow");
if !held.iter().flatten().any(|&(id, _)| bow_like(id)) {
return [true, true];
}
match using {
None => [true, !charged_crossbow(held[0])],
Some((hand, id)) if bow_like(id) => [hand == Hand::Main, hand == Hand::Off],
Some((hand, _)) => [true, !(hand == Hand::Main && charged_crossbow(held[1]))],
}
}
/// `InteractionHand`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hand {
@@ -740,36 +719,6 @@ impl Clicks {
mod tests {
use super::*;
#[test]
fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() {
let (bow, apple) = (
Some(("minecraft:bow", false)),
Some(("minecraft:apple", false)),
);
let charged = Some(("minecraft:crossbow", true));
assert_eq!(hands_to_render([apple, apple], None), [true, true]);
assert_eq!(hands_to_render([bow, apple], None), [true, true]);
assert_eq!(hands_to_render([charged, apple], None), [true, false]);
assert_eq!(hands_to_render([apple, charged], None), [true, true]);
let drawing = |hand| Some((hand, "minecraft:bow"));
assert_eq!(
hands_to_render([bow, apple], drawing(Hand::Main)),
[true, false]
);
assert_eq!(
hands_to_render([apple, bow], drawing(Hand::Off)),
[false, true]
);
let eating = |hand| Some((hand, "minecraft:apple"));
assert_eq!(
hands_to_render([apple, charged], eating(Hand::Main)),
[true, false]
);
assert_eq!(
hands_to_render([charged, apple], eating(Hand::Off)),
[true, true]
);
}
#[test]
fn a_swing_lasts_its_duration_and_restarts_after_half() {
let mut s = SwingState::default();
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
+16
View File
@@ -119,6 +119,22 @@ impl FogState {
}
}
/// The fog tables and evolving state kept by the game.
pub struct FogRenderer {
pub tables: FogTables,
pub state: FogState,
}
impl FogRenderer {
pub fn load(root: &Path, biomes: &Biomes) -> Self {
Self {
tables: FogTables::load(root, biomes),
state: FogState::default(),
}
}
pub fn setup(&self, input: &FogInputs) -> FogData {
setup_fog(&self.tables, &self.state, input)
}
}
/// `FluidState.getHeightForCamera`.
fn camera_fluid_height(world: &World, facts: &BlockFacts, b: [i32; 3]) -> Option<(Fluid, f64)> {
let s = facts.get(world.block(b[0], b[1], b[2]));
+6 -119
View File
@@ -318,7 +318,6 @@ impl Title {
x: f32,
y: f32,
) -> Action {
self.remember_direct_address();
if self.skip_opening() {
return Action::None;
}
@@ -442,9 +441,7 @@ impl Title {
Screen::Direct { .. } => 53,
Screen::Friend { .. } => 57,
Screen::Disconnected { .. } => 54,
Screen::ConfirmDelete { .. } => 59,
}),
CONFIRM_DELETE => Some(58),
CANCEL_RETRY => Some(55),
DONE => Some(match screen {
Screen::Direct { .. } => 52,
@@ -473,7 +470,7 @@ impl Title {
_ => None,
};
if let Some(id) = id {
match self.multiplayer_click(Some(id), 0.0, 0.0, (0.0, 0.0)) {
match screen.click(Some(id), 0.0, 0.0, (0.0, 0.0), &saves) {
Outcome::Back => self.mode = Mode::Main,
Outcome::Connect(address) => return Action::Connect { address },
Outcome::None => {}
@@ -843,14 +840,8 @@ fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
Some(Ping::Ok {
motd, players, ms, ..
}) => (
motd.resolve_legacy()
.plain()
.lines()
.next()
.unwrap_or("")
.trim()
.to_owned(),
format!("{} · {ms} ms", Ping::player_count(*players).plain()),
motd.lines().next().unwrap_or("").trim().to_owned(),
format!("{players} · {ms} ms"),
color::ACCENT,
),
Some(Ping::Failed(_)) => (
@@ -1044,40 +1035,6 @@ fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
ui.text(Face::Regular, SMALL, x, ry + 12.0, color::MUTED, &note);
}
}
Screen::ConfirmDelete { name, .. } => {
let mut y = header(
ui,
x,
"Remove this server?",
"It leaves your list. You can add it again later.",
);
ui.divider(x, y, w);
y += 16.0;
y += notice(
ui,
x,
y,
w,
Some(name),
"Its address and icon will be forgotten.",
) + 16.0;
let dw = ui.button_width("Delete server", false);
ui.button(
CONFIRM_DELETE,
[x, y, dw, CONTROL],
"Delete server",
Kind::Danger,
true,
);
let cw = ui.button_width("Cancel", false);
ui.button(
BACK,
[x + dw + 8.0, y, cw, CONTROL],
"Cancel",
Kind::Ghost,
true,
);
}
Screen::Disconnected { reason, retry } => {
let mut y = header(
ui,
@@ -1173,7 +1130,6 @@ pub mod pause {
quit_label: &str,
opened: Instant,
friends: &FriendsView,
remote: bool,
) {
let (w, h) = (ui.w, ui.h);
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
@@ -1208,11 +1164,11 @@ pub mod pause {
Kind::Primary,
true,
);
let lw = ui.button_width("World Options", true);
let lw = ui.button_width("Open to LAN", true);
ui.button(
act(9),
[x + bw + 8.0, y, lw, CONTROL_LARGE],
"World Options",
"Open to LAN",
Kind::Secondary,
true,
);
@@ -1236,11 +1192,7 @@ pub mod pause {
x,
y,
"Leave",
if remote {
"Disconnects from the server, then returns to the title."
} else {
"Saves the world, then returns to the title."
},
"Saves the world, then returns to the title.",
cw - t::ROW_LABEL - 220.0,
);
let qw = ui.button_width(quit_label, false);
@@ -1368,7 +1320,6 @@ pub mod pause {
world: &str,
quit_label: &str,
friends: &FriendsView,
remote: bool,
) -> Option<u8> {
let mut ui = Ui::dry(atlas, batch, (x, y));
draw(
@@ -1377,7 +1328,6 @@ pub mod pause {
quit_label,
Instant::now() - std::time::Duration::from_secs(5),
friends,
remote,
);
let hit = ui.hit((x, y))?;
let nav = id("pause.nav", 0);
@@ -1398,47 +1348,6 @@ mod tests {
use super::*;
use horizon_world::nbt::Tag;
#[test]
fn pause_world_options_and_disconnect_are_clickable_on_servers() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let friends = FriendsView::default();
for remote in [false, true] {
let quit = if remote {
"Disconnect"
} else {
"Save and quit"
};
for action in [9u8, 2] {
let mut batch = HudBatch::new([1280, 720]);
let (rect, density) = {
let mut ui = Ui::dry(&atlas, &mut batch, (0.0, 0.0));
pause::draw(
&mut ui,
"World",
quit,
Instant::now() - std::time::Duration::from_secs(5),
&friends,
remote,
);
let hit = ui
.hits
.iter()
.find(|h| h.id == id("pause", u64::from(action)))
.expect("pause action available");
(hit.rect, ui.density)
};
let k = density / batch.scale;
let x = (rect[0] + rect[2]) / 2.0 * k;
let y = (rect[1] + rect[3]) / 2.0 * k;
assert_eq!(
pause::click(&atlas, &mut batch, x, y, "World", quit, &friends, remote),
Some(action)
);
}
}
}
fn world(saves: &std::path::Path, folder: &str, last_played: i64) {
crate::save::write_dat(
&saves.join(folder).join("level.dat"),
@@ -1481,28 +1390,6 @@ mod tests {
}
}
#[test]
fn horizon_direct_connection_remembers_the_address_after_leaving() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let saves =
std::env::temp_dir().join(format!("horizon-front-direct-{}", std::process::id()));
let mut title = Title::new(saves.clone(), &saves);
title.last_server = "localhost:25566".into();
title.mode = Mode::Multiplayer(Screen::list(&saves));
press(&mut title, &atlas, DIRECT);
assert_eq!(title.focused_field().unwrap().1, "localhost:25566");
*title.focused_field().unwrap().1 = "example.org:25567".into();
press(&mut title, &atlas, BACK);
press(&mut title, &atlas, DIRECT);
assert_eq!(title.focused_field().unwrap().1, "example.org:25567");
*title.focused_field().unwrap().1 = "localhost:25568".into();
press(&mut title, &atlas, NAV);
press(&mut title, &atlas, NAV + 2);
press(&mut title, &atlas, DIRECT);
assert_eq!(title.focused_field().unwrap().1, "localhost:25568");
}
#[test]
fn horizon_world_delete_confirms_and_keeps_the_selection_in_place() {
let atlas =
File diff suppressed because it is too large. Load diff
+99 -172
View File
@@ -7,11 +7,10 @@ use horizon_client::{
hud::HudBatch,
items::ItemStack,
text::{self, Style, Text},
toasts::Slide,
};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
collections::{BTreeMap, VecDeque},
sync::Arc,
time::Instant,
};
@@ -69,25 +68,61 @@ impl Toast {
(fully_for as f64 / (5000.0 * multiplier / n).max(1.0) % n) as usize
}
}
/// `ToastManager.ToastInstance`: slides a toast in and out over 600 ms,
/// eased by the square, and counts how long it has been fully shown.
#[derive(Default)]
struct Slide {
/// When it first updated; the times below are milliseconds since.
origin: Option<Instant>,
begun: bool,
/// `animationStartTime`.
start: i64,
/// `becameFullyVisibleAt`.
fully_at: i64,
show: bool,
/// `visiblePortion`: 0 off screen, 1 fully in.
portion: f32,
/// `fullyVisibleFor`.
fully_for: i64,
}
impl Slide {
const MS: i64 = 600;
/// `ToastInstance.update` at `now`; `wants_show` is the toast's own
/// update. The visibility it switched to (the sound to play), and whether
/// it has finished sliding out.
fn update(&mut self, now: i64, wants_show: impl FnOnce(i64) -> bool) -> (Option<bool>, bool) {
let was = self.show;
if !self.begun {
self.begun = true;
self.start = now;
self.show = true;
}
if self.show && now - self.start <= Self::MS {
self.fully_at = now;
}
self.fully_for = now - self.fully_at;
let progress = ((now - self.start) as f32 / Self::MS as f32).clamp(0.0, 1.0);
let progress = progress * progress;
self.portion = if self.show { progress } else { 1.0 - progress };
let wanted = wants_show(self.fully_for);
if wanted != self.show {
self.start = now - ((1.0 - self.portion) * Self::MS as f32) as i64;
self.show = wanted;
}
let finished = !self.show && now - self.start > Self::MS;
((self.show != was).then_some(self.show), finished)
}
}
#[derive(Default)]
pub(super) struct AdvState {
pub(super) tree: Arc<Tree>,
/// `ClientAdvancements`: a server's tree carries each advancement's
/// place on its tab (`DisplayInfo` x, y), in tree order.
pub(super) positions: Option<Vec<(f32, f32)>>,
pub(super) progress: Progress,
pub(super) loaded: bool,
pub(super) toasts: VecDeque<Toast>,
/// Awards skip the toast (`/advancement grant ... everything`).
pub(super) quiet: bool,
/// The inventory last seen by `inventory_changed`.
pub(super) last_inventory: u64,
/// While the game acts for a network player (`as_guest`): the
/// advancements whose progress changed (`progressChanged`), the recipes
/// unlocked and the announcements, for their client and everyone's.
pub(super) changed: BTreeSet<usize>,
pub(super) unlocked: Vec<String>,
pub(super) announced: Vec<Text>,
last_inventory: u64,
}
/// The game's world for predicates.
struct View<'a, 'g>(&'a Game<'g>);
@@ -118,7 +153,7 @@ impl adv::World for View<'_, '_> {
let name = self.0.res.facts.get(state).name.to_string();
let props = self
.0
.pack
.res
.assets
.registry
.state(state)
@@ -180,10 +215,29 @@ impl adv::World for View<'_, '_> {
}
}
fn now_text() -> String {
// "yyyy-MM-dd HH:mm:ss Z" in UTC.
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64;
adv::date_text(secs)
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
// Civil from days (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} +0000",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
impl Game<'_> {
/// `ItemPredicate.test` (the `item_filter` of a filtered item modifier).
@@ -207,30 +261,30 @@ pub(super) fn item_view(s: &ItemStack) -> ItemView {
}
impl Game<'_> {
fn adv_path(&self) -> Option<std::path::PathBuf> {
Some(super::stats::player_json(
&self.world_root,
"advancements",
self.level.player_uuid?,
))
}
/// A server with no player of its own and nobody it acts for: there is
/// no player for advancements and statistics.
pub(super) fn no_player_here(&self) -> bool {
self.options.dedicated.is_some() && self.lan_acting.is_none()
}
/// The advancement tree, loaded once.
pub(super) fn advancement_tree(&mut self) -> Arc<Tree> {
if self.adv.tree.list.is_empty() {
self.adv.tree = Arc::new(Tree::load(&self.options.resources.join("data")));
}
self.adv.tree.clone()
let uuid = self.level.player_uuid?;
let hex: String = uuid.iter().map(|v| format!("{:08x}", *v as u32)).collect();
let text = format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
);
Some(
self.world_root
.join("players/advancements")
.join(format!("{text}.json")),
)
}
pub(super) fn load_advancements(&mut self) {
if self.adv.loaded || self.remote.is_some() || self.no_player_here() {
if self.adv.loaded || self.remote.is_some() {
return;
}
self.adv.loaded = true;
self.advancement_tree();
if self.adv.tree.list.is_empty() {
self.adv.tree = Arc::new(Tree::load(&self.options.resources.join("data")));
}
let progress = self
.adv_path()
.and_then(|p| std::fs::read_to_string(p).ok())
@@ -244,7 +298,7 @@ impl Game<'_> {
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
}
pub(super) fn save_advancements(&self) {
if !self.adv.loaded || self.remote.is_some() || self.no_player_here() {
if !self.adv.loaded || self.remote.is_some() {
return;
}
let Some(path) = self.adv_path() else {
@@ -366,7 +420,7 @@ impl Game<'_> {
/// `SimpleCriterionTrigger.trigger`: test each open criterion of the
/// trigger and award what it completes.
pub(super) fn trigger(&mut self, name: &str, test: impl Fn(&Value, &dyn adv::World) -> bool) {
if self.remote.is_some() || self.no_player_here() {
if self.remote.is_some() {
return;
}
self.load_advancements();
@@ -393,27 +447,15 @@ impl Game<'_> {
let a = &tree.list[ai];
let was = self.adv.progress.done(a);
self.adv.progress.grant(&a.id, &a.criteria[ci].name, &date);
self.progress_changed(ai);
if !was && self.adv.progress.done(a) {
self.award(ai);
}
}
}
/// `PlayerAdvancements.progressChanged`, kept for a network player's client.
fn progress_changed(&mut self, index: usize) {
if self.lan_acting.is_some() {
self.adv.changed.insert(index);
}
}
/// After `ServerRecipeBook.addRecipes`: the toast and the
/// `recipe_unlocked` trigger of each recipe now known.
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
if self.lan_acting.is_some() {
// A network player's client gets them in ClientboundRecipeBookAddPacket.
self.adv.unlocked.extend_from_slice(unlocked);
} else {
self.recipe_toast(unlocked);
}
self.recipe_toast(unlocked);
for r in unlocked {
let r = r.clone();
self.trigger("minecraft:recipe_unlocked", move |c, _| {
@@ -455,8 +497,7 @@ impl Game<'_> {
}
}
if let Some(d) = &a.display {
// A network player's client shows its own toast.
if d.show_toast && !self.adv.quiet && self.lan_acting.is_none() {
if d.show_toast && !self.adv.quiet {
self.adv.toasts.push_back(Toast {
icon: d.icon.clone(),
frame: d.frame,
@@ -474,18 +515,10 @@ impl Game<'_> {
Frame::Challenge => "chat.type.advancement.challenge",
};
let name = adv_name(a);
// PlayerList.broadcastSystemMessage: everyone hears of a
// network player's advancement.
match self.lan_acting_name.clone() {
Some(player) => self
.adv
.announced
.push(Text::translate(key, &[Text::literal(&player), name])),
None => self.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
)),
}
self.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
));
}
}
self.recipe_unlocked(&unlocked);
@@ -624,7 +657,7 @@ impl Game<'_> {
}
/// `EnterBlockTrigger`: the block, its state properties and the player.
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
let Some(definition) = self.res.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
@@ -757,11 +790,11 @@ impl Game<'_> {
if let Some((station, item)) = recipe {
// RecipeToast: black description under a purple title, the
// station at 0.6 scale in the corner over the cycling result.
let atlas = &self.atlas;
let atlas = &self.res.atlas;
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
let title = self.translate("recipe.toast.title", "New Recipe(s) Unlocked!");
let description = self.translate("recipe.toast.description", "Check your recipe book");
let atlas = &self.atlas;
let atlas = &self.res.atlas;
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
let start = batch.vertices.len();
@@ -770,7 +803,7 @@ impl Game<'_> {
self.draw_item_model(batch, &item, x + 8.0, 8.0);
return;
}
let atlas = &self.atlas;
let atlas = &self.res.atlas;
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
let (key, color) = match frame {
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
@@ -831,7 +864,6 @@ impl Game<'_> {
let was = self.adv.progress.done(a);
if self.adv.progress.grant(&a.id, c, &date) {
changed = true;
self.progress_changed(index);
if !was && self.adv.progress.done(a) {
self.award(index);
}
@@ -843,7 +875,6 @@ impl Game<'_> {
pub(super) fn revoke_advancement(&mut self, index: usize, criterion: Option<&str>) -> bool {
let tree = self.adv.tree.clone();
let a = &tree.list[index];
self.progress_changed(index);
match criterion {
Some(c) => self.adv.progress.revoke(&a.id, c),
None => {
@@ -856,110 +887,6 @@ impl Game<'_> {
}
}
}
impl Game<'_> {
/// `ClientAdvancements.update` (ClientboundUpdateAdvancementsPacket):
/// the server's advancements replace or join the tree, each changed one
/// takes the progress sent, and one just done shows its toast. `icons`
/// are the added advancements' icon items by name.
pub(super) fn remote_advancements(
&mut self,
p: horizon_protocol::packets::play::UpdateAdvancements,
icons: Vec<String>,
) {
use horizon_client::scoreboard::tag_to_json;
let mut list: Vec<adv::Advancement> = Vec::new();
let mut positions: Vec<(f32, f32)> = Vec::new();
if !p.reset {
let old = self.adv.positions.take().unwrap_or_default();
for (i, a) in self.adv.tree.list.iter().enumerate() {
if !p.removed.contains(&a.id) {
list.push(a.clone());
positions.push(old.get(i).copied().unwrap_or((0.0, 0.0)));
}
}
} else {
self.adv.progress = Progress::default();
}
for id in &p.removed {
self.adv.progress.criteria.remove(id);
}
for (a, icon) in p.added.into_iter().zip(icons) {
list.retain(|o| o.id != a.id);
let display = a.display.map(|d| adv::Display {
icon,
title: tag_to_json(&d.title),
description: tag_to_json(&d.description),
frame: match d.frame {
1 => Frame::Challenge,
2 => Frame::Goal,
_ => Frame::Task,
},
background: d.background,
show_toast: d.show_toast,
announce: false,
hidden: d.hidden,
});
let mut criteria: Vec<adv::Criterion> = Vec::new();
for name in a.requirements.iter().flatten() {
if !criteria.iter().any(|c| &c.name == name) {
criteria.push(adv::Criterion {
name: name.clone(),
trigger: String::new(),
conditions: Value::Null,
});
}
}
list.push(adv::Advancement {
id: a.id,
parent: a.parent,
display,
criteria,
requirements: a.requirements,
rewards: Default::default(),
});
positions.push((a.x, a.y));
}
let mut tree = Tree {
list,
..Tree::default()
};
for (i, a) in tree.list.iter().enumerate() {
tree.by_id.insert(a.id.clone(), i);
}
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
self.adv.tree = Arc::new(tree);
self.adv.positions = Some(positions);
let tree = self.adv.tree.clone();
for (id, criteria) in p.progress {
let Some(&i) = tree.by_id.get(&id) else {
continue;
};
// The client only shows whether a criterion is obtained, not when.
let obtained: BTreeMap<String, String> = criteria
.into_iter()
.filter(|(_, at)| at.is_some())
.map(|(name, _)| (name, String::new()))
.collect();
self.adv.progress.criteria.insert(id, obtained);
let a = &tree.list[i];
if !p.reset
&& p.show
&& self.adv.progress.done(a)
&& let Some(d) = a.display.as_ref().filter(|d| d.show_toast)
{
self.adv.toasts.push_back(Toast {
icon: d.icon.clone(),
frame: d.frame,
title: text_of(&d.title),
slide: Slide::default(),
recipes: Vec::new(),
changed: false,
last_changed: 0,
});
}
}
}
}
/// A text component from advancement JSON (`translate` or `text`).
pub(super) fn text_of(v: &Value) -> Text {
if let Some(key) = v["translate"].as_str() {
@@ -163,18 +163,11 @@ impl Game<'_> {
}
let tree = self.adv.tree.clone();
let kids = tree.children();
// A server's advancements come placed (`DisplayInfo` x, y).
let pos = match &self.adv.positions {
Some(server) => server
.iter()
.map(|&(x, y)| Some(((x * 28.0).floor() as i32, (y * 27.0).floor() as i32)))
.collect(),
None => tree
.layout()
.into_iter()
.map(|p| p.map(|(x, y)| (x * 28, (y * 27.0).floor() as i32)))
.collect(),
};
let pos = tree
.layout()
.into_iter()
.map(|p| p.map(|(x, y)| (x * 28, (y * 27.0).floor() as i32)))
.collect();
self.adv_ui.cache = Some(Cache { tree, kids, pos });
}
fn adv_done(&self, i: usize) -> bool {
@@ -191,12 +184,7 @@ impl Game<'_> {
};
};
let tree = &c.tree;
// A server sends only what the player may see.
let visible = if self.adv.positions.is_some() {
vec![true; tree.list.len()]
} else {
tree.visible(&c.kids, |i| self.adv.progress.done(&tree.list[i]))
};
let visible = tree.visible(&c.kids, |i| self.adv.progress.done(&tree.list[i]));
// AdvancementTab.create: a displayed root with a background.
let mut tabs: Vec<usize> = (0..tree.list.len())
.filter(|&i| {
@@ -269,7 +257,7 @@ impl Game<'_> {
}
/// `AdvancementWidget`'s title lines, description lines and width.
fn adv_widget_text(&self, i: usize) -> (Vec<Text>, Vec<Text>, f32) {
let font = &self.atlas.font;
let font = &self.res.atlas.font;
let a = &self.adv.tree.list[i];
let Some(d) = &a.display else {
return (Vec::new(), Vec::new(), 0.0);
@@ -332,7 +320,7 @@ impl Game<'_> {
let (sx, sy) = self.adv_ui.scroll.unwrap_or_default();
let (sx, sy) = (sx.floor() as i32, sy.floor() as i32);
self.adv_tick_hover(&view, sx, sy, left, top);
let atlas = &self.atlas;
let atlas = &self.res.atlas;
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let title = Text::translate("gui.advancements", &[]);
let tw = atlas.font.width_text(&title);
@@ -444,7 +432,7 @@ impl Game<'_> {
}
let (x, y) = pos(i);
let (x, y) = ((ox + x) as f32, (oy + y) as f32);
let atlas = &self.atlas;
let atlas = &self.res.atlas;
batch.sprite_scaled(
atlas,
frame_sprite(d.frame, done),
@@ -461,7 +449,7 @@ impl Game<'_> {
}
}
// The window, tabs and title.
let atlas = &self.atlas;
let atlas = &self.res.atlas;
batch.sprite_region(
atlas,
"gui/advancements/window",
@@ -475,7 +463,7 @@ impl Game<'_> {
let (x, y) = tab_pos(ty, i);
let sprite = tab_sprite(ty, i, ty.3, Some(t) == tab);
batch.sprite_scaled(
&self.atlas,
&self.res.atlas,
&sprite,
left + x,
top + y,
@@ -494,7 +482,7 @@ impl Game<'_> {
}
}
}
let atlas = &self.atlas;
let atlas = &self.res.atlas;
let title = tab.and_then(|t| tree.list[t].display.as_ref()).map_or_else(
|| Text::translate("gui.advancements", &[]),
|d| text_of(&d.title),
@@ -509,7 +497,7 @@ impl Game<'_> {
}
}
// Done.
let atlas = &self.atlas;
let atlas = &self.res.atlas;
let (bx, by) = ((w / 2.0 - 100.0).floor(), h - FOOTER + 6.0);
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
ui::button(
@@ -532,7 +520,7 @@ impl Game<'_> {
let (cx, cy) = self.cursor;
if cx > x && cx < x + ty.1 && cy > y && cy < y + ty.2 {
if let Some(d) = &tree.list[t].display {
ui::tooltip(batch, &self.atlas, &[text_of(&d.title)], self.cursor);
ui::tooltip(batch, &self.res.atlas, &[text_of(&d.title)], self.cursor);
}
break;
}
@@ -616,7 +604,7 @@ impl Game<'_> {
],
)
});
let atlas = &self.atlas;
let atlas = &self.res.atlas;
let progress_w = progress.as_ref().map_or(0.0, |t| atlas.font.width_text(t)) as i32;
let top_side = title_bottom + desc_h >= INSIDE_H;
let amount = if a.criteria.is_empty() || total == 0 {
Loaded 100 of 586 files, more files were not shown because too many files have changed in this diff. Show more