mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
449
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
226df33b82 | ||
|
|
30144e478d | ||
|
|
087cbacdc4 | ||
|
|
416012ad4c | ||
|
|
5c2201a781 | ||
|
|
44a78fe65e | ||
|
|
12b5e39ac8 | ||
|
|
7a184a41f0 | ||
|
|
ef569d3201 | ||
|
|
20ecaf0254 | ||
|
|
503b91df33 | ||
|
|
22e548471c | ||
|
|
25858f63ae | ||
|
|
4c7d77352e | ||
|
|
21411176a7 | ||
|
|
ede136bdd2 | ||
|
|
e6be303517 | ||
|
|
b691b5ebaa | ||
|
|
ff5df6aa5e | ||
|
|
7cfede86f4 | ||
|
|
bf74ac6f30 | ||
|
|
635622652e | ||
|
|
210a6f5ddd | ||
|
|
9927d06049 | ||
|
|
c9b3a06cf7 | ||
|
|
7a475a948c | ||
|
|
fe7df463fa | ||
|
|
9e6d248fbe | ||
|
|
872e77e19b | ||
|
|
a3913be641 | ||
|
|
241dee2649 | ||
|
|
341aa98a96 | ||
|
|
21a70c8e97 | ||
|
|
c7f30d2105 | ||
|
|
7784350bad | ||
|
|
5d29f31663 | ||
|
|
6ddabae4dd | ||
|
|
6da701a669 | ||
|
|
61b0aafbf8 | ||
|
|
5189a735c7 | ||
|
|
de464a8fc0 | ||
|
|
74c1a90980 | ||
|
|
60ddf3daf8 | ||
|
|
186cf8c320 | ||
|
|
56861be08c | ||
|
|
f12578238e | ||
|
|
b3755bd6b1 | ||
|
|
12c1af0261 | ||
|
|
d7b975185c | ||
|
|
a2b2b3f776 | ||
|
|
89b58991bb | ||
|
|
43138159b2 | ||
|
|
44144373ea | ||
|
|
da0c985d1d | ||
|
|
a794c05417 | ||
|
|
90bdf5bb1a | ||
|
|
34628c1471 | ||
|
|
8df59b96a9 | ||
|
|
de75330b21 | ||
|
|
142b8da33e | ||
|
|
f9fdcd2912 | ||
|
|
930f6fa19e | ||
|
|
90f84ac10d | ||
|
|
e96e1571e9 | ||
|
|
af0e0db4ec | ||
|
|
adbbff5904 | ||
|
|
1ac9e4860a | ||
|
|
51a939bd00 | ||
|
|
ccbf6f09c6 | ||
|
|
61cb0c92bb | ||
|
|
fa8056e172 | ||
|
|
a18410f92f | ||
|
|
48c85fa19f | ||
|
|
f02db7aede | ||
|
|
4d7ca0fc93 | ||
|
|
b980ae9150 | ||
|
|
b6f1ca63ba | ||
|
|
2ce2ddcd4d | ||
|
|
7a07251acf | ||
|
|
cc0d6736a1 | ||
|
|
86e0e122f4 | ||
|
|
4552494550 | ||
|
|
339bed0b7d | ||
|
|
440d9257ed | ||
|
|
8f3f72c7ef | ||
|
|
aec1519f91 | ||
|
|
d85aa27aa8 | ||
|
|
a3fcacb212 | ||
|
|
aec497c94b | ||
|
|
e08687f362 | ||
|
|
bf7b3d140f | ||
|
|
dacdf69293 | ||
|
|
830e8adbe5 | ||
|
|
2217ebcb33 | ||
|
|
02df01adbb | ||
|
|
fe1b08f83a | ||
|
|
b009c7652f | ||
|
|
0a0504dd63 | ||
|
|
792aa79b0a | ||
|
|
99514c4b79 | ||
|
|
8a95aa79a7 | ||
|
|
5763ec7e58 | ||
|
|
3e39dfae33 | ||
|
|
a248e9242a | ||
|
|
cd39680534 | ||
|
|
2094fb58ad | ||
|
|
275f60efec | ||
|
|
fcbbff26a7 | ||
|
|
c49a91a22c | ||
|
|
7df75b9d3b | ||
|
|
86149f7da6 | ||
|
|
7d26cb0a5b | ||
|
|
1a730659f0 | ||
|
|
4b4b36b716 | ||
|
|
a5bd5ebf7a | ||
|
|
c8dbb00031 | ||
|
|
288ea9dfa1 | ||
|
|
e17a40fc6b | ||
|
|
b91288a937 | ||
|
|
1643b78d7b | ||
|
|
d93b347b41 | ||
|
|
95db0fce9d | ||
|
|
b48539197a | ||
|
|
73a019967e | ||
|
|
3bf8387ca8 | ||
|
|
d6f729321b | ||
|
|
de5fb47174 | ||
|
|
3319e6e204 | ||
|
|
ee697cadc6 | ||
|
|
6ab958a423 | ||
|
|
087b2526c6 | ||
|
|
e8efd36e5b | ||
|
|
e57a43745a | ||
|
|
89eaaab2ee | ||
|
|
24d8c57932 | ||
|
|
f8341dc415 | ||
|
|
aa5305772d | ||
|
|
b37db61713 | ||
|
|
329334723d | ||
|
|
94aeab9a42 | ||
|
|
cab02f7e5e | ||
|
|
237e9b9ff3 | ||
|
|
c176b4f8bc | ||
|
|
7d1b2a5777 | ||
|
|
b857fc2b72 | ||
|
|
4704e98bda | ||
|
|
2f34bf7b46 | ||
|
|
42eda649a8 | ||
|
|
e7e35fa6e1 | ||
|
|
792a681497 | ||
|
|
f465e1b6ae | ||
|
|
07f1c34f96 | ||
|
|
37043b3dff | ||
|
|
228bdda201 | ||
|
|
1d93642094 | ||
|
|
645109d427 | ||
|
|
f442374e6d | ||
|
|
60d9bffc86 | ||
|
|
ce9c1d3a5c | ||
|
|
5797cf2702 | ||
|
|
43b45fabc7 | ||
|
|
21aab8b164 | ||
|
|
35120b0e91 | ||
|
|
294010913b | ||
|
|
ae460db2e8 | ||
|
|
af6bb6bc8c | ||
|
|
7566a4753c | ||
|
|
12e8d5b71e | ||
|
|
c166ea57d1 | ||
|
|
beac11eaa5 | ||
|
|
98ca844f07 | ||
|
|
e57c32efb8 | ||
|
|
76f3e7c2a8 | ||
|
|
fa61f262c3 | ||
|
|
6d759dc7bb | ||
|
|
9e016b6235 | ||
|
|
fb256809a7 | ||
|
|
cfad57d0b1 | ||
|
|
e994c8ab95 | ||
|
|
7b82ee8c16 | ||
|
|
7adfe44f6c | ||
|
|
f6191fe8ee | ||
|
|
8aacb48798 | ||
|
|
989f843b1c | ||
|
|
874d111d60 | ||
|
|
03309dee95 | ||
|
|
7338f55b9a | ||
|
|
4559a92942 | ||
|
|
e9da898f97 | ||
|
|
6c4ad9d825 | ||
|
|
70fd5f5c77 | ||
|
|
88b9a82688 | ||
|
|
9ae39fac28 | ||
|
|
f04d47e14d | ||
|
|
af8700bd20 | ||
|
|
d299d1f07a | ||
|
|
7156602a7f | ||
|
|
1f8a2b28d6 | ||
|
|
8caee51897 | ||
|
|
d69a380851 | ||
|
|
79ab688bda | ||
|
|
322a87cf93 | ||
|
|
517e275c79 | ||
|
|
cb3b5abf6b | ||
|
|
1df16591b4 | ||
|
|
56aae0e902 | ||
|
|
e15c879fe7 | ||
|
|
11b7038b39 | ||
|
|
20988e810e | ||
|
|
9f07f64bff | ||
|
|
d25d6184c2 | ||
|
|
ba3f4003cd | ||
|
|
0ed50ae6ae | ||
|
|
1b1e129112 | ||
|
|
72074eb080 | ||
|
|
027ac3cdf1 | ||
|
|
190f89b214 | ||
|
|
1596210bba | ||
|
|
aab88753b6 | ||
|
|
8987369b07 | ||
|
|
d4e0dc4917 | ||
|
|
6bed9b92ca | ||
|
|
324eb2836c | ||
|
|
82bee83dc5 | ||
|
|
f38e8cc393 | ||
|
|
0acd686720 | ||
|
|
6957d74742 | ||
|
|
ac97e9c0b9 | ||
|
|
89b2ca64a9 | ||
|
|
c8629f3378 | ||
|
|
c6634830d3 | ||
|
|
f83bc0ab70 | ||
|
|
eebf5621bf | ||
|
|
d1517fa79b | ||
|
|
b7f14763a7 | ||
|
|
663f61c8c3 | ||
|
|
365d35f53a | ||
|
|
a08577e301 | ||
|
|
c4b0e8efe0 | ||
|
|
b7fc86279c | ||
|
|
40fce014f1 | ||
|
|
ccdc4fe682 | ||
|
|
b61455e562 | ||
|
|
d8ad1dad7a | ||
|
|
3dcbd2228b | ||
|
|
1be820b091 | ||
|
|
97a65cd5aa | ||
|
|
61e5b70fde | ||
|
|
4ac53c0867 | ||
|
|
afce3f7aa1 | ||
|
|
79e04e22d6 | ||
|
|
db1a895c05 | ||
|
|
b5690ad295 | ||
|
|
32bb3612fb | ||
|
|
e8ca05af19 | ||
|
|
ce85fd98ed | ||
|
|
2470ba9b83 | ||
|
|
318988d767 | ||
|
|
25d1d3cc07 | ||
|
|
840e8ad4a8 | ||
|
|
ea93297579 | ||
|
|
51dc44f64f | ||
|
|
90115e5574 | ||
|
|
5cf5b6bfd2 | ||
|
|
02104eab29 | ||
|
|
28b01e9eca | ||
|
|
b6df5a91a9 | ||
|
|
2edc69ef99 | ||
|
|
0dd8ed546a | ||
|
|
52975ad988 | ||
|
|
79ea17ffc5 | ||
|
|
b58e96436a | ||
|
|
cbf4b53b0c | ||
|
|
e145a32322 | ||
|
|
a28667923a | ||
|
|
f1e0b093a5 | ||
|
|
e70b5e2882 | ||
|
|
d1d727f838 | ||
|
|
98c71567e3 | ||
|
|
e9e4ef96a7 | ||
|
|
842d11efc4 | ||
|
|
425021dda5 | ||
|
|
b7330ac0cc | ||
|
|
e905c10400 | ||
|
|
8355a6ff14 | ||
|
|
74821bf188 | ||
|
|
0818190ee4 | ||
|
|
767ed4cb47 | ||
|
|
1f06156649 | ||
|
|
9f0df29d6c | ||
|
|
b2462d54c4 | ||
|
|
eccb222e9f | ||
|
|
6915757dd4 | ||
|
|
1c957610bc | ||
|
|
3f61995c8b | ||
|
|
a3d3297840 | ||
|
|
7ace13cc82 | ||
|
|
80645bf0de | ||
|
|
45663c9e39 | ||
|
|
f22e18114d | ||
|
|
29fbebf3d7 | ||
|
|
2cfeed118b | ||
|
|
452e2b7a9f | ||
|
|
66c6bd6b13 | ||
|
|
6c210fe9fd | ||
|
|
39e8bbb2f4 | ||
|
|
2b9674af8f | ||
|
|
9bc9a1e159 | ||
|
|
e828354040 | ||
|
|
744b61efc3 | ||
|
|
1569572ba0 | ||
|
|
832a8e3f46 | ||
|
|
ead31a40f9 | ||
|
|
ac96218bfe | ||
|
|
dc74a3fe71 | ||
|
|
51601ba2a3 | ||
|
|
b746580c50 | ||
|
|
70583cbf2a | ||
|
|
cadc4698c1 | ||
|
|
158e0c6346 | ||
|
|
63bb9bc37c | ||
|
|
a995fd3b46 | ||
|
|
9fa3a684c6 | ||
|
|
efa2f0e3be | ||
|
|
383fc116c9 | ||
|
|
27fc327195 | ||
|
|
ada71df410 | ||
|
|
2236c9607f | ||
|
|
70fad91ee5 | ||
|
|
f3dda9c412 | ||
|
|
f881917d91 | ||
|
|
d9461c6d2d | ||
|
|
6ce83021b5 | ||
|
|
6f2823b652 | ||
|
|
4e36b2a217 | ||
|
|
4ce9a5642d | ||
|
|
2b5313b943 | ||
|
|
6ca6d0b919 | ||
|
|
a56d63d03f | ||
|
|
8dd63c69ef | ||
|
|
0b362e270f | ||
|
|
0d0378916b | ||
|
|
d75ad778e3 | ||
|
|
84adffc3f2 | ||
|
|
162e3d36f7 | ||
|
|
9bbb674184 | ||
|
|
f1cc118df0 | ||
|
|
66ce394d2d | ||
|
|
b4c63fa207 | ||
|
|
97d3282161 | ||
|
|
115b0b856b | ||
|
|
0d3628a035 | ||
|
|
12eca8a5c1 | ||
|
|
8c0586db4b | ||
|
|
b723ef9828 | ||
|
|
726f88fcfc | ||
|
|
778890207a | ||
|
|
5903b5981d | ||
|
|
6c1000a374 | ||
|
|
71cee5279c | ||
|
|
d1cf9b4930 | ||
|
|
2b7c72255c | ||
|
|
e5c061dba6 | ||
|
|
3879ca8ec7 | ||
|
|
069acd9aba | ||
|
|
1499c2a888 | ||
|
|
2c57fcc09f | ||
|
|
317da28481 | ||
|
|
147ea6661f | ||
|
|
19ec3213fd | ||
|
|
4c4e579d3f | ||
|
|
2c8de2b015 | ||
|
|
897ca2aa5e | ||
|
|
8ad1e52067 | ||
|
|
373fa31a35 | ||
|
|
0944280fb3 | ||
|
|
a72dd98e2a | ||
|
|
7b21d4bc6c | ||
|
|
c810a3213c | ||
|
|
193c08e90f | ||
|
|
393d113dfb | ||
|
|
92209ddeee | ||
|
|
5cb14ee496 | ||
|
|
5a5e8ffa71 | ||
|
|
d17e5da848 | ||
|
|
7fab5ec1ff | ||
|
|
43cff3b44e | ||
|
|
2e7e249096 | ||
|
|
59ce240633 | ||
|
|
502c8b9311 | ||
|
|
2ee7d7af9f | ||
|
|
a3dd34a8f3 | ||
|
|
299280baa2 | ||
|
|
60fc617402 | ||
|
|
cac89a9995 | ||
|
|
5ea60fbd8d | ||
|
|
c0f468a871 | ||
|
|
6716b0d953 | ||
|
|
601fbb533a | ||
|
|
772e0a7070 | ||
|
|
24a70e84a8 | ||
|
|
7bb3347edd | ||
|
|
2df60cd388 | ||
|
|
e67ccd5589 | ||
|
|
97245773d1 | ||
|
|
1404dd5a0a | ||
|
|
870f2b45f7 | ||
|
|
8fa3eef809 | ||
|
|
22756c28d8 | ||
|
|
f0be91777f | ||
|
|
b797839f77 | ||
|
|
3a522af067 | ||
|
|
c989692bd9 | ||
|
|
a96580aa2e | ||
|
|
cfdcd7f7eb | ||
|
|
7f7cdf01c2 | ||
|
|
6cd64ddcae | ||
|
|
f4719d974d | ||
|
|
4ecb4c85e7 | ||
|
|
189119a861 | ||
|
|
3738c876bc | ||
|
|
1c0a47e012 | ||
|
|
2518075339 | ||
|
|
6d7f11feb2 | ||
|
|
17f0029d08 | ||
|
|
f53f198f03 | ||
|
|
383afd81eb | ||
|
|
3f3f294c52 | ||
|
|
b6ee20d0e8 | ||
|
|
85851909d6 | ||
|
|
929fa4ec5a | ||
|
|
9006566bbd | ||
|
|
73ca04b10d | ||
|
|
9dc0803a24 | ||
|
|
87733155b3 | ||
|
|
7f1e197428 | ||
|
|
ad9cd6c67e | ||
|
|
ef4fc67f1b | ||
|
|
e0283c70db | ||
|
|
c2c7614a2f | ||
|
|
ad701d73d9 | ||
|
|
66e41ed573 | ||
|
|
72542a89e2 | ||
|
|
45bd1a7be8 | ||
|
|
0734daa911 | ||
|
|
d9351ec054 | ||
|
|
eb18bb42af | ||
|
|
f2c7a2d359 | ||
|
|
c982fadaf5 |
No files matched your search
@@ -0,0 +1,8 @@
|
||||
# Docker builds (the relay) need the Rust sources only.
|
||||
target/
|
||||
.local/
|
||||
**/node_modules/
|
||||
website/
|
||||
benchmarks/
|
||||
packs/
|
||||
mods/
|
||||
@@ -0,0 +1,3 @@
|
||||
self-hosted-runner:
|
||||
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
|
||||
labels: [horizon-game, ludovics-mac-mini]
|
||||
@@ -0,0 +1,19 @@
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
|
||||
|
||||
## Self-check
|
||||
|
||||
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- For anything players see: before and after. Delete this section otherwise. -->
|
||||
|
||||
<!-- Please tick "Allow edits by maintainers": we push small fixups ourselves instead of a round trip.
|
||||
See CONTRIBUTING.md. -->
|
||||
@@ -1,14 +1,25 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game). A fork's pull request runs it only when
|
||||
# a maintainer adds the run-parity label, once per label. See docs/release-flow.md.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
# A new push to a pull request replaces its running checks; main's runs all finish (releases follow them).
|
||||
group: checks-${{ github.event.pull_request.number || github.sha }}-${{ github.event.action == 'labeled' && 'label' || 'run' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
verify:
|
||||
if: github.event.action != 'labeled'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -22,16 +33,54 @@ jobs:
|
||||
python tools/check_public_tree.py --history
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA headers the client's audio links against
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
run: cargo +stable test --workspace
|
||||
parity:
|
||||
if: >-
|
||||
github.event_name == 'pull_request' && (
|
||||
(github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled') ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'run-parity'))
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The commit the event names: a fork's later pushes need another run-parity label.
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The harnesses this change can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
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
|
||||
with:
|
||||
name: parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
|
||||
# (label horizon-game) holding the maintainer's own copy: the official launcher's
|
||||
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
|
||||
@@ -493,3 +542,4 @@ jobs:
|
||||
dist/client-graphics-smoke.json
|
||||
dist/client-graphics-macos-title.json
|
||||
dist/client-graphics-macos-world.json
|
||||
retention-days: 3
|
||||
+194
-35
@@ -1,94 +1,253 @@
|
||||
name: launcher
|
||||
# Builds the launcher on Linux, macOS and Windows. A pushed `launcher-vX.Y.Z` tag also puts the
|
||||
# three archives on a draft GitHub release of that tag, to publish by hand. Horizon's own
|
||||
# releases are tagged `vYYYY.MM.DD`; the launcher reads only `launcher-v*` releases for itself.
|
||||
# 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),
|
||||
# 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
|
||||
# request is labelled feature. The version is given to the build (HORIZON_LAUNCHER_VERSION): no bump commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
# gh workflow run launcher --ref main -f dry_run=false -f force=true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [launcher]
|
||||
tags: ['launcher-v*']
|
||||
branches: [main, launcher, 'launcher-*']
|
||||
paths:
|
||||
- 'launcher/**'
|
||||
- 'crates/horizon-prepare/**'
|
||||
- 'crates/horizon-theme/**'
|
||||
- '.github/workflows/launcher.yml'
|
||||
schedule:
|
||||
- cron: '7-59/10 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build everything and write the notes, but publish nothing
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: Release even without launcher changes, before merges settle or while paused
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
decide:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mode
|
||||
# Off main every run is a dry run (a launcher branch's push only builds).
|
||||
run: echo "dry_run=${{ github.ref != 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }}" >> "$GITHUB_OUTPUT"
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
|
||||
# A push or a run off main builds its own commit; otherwise main's newest commit with green checks.
|
||||
HEAD_SHA: ${{ (github.event_name == 'push' || steps.mode.outputs.dry_run == 'true') && github.sha || '' }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind launcher ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
tag=$(jq -r '.tag' decision.json)
|
||||
echo "version=${tag#launcher-v}" >> "$GITHUB_OUTPUT"
|
||||
# A dry run (and a launcher branch's push) builds whatever the decision.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then
|
||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
asset: horizon-launcher-linux-x86_64.tar.gz
|
||||
binary: horizon-launcher
|
||||
- os: macos-latest
|
||||
- name: macos
|
||||
mac: true
|
||||
asset: horizon-launcher-macos-arm64.zip
|
||||
binary: horizon-launcher
|
||||
- os: windows-latest
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
binary: horizon-launcher.exe
|
||||
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
|
||||
# which only the Visual C++ redistributable installs.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
|
||||
# variable, not a secret. Unset, the launcher says sign-in is not configured.
|
||||
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
|
||||
# The release's version, built in (launcher/src/backend.rs VERSION); a launcher branch's build keeps
|
||||
# Cargo.toml's.
|
||||
HORIZON_LAUNCHER_VERSION: ${{ (github.event_name != 'push' || github.ref == 'refs/heads/main') && needs.decide.outputs.version || '' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: launcher
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
# The Mac mini's runner keeps its target/ between runs.
|
||||
clean: ${{ runner.environment == 'github-hosted' }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher-${{ matrix.name }}
|
||||
- name: Linux libraries GPUI links to
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
|
||||
- name: The tag matches Cargo.toml
|
||||
if: startsWith(github.ref, 'refs/tags/launcher-v')
|
||||
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
|
||||
- run: cargo test --release --locked --lib
|
||||
- run: cargo build --release --locked
|
||||
- name: Metal shaders compiled now, or at run time without the Metal toolchain
|
||||
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
|
||||
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
if ! xcrun metal --version > /dev/null 2>&1; then
|
||||
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
- run: cargo test --release --locked --lib ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$(target/release/${{ matrix.binary }} --version)
|
||||
echo "$out"
|
||||
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
|
||||
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
|
||||
- name: Only system libraries
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_imports.py target/release/${{ matrix.binary }}
|
||||
run: |
|
||||
file=target/release/${{ matrix.binary }}
|
||||
case "$RUNNER_OS" in
|
||||
Windows) python ../tools/check_imports.py "$file" ;;
|
||||
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::links $others"; exit 1; } ;;
|
||||
esac
|
||||
- name: Smoke test
|
||||
env:
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE: '1'
|
||||
HORIZON_GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
|
||||
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
launcher="$PWD/target/release/${{ matrix.binary }}"
|
||||
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
|
||||
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
|
||||
retry "$launcher" versions
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
"$launcher" menu-style classic
|
||||
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
|
||||
"$launcher" menu-style horizon
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
# The game's Linux build is the game's to keep portable (release.yml checks it): a
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
python ../tools/check_imports.py "$client"
|
||||
elif [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
soft python3 ../tools/check_imports.py "$client"
|
||||
fi
|
||||
# The client starts: --help prints before any window or GPU is needed.
|
||||
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
|
||||
soft help
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/release/${{ matrix.binary }} dist/
|
||||
cd dist
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
tar czf ../${{ matrix.asset }} ${{ matrix.binary }}
|
||||
elif [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
7z a ../${{ matrix.asset }} ${{ matrix.binary }}
|
||||
rm -rf dist out && mkdir -p out
|
||||
if [[ "$RUNNER_OS" == "macOS" ]]; then
|
||||
# Horizon.app, zipped (self-update) and in a .dmg (installer): packaging/macos.
|
||||
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
zip ../${{ matrix.asset }} ${{ matrix.binary }}
|
||||
mkdir -p dist
|
||||
cp target/release/${{ matrix.binary }} dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
|
||||
else
|
||||
(cd dist && 7z a ../out/${{ matrix.asset }} ${{ matrix.binary }})
|
||||
fi
|
||||
fi
|
||||
ls -l out
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: launcher/${{ matrix.asset }}
|
||||
path: launcher/out/*
|
||||
retention-days: 3
|
||||
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/launcher-v')
|
||||
needs: build
|
||||
needs: [decide, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
# A draft, and never marked latest: Horizon's own releases stay the repository's latest.
|
||||
- uses: softprops/action-gh-release@v2
|
||||
# Never marked latest: Horizon's own releases stay the repository's latest.
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest=false dist/horizon-launcher-*
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Horizon launcher ${{ github.ref_name }}
|
||||
draft: true
|
||||
make_latest: false
|
||||
files: dist/*
|
||||
name: launcher-release-notes
|
||||
path: notes/
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,79 @@
|
||||
name: nightly audit
|
||||
# Every vanilla harness, from scratch, on main, at 03:00 Paris time on the Mac mini's runner (it holds the
|
||||
# maintainer's own Minecraft files). The resources are prepared again first when the preparer or its exporters
|
||||
# changed (tools/ci/vanilla-data.sh). A harness that passed in the previous audit and fails now gets an issue
|
||||
# labelled parity-regression (one per harness); its issue closes when it passes again
|
||||
# (tools/ci/parity_issues.py). The bugs session picks those up. See docs/release-flow.md.
|
||||
# gh workflow run "nightly audit" # now
|
||||
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
|
||||
on:
|
||||
schedule:
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
|
||||
- cron: '7 1 * * *'
|
||||
- cron: '7 2 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Run the audit, but only print what it would do with issues
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: nightly-audit
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: 03:00 in Paris
|
||||
id: when
|
||||
run: |
|
||||
hour=$(TZ=Europe/Paris date +%H)
|
||||
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
|
||||
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.when.outputs.run == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.when.outputs.run == 'true'
|
||||
- name: Tools from the runner
|
||||
if: steps.when.outputs.run == 'true'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, prepared again if the preparer changed
|
||||
if: steps.when.outputs.run == 'true'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: Every harness
|
||||
id: audit
|
||||
if: steps.when.outputs.run == 'true'
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
run: |
|
||||
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
|
||||
set +e
|
||||
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Issues for the harnesses that regressed
|
||||
if: steps.when.outputs.run == 'true' && always() && steps.audit.outputs.since
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
+211
-37
@@ -1,51 +1,225 @@
|
||||
name: release builds
|
||||
# Started by hand: gh workflow run release.yml --ref <branch>
|
||||
# Builds the Windows x64 and Linux x86_64 clients and uploads the playable folders as artifacts.
|
||||
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
|
||||
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
|
||||
# through nix-ld): a build made on NixOS asks for a /nix/store loader no other system has.
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 15 minutes
|
||||
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# publishes vYYYY.MM.DD[.N] with notes from the merged pull requests.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [checks]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '2-59/10 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build everything and write the notes, but publish nothing
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: Release even without player-facing changes, before merges settle or while paused
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: game-release
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
decide:
|
||||
if: >-
|
||||
github.event_name != 'workflow_run' ||
|
||||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- name: Build the client
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mode
|
||||
# A dry run never publishes; a hand-started run off main is always one.
|
||||
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
|
||||
- id: decide
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Only system DLLs
|
||||
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
|
||||
# "DLL not found" dialog on some players' machines.
|
||||
run: python tools/check_imports.py target/release/horizon-client.exe
|
||||
- name: Package
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
|
||||
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
|
||||
# main's newest commit with green checks.
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
build:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-windows-x64
|
||||
path: dist/horizon-windows-x64
|
||||
linux:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- name: ALSA headers
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
|
||||
- name: Build the client
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Standard loader and libraries only
|
||||
run: python3 tools/check_imports.py target/release/horizon-client
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
# On the Mac mini's runner, keep target/ (incremental) and .local/ (the prepared resources).
|
||||
clean: ${{ runner.environment == 'github-hosted' }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: release-${{ matrix.name }}
|
||||
- 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 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; 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; 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)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
else
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --version
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The game runs (offscreen, a new world, 1500 frames)
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
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 --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
|
||||
report = json.load(open(sys.argv[1]))
|
||||
print(json.dumps(report))
|
||||
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
- name: Package
|
||||
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
|
||||
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
|
||||
else ditto -c -k --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"; fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-linux-x86_64
|
||||
path: dist/horizon-linux-x86_64
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [decide, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-notes
|
||||
path: notes/
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,34 @@
|
||||
name: telemetry-server
|
||||
# The ingest server is its own workspace (crates/horizon-telemetry-server), so the checks job doesn't build
|
||||
# it: this one does, when it or the crate it shares with the launcher and the game changes.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- 'crates/horizon-telemetry-server/**'
|
||||
- '.github/workflows/telemetry-server.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/horizon-telemetry/**'
|
||||
- 'crates/horizon-telemetry-server/**'
|
||||
- '.github/workflows/telemetry-server.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/horizon-telemetry-server
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates/horizon-telemetry-server
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo +stable fmt --check
|
||||
- run: cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo +stable test --locked
|
||||
+10
@@ -16,5 +16,15 @@
|
||||
/dist/
|
||||
/mods/horizon/build/
|
||||
/saves/
|
||||
/backups/
|
||||
/tools/logs/
|
||||
.DS_Store
|
||||
# What the client writes to its working directory when run from the repo root.
|
||||
/options.txt
|
||||
/horizon.txt
|
||||
/horizon-graphics.txt
|
||||
/horizon-controller.txt
|
||||
/servers.dat
|
||||
/screenshots/
|
||||
/crash-reports/
|
||||
/debug/
|
||||
@@ -0,0 +1,77 @@
|
||||
# Contributing to Horizon
|
||||
|
||||
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
|
||||
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).
|
||||
|
||||
## Build it
|
||||
|
||||
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
|
||||
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
|
||||
[Build from source](README.md#build-from-source).
|
||||
|
||||
```sh
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
|
||||
|
||||
## Vanilla first
|
||||
|
||||
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
|
||||
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
|
||||
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
|
||||
probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
|
||||
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
|
||||
Say in your pull request which vanilla classes and methods you followed.
|
||||
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
|
||||
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
|
||||
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
|
||||
off by default wherever they would change the vanilla look.
|
||||
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
|
||||
for player-facing code.
|
||||
|
||||
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
|
||||
them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
|
||||
## Before asking for review
|
||||
|
||||
Run the self-check and paste the block it prints into your pull request:
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on the main repository
|
||||
```
|
||||
|
||||
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
|
||||
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
|
||||
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
|
||||
`--quick` skips the harnesses. It exits non-zero when something fails.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
|
||||
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
|
||||
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
|
||||
can see.
|
||||
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
|
||||
ourselves instead of sending it back to you; only design questions come back.
|
||||
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
|
||||
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
|
||||
|
||||
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
|
||||
|
||||
## Parity checks on forks
|
||||
|
||||
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
|
||||
harnesses on a maintainer's machine, which holds the game files, so it never runs a fork's code on its own: a
|
||||
maintainer reads your change, then adds the `run-parity` label, which runs it once on your current commit. After
|
||||
new pushes, we add it again.
|
||||
|
||||
## Talk to us
|
||||
|
||||
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
|
||||
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
|
||||
Generated
+1128
-4
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher"]
|
||||
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server"]
|
||||
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-friends", "crates/horizon-relay", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.jpg" alt="Horizon: Minecraft Java Edition 26.3, rewritten from scratch in Rust" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -12,7 +12,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://playhorizon.dev"><b>Website</b></a> ·
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="#what-changed-in-v202610131"><b>What changed</b></a> ·
|
||||
<a href="#screenshots"><b>Screenshots</b></a> ·
|
||||
<a href="#project-status"><b>Parity status</b></a> ·
|
||||
<a href="docs/README.md"><b>Documentation</b></a>
|
||||
@@ -26,9 +28,18 @@ Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS:
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
|
||||
## What changed in v2026.10.13.1
|
||||
|
||||
The latest release ([notes](https://github.com/ludovic111/horizon/releases/tag/v2026.10.13.1)):
|
||||
|
||||
- **Horizon's own menus:** a new title screen, world list, options and pause menu in the launcher's style, over a live frozen landscape. The vanilla look is still available: Options → Interface → Menu style → Classic.
|
||||
- **Graphics settings, Simple and Advanced:** presets (Performance, Balanced, Quality, Ultra), or every rendering, quality, display and performance setting, each with a short description and its cost, plus a performance overlay.
|
||||
- **Much faster rendering:** for example, at render distance 8 over a village, 363 → 2,409 FPS on an RTX 4080. Render distance 32 still stutters in places.
|
||||
- **Fixed:** no sound on Linux; Linux builds that only ran on the machine they were built on; a missing DLL on Windows (the launcher no longer needs the Visual C++ runtime); memory growing and the frame rate dropping near villages (mobs were duplicated when chunks reloaded); resource preparation failing on Linux with "Missing … java-objc-bridge" ([#41](https://github.com/ludovic111/horizon/issues/41)); the hand and held item at high FOV; missing swing animations.
|
||||
|
||||
## Download
|
||||
|
||||
**[Download the Horizon launcher](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true)** for Linux x86-64, macOS Apple Silicon or Windows x64: take the newest `launcher-v*` release (currently [launcher-v0.1.1](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.1)); the launcher then updates itself. The launcher is the way to get Horizon: it installs and updates the game, prepares its resources from your own copy of Minecraft, and starts the client or a dedicated server.
|
||||
Horizon is downloaded only through **the Horizon launcher**: get it from [playhorizon.dev](https://playhorizon.dev) or from [the launcher's releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true) (the newest `launcher-v*` release, currently [launcher-v0.1.3](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.3)), for Linux x86-64, macOS Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its resources from your own copy of Minecraft, starts the client or a dedicated server, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition installed with the official launcher, with 26.3 played once; Python 3.10 or newer and Java 25 (a JDK, e.g. Eclipse Temurin 25) for the one-time resource preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac.
|
||||
|
||||
@@ -36,31 +47,47 @@ Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="launcher/docs/play.png" alt="The launcher's Play page"></td>
|
||||
<td width="50%"><img src="launcher/docs/versions.png" alt="The launcher's Versions page"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: v2026.10.14.1 ready to play, its summary, Client and Server, the version picker and Play, the resources and the Minecraft folder as a numbered list"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page, showing the notes of Horizon 2026.10.14.1"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes and the roadmap, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
|
||||
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||
Captured in the Rust client itself, with vanilla assets and no mods or shaders.
|
||||
|
||||
### Menus and settings
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/media/sunset.jpg" alt="Sunset over a savanna river valley and a plains village"></td>
|
||||
<td colspan="2"><img src="docs/media/menu-title.webp" alt="Horizon's title screen: the mark and the pages on the left, the Horizon title, Singleplayer and Multiplayer, and the last world played and the faint wordmark, over a veiled snowy landscape"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.jpg" alt="A plains village with farms, an iron golem and animals"></td>
|
||||
<td width="50%"><img src="docs/media/survival.jpg" alt="Survival at dusk: a zombie attacks near the village"></td>
|
||||
<td width="50%"><img src="docs/media/menu-worlds.webp" alt="The Singleplayer page: three worlds with their dates and game modes, and Create world and Play buttons"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The vanilla menus are one setting away: Options → Interface → Menu style → Classic. See [Graphics settings](docs/graphics.md).
|
||||
|
||||
### In game
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/nether.jpg" alt="A crimson forest in the Nether with a zombified piglin"></td>
|
||||
<td width="50%"><img src="docs/media/night.jpg" alt="Moonrise over a torch-lit village and river"></td>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village with farms, an iron golem and animals"></td>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie attacks near the village"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/underwater.jpg" alt="Salmon swimming in a river"></td>
|
||||
<td width="50%"><img src="docs/media/nether.webp" alt="A crimson forest in the Nether with a zombified piglin"></td>
|
||||
<td width="50%"><img src="docs/media/night.webp" alt="Moonrise over a torch-lit village and river"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/underwater.webp" alt="Salmon swimming in a river"></td>
|
||||
<td width="50%" valign="middle">
|
||||
Vanilla world generation ported to Rust (terrain, aquifers, carvers, features, villages and the Nether), every block model with smooth lighting and biome tints, the vanilla sky and fog, mobs, the HUD, chat and advancements: everything in these frames is drawn by Horizon.
|
||||
</td>
|
||||
@@ -86,9 +113,39 @@ The [native client](docs/native-client.md) opens on a vanilla-style title screen
|
||||
- shields, maps, books, fireworks, beacons, advancements and statistics;
|
||||
- Anvil saves, joining vanilla servers, and opening a world to LAN.
|
||||
|
||||
## Performance
|
||||
|
||||
Horizon against vanilla 26.3 and vanilla with Sodium, Lithium, FerriteCore and ImmediatelyFast: same seed (1234), same scenes (a village and a mountain), the same 1280×720 view, three games each, all driven and measured by `tools/perf_bench.py` ([method](docs/benchmark.md)). Each cell is the mean of the three games, with the range in brackets.
|
||||
|
||||
| Mac mini M4 Pro, render distance 16 | Horizon | Vanilla | Vanilla + mods |
|
||||
| --- | --- | --- | --- |
|
||||
| Launch to title screen | 5.3 s (3.7–7.9) | 12.4 s (10.1–14.5) | 19.3 s (16.0–21.1) |
|
||||
| Launch to playing in a new world | 19.4 s (11.1–31.6) | 37.1 s (30.9–42.0) | 50.4 s (41.1–56.0) |
|
||||
| Teleport to the village, chunks loaded | 4.2 s (3.2–5.4) | 12.5 s (11.9–13.7) | 13.4 s (10.3–17.7) |
|
||||
| Teleport to the mountain, chunks loaded | 22.1 s (12.8–32.7) | 24.6 s (22.4–27.8) | 25.5 s (21.5–32.6) |
|
||||
| Memory while flying | 1.66 GB | 2.23 GB | 2.37 GB |
|
||||
| Average FPS flying over the village | 393 (122–598) | 61 (47–68) | 53 (50–59) |
|
||||
|
||||
Measured on 2026-10-15 on main with #267, #269 and #280, macOS 27, in a locked screen session shared with CI runners (1-minute load average 36–47, reported in each run): treat the spread as part of the result. Horizon drew offscreen there and vanilla in a window, so its FPS has no presentation cost; loading, start-up and memory don't depend on it. At render distance 8 the order is the same. Every run's full report (frame-time histogram, 1% and 0.1% lows, conditions, hardware) is in [benchmarks/perf/2026-10-15-mac-mini-m4pro](benchmarks/perf/2026-10-15-mac-mini-m4pro); runs on an RTX 4080, an integrated GPU and a laptop on battery come next.
|
||||
|
||||
## Agent control
|
||||
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
|
||||
|
||||
```sh
|
||||
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
|
||||
horizon-ctl state # position, health, food, target, screen...
|
||||
horizon-ctl find-block birch_log # the nearest logs
|
||||
horizon-ctl goto -0.5 65 -2.5 --near 2.5 # walks there along a path
|
||||
horizon-ctl break -1 65 -3 # mines it; replies with the drops
|
||||
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
|
||||
```
|
||||
|
||||
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
|
||||
## Build from source
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA development headers (`libasound2-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
|
||||
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
@@ -98,18 +155,18 @@ Horizon contains no game files and never downloads them. It builds its resources
|
||||
```sh
|
||||
git clone https://github.com/ludovic111/horizon.git
|
||||
cd horizon
|
||||
python3 tools/prepare_native_client.py --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
python3 tools/fetch_sounds.py # optional: sounds from your launcher install
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl`, and the architecture are described in [the native client guide](docs/native-client.md). The rest of the documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, playing and scripting through `horizon-ctl` and its MCP server, and the architecture are described in [the native client guide](docs/native-client.md). The rest of the documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
|
||||
## Project status
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences and the final checks are listed in the roadmap.
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
@@ -138,34 +195,20 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
|
||||
|
||||
### Roadmap
|
||||
### Known issues
|
||||
|
||||
**Known differences.** Online-mode servers (Mojang's session servers) are not supported; skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
**Final checks.** A from-scratch audit (`tools/parity.py --all`) on Linux and on macOS, and the launch checks on the packaged Linux, macOS and Windows builds listed in [Rust rewrite status](docs/rust-parity.md#completion-criteria-and-playable-builds).
|
||||
|
||||
**Next.** With vanilla parity as the base:
|
||||
|
||||
- **Performance:** optimize the client and the server as far as they can go (rendering, chunk generation and meshing, memory, multithreaded ticking) while the differential harnesses keep gameplay identical to vanilla.
|
||||
- **Modding support:** a stable API for writing mods against Horizon.
|
||||
- **Agent control:** an MCP server and a CLI so AI agents and scripts can play and drive the game, growing out of today's `horizon-ctl` scripting socket.
|
||||
- **Built-in recording:** capture gameplay video in a keypress, without external tools.
|
||||
- **Automatic version updates:** a pipeline that ports each new Minecraft release on its own: compare the new version with the pinned one, regenerate the data, rewrite the changed parts and prove them with the same differential harnesses.
|
||||
Online-mode servers (Mojang's session servers) are not supported; skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome. Before opening a pull request, run the offline checks:
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
python3 -m unittest discover -s tests -v
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
python3 tools/check_public_tree.py --history
|
||||
python3 tools/pr_ready.py
|
||||
```
|
||||
|
||||
CI runs these checks on GitHub's Linux runners. The vanilla comparisons need the game, which Horizon never downloads, so they run only when started by hand on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
{
|
||||
"label": "after-4080",
|
||||
"date": "2026-10-13",
|
||||
"commit": "9006566b",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": true,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 133,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 941908352
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 2409.179688612205,
|
||||
"cpu": {
|
||||
"avg_ms": 0.41004928978864236,
|
||||
"max_ms": 18.15971565246582,
|
||||
"p50_ms": 0.2718150019645691,
|
||||
"p95_ms": 0.7112299799919128,
|
||||
"p99_ms": 1.316559076309204
|
||||
},
|
||||
"frames": 28910,
|
||||
"gpu": {
|
||||
"avg_ms": 0.09866036293994015,
|
||||
"max_ms": 0.25088000297546387,
|
||||
"p50_ms": 0.08806400001049042,
|
||||
"p95_ms": 0.1740799993276596,
|
||||
"p99_ms": 0.19968000054359436
|
||||
},
|
||||
"low_1pct_fps": 160.80638022914025,
|
||||
"max_ms": 18.175312042236328,
|
||||
"p50_ms": 0.2760099768638611,
|
||||
"p95_ms": 0.7161250114440918,
|
||||
"p99_ms": 1.4078609943389893,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.20953580908804606,
|
||||
"render": 0.12439006986512069,
|
||||
"tick": 0.05299293669455945,
|
||||
"update": 0.023244019639293335
|
||||
},
|
||||
"seconds": 11.999935138359666,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.4052206484151298,
|
||||
"render": 0.41893499056840766,
|
||||
"tick": 5.252727109792939,
|
||||
"update": 0.0936474929949076
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 1099.4635681039538,
|
||||
"cpu": {
|
||||
"avg_ms": 0.9003613499442573,
|
||||
"max_ms": 23.31729507446289,
|
||||
"p50_ms": 0.6657320261001587,
|
||||
"p95_ms": 0.9898859858512878,
|
||||
"p99_ms": 9.592857360839844
|
||||
},
|
||||
"frames": 13194,
|
||||
"gpu": {
|
||||
"avg_ms": 0.2639814413552015,
|
||||
"max_ms": 3.0013439655303955,
|
||||
"p50_ms": 0.2549760043621063,
|
||||
"p95_ms": 0.3553279936313629,
|
||||
"p99_ms": 0.37171199917793274
|
||||
},
|
||||
"low_1pct_fps": 82.87026130908886,
|
||||
"max_ms": 27.990703582763672,
|
||||
"p50_ms": 0.6705269813537598,
|
||||
"p95_ms": 1.0082690715789795,
|
||||
"p99_ms": 9.64776611328125,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.571024256178027,
|
||||
"render": 0.12442494783992657,
|
||||
"tick": 0.1706307831885292,
|
||||
"update": 0.03442592136820218
|
||||
},
|
||||
"seconds": 12.000397632777691,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.7136096191225629,
|
||||
"render": 0.43175565880356415,
|
||||
"tick": 10.713303016893791,
|
||||
"update": 0.05390534066651581
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 687.0806476294383,
|
||||
"cpu": {
|
||||
"avg_ms": 1.4490217101725757,
|
||||
"max_ms": 29.75178337097168,
|
||||
"p50_ms": 0.9425880312919617,
|
||||
"p95_ms": 1.6511069536209106,
|
||||
"p99_ms": 15.603099822998047
|
||||
},
|
||||
"frames": 8245,
|
||||
"gpu": {
|
||||
"avg_ms": 0.3711021953564546,
|
||||
"max_ms": 1.5022079944610596,
|
||||
"p50_ms": 0.3665919899940491,
|
||||
"p95_ms": 0.43827199935913086,
|
||||
"p99_ms": 0.4679679870605469
|
||||
},
|
||||
"low_1pct_fps": 47.486143092805435,
|
||||
"max_ms": 29.770263671875,
|
||||
"p50_ms": 0.947784960269928,
|
||||
"p95_ms": 1.6628600358963013,
|
||||
"p99_ms": 15.621638298034668,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.8449565766073126,
|
||||
"render": 0.1554101111230668,
|
||||
"tick": 0.3840128544967872,
|
||||
"update": 0.06481705411812297
|
||||
},
|
||||
"seconds": 12.000046906352043,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.2885668083845851,
|
||||
"render": 0.45316039797771407,
|
||||
"tick": 19.216593845781073,
|
||||
"update": 0.08380924088947744
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 343.77852072094186,
|
||||
"cpu": {
|
||||
"avg_ms": 1.5961282111390964,
|
||||
"max_ms": 22.24184799194336,
|
||||
"p50_ms": 0.8254669904708862,
|
||||
"p95_ms": 11.660508155822754,
|
||||
"p99_ms": 14.398658752441406
|
||||
},
|
||||
"frames": 4113,
|
||||
"gpu": {
|
||||
"avg_ms": 0.3449904050323872,
|
||||
"max_ms": 2.9040639400482178,
|
||||
"p50_ms": 0.3338240087032318,
|
||||
"p95_ms": 0.43724799156188965,
|
||||
"p99_ms": 0.5171200037002563
|
||||
},
|
||||
"low_1pct_fps": 29.95597087331344,
|
||||
"max_ms": 33.422760009765625,
|
||||
"p50_ms": 0.8299460411071777,
|
||||
"p95_ms": 33.33942413330078,
|
||||
"p99_ms": 33.37727355957031,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.7147062004496516,
|
||||
"render": 0.14707977256612884,
|
||||
"tick": 0.6855548198381525,
|
||||
"update": 0.04897363143349227
|
||||
},
|
||||
"seconds": 11.96409825539589,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.8949875491006034,
|
||||
"render": 0.6183000469491595,
|
||||
"tick": 14.63794419879005,
|
||||
"update": 0.8699657806594457
|
||||
}
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 16462208
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 1041.3622747953375,
|
||||
"cpu": {
|
||||
"avg_ms": 0.9549695556642304,
|
||||
"max_ms": 26.009796142578125,
|
||||
"p50_ms": 0.4810290038585663,
|
||||
"p95_ms": 2.5506999492645264,
|
||||
"p99_ms": 9.879941940307617
|
||||
},
|
||||
"frames": 12496,
|
||||
"gpu": {
|
||||
"avg_ms": 0.22781361336253403,
|
||||
"max_ms": 1.7469439506530762,
|
||||
"p50_ms": 0.18534399569034576,
|
||||
"p95_ms": 0.6113280057907104,
|
||||
"p99_ms": 0.809984028339386
|
||||
},
|
||||
"low_1pct_fps": 64.04348583959742,
|
||||
"max_ms": 26.032779693603516,
|
||||
"p50_ms": 0.48582199215888977,
|
||||
"p95_ms": 2.5584940910339355,
|
||||
"p99_ms": 9.892412185668945,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.37678885421859964,
|
||||
"render": 0.3450905326050295,
|
||||
"tick": 0.1742891420732452,
|
||||
"update": 0.0590072420405441
|
||||
},
|
||||
"seconds": 11.999666496902703,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.4649118003845214,
|
||||
"render": 1.1414484503269196,
|
||||
"tick": 12.881806396484375,
|
||||
"update": 0.11011978423595428
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 316.2068527731055,
|
||||
"cpu": {
|
||||
"avg_ms": 3.154168446387541,
|
||||
"max_ms": 26.147607803344727,
|
||||
"p50_ms": 2.2156569957733154,
|
||||
"p95_ms": 12.254712104797363,
|
||||
"p99_ms": 17.7707462310791
|
||||
},
|
||||
"frames": 3795,
|
||||
"gpu": {
|
||||
"avg_ms": 0.7502970771905776,
|
||||
"max_ms": 2.599936008453369,
|
||||
"p50_ms": 0.7239680290222168,
|
||||
"p95_ms": 1.3158400058746338,
|
||||
"p99_ms": 1.3772799968719482
|
||||
},
|
||||
"low_1pct_fps": 49.59272936233826,
|
||||
"max_ms": 26.164094924926758,
|
||||
"p50_ms": 2.222438097000122,
|
||||
"p95_ms": 12.270298957824707,
|
||||
"p99_ms": 17.786956787109375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.5666706890613822,
|
||||
"render": 0.7056146899387927,
|
||||
"tick": 0.7469899302897249,
|
||||
"update": 0.13521884845002838
|
||||
},
|
||||
"seconds": 12.001637430429458,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.9408628783727948,
|
||||
"render": 1.194917630208166,
|
||||
"tick": 16.762235842253034,
|
||||
"update": 0.24904731876756014
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 385.09978163469907,
|
||||
"cpu": {
|
||||
"avg_ms": 2.589322079316407,
|
||||
"max_ms": 18.984909057617188,
|
||||
"p50_ms": 2.0551369190216064,
|
||||
"p95_ms": 8.740593910217285,
|
||||
"p99_ms": 11.559625625610352
|
||||
},
|
||||
"frames": 4622,
|
||||
"gpu": {
|
||||
"avg_ms": 0.6778284030938654,
|
||||
"max_ms": 2.398207902908325,
|
||||
"p50_ms": 0.7833600044250488,
|
||||
"p95_ms": 0.9431040287017822,
|
||||
"p99_ms": 1.511423945426941
|
||||
},
|
||||
"low_1pct_fps": 71.97424221133898,
|
||||
"max_ms": 19.0070858001709,
|
||||
"p50_ms": 2.061105966567993,
|
||||
"p95_ms": 8.753417015075684,
|
||||
"p99_ms": 11.577004432678223,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.3568510900633204,
|
||||
"render": 0.7359144338432817,
|
||||
"tick": 0.41578289517198963,
|
||||
"update": 0.0810434000275639
|
||||
},
|
||||
"seconds": 12.002084188103677,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.6523635336693296,
|
||||
"render": 1.2426407299143203,
|
||||
"tick": 10.818658037388579,
|
||||
"update": 0.16372418847489864
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 219.70895893367268,
|
||||
"cpu": {
|
||||
"avg_ms": 3.098228604710274,
|
||||
"max_ms": 23.518693923950195,
|
||||
"p50_ms": 2.0422279834747314,
|
||||
"p95_ms": 10.511551856994629,
|
||||
"p99_ms": 16.436803817749023
|
||||
},
|
||||
"frames": 2634,
|
||||
"gpu": {
|
||||
"avg_ms": 0.9377313036310374,
|
||||
"max_ms": 9.588735580444336,
|
||||
"p50_ms": 0.8488960266113281,
|
||||
"p95_ms": 0.9728000164031982,
|
||||
"p99_ms": 3.4201600551605225
|
||||
},
|
||||
"low_1pct_fps": 29.957383952355947,
|
||||
"max_ms": 33.38852310180664,
|
||||
"p50_ms": 2.0481808185577393,
|
||||
"p95_ms": 33.341556549072266,
|
||||
"p99_ms": 33.377445220947266,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.3405477057013806,
|
||||
"render": 0.8202416233113071,
|
||||
"tick": 0.8190081106798682,
|
||||
"update": 0.11872799601335143
|
||||
},
|
||||
"seconds": 11.988587141752243,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 1.648652986243919,
|
||||
"render": 1.7585702317732352,
|
||||
"tick": 14.372832368921351,
|
||||
"update": 1.0560209235107456
|
||||
}
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 212,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 255897088
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 48.3055081533635,
|
||||
"cpu": {
|
||||
"avg_ms": 9.376506657416572,
|
||||
"max_ms": 861.2249145507812,
|
||||
"p50_ms": 3.0502548217773438,
|
||||
"p95_ms": 32.94725036621094,
|
||||
"p99_ms": 74.04153442382812
|
||||
},
|
||||
"frames": 1271,
|
||||
"gpu": {
|
||||
"avg_ms": 1.7700931664342303,
|
||||
"max_ms": 20.69094467163086,
|
||||
"p50_ms": 1.2410880327224731,
|
||||
"p95_ms": 7.598080158233643,
|
||||
"p99_ms": 15.925248146057129
|
||||
},
|
||||
"low_1pct_fps": 0.761712600806078,
|
||||
"max_ms": 14330.3583984375,
|
||||
"p50_ms": 3.071429967880249,
|
||||
"p95_ms": 33.099002838134766,
|
||||
"p99_ms": 75.7547378540039,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 1.518610580008767,
|
||||
"render": 1.6828305973582713,
|
||||
"tick": 5.15484752775804,
|
||||
"update": 1.020680267595335
|
||||
},
|
||||
"seconds": 26.31169919514656,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.9654910931220422,
|
||||
"render": 3.5708756630237284,
|
||||
"tick": 196.6683132465069,
|
||||
"update": 14.971596607795128
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 27.53318156189366,
|
||||
"cpu": {
|
||||
"avg_ms": 40.13187835841161,
|
||||
"max_ms": 1166.3994140625,
|
||||
"p50_ms": 6.5535569190979,
|
||||
"p95_ms": 63.877315521240234,
|
||||
"p99_ms": 1108.11865234375
|
||||
},
|
||||
"frames": 271,
|
||||
"gpu": {
|
||||
"avg_ms": 2.297342118961785,
|
||||
"max_ms": 22.554624557495117,
|
||||
"p50_ms": 0.7065600156784058,
|
||||
"p95_ms": 7.245823860168457,
|
||||
"p99_ms": 9.055232048034668
|
||||
},
|
||||
"low_1pct_fps": 0.8848759980013957,
|
||||
"max_ms": 1166.4168701171875,
|
||||
"p50_ms": 6.675865173339844,
|
||||
"p95_ms": 59.150550842285156,
|
||||
"p99_ms": 1108.134521484375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 2.83869483858017,
|
||||
"render": 2.5961379855321343,
|
||||
"tick": 33.04425997872419,
|
||||
"update": 1.6535338119387186
|
||||
},
|
||||
"seconds": 9.842669267654419,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 2.766425689061483,
|
||||
"render": 4.919464270273845,
|
||||
"tick": 1112.3431803385417,
|
||||
"update": 10.057182629903158
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 67.01857611699128,
|
||||
"cpu": {
|
||||
"avg_ms": 14.669945189483148,
|
||||
"max_ms": 113.66764068603516,
|
||||
"p50_ms": 4.473541736602783,
|
||||
"p95_ms": 51.374263763427734,
|
||||
"p99_ms": 94.75230407714844
|
||||
},
|
||||
"frames": 802,
|
||||
"gpu": {
|
||||
"avg_ms": 2.9074973299021734,
|
||||
"max_ms": 22.526975631713867,
|
||||
"p50_ms": 1.8206720352172852,
|
||||
"p95_ms": 8.869888305664062,
|
||||
"p99_ms": 11.596799850463867
|
||||
},
|
||||
"low_1pct_fps": 10.111656570136391,
|
||||
"max_ms": 113.68443298339844,
|
||||
"p50_ms": 4.488469123840332,
|
||||
"p95_ms": 51.39033508300781,
|
||||
"p99_ms": 94.76870727539062,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 2.4291724538862556,
|
||||
"render": 2.242118674918006,
|
||||
"tick": 9.334139847333992,
|
||||
"update": 0.6651962902814967
|
||||
},
|
||||
"seconds": 11.966831384181976,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 2.7512698968251548,
|
||||
"render": 3.2687110900878906,
|
||||
"tick": 90.47323608398438,
|
||||
"update": 2.3865103986528187
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 28.70327462003782,
|
||||
"cpu": {
|
||||
"avg_ms": 16.062305691622306,
|
||||
"max_ms": 67.72319793701172,
|
||||
"p50_ms": 10.215509414672852,
|
||||
"p95_ms": 45.747154235839844,
|
||||
"p99_ms": 63.64399337768555
|
||||
},
|
||||
"frames": 345,
|
||||
"gpu": {
|
||||
"avg_ms": 8.470281650363535,
|
||||
"max_ms": 21.519359588623047,
|
||||
"p50_ms": 8.845312118530273,
|
||||
"p95_ms": 15.868927955627441,
|
||||
"p99_ms": 17.557504653930664
|
||||
},
|
||||
"low_1pct_fps": 15.182693348952107,
|
||||
"max_ms": 67.74352264404297,
|
||||
"p50_ms": 33.36533737182617,
|
||||
"p95_ms": 46.64082336425781,
|
||||
"p99_ms": 63.65925979614258,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.8729904708654984,
|
||||
"render": 3.4296869785889337,
|
||||
"tick": 8.941314909811826,
|
||||
"update": 2.8190046198558116
|
||||
},
|
||||
"seconds": 12.01953451538086,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.8918330073356628,
|
||||
"render": 4.432150959968567,
|
||||
"tick": 50.667978286743164,
|
||||
"update": 9.854933142662048
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"load_before": [
|
||||
1.86328125,
|
||||
2.111328125,
|
||||
3.9072265625
|
||||
],
|
||||
"load_after": [
|
||||
8.4560546875,
|
||||
5.08544921875,
|
||||
4.68798828125
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
{
|
||||
"label": "after-lavapipe",
|
||||
"date": "2026-10-13",
|
||||
"commit": "9006566b",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": false,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 156,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 1278284288
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 58.3354572261441,
|
||||
"cpu": {
|
||||
"avg_ms": 11.363309368576322,
|
||||
"max_ms": 36.24496841430664,
|
||||
"p50_ms": 13.213258743286133,
|
||||
"p95_ms": 19.535802841186523,
|
||||
"p99_ms": 24.83148956298828
|
||||
},
|
||||
"frames": 700,
|
||||
"gpu": {
|
||||
"avg_ms": 13.518205827985492,
|
||||
"max_ms": 34.97639083862305,
|
||||
"p50_ms": 13.195829391479492,
|
||||
"p95_ms": 18.039182662963867,
|
||||
"p99_ms": 23.378856658935547
|
||||
},
|
||||
"low_1pct_fps": 31.312386939419746,
|
||||
"max_ms": 36.36259078979492,
|
||||
"p50_ms": 16.02706527709961,
|
||||
"p95_ms": 21.249738693237305,
|
||||
"p99_ms": 29.130090713500977,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.2306461141364915,
|
||||
"render": 9.720663217795746,
|
||||
"tick": 1.3112367039202113,
|
||||
"update": 0.10169148096282567
|
||||
},
|
||||
"seconds": 11.99956310081482,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.35282185673713684,
|
||||
"render": 25.05345916748047,
|
||||
"tick": 3.1022580585309436,
|
||||
"update": 0.38026727948869976
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 41.90001789962103,
|
||||
"cpu": {
|
||||
"avg_ms": 18.045572770611635,
|
||||
"max_ms": 36.546627044677734,
|
||||
"p50_ms": 20.543725967407227,
|
||||
"p95_ms": 30.5231990814209,
|
||||
"p99_ms": 34.017364501953125
|
||||
},
|
||||
"frames": 504,
|
||||
"gpu": {
|
||||
"avg_ms": 21.35815803588383,
|
||||
"max_ms": 36.23066329956055,
|
||||
"p50_ms": 19.825437545776367,
|
||||
"p95_ms": 29.274858474731445,
|
||||
"p99_ms": 33.32203674316406
|
||||
},
|
||||
"low_1pct_fps": 20.022920779459263,
|
||||
"max_ms": 99.08029174804688,
|
||||
"p50_ms": 22.576229095458984,
|
||||
"p95_ms": 32.772239685058594,
|
||||
"p99_ms": 36.67753982543945,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.22351377572686898,
|
||||
"render": 16.234788014183916,
|
||||
"tick": 1.4387834655649459,
|
||||
"update": 0.14943464463281017
|
||||
},
|
||||
"seconds": 12.028634479522704,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.2705736731489499,
|
||||
"render": 32.015567461649574,
|
||||
"tick": 2.741411516830946,
|
||||
"update": 0.3076988315830628
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 60.413862302111134,
|
||||
"cpu": {
|
||||
"avg_ms": 10.874024665643429,
|
||||
"max_ms": 29.430055618286133,
|
||||
"p50_ms": 12.944002151489258,
|
||||
"p95_ms": 17.997587203979492,
|
||||
"p99_ms": 25.568126678466797
|
||||
},
|
||||
"frames": 725,
|
||||
"gpu": {
|
||||
"avg_ms": 12.736994123787715,
|
||||
"max_ms": 28.143625259399414,
|
||||
"p50_ms": 12.234904289245605,
|
||||
"p95_ms": 15.61594295501709,
|
||||
"p99_ms": 24.476089477539062
|
||||
},
|
||||
"low_1pct_fps": 34.82401357005238,
|
||||
"max_ms": 33.36631393432617,
|
||||
"p50_ms": 16.01299476623535,
|
||||
"p95_ms": 18.74709701538086,
|
||||
"p99_ms": 26.6020565032959,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.4042872669162421,
|
||||
"render": 9.401332649341946,
|
||||
"tick": 0.9126873913477977,
|
||||
"update": 0.15663292961387798
|
||||
},
|
||||
"seconds": 12.000557030677795,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.3962315060198307,
|
||||
"render": 26.506223678588867,
|
||||
"tick": 0.0020812499860767275,
|
||||
"update": 0.5454612672328949
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 37.77761368324693,
|
||||
"cpu": {
|
||||
"avg_ms": 11.491443304826092,
|
||||
"max_ms": 34.474151611328125,
|
||||
"p50_ms": 13.871685981750488,
|
||||
"p95_ms": 20.351163864135742,
|
||||
"p99_ms": 23.77434539794922
|
||||
},
|
||||
"frames": 453,
|
||||
"gpu": {
|
||||
"avg_ms": 14.109076927302953,
|
||||
"max_ms": 35.115882873535156,
|
||||
"p50_ms": 13.555350303649902,
|
||||
"p95_ms": 18.336809158325195,
|
||||
"p99_ms": 30.865131378173828
|
||||
},
|
||||
"low_1pct_fps": 27.343844611059406,
|
||||
"max_ms": 39.0676155090332,
|
||||
"p50_ms": 33.33863830566406,
|
||||
"p95_ms": 33.36062240600586,
|
||||
"p99_ms": 34.297752380371094,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.22927819613457778,
|
||||
"render": 9.350584785242217,
|
||||
"tick": 1.7383085893987211,
|
||||
"update": 0.17420387428015405
|
||||
},
|
||||
"seconds": 11.991228556632995,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.2908208012580872,
|
||||
"render": 25.648976135253907,
|
||||
"tick": 1.9043863375205548,
|
||||
"update": 0.7624145835638046
|
||||
}
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 36807552
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 20.701493975921895,
|
||||
"cpu": {
|
||||
"avg_ms": 31.808356682459515,
|
||||
"max_ms": 74.47195434570312,
|
||||
"p50_ms": 41.86561584472656,
|
||||
"p95_ms": 56.75548553466797,
|
||||
"p99_ms": 73.00084686279297
|
||||
},
|
||||
"frames": 249,
|
||||
"gpu": {
|
||||
"avg_ms": 40.500388481140135,
|
||||
"max_ms": 67.22461700439453,
|
||||
"p50_ms": 40.38219451904297,
|
||||
"p95_ms": 50.01243209838867,
|
||||
"p99_ms": 62.00723648071289
|
||||
},
|
||||
"low_1pct_fps": 8.83914293384798,
|
||||
"max_ms": 133.2972869873047,
|
||||
"p50_ms": 47.37559509277344,
|
||||
"p95_ms": 61.3382682800293,
|
||||
"p99_ms": 74.61792755126953,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.30830518620081215,
|
||||
"render": 25.584145257750667,
|
||||
"tick": 5.116575629957185,
|
||||
"update": 0.8002672145165592
|
||||
},
|
||||
"seconds": 12.028117404937744,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.3641819953918457,
|
||||
"render": 44.77838750680288,
|
||||
"tick": 6.285618623097737,
|
||||
"update": 22.415442685286205
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 18.118065350089793,
|
||||
"cpu": {
|
||||
"avg_ms": 36.827518428137545,
|
||||
"max_ms": 85.7597427368164,
|
||||
"p50_ms": 49.536197662353516,
|
||||
"p95_ms": 62.31198501586914,
|
||||
"p99_ms": 66.591064453125
|
||||
},
|
||||
"frames": 218,
|
||||
"gpu": {
|
||||
"avg_ms": 47.82228247616269,
|
||||
"max_ms": 76.9173355102539,
|
||||
"p50_ms": 46.98957824707031,
|
||||
"p95_ms": 57.49673843383789,
|
||||
"p99_ms": 69.92066192626953
|
||||
},
|
||||
"low_1pct_fps": 11.528998473378772,
|
||||
"max_ms": 93.62928009033203,
|
||||
"p50_ms": 53.71598434448242,
|
||||
"p95_ms": 66.69908905029297,
|
||||
"p99_ms": 80.71000671386719,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.29564340877423595,
|
||||
"render": 30.678313442326466,
|
||||
"tick": 4.647872053591402,
|
||||
"update": 1.206680115073099
|
||||
},
|
||||
"seconds": 12.032189739227295,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.37793900569279987,
|
||||
"render": 66.15962092081706,
|
||||
"tick": 5.583541075388591,
|
||||
"update": 1.5741689602533977
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 8.272097120557788,
|
||||
"cpu": {
|
||||
"avg_ms": 96.70907358733974,
|
||||
"max_ms": 208.84027099609375,
|
||||
"p50_ms": 89.04510498046875,
|
||||
"p95_ms": 169.3397979736328,
|
||||
"p99_ms": 208.84027099609375
|
||||
},
|
||||
"frames": 98,
|
||||
"gpu": {
|
||||
"avg_ms": 57.298700176939676,
|
||||
"max_ms": 66.62165832519531,
|
||||
"p50_ms": 57.89077377319336,
|
||||
"p95_ms": 65.73504638671875,
|
||||
"p99_ms": 66.62165832519531
|
||||
},
|
||||
"low_1pct_fps": 4.785729156618201,
|
||||
"max_ms": 208.95457458496094,
|
||||
"p50_ms": 140.0601348876953,
|
||||
"p95_ms": 178.57022094726562,
|
||||
"p99_ms": 208.95457458496094,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 3.3946731230434106,
|
||||
"render": 34.994675042982,
|
||||
"tick": 57.14290783599932,
|
||||
"update": 1.177816742536973
|
||||
},
|
||||
"seconds": 11.847056262969971,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 7.7776899337768555,
|
||||
"render": 70.36097717285156,
|
||||
"tick": 128.46734619140625,
|
||||
"update": 2.2351090908050537
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 11.65883600782651,
|
||||
"cpu": {
|
||||
"avg_ms": 56.6912090659987,
|
||||
"max_ms": 107.11215209960938,
|
||||
"p50_ms": 77.04230499267578,
|
||||
"p95_ms": 97.60908508300781,
|
||||
"p99_ms": 104.08527374267578
|
||||
},
|
||||
"frames": 141,
|
||||
"gpu": {
|
||||
"avg_ms": 70.81221792731486,
|
||||
"max_ms": 96.33728790283203,
|
||||
"p50_ms": 69.16046905517578,
|
||||
"p95_ms": 86.61634063720703,
|
||||
"p99_ms": 92.56858825683594
|
||||
},
|
||||
"low_1pct_fps": 4.93309783391495,
|
||||
"max_ms": 216.64273071289062,
|
||||
"p50_ms": 81.59197235107422,
|
||||
"p95_ms": 104.20377349853516,
|
||||
"p99_ms": 188.7820281982422,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.31791428133105554,
|
||||
"render": 45.35119773817401,
|
||||
"tick": 9.391353438086544,
|
||||
"update": 1.6317692605104852
|
||||
},
|
||||
"seconds": 12.093831657409668,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.348672017455101,
|
||||
"render": 86.63534545898438,
|
||||
"tick": 13.065274715423584,
|
||||
"update": 5.5503233671188354
|
||||
}
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 119,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 222619264
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 5.310476400246329,
|
||||
"cpu": {
|
||||
"avg_ms": 111.2176975607872,
|
||||
"max_ms": 224.16696166992188,
|
||||
"p50_ms": 76.4469985961914,
|
||||
"p95_ms": 218.82273864746094,
|
||||
"p99_ms": 224.16696166992188
|
||||
},
|
||||
"frames": 64,
|
||||
"gpu": {
|
||||
"avg_ms": 121.35315322875977,
|
||||
"max_ms": 171.11207580566406,
|
||||
"p50_ms": 125.45780944824219,
|
||||
"p95_ms": 166.71322631835938,
|
||||
"p99_ms": 171.11207580566406
|
||||
},
|
||||
"low_1pct_fps": 3.59762228435401,
|
||||
"max_ms": 277.96136474609375,
|
||||
"p50_ms": 192.1078338623047,
|
||||
"p95_ms": 232.86318969726562,
|
||||
"p99_ms": 277.96136474609375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.524940284434706,
|
||||
"render": 56.89460512250662,
|
||||
"tick": 38.0364211499691,
|
||||
"update": 15.762759290635586
|
||||
},
|
||||
"seconds": 12.051649452209473,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.5129500031471252,
|
||||
"render": 147.3928985595703,
|
||||
"tick": 62.813438415527344,
|
||||
"update": 13.448556900024414
|
||||
}
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 3.899515654388116,
|
||||
"cpu": {
|
||||
"avg_ms": 155.46855975211935,
|
||||
"max_ms": 270.7467041015625,
|
||||
"p50_ms": 83.60679626464844,
|
||||
"p95_ms": 268.6576843261719,
|
||||
"p99_ms": 270.7467041015625
|
||||
},
|
||||
"frames": 47,
|
||||
"gpu": {
|
||||
"avg_ms": 192.77972445280656,
|
||||
"max_ms": 225.69723510742188,
|
||||
"p50_ms": 189.4962615966797,
|
||||
"p95_ms": 217.36190795898438,
|
||||
"p99_ms": 225.69723510742188
|
||||
},
|
||||
"low_1pct_fps": 3.3773198460417087,
|
||||
"max_ms": 296.0927734375,
|
||||
"p50_ms": 259.49017333984375,
|
||||
"p95_ms": 283.4007568359375,
|
||||
"p99_ms": 296.0927734375,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.5114446409205173,
|
||||
"render": 99.58217374821926,
|
||||
"tick": 43.03971359577585,
|
||||
"update": 12.336258025879555
|
||||
},
|
||||
"seconds": 12.052778900146484,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.5626839995384216,
|
||||
"render": 208.71124267578125,
|
||||
"tick": 53.47932052612305,
|
||||
"update": 7.994467258453369
|
||||
}
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.9455219461811217,
|
||||
"cpu": {
|
||||
"avg_ms": 287.98993862376494,
|
||||
"max_ms": 464.7646484375,
|
||||
"p50_ms": 286.4447326660156,
|
||||
"p95_ms": 444.6121520996094,
|
||||
"p99_ms": 464.7646484375
|
||||
},
|
||||
"frames": 34,
|
||||
"gpu": {
|
||||
"avg_ms": 116.96576734270369,
|
||||
"max_ms": 158.26394653320312,
|
||||
"p50_ms": 106.45819854736328,
|
||||
"p95_ms": 158.1454620361328,
|
||||
"p99_ms": 158.26394653320312
|
||||
},
|
||||
"low_1pct_fps": 2.151113067092828,
|
||||
"max_ms": 464.8756103515625,
|
||||
"p50_ms": 315.1367492675781,
|
||||
"p95_ms": 456.3304138183594,
|
||||
"p99_ms": 464.8756103515625,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 4.395756518139558,
|
||||
"render": 69.52432030789993,
|
||||
"tick": 206.13154108384077,
|
||||
"update": 7.93927874284632
|
||||
},
|
||||
"seconds": 11.542945739746093,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 4.144052982330322,
|
||||
"render": 166.14959716796875,
|
||||
"tick": 285.5132751464844,
|
||||
"update": 8.958683967590332
|
||||
}
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 3.983176689759444,
|
||||
"cpu": {
|
||||
"avg_ms": 171.41208145466257,
|
||||
"max_ms": 413.8367919921875,
|
||||
"p50_ms": 164.3318328857422,
|
||||
"p95_ms": 360.7731628417969,
|
||||
"p99_ms": 413.8367919921875
|
||||
},
|
||||
"frames": 47,
|
||||
"gpu": {
|
||||
"avg_ms": 166.80018520355225,
|
||||
"max_ms": 304.8785400390625,
|
||||
"p50_ms": 143.19728088378906,
|
||||
"p95_ms": 275.3953552246094,
|
||||
"p99_ms": 304.8785400390625
|
||||
},
|
||||
"low_1pct_fps": 2.418344167836982,
|
||||
"max_ms": 413.5060729980469,
|
||||
"p50_ms": 230.7030029296875,
|
||||
"p95_ms": 365.4028625488281,
|
||||
"p99_ms": 413.5060729980469,
|
||||
"phases_avg_ms": {
|
||||
"prepare": 0.496992789684458,
|
||||
"render": 94.4349257743105,
|
||||
"tick": 61.33542941478973,
|
||||
"update": 15.14569672117842
|
||||
},
|
||||
"seconds": 11.799627197265625,
|
||||
"slow_phases_avg_ms": {
|
||||
"prepare": 0.4748440086841583,
|
||||
"render": 312.3215637207031,
|
||||
"tick": 86.37409973144531,
|
||||
"update": 14.667168617248535
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"load_before": [
|
||||
8.4560546875,
|
||||
5.08544921875,
|
||||
4.68798828125
|
||||
],
|
||||
"load_after": [
|
||||
10.98291015625,
|
||||
7.201171875,
|
||||
5.5615234375
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
{
|
||||
"label": "baseline-4080",
|
||||
"date": "2026-10-13",
|
||||
"commit": "27a32cc3",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": true,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 158,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 1137747200
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 362.94072531330323,
|
||||
"cpu": {
|
||||
"avg_ms": 2.734311161203731,
|
||||
"max_ms": 68.06377410888672,
|
||||
"p50_ms": 1.4820960760116577,
|
||||
"p95_ms": 7.922675609588623,
|
||||
"p99_ms": 35.78989028930664
|
||||
},
|
||||
"frames": 4373,
|
||||
"gpu": {
|
||||
"avg_ms": 0.4188921034758625,
|
||||
"max_ms": 2.5876479148864746,
|
||||
"p50_ms": 0.4106239974498749,
|
||||
"p95_ms": 0.5283839702606201,
|
||||
"p99_ms": 0.5427200198173523
|
||||
},
|
||||
"low_1pct_fps": 21.64332698060583,
|
||||
"max_ms": 68.08219146728516,
|
||||
"p50_ms": 1.4894599914550781,
|
||||
"p95_ms": 7.936410427093506,
|
||||
"p99_ms": 36.54151153564453,
|
||||
"seconds": 12.048799418210983
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 613.1553113846195,
|
||||
"cpu": {
|
||||
"avg_ms": 1.6186122214214547,
|
||||
"max_ms": 216.98960876464844,
|
||||
"p50_ms": 1.10076105594635,
|
||||
"p95_ms": 3.0016868114471436,
|
||||
"p99_ms": 10.144570350646973
|
||||
},
|
||||
"frames": 6746,
|
||||
"gpu": {
|
||||
"avg_ms": 0.395751640401594,
|
||||
"max_ms": 1.99782395362854,
|
||||
"p50_ms": 0.3983359932899475,
|
||||
"p95_ms": 0.4833280146121979,
|
||||
"p99_ms": 0.5375999808311462
|
||||
},
|
||||
"low_1pct_fps": 49.639170314777054,
|
||||
"max_ms": 217.0211181640625,
|
||||
"p50_ms": 1.109616994857788,
|
||||
"p95_ms": 3.0161218643188477,
|
||||
"p99_ms": 10.163111686706543,
|
||||
"seconds": 11.002106439828873
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 323.23791637473414,
|
||||
"cpu": {
|
||||
"avg_ms": 2.7230400277200597,
|
||||
"max_ms": 4748.7744140625,
|
||||
"p50_ms": 0.9233829975128174,
|
||||
"p95_ms": 1.677724003791809,
|
||||
"p99_ms": 7.7397589683532715
|
||||
},
|
||||
"frames": 3870,
|
||||
"gpu": {
|
||||
"avg_ms": 0.24116999250656182,
|
||||
"max_ms": 0.954367995262146,
|
||||
"p50_ms": 0.2754560112953186,
|
||||
"p95_ms": 0.3389439880847931,
|
||||
"p99_ms": 0.3532800078392029
|
||||
},
|
||||
"low_1pct_fps": 5.421154869716093,
|
||||
"max_ms": 4748.80615234375,
|
||||
"p50_ms": 0.9285120368003845,
|
||||
"p95_ms": 1.7079449892044067,
|
||||
"p99_ms": 33.33257293701172,
|
||||
"seconds": 11.97260532861948
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 28.19080143244723,
|
||||
"cpu": {
|
||||
"avg_ms": 12.358911560308298,
|
||||
"max_ms": 187.3409881591797,
|
||||
"p50_ms": 9.513923645019531,
|
||||
"p95_ms": 25.783824920654297,
|
||||
"p99_ms": 110.71585845947266
|
||||
},
|
||||
"frames": 338,
|
||||
"gpu": {
|
||||
"avg_ms": 0.46002139662497143,
|
||||
"max_ms": 1.5626239776611328,
|
||||
"p50_ms": 0.4546560049057007,
|
||||
"p95_ms": 0.5140479803085327,
|
||||
"p99_ms": 0.5959680080413818
|
||||
},
|
||||
"low_1pct_fps": 7.428141780247131,
|
||||
"max_ms": 187.36033630371094,
|
||||
"p50_ms": 33.334835052490234,
|
||||
"p95_ms": 35.65903854370117,
|
||||
"p99_ms": 110.73509216308594,
|
||||
"seconds": 11.98972653579712
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 54284928
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 7.569165639843409,
|
||||
"cpu": {
|
||||
"avg_ms": 138.25648041658624,
|
||||
"max_ms": 877.6473999023438,
|
||||
"p50_ms": 103.97663879394531,
|
||||
"p95_ms": 318.5262145996094,
|
||||
"p99_ms": 877.6473999023438
|
||||
},
|
||||
"frames": 86,
|
||||
"gpu": {
|
||||
"avg_ms": 3.585262137790059,
|
||||
"max_ms": 8.415231704711914,
|
||||
"p50_ms": 3.299328088760376,
|
||||
"p95_ms": 7.171072006225586,
|
||||
"p99_ms": 8.415231704711914
|
||||
},
|
||||
"low_1pct_fps": 2.44461512120682,
|
||||
"max_ms": 409.0623474121094,
|
||||
"p50_ms": 104.01687622070312,
|
||||
"p95_ms": 318.5569763183594,
|
||||
"p99_ms": 409.0623474121094,
|
||||
"seconds": 11.361886381149292
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 27.65529078792617,
|
||||
"cpu": {
|
||||
"avg_ms": 37.926895780927815,
|
||||
"max_ms": 3484.08349609375,
|
||||
"p50_ms": 3.398620128631592,
|
||||
"p95_ms": 44.56385040283203,
|
||||
"p99_ms": 684.4393920898438
|
||||
},
|
||||
"frames": 314,
|
||||
"gpu": {
|
||||
"avg_ms": 2.683724634467986,
|
||||
"max_ms": 9.881600379943848,
|
||||
"p50_ms": 1.8923519849777222,
|
||||
"p95_ms": 7.221248149871826,
|
||||
"p99_ms": 9.04089641571045
|
||||
},
|
||||
"low_1pct_fps": 0.4753349245465373,
|
||||
"max_ms": 3484.115234375,
|
||||
"p50_ms": 3.5420219898223877,
|
||||
"p95_ms": 44.591251373291016,
|
||||
"p99_ms": 159.3817901611328,
|
||||
"seconds": 11.354066113710404
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 59.16125632179682,
|
||||
"cpu": {
|
||||
"avg_ms": 16.807428855239674,
|
||||
"max_ms": 106.0348129272461,
|
||||
"p50_ms": 9.335607528686523,
|
||||
"p95_ms": 44.57380294799805,
|
||||
"p99_ms": 84.97154998779297
|
||||
},
|
||||
"frames": 712,
|
||||
"gpu": {
|
||||
"avg_ms": 1.958949391112736,
|
||||
"max_ms": 8.635392189025879,
|
||||
"p50_ms": 1.1182080507278442,
|
||||
"p95_ms": 5.493760108947754,
|
||||
"p99_ms": 7.108607769012451
|
||||
},
|
||||
"low_1pct_fps": 10.542641239411056,
|
||||
"max_ms": 106.06544494628906,
|
||||
"p50_ms": 9.364562034606934,
|
||||
"p95_ms": 45.14366912841797,
|
||||
"p99_ms": 84.9927749633789,
|
||||
"seconds": 12.034903317928315
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 26.78175419456072,
|
||||
"cpu": {
|
||||
"avg_ms": 20.230309660251326,
|
||||
"max_ms": 128.05508422851562,
|
||||
"p50_ms": 15.369550704956055,
|
||||
"p95_ms": 67.15973663330078,
|
||||
"p99_ms": 104.6961898803711
|
||||
},
|
||||
"frames": 325,
|
||||
"gpu": {
|
||||
"avg_ms": 7.143625645270714,
|
||||
"max_ms": 21.25823974609375,
|
||||
"p50_ms": 7.187456130981445,
|
||||
"p95_ms": 12.228608131408691,
|
||||
"p99_ms": 19.310592651367188
|
||||
},
|
||||
"low_1pct_fps": 7.777799947825389,
|
||||
"max_ms": 155.30685424804688,
|
||||
"p50_ms": 33.335182189941406,
|
||||
"p95_ms": 68.94293975830078,
|
||||
"p99_ms": 109.09304809570312,
|
||||
"seconds": 12.135127431869506
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 0,
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"present_mode": "Immediate",
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"uploaded_bytes": 4829184
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 4.1477100728464,
|
||||
"cpu": {
|
||||
"avg_ms": 244.17284844846142,
|
||||
"max_ms": 386.7664489746094,
|
||||
"p50_ms": 244.8783416748047,
|
||||
"p95_ms": 363.0765380859375,
|
||||
"p99_ms": 386.7664489746094
|
||||
},
|
||||
"frames": 49,
|
||||
"gpu": {
|
||||
"avg_ms": 10.357822744213806,
|
||||
"max_ms": 23.010303497314453,
|
||||
"p50_ms": 9.227264404296875,
|
||||
"p95_ms": 19.309568405151367,
|
||||
"p99_ms": 23.010303497314453
|
||||
},
|
||||
"low_1pct_fps": 2.5853636290905797,
|
||||
"max_ms": 386.79278564453125,
|
||||
"p50_ms": 230.8652801513672,
|
||||
"p95_ms": 363.0941162109375,
|
||||
"p99_ms": 386.79278564453125,
|
||||
"seconds": 11.813747619628906
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 8.3624969613463,
|
||||
"cpu": {
|
||||
"avg_ms": 118.74922356747165,
|
||||
"max_ms": 278.8187255859375,
|
||||
"p50_ms": 123.03407287597656,
|
||||
"p95_ms": 231.6608123779297,
|
||||
"p99_ms": 260.8450622558594
|
||||
},
|
||||
"frames": 101,
|
||||
"gpu": {
|
||||
"avg_ms": 15.898816661669477,
|
||||
"max_ms": 72.880126953125,
|
||||
"p50_ms": 20.789247512817383,
|
||||
"p95_ms": 28.441600799560547,
|
||||
"p99_ms": 60.816383361816406
|
||||
},
|
||||
"low_1pct_fps": 3.7057290596141406,
|
||||
"max_ms": 278.83917236328125,
|
||||
"p50_ms": 123.19290161132812,
|
||||
"p95_ms": 231.68026733398438,
|
||||
"p99_ms": 260.8656921386719,
|
||||
"seconds": 12.077732340812684
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.3954947141714045,
|
||||
"cpu": {
|
||||
"avg_ms": 424.7621285574777,
|
||||
"max_ms": 625.8532104492188,
|
||||
"p50_ms": 435.64044189453125,
|
||||
"p95_ms": 577.7593994140625,
|
||||
"p99_ms": 625.8532104492188
|
||||
},
|
||||
"frames": 28,
|
||||
"gpu": {
|
||||
"avg_ms": 9.483117733682905,
|
||||
"max_ms": 12.973055839538574,
|
||||
"p50_ms": 9.90719985961914,
|
||||
"p95_ms": 12.610560417175293,
|
||||
"p99_ms": 12.973055839538574
|
||||
},
|
||||
"low_1pct_fps": 1.5977632874291703,
|
||||
"max_ms": 625.8749389648438,
|
||||
"p50_ms": 433.9859619140625,
|
||||
"p95_ms": 577.7803955078125,
|
||||
"p99_ms": 625.8749389648438,
|
||||
"seconds": 11.688608551025391
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 3.3756775332390268,
|
||||
"cpu": {
|
||||
"avg_ms": 300.512197249975,
|
||||
"max_ms": 407.4363708496094,
|
||||
"p50_ms": 292.483642578125,
|
||||
"p95_ms": 394.8424377441406,
|
||||
"p99_ms": 407.4363708496094
|
||||
},
|
||||
"frames": 39,
|
||||
"gpu": {
|
||||
"avg_ms": 9.728866460995796,
|
||||
"max_ms": 18.930688858032227,
|
||||
"p50_ms": 8.820735931396484,
|
||||
"p95_ms": 17.399808883666992,
|
||||
"p99_ms": 18.930688858032227
|
||||
},
|
||||
"low_1pct_fps": 2.4541792422631716,
|
||||
"max_ms": 407.46820068359375,
|
||||
"p50_ms": 292.4177551269531,
|
||||
"p95_ms": 394.8649597167969,
|
||||
"p99_ms": 407.46820068359375,
|
||||
"seconds": 11.553236236572266
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
{
|
||||
"label": "baseline-lavapipe",
|
||||
"date": "2026-10-13",
|
||||
"commit": "27a32cc3",
|
||||
"host": "ludodesktop",
|
||||
"offscreen": false,
|
||||
"seed": "1234",
|
||||
"flight_seconds": 12.0,
|
||||
"scenes": {
|
||||
"village": {
|
||||
"from": [
|
||||
-960.0,
|
||||
125.0,
|
||||
80.0
|
||||
],
|
||||
"to": [
|
||||
-960.0,
|
||||
125.0,
|
||||
272.0
|
||||
],
|
||||
"pitch": 35.0
|
||||
},
|
||||
"forest": {
|
||||
"from": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-560.0
|
||||
],
|
||||
"to": [
|
||||
-1100.0,
|
||||
120.0,
|
||||
-368.0
|
||||
],
|
||||
"pitch": 25.0
|
||||
},
|
||||
"ocean": {
|
||||
"from": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-420.0
|
||||
],
|
||||
"to": [
|
||||
-300.0,
|
||||
85.0,
|
||||
-228.0
|
||||
],
|
||||
"pitch": 10.0
|
||||
},
|
||||
"mountain": {
|
||||
"from": [
|
||||
560.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"to": [
|
||||
752.0,
|
||||
260.0,
|
||||
860.0
|
||||
],
|
||||
"pitch": 30.0
|
||||
}
|
||||
},
|
||||
"distances": {
|
||||
"8": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 146,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 1288851072
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 16.38225374384963,
|
||||
"cpu": {
|
||||
"avg_ms": 48.17248754105975,
|
||||
"max_ms": 134.31028747558594,
|
||||
"p50_ms": 44.57072067260742,
|
||||
"p95_ms": 97.15985870361328,
|
||||
"p99_ms": 127.31073760986328
|
||||
},
|
||||
"frames": 199,
|
||||
"gpu": {
|
||||
"avg_ms": 26.732369270324707,
|
||||
"max_ms": 47.3107795715332,
|
||||
"p50_ms": 26.284116744995117,
|
||||
"p95_ms": 35.006874084472656,
|
||||
"p99_ms": 39.38005828857422
|
||||
},
|
||||
"low_1pct_fps": 6.569673692138871,
|
||||
"max_ms": 169.97352600097656,
|
||||
"p50_ms": 56.791988372802734,
|
||||
"p95_ms": 109.15220642089844,
|
||||
"p99_ms": 134.45559692382812,
|
||||
"seconds": 12.147290788650512
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 19.917172468484612,
|
||||
"cpu": {
|
||||
"avg_ms": 31.039839970568817,
|
||||
"max_ms": 106.39506530761719,
|
||||
"p50_ms": 33.26064682006836,
|
||||
"p95_ms": 70.09876251220703,
|
||||
"p99_ms": 86.21255493164062
|
||||
},
|
||||
"frames": 240,
|
||||
"gpu": {
|
||||
"avg_ms": 37.272586759195285,
|
||||
"max_ms": 77.586669921875,
|
||||
"p50_ms": 34.38500213623047,
|
||||
"p95_ms": 59.649635314941406,
|
||||
"p99_ms": 66.52396392822266
|
||||
},
|
||||
"low_1pct_fps": 9.909291086862112,
|
||||
"max_ms": 106.57542419433594,
|
||||
"p50_ms": 46.05862808227539,
|
||||
"p95_ms": 80.38935852050781,
|
||||
"p99_ms": 94.94140625,
|
||||
"seconds": 12.049903186798096
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 27.969763129786234,
|
||||
"cpu": {
|
||||
"avg_ms": 25.69895526429727,
|
||||
"max_ms": 930.7354125976562,
|
||||
"p50_ms": 18.981081008911133,
|
||||
"p95_ms": 39.42117691040039,
|
||||
"p99_ms": 168.07225036621094
|
||||
},
|
||||
"frames": 336,
|
||||
"gpu": {
|
||||
"avg_ms": 18.856842384451912,
|
||||
"max_ms": 57.835872650146484,
|
||||
"p50_ms": 18.172868728637695,
|
||||
"p95_ms": 28.62451171875,
|
||||
"p99_ms": 44.414398193359375
|
||||
},
|
||||
"low_1pct_fps": 1.527565223414601,
|
||||
"max_ms": 930.8734741210938,
|
||||
"p50_ms": 26.45800018310547,
|
||||
"p95_ms": 50.053672790527344,
|
||||
"p99_ms": 180.92478942871094,
|
||||
"seconds": 12.012972667694092
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 26.5942562164059,
|
||||
"cpu": {
|
||||
"avg_ms": 22.482411369495093,
|
||||
"max_ms": 104.89199829101562,
|
||||
"p50_ms": 22.033512115478516,
|
||||
"p95_ms": 46.385555267333984,
|
||||
"p99_ms": 61.15697479248047
|
||||
},
|
||||
"frames": 320,
|
||||
"gpu": {
|
||||
"avg_ms": 23.762589550018312,
|
||||
"max_ms": 48.10239028930664,
|
||||
"p50_ms": 22.580347061157227,
|
||||
"p95_ms": 35.14606475830078,
|
||||
"p99_ms": 42.47938919067383
|
||||
},
|
||||
"low_1pct_fps": 11.197907022053611,
|
||||
"max_ms": 105.07585906982422,
|
||||
"p50_ms": 33.362815856933594,
|
||||
"p95_ms": 53.36695098876953,
|
||||
"p99_ms": 74.80219268798828,
|
||||
"seconds": 12.03267342376709
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 16106880
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 10.754658718707336,
|
||||
"cpu": {
|
||||
"avg_ms": 69.22208800682655,
|
||||
"max_ms": 193.21267700195312,
|
||||
"p50_ms": 66.95130157470703,
|
||||
"p95_ms": 154.44834899902344,
|
||||
"p99_ms": 191.29208374023438
|
||||
},
|
||||
"frames": 130,
|
||||
"gpu": {
|
||||
"avg_ms": 49.041961078495945,
|
||||
"max_ms": 73.83775329589844,
|
||||
"p50_ms": 48.90928268432617,
|
||||
"p95_ms": 60.60416793823242,
|
||||
"p99_ms": 66.1044921875
|
||||
},
|
||||
"low_1pct_fps": 4.171866301284614,
|
||||
"max_ms": 252.21458435058594,
|
||||
"p50_ms": 79.8940200805664,
|
||||
"p95_ms": 173.05947875976562,
|
||||
"p99_ms": 227.18716430664062,
|
||||
"seconds": 12.087784782409669
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 12.167278778001634,
|
||||
"cpu": {
|
||||
"avg_ms": 58.366991678873696,
|
||||
"max_ms": 156.683349609375,
|
||||
"p50_ms": 69.24014282226562,
|
||||
"p95_ms": 110.83757019042969,
|
||||
"p99_ms": 134.16554260253906
|
||||
},
|
||||
"frames": 147,
|
||||
"gpu": {
|
||||
"avg_ms": 56.45229888606716,
|
||||
"max_ms": 78.26596069335938,
|
||||
"p50_ms": 55.92947006225586,
|
||||
"p95_ms": 63.54412841796875,
|
||||
"p99_ms": 66.5202407836914
|
||||
},
|
||||
"low_1pct_fps": 5.722095670390665,
|
||||
"max_ms": 212.99729919433594,
|
||||
"p50_ms": 77.36986541748047,
|
||||
"p95_ms": 122.84784698486328,
|
||||
"p99_ms": 136.52499389648438,
|
||||
"seconds": 12.081583950042724
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 7.681804845753147,
|
||||
"cpu": {
|
||||
"avg_ms": 98.35419226740743,
|
||||
"max_ms": 385.2493591308594,
|
||||
"p50_ms": 78.85044860839844,
|
||||
"p95_ms": 225.36231994628906,
|
||||
"p99_ms": 385.2493591308594
|
||||
},
|
||||
"frames": 91,
|
||||
"gpu": {
|
||||
"avg_ms": 71.51263585297957,
|
||||
"max_ms": 128.29212951660156,
|
||||
"p50_ms": 67.92412567138672,
|
||||
"p95_ms": 101.93219757080078,
|
||||
"p99_ms": 128.29212951660156
|
||||
},
|
||||
"low_1pct_fps": 2.5948566279945817,
|
||||
"max_ms": 385.37774658203125,
|
||||
"p50_ms": 123.91693878173828,
|
||||
"p95_ms": 225.49420166015625,
|
||||
"p99_ms": 385.37774658203125,
|
||||
"seconds": 11.846174411773681
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 5.886844433101332,
|
||||
"cpu": {
|
||||
"avg_ms": 124.87309415575484,
|
||||
"max_ms": 321.2157897949219,
|
||||
"p50_ms": 117.2811050415039,
|
||||
"p95_ms": 237.34304809570312,
|
||||
"p99_ms": 321.2157897949219
|
||||
},
|
||||
"frames": 71,
|
||||
"gpu": {
|
||||
"avg_ms": 95.46965434816148,
|
||||
"max_ms": 166.9488067626953,
|
||||
"p50_ms": 83.54202270507812,
|
||||
"p95_ms": 160.2403564453125,
|
||||
"p99_ms": 166.9488067626953
|
||||
},
|
||||
"low_1pct_fps": 3.111272818975726,
|
||||
"max_ms": 321.411865234375,
|
||||
"p50_ms": 153.0963134765625,
|
||||
"p95_ms": 275.37579345703125,
|
||||
"p99_ms": 321.411865234375,
|
||||
"seconds": 12.060790939331055
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"renderer": {
|
||||
"backend": "Vulkan",
|
||||
"drawn_sections": 1,
|
||||
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
|
||||
"present_mode": "Mailbox",
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"uploaded_bytes": 19154560
|
||||
},
|
||||
"village": {
|
||||
"avg_fps": 2.5956714101148957,
|
||||
"cpu": {
|
||||
"avg_ms": 345.63314671670236,
|
||||
"max_ms": 474.2013854980469,
|
||||
"p50_ms": 347.07415771484375,
|
||||
"p95_ms": 468.0090026855469,
|
||||
"p99_ms": 474.2013854980469
|
||||
},
|
||||
"frames": 31,
|
||||
"gpu": {
|
||||
"avg_ms": 85.50063645839691,
|
||||
"max_ms": 134.11460876464844,
|
||||
"p50_ms": 82.40946960449219,
|
||||
"p95_ms": 133.71929931640625,
|
||||
"p99_ms": 134.11460876464844
|
||||
},
|
||||
"low_1pct_fps": 2.01406006678458,
|
||||
"max_ms": 496.509521484375,
|
||||
"p50_ms": 390.230224609375,
|
||||
"p95_ms": 493.8070373535156,
|
||||
"p99_ms": 496.509521484375,
|
||||
"seconds": 11.942960067749024
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 2.2283930626963917,
|
||||
"cpu": {
|
||||
"avg_ms": 354.7813931978666,
|
||||
"max_ms": 611.8099975585938,
|
||||
"p50_ms": 293.61376953125,
|
||||
"p95_ms": 601.3502197265625,
|
||||
"p99_ms": 611.8099975585938
|
||||
},
|
||||
"frames": 26,
|
||||
"gpu": {
|
||||
"avg_ms": 201.54420654296874,
|
||||
"max_ms": 357.0310363769531,
|
||||
"p50_ms": 172.65980529785156,
|
||||
"p95_ms": 341.8655700683594,
|
||||
"p99_ms": 357.0310363769531
|
||||
},
|
||||
"low_1pct_fps": 1.52339962842959,
|
||||
"max_ms": 656.4265747070312,
|
||||
"p50_ms": 407.1051330566406,
|
||||
"p95_ms": 611.97119140625,
|
||||
"p99_ms": 656.4265747070312,
|
||||
"seconds": 11.667600494384766
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2.6137193379326398,
|
||||
"cpu": {
|
||||
"avg_ms": 330.783615620931,
|
||||
"max_ms": 730.7304077148438,
|
||||
"p50_ms": 296.65692138671875,
|
||||
"p95_ms": 680.8717041015625,
|
||||
"p99_ms": 730.7304077148438
|
||||
},
|
||||
"frames": 30,
|
||||
"gpu": {
|
||||
"avg_ms": 113.80178985595703,
|
||||
"max_ms": 216.57342529296875,
|
||||
"p50_ms": 104.23703002929688,
|
||||
"p95_ms": 193.67672729492188,
|
||||
"p99_ms": 216.57342529296875
|
||||
},
|
||||
"low_1pct_fps": 1.1784160273210693,
|
||||
"max_ms": 848.5967407226562,
|
||||
"p50_ms": 346.120849609375,
|
||||
"p95_ms": 681.0716552734375,
|
||||
"p99_ms": 848.5967407226562,
|
||||
"seconds": 11.477896484375
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 2.72441042065798,
|
||||
"cpu": {
|
||||
"avg_ms": 325.24547207740045,
|
||||
"max_ms": 578.7177124023438,
|
||||
"p50_ms": 295.25457763671875,
|
||||
"p95_ms": 569.8099975585938,
|
||||
"p99_ms": 578.7177124023438
|
||||
},
|
||||
"frames": 31,
|
||||
"gpu": {
|
||||
"avg_ms": 104.99177145957947,
|
||||
"max_ms": 163.95635986328125,
|
||||
"p50_ms": 99.96784210205078,
|
||||
"p95_ms": 160.04824829101562,
|
||||
"p99_ms": 163.95635986328125
|
||||
},
|
||||
"low_1pct_fps": 1.7544071931551564,
|
||||
"max_ms": 569.9931030273438,
|
||||
"p50_ms": 378.246337890625,
|
||||
"p95_ms": 555.5369873046875,
|
||||
"p99_ms": 569.9931030273438,
|
||||
"seconds": 11.378608657836914
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-15T04:35:57Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "3f42787b"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "",
|
||||
"driver": "",
|
||||
"backend": "metal",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 46.22,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 5281.435,
|
||||
"stdev": 2260.202,
|
||||
"min": 3670.293,
|
||||
"max": 7865.118,
|
||||
"ci95": 5615.107
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 19399.299,
|
||||
"stdev": 10767.032,
|
||||
"min": 11064.274,
|
||||
"max": 31555.835,
|
||||
"ci95": 26748.949
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4216.286,
|
||||
"stdev": 1125.65,
|
||||
"min": 3171.995,
|
||||
"max": 5408.641,
|
||||
"ci95": 2796.496
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 46008.667,
|
||||
"stdev": 2182.164,
|
||||
"min": 43575.0,
|
||||
"max": 47791.0,
|
||||
"ci95": 5421.234
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 392.708,
|
||||
"stdev": 244.514,
|
||||
"min": 122.101,
|
||||
"max": 597.769,
|
||||
"ci95": 607.456
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 34.096,
|
||||
"stdev": 26.158,
|
||||
"min": 6.503,
|
||||
"max": 58.533,
|
||||
"ci95": 64.986
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 15.369,
|
||||
"stdev": 10.165,
|
||||
"min": 5.019,
|
||||
"max": 25.339,
|
||||
"ci95": 25.254
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 22109.864,
|
||||
"stdev": 9981.189,
|
||||
"min": 12833.391,
|
||||
"max": 32671.311,
|
||||
"ci95": 24796.65
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 60214.0,
|
||||
"stdev": 20370.065,
|
||||
"min": 40988.0,
|
||||
"max": 81562.0,
|
||||
"ci95": 50606.13
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 168.488,
|
||||
"stdev": 132.476,
|
||||
"min": 21.538,
|
||||
"max": 278.763,
|
||||
"ci95": 329.116
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 12.712,
|
||||
"stdev": 8.34,
|
||||
"min": 3.127,
|
||||
"max": 18.315,
|
||||
"ci95": 20.72
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.659,
|
||||
"stdev": 4.129,
|
||||
"min": 3.005,
|
||||
"max": 10.881,
|
||||
"ci95": 10.257
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1828.172,
|
||||
"stdev": 12.996,
|
||||
"min": 1814.656,
|
||||
"max": 1840.578,
|
||||
"ci95": 32.288
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1659.659,
|
||||
"stdev": 7.872,
|
||||
"min": 1650.766,
|
||||
"max": 1665.734,
|
||||
"ci95": 19.557
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 7174,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 597.769,
|
||||
"low_1pct_fps": 58.533,
|
||||
"low_01pct_fps": 25.339,
|
||||
"p50_ms": 1.105,
|
||||
"p95_ms": 4.246,
|
||||
"p99_ms": 10.609,
|
||||
"p999_ms": 25.118,
|
||||
"max_ms": 75.71,
|
||||
"histogram": [
|
||||
1531,
|
||||
4741,
|
||||
366,
|
||||
175,
|
||||
35,
|
||||
50,
|
||||
65,
|
||||
66,
|
||||
60,
|
||||
38,
|
||||
14,
|
||||
12,
|
||||
6,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1462,
|
||||
"seconds": 11.974,
|
||||
"avg_fps": 122.101,
|
||||
"low_1pct_fps": 6.503,
|
||||
"low_01pct_fps": 5.019,
|
||||
"p50_ms": 2.058,
|
||||
"p95_ms": 31.007,
|
||||
"p99_ms": 112.655,
|
||||
"p999_ms": 194.129,
|
||||
"max_ms": 204.335,
|
||||
"histogram": [
|
||||
21,
|
||||
687,
|
||||
195,
|
||||
84,
|
||||
43,
|
||||
33,
|
||||
45,
|
||||
37,
|
||||
47,
|
||||
51,
|
||||
31,
|
||||
33,
|
||||
25,
|
||||
36,
|
||||
23,
|
||||
11,
|
||||
15,
|
||||
14,
|
||||
12,
|
||||
11,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 5499,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 458.253,
|
||||
"low_1pct_fps": 37.251,
|
||||
"low_01pct_fps": 15.749,
|
||||
"p50_ms": 1.253,
|
||||
"p95_ms": 6.822,
|
||||
"p99_ms": 13.254,
|
||||
"p999_ms": 53.18,
|
||||
"max_ms": 77.046,
|
||||
"histogram": [
|
||||
712,
|
||||
3496,
|
||||
527,
|
||||
266,
|
||||
83,
|
||||
83,
|
||||
74,
|
||||
74,
|
||||
72,
|
||||
44,
|
||||
18,
|
||||
11,
|
||||
9,
|
||||
11,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 260,
|
||||
"seconds": 12.072,
|
||||
"avg_fps": 21.538,
|
||||
"low_1pct_fps": 3.127,
|
||||
"low_01pct_fps": 3.005,
|
||||
"p50_ms": 20.354,
|
||||
"p95_ms": 174.542,
|
||||
"p99_ms": 294.454,
|
||||
"p999_ms": 332.752,
|
||||
"max_ms": 332.752,
|
||||
"histogram": [
|
||||
0,
|
||||
36,
|
||||
17,
|
||||
17,
|
||||
6,
|
||||
6,
|
||||
3,
|
||||
5,
|
||||
6,
|
||||
10,
|
||||
6,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
19,
|
||||
16,
|
||||
10,
|
||||
13,
|
||||
25,
|
||||
16,
|
||||
18,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 3345,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 278.763,
|
||||
"low_1pct_fps": 16.694,
|
||||
"low_01pct_fps": 9.092,
|
||||
"p50_ms": 1.593,
|
||||
"p95_ms": 12.614,
|
||||
"p99_ms": 37.671,
|
||||
"p999_ms": 92.703,
|
||||
"max_ms": 133.394,
|
||||
"histogram": [
|
||||
0,
|
||||
2422,
|
||||
285,
|
||||
134,
|
||||
57,
|
||||
73,
|
||||
51,
|
||||
68,
|
||||
41,
|
||||
34,
|
||||
36,
|
||||
36,
|
||||
16,
|
||||
21,
|
||||
22,
|
||||
17,
|
||||
12,
|
||||
12,
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 2462,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 205.163,
|
||||
"low_1pct_fps": 18.315,
|
||||
"low_01pct_fps": 10.881,
|
||||
"p50_ms": 2.006,
|
||||
"p95_ms": 17.997,
|
||||
"p99_ms": 40.1,
|
||||
"p999_ms": 77.189,
|
||||
"max_ms": 109.276,
|
||||
"histogram": [
|
||||
0,
|
||||
1228,
|
||||
355,
|
||||
226,
|
||||
94,
|
||||
77,
|
||||
62,
|
||||
65,
|
||||
60,
|
||||
64,
|
||||
38,
|
||||
54,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
18,
|
||||
14,
|
||||
7,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "pub-horizon"
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-15T04:32:05Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "3f42787b"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "",
|
||||
"driver": "",
|
||||
"backend": "metal",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 46.288,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7786.967,
|
||||
"stdev": 4088.035,
|
||||
"min": 5120.805,
|
||||
"max": 12493.58,
|
||||
"ci95": 10156.062
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 23146.275,
|
||||
"stdev": 12436.963,
|
||||
"min": 14639.422,
|
||||
"max": 37419.838,
|
||||
"ci95": 30897.623
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5013.855,
|
||||
"stdev": 2028.402,
|
||||
"min": 2907.197,
|
||||
"max": 6953.7,
|
||||
"ci95": 5039.237
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 10117.0,
|
||||
"stdev": 1588.092,
|
||||
"min": 8930.0,
|
||||
"max": 11921.0,
|
||||
"ci95": 3945.358
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 380.117,
|
||||
"stdev": 152.317,
|
||||
"min": 207.974,
|
||||
"max": 497.424,
|
||||
"ci95": 378.408
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.297,
|
||||
"stdev": 5.55,
|
||||
"min": 11.279,
|
||||
"max": 22.214,
|
||||
"ci95": 13.788
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 10.398,
|
||||
"stdev": 1.441,
|
||||
"min": 8.842,
|
||||
"max": 11.686,
|
||||
"ci95": 3.579
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8664.268,
|
||||
"stdev": 4657.457,
|
||||
"min": 3541.194,
|
||||
"max": 12642.662,
|
||||
"ci95": 11570.697
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 17739.667,
|
||||
"stdev": 4124.842,
|
||||
"min": 13499.0,
|
||||
"max": 21738.0,
|
||||
"ci95": 10247.503
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 467.08,
|
||||
"stdev": 76.765,
|
||||
"min": 403.274,
|
||||
"max": 552.27,
|
||||
"ci95": 190.711
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 21.042,
|
||||
"stdev": 7.793,
|
||||
"min": 13.974,
|
||||
"max": 29.4,
|
||||
"ci95": 19.361
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.903,
|
||||
"stdev": 2.189,
|
||||
"min": 8.54,
|
||||
"max": 12.428,
|
||||
"ci95": 5.438
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1577.167,
|
||||
"stdev": 21.646,
|
||||
"min": 1552.172,
|
||||
"max": 1589.75,
|
||||
"ci95": 53.777
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1537.297,
|
||||
"stdev": 18.879,
|
||||
"min": 1524.484,
|
||||
"max": 1558.977,
|
||||
"ci95": 46.901
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 2491,
|
||||
"seconds": 11.977,
|
||||
"avg_fps": 207.974,
|
||||
"low_1pct_fps": 11.279,
|
||||
"low_01pct_fps": 8.842,
|
||||
"p50_ms": 0.88,
|
||||
"p95_ms": 24.632,
|
||||
"p99_ms": 67.621,
|
||||
"p999_ms": 110.416,
|
||||
"max_ms": 116.484,
|
||||
"histogram": [
|
||||
1508,
|
||||
356,
|
||||
94,
|
||||
51,
|
||||
37,
|
||||
46,
|
||||
46,
|
||||
39,
|
||||
34,
|
||||
47,
|
||||
28,
|
||||
28,
|
||||
23,
|
||||
33,
|
||||
31,
|
||||
23,
|
||||
21,
|
||||
21,
|
||||
19,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 5220,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 434.954,
|
||||
"low_1pct_fps": 18.398,
|
||||
"low_01pct_fps": 10.666,
|
||||
"p50_ms": 0.774,
|
||||
"p95_ms": 7.82,
|
||||
"p99_ms": 33.91,
|
||||
"p999_ms": 84.374,
|
||||
"max_ms": 103.752,
|
||||
"histogram": [
|
||||
3897,
|
||||
531,
|
||||
180,
|
||||
107,
|
||||
73,
|
||||
74,
|
||||
57,
|
||||
54,
|
||||
36,
|
||||
37,
|
||||
22,
|
||||
30,
|
||||
24,
|
||||
21,
|
||||
19,
|
||||
18,
|
||||
16,
|
||||
14,
|
||||
9,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 5973,
|
||||
"seconds": 12.008,
|
||||
"avg_fps": 497.424,
|
||||
"low_1pct_fps": 22.214,
|
||||
"low_01pct_fps": 11.686,
|
||||
"p50_ms": 0.762,
|
||||
"p95_ms": 6.934,
|
||||
"p99_ms": 26.212,
|
||||
"p999_ms": 74.127,
|
||||
"max_ms": 105.126,
|
||||
"histogram": [
|
||||
4592,
|
||||
569,
|
||||
195,
|
||||
97,
|
||||
78,
|
||||
76,
|
||||
72,
|
||||
61,
|
||||
39,
|
||||
28,
|
||||
17,
|
||||
33,
|
||||
23,
|
||||
28,
|
||||
24,
|
||||
11,
|
||||
14,
|
||||
9,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 4836,
|
||||
"seconds": 11.992,
|
||||
"avg_fps": 403.274,
|
||||
"low_1pct_fps": 13.974,
|
||||
"low_01pct_fps": 8.742,
|
||||
"p50_ms": 0.707,
|
||||
"p95_ms": 8.505,
|
||||
"p99_ms": 38.346,
|
||||
"p999_ms": 109.016,
|
||||
"max_ms": 120.946,
|
||||
"histogram": [
|
||||
3661,
|
||||
433,
|
||||
165,
|
||||
121,
|
||||
66,
|
||||
50,
|
||||
53,
|
||||
39,
|
||||
47,
|
||||
29,
|
||||
15,
|
||||
28,
|
||||
25,
|
||||
26,
|
||||
25,
|
||||
5,
|
||||
12,
|
||||
10,
|
||||
17,
|
||||
9,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 5353,
|
||||
"seconds": 12.01,
|
||||
"avg_fps": 445.695,
|
||||
"low_1pct_fps": 19.753,
|
||||
"low_01pct_fps": 8.54,
|
||||
"p50_ms": 0.71,
|
||||
"p95_ms": 8.506,
|
||||
"p99_ms": 30.239,
|
||||
"p999_ms": 82.313,
|
||||
"max_ms": 218.733,
|
||||
"histogram": [
|
||||
4012,
|
||||
535,
|
||||
187,
|
||||
104,
|
||||
66,
|
||||
72,
|
||||
53,
|
||||
46,
|
||||
44,
|
||||
45,
|
||||
33,
|
||||
23,
|
||||
21,
|
||||
32,
|
||||
36,
|
||||
16,
|
||||
10,
|
||||
11,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 6628,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 552.27,
|
||||
"low_1pct_fps": 29.4,
|
||||
"low_01pct_fps": 12.428,
|
||||
"p50_ms": 0.714,
|
||||
"p95_ms": 6.707,
|
||||
"p99_ms": 18.865,
|
||||
"p999_ms": 54.65,
|
||||
"max_ms": 133.947,
|
||||
"histogram": [
|
||||
4932,
|
||||
707,
|
||||
271,
|
||||
149,
|
||||
80,
|
||||
99,
|
||||
79,
|
||||
56,
|
||||
61,
|
||||
53,
|
||||
31,
|
||||
28,
|
||||
20,
|
||||
19,
|
||||
20,
|
||||
9,
|
||||
7,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "pub-horizon"
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-15T04:56:59Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 35.961,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 19300.326,
|
||||
"stdev": 2828.141,
|
||||
"min": 16043.14,
|
||||
"max": 21132.475,
|
||||
"ci95": 7026.058
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 50423.659,
|
||||
"stdev": 8119.357,
|
||||
"min": 41103.14,
|
||||
"max": 55961.362,
|
||||
"ci95": 20171.23
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13437.795,
|
||||
"stdev": 3809.937,
|
||||
"min": 10283.957,
|
||||
"max": 17670.952,
|
||||
"ci95": 9465.173
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 53.32,
|
||||
"stdev": 5.103,
|
||||
"min": 49.967,
|
||||
"max": 59.193,
|
||||
"ci95": 12.677
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.817,
|
||||
"stdev": 0.633,
|
||||
"min": 9.307,
|
||||
"max": 10.525,
|
||||
"ci95": 1.572
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.717,
|
||||
"stdev": 1.007,
|
||||
"min": 5.78,
|
||||
"max": 7.781,
|
||||
"ci95": 2.501
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 25536.212,
|
||||
"stdev": 6161.551,
|
||||
"min": 21454.85,
|
||||
"max": 32623.83,
|
||||
"ci95": 15307.376
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 36.87,
|
||||
"stdev": 18.572,
|
||||
"min": 19.41,
|
||||
"max": 56.383,
|
||||
"ci95": 46.139
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.045,
|
||||
"stdev": 3.0,
|
||||
"min": 3.708,
|
||||
"max": 9.428,
|
||||
"ci95": 7.454
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.864,
|
||||
"stdev": 1.619,
|
||||
"min": 3.288,
|
||||
"max": 6.522,
|
||||
"ci95": 4.021
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2672.901,
|
||||
"stdev": 108.822,
|
||||
"min": 2547.469,
|
||||
"max": 2742.125,
|
||||
"ci95": 270.351
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2372.76,
|
||||
"stdev": 117.571,
|
||||
"min": 2243.563,
|
||||
"max": 2473.469,
|
||||
"ci95": 292.086
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 610,
|
||||
"seconds": 12.008,
|
||||
"avg_fps": 50.801,
|
||||
"low_1pct_fps": 9.618,
|
||||
"low_01pct_fps": 5.78,
|
||||
"p50_ms": 14.837,
|
||||
"p95_ms": 47.768,
|
||||
"p99_ms": 76.311,
|
||||
"p999_ms": 173.006,
|
||||
"max_ms": 173.006,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
15,
|
||||
39,
|
||||
51,
|
||||
48,
|
||||
77,
|
||||
59,
|
||||
60,
|
||||
48,
|
||||
70,
|
||||
58,
|
||||
35,
|
||||
20,
|
||||
18,
|
||||
8,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 600,
|
||||
"seconds": 12.008,
|
||||
"avg_fps": 49.967,
|
||||
"low_1pct_fps": 9.307,
|
||||
"low_01pct_fps": 7.781,
|
||||
"p50_ms": 15.377,
|
||||
"p95_ms": 45.626,
|
||||
"p99_ms": 90.931,
|
||||
"p999_ms": 128.516,
|
||||
"max_ms": 128.516,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
15,
|
||||
44,
|
||||
53,
|
||||
72,
|
||||
73,
|
||||
77,
|
||||
51,
|
||||
80,
|
||||
65,
|
||||
26,
|
||||
14,
|
||||
14,
|
||||
9,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 711,
|
||||
"seconds": 12.012,
|
||||
"avg_fps": 59.193,
|
||||
"low_1pct_fps": 10.525,
|
||||
"low_01pct_fps": 6.589,
|
||||
"p50_ms": 13.784,
|
||||
"p95_ms": 34.98,
|
||||
"p99_ms": 68.485,
|
||||
"p999_ms": 151.765,
|
||||
"max_ms": 151.765,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
8,
|
||||
14,
|
||||
21,
|
||||
55,
|
||||
72,
|
||||
80,
|
||||
106,
|
||||
92,
|
||||
83,
|
||||
72,
|
||||
56,
|
||||
14,
|
||||
10,
|
||||
11,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 418,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 34.817,
|
||||
"low_1pct_fps": 4.998,
|
||||
"low_01pct_fps": 4.781,
|
||||
"p50_ms": 18.052,
|
||||
"p95_ms": 84.979,
|
||||
"p99_ms": 184.893,
|
||||
"p999_ms": 209.176,
|
||||
"max_ms": 209.176,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
4,
|
||||
19,
|
||||
21,
|
||||
19,
|
||||
38,
|
||||
42,
|
||||
43,
|
||||
41,
|
||||
45,
|
||||
42,
|
||||
24,
|
||||
23,
|
||||
21,
|
||||
18,
|
||||
6,
|
||||
9,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 233,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 19.41,
|
||||
"low_1pct_fps": 3.708,
|
||||
"low_01pct_fps": 3.288,
|
||||
"p50_ms": 29.988,
|
||||
"p95_ms": 168.153,
|
||||
"p99_ms": 241.102,
|
||||
"p999_ms": 304.121,
|
||||
"max_ms": 304.121,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
2,
|
||||
14,
|
||||
10,
|
||||
9,
|
||||
12,
|
||||
18,
|
||||
24,
|
||||
29,
|
||||
15,
|
||||
15,
|
||||
16,
|
||||
22,
|
||||
22,
|
||||
13,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 677,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 56.383,
|
||||
"low_1pct_fps": 9.428,
|
||||
"low_01pct_fps": 6.522,
|
||||
"p50_ms": 13.331,
|
||||
"p95_ms": 45.556,
|
||||
"p99_ms": 78.202,
|
||||
"p999_ms": 153.321,
|
||||
"max_ms": 153.321,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
7,
|
||||
15,
|
||||
32,
|
||||
36,
|
||||
78,
|
||||
61,
|
||||
62,
|
||||
72,
|
||||
79,
|
||||
52,
|
||||
59,
|
||||
49,
|
||||
31,
|
||||
17,
|
||||
15,
|
||||
9,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "pub-vanilla-mods"
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-15T04:50:33Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.616,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 11913.795,
|
||||
"stdev": 1682.464,
|
||||
"min": 10064.344,
|
||||
"max": 13353.625,
|
||||
"ci95": 4179.81
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 23628.795,
|
||||
"stdev": 3821.829,
|
||||
"min": 19928.344,
|
||||
"max": 27561.417,
|
||||
"ci95": 9494.716
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5859.756,
|
||||
"stdev": 671.071,
|
||||
"min": 5318.887,
|
||||
"max": 6610.744,
|
||||
"ci95": 1667.167
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 93.728,
|
||||
"stdev": 30.987,
|
||||
"min": 74.79,
|
||||
"max": 129.488,
|
||||
"ci95": 76.983
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.012,
|
||||
"stdev": 10.129,
|
||||
"min": 9.452,
|
||||
"max": 28.521,
|
||||
"ci95": 25.164
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 11.577,
|
||||
"stdev": 6.366,
|
||||
"min": 6.817,
|
||||
"max": 18.808,
|
||||
"ci95": 15.815
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11856.681,
|
||||
"stdev": 4220.225,
|
||||
"min": 7673.523,
|
||||
"max": 16113.02,
|
||||
"ci95": 10484.465
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 144.139,
|
||||
"stdev": 18.171,
|
||||
"min": 123.234,
|
||||
"max": 156.15,
|
||||
"ci95": 45.144
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.863,
|
||||
"stdev": 7.609,
|
||||
"min": 8.317,
|
||||
"max": 22.902,
|
||||
"ci95": 18.903
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.871,
|
||||
"stdev": 4.76,
|
||||
"min": 6.407,
|
||||
"max": 15.299,
|
||||
"ci95": 11.826
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2052.464,
|
||||
"stdev": 24.452,
|
||||
"min": 2033.672,
|
||||
"max": 2080.109,
|
||||
"ci95": 60.747
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1998.708,
|
||||
"stdev": 35.637,
|
||||
"min": 1960.07,
|
||||
"max": 2030.289,
|
||||
"ci95": 88.535
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 923,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 76.906,
|
||||
"low_1pct_fps": 13.064,
|
||||
"low_01pct_fps": 9.107,
|
||||
"p50_ms": 9.609,
|
||||
"p95_ms": 33.104,
|
||||
"p99_ms": 58.172,
|
||||
"p999_ms": 109.81,
|
||||
"max_ms": 109.81,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
8,
|
||||
136,
|
||||
75,
|
||||
69,
|
||||
66,
|
||||
72,
|
||||
51,
|
||||
73,
|
||||
71,
|
||||
74,
|
||||
69,
|
||||
61,
|
||||
53,
|
||||
14,
|
||||
12,
|
||||
12,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 901,
|
||||
"seconds": 12.047,
|
||||
"avg_fps": 74.79,
|
||||
"low_1pct_fps": 9.452,
|
||||
"low_01pct_fps": 6.817,
|
||||
"p50_ms": 7.86,
|
||||
"p95_ms": 43.522,
|
||||
"p99_ms": 77.616,
|
||||
"p999_ms": 146.699,
|
||||
"max_ms": 146.699,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
81,
|
||||
71,
|
||||
97,
|
||||
118,
|
||||
119,
|
||||
84,
|
||||
53,
|
||||
48,
|
||||
46,
|
||||
47,
|
||||
36,
|
||||
30,
|
||||
19,
|
||||
16,
|
||||
18,
|
||||
12,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 1555,
|
||||
"seconds": 12.009,
|
||||
"avg_fps": 129.488,
|
||||
"low_1pct_fps": 28.521,
|
||||
"low_01pct_fps": 18.808,
|
||||
"p50_ms": 6.567,
|
||||
"p95_ms": 16.327,
|
||||
"p99_ms": 28.019,
|
||||
"p999_ms": 47.44,
|
||||
"max_ms": 58.896,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
32,
|
||||
221,
|
||||
169,
|
||||
221,
|
||||
259,
|
||||
225,
|
||||
126,
|
||||
109,
|
||||
65,
|
||||
57,
|
||||
28,
|
||||
19,
|
||||
17,
|
||||
4,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 1837,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 153.034,
|
||||
"low_1pct_fps": 22.902,
|
||||
"low_01pct_fps": 15.299,
|
||||
"p50_ms": 4.665,
|
||||
"p95_ms": 17.025,
|
||||
"p99_ms": 32.332,
|
||||
"p999_ms": 64.213,
|
||||
"max_ms": 66.518,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
353,
|
||||
421,
|
||||
225,
|
||||
203,
|
||||
164,
|
||||
127,
|
||||
91,
|
||||
63,
|
||||
47,
|
||||
48,
|
||||
31,
|
||||
22,
|
||||
24,
|
||||
10,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 1874,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 156.15,
|
||||
"low_1pct_fps": 19.369,
|
||||
"low_01pct_fps": 7.907,
|
||||
"p50_ms": 4.561,
|
||||
"p95_ms": 16.565,
|
||||
"p99_ms": 34.031,
|
||||
"p999_ms": 81.478,
|
||||
"max_ms": 171.477,
|
||||
"histogram": [
|
||||
0,
|
||||
18,
|
||||
334,
|
||||
456,
|
||||
220,
|
||||
243,
|
||||
167,
|
||||
145,
|
||||
84,
|
||||
46,
|
||||
36,
|
||||
32,
|
||||
24,
|
||||
24,
|
||||
26,
|
||||
8,
|
||||
6,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 1483,
|
||||
"seconds": 12.034,
|
||||
"avg_fps": 123.234,
|
||||
"low_1pct_fps": 8.317,
|
||||
"low_01pct_fps": 6.407,
|
||||
"p50_ms": 4.438,
|
||||
"p95_ms": 26.448,
|
||||
"p99_ms": 93.738,
|
||||
"p999_ms": 154.876,
|
||||
"max_ms": 157.287,
|
||||
"histogram": [
|
||||
0,
|
||||
32,
|
||||
287,
|
||||
363,
|
||||
158,
|
||||
167,
|
||||
134,
|
||||
89,
|
||||
61,
|
||||
36,
|
||||
31,
|
||||
12,
|
||||
19,
|
||||
18,
|
||||
12,
|
||||
19,
|
||||
12,
|
||||
15,
|
||||
4,
|
||||
12,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "pub-vanilla-mods"
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-15T04:45:34Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 47.376,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 12397.912,
|
||||
"stdev": 2214.074,
|
||||
"min": 10094.327,
|
||||
"max": 14510.041,
|
||||
"ci95": 5500.509
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 37146.912,
|
||||
"stdev": 5671.908,
|
||||
"min": 30897.327,
|
||||
"max": 41968.041,
|
||||
"ci95": 14090.938
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12510.29,
|
||||
"stdev": 1034.733,
|
||||
"min": 11864.748,
|
||||
"max": 13703.767,
|
||||
"ci95": 2570.626
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 60.563,
|
||||
"stdev": 11.943,
|
||||
"min": 46.795,
|
||||
"max": 68.132,
|
||||
"ci95": 29.67
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 12.993,
|
||||
"stdev": 1.675,
|
||||
"min": 11.965,
|
||||
"max": 14.926,
|
||||
"ci95": 4.162
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 8.751,
|
||||
"stdev": 0.598,
|
||||
"min": 8.239,
|
||||
"max": 9.409,
|
||||
"ci95": 1.487
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 24624.587,
|
||||
"stdev": 2787.515,
|
||||
"min": 22405.306,
|
||||
"max": 27753.221,
|
||||
"ci95": 6925.13
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 46.852,
|
||||
"stdev": 4.543,
|
||||
"min": 41.755,
|
||||
"max": 50.474,
|
||||
"ci95": 11.286
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.08,
|
||||
"stdev": 1.001,
|
||||
"min": 7.975,
|
||||
"max": 9.924,
|
||||
"ci95": 2.486
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.003,
|
||||
"stdev": 1.502,
|
||||
"min": 5.352,
|
||||
"max": 8.288,
|
||||
"ci95": 3.731
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2487.797,
|
||||
"stdev": 85.991,
|
||||
"min": 2392.141,
|
||||
"max": 2558.688,
|
||||
"ci95": 213.631
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2228.044,
|
||||
"stdev": 56.806,
|
||||
"min": 2172.375,
|
||||
"max": 2285.922,
|
||||
"ci95": 141.124
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 803,
|
||||
"seconds": 12.028,
|
||||
"avg_fps": 66.761,
|
||||
"low_1pct_fps": 11.965,
|
||||
"low_01pct_fps": 8.606,
|
||||
"p50_ms": 11.953,
|
||||
"p95_ms": 32.478,
|
||||
"p99_ms": 60.875,
|
||||
"p999_ms": 116.201,
|
||||
"max_ms": 116.201,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
17,
|
||||
99,
|
||||
122,
|
||||
166,
|
||||
129,
|
||||
90,
|
||||
50,
|
||||
53,
|
||||
39,
|
||||
11,
|
||||
11,
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 562,
|
||||
"seconds": 12.01,
|
||||
"avg_fps": 46.795,
|
||||
"low_1pct_fps": 12.088,
|
||||
"low_01pct_fps": 9.409,
|
||||
"p50_ms": 17.366,
|
||||
"p95_ms": 46.135,
|
||||
"p99_ms": 74.565,
|
||||
"p999_ms": 106.285,
|
||||
"max_ms": 106.285,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
14,
|
||||
30,
|
||||
53,
|
||||
64,
|
||||
94,
|
||||
94,
|
||||
68,
|
||||
72,
|
||||
28,
|
||||
15,
|
||||
15,
|
||||
12,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 818,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 68.132,
|
||||
"low_1pct_fps": 14.926,
|
||||
"low_01pct_fps": 8.239,
|
||||
"p50_ms": 12.761,
|
||||
"p95_ms": 29.499,
|
||||
"p99_ms": 39.102,
|
||||
"p999_ms": 121.374,
|
||||
"max_ms": 121.374,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
17,
|
||||
85,
|
||||
109,
|
||||
141,
|
||||
147,
|
||||
117,
|
||||
79,
|
||||
53,
|
||||
39,
|
||||
19,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 502,
|
||||
"seconds": 12.022,
|
||||
"avg_fps": 41.755,
|
||||
"low_1pct_fps": 9.924,
|
||||
"low_01pct_fps": 7.369,
|
||||
"p50_ms": 18.716,
|
||||
"p95_ms": 58.279,
|
||||
"p99_ms": 82.805,
|
||||
"p999_ms": 135.701,
|
||||
"max_ms": 135.701,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
5,
|
||||
27,
|
||||
24,
|
||||
49,
|
||||
57,
|
||||
53,
|
||||
52,
|
||||
58,
|
||||
69,
|
||||
37,
|
||||
30,
|
||||
18,
|
||||
15,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 580,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 48.327,
|
||||
"low_1pct_fps": 9.342,
|
||||
"low_01pct_fps": 8.288,
|
||||
"p50_ms": 15.232,
|
||||
"p95_ms": 55.367,
|
||||
"p99_ms": 98.37,
|
||||
"p999_ms": 120.663,
|
||||
"max_ms": 120.663,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
4,
|
||||
25,
|
||||
49,
|
||||
51,
|
||||
61,
|
||||
70,
|
||||
56,
|
||||
60,
|
||||
68,
|
||||
52,
|
||||
27,
|
||||
19,
|
||||
21,
|
||||
13,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 606,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 50.474,
|
||||
"low_1pct_fps": 7.975,
|
||||
"low_01pct_fps": 5.352,
|
||||
"p50_ms": 14.435,
|
||||
"p95_ms": 48.644,
|
||||
"p99_ms": 94.658,
|
||||
"p999_ms": 186.844,
|
||||
"max_ms": 186.844,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
17,
|
||||
50,
|
||||
64,
|
||||
72,
|
||||
84,
|
||||
67,
|
||||
61,
|
||||
66,
|
||||
50,
|
||||
29,
|
||||
15,
|
||||
15,
|
||||
11,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "pub-vanilla"
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-15T04:39:59Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 40.387,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 12839.436,
|
||||
"stdev": 2322.332,
|
||||
"min": 10161.349,
|
||||
"max": 14297.279,
|
||||
"ci95": 5769.458
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 25355.436,
|
||||
"stdev": 5418.929,
|
||||
"min": 19177.349,
|
||||
"max": 29303.68,
|
||||
"ci95": 13462.453
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6388.631,
|
||||
"stdev": 2036.184,
|
||||
"min": 4264.091,
|
||||
"max": 8323.134,
|
||||
"ci95": 5058.568
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 115.902,
|
||||
"stdev": 65.065,
|
||||
"min": 47.911,
|
||||
"max": 177.581,
|
||||
"ci95": 161.643
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 20.334,
|
||||
"stdev": 14.036,
|
||||
"min": 5.317,
|
||||
"max": 33.122,
|
||||
"ci95": 34.87
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 11.963,
|
||||
"stdev": 7.6,
|
||||
"min": 4.15,
|
||||
"max": 19.33,
|
||||
"ci95": 18.881
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12173.22,
|
||||
"stdev": 2593.324,
|
||||
"min": 9195.096,
|
||||
"max": 13933.227,
|
||||
"ci95": 6442.695
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 163.112,
|
||||
"stdev": 58.942,
|
||||
"min": 95.064,
|
||||
"max": 198.232,
|
||||
"ci95": 146.431
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 30.409,
|
||||
"stdev": 16.505,
|
||||
"min": 13.19,
|
||||
"max": 46.093,
|
||||
"ci95": 41.004
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 19.518,
|
||||
"stdev": 10.651,
|
||||
"min": 9.845,
|
||||
"max": 30.932,
|
||||
"ci95": 26.46
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2001.417,
|
||||
"stdev": 56.845,
|
||||
"min": 1967.063,
|
||||
"max": 2067.031,
|
||||
"ci95": 141.222
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1948.943,
|
||||
"stdev": 97.998,
|
||||
"min": 1885.516,
|
||||
"max": 2061.813,
|
||||
"ci95": 243.46
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 2131,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 177.581,
|
||||
"low_1pct_fps": 33.122,
|
||||
"low_01pct_fps": 19.33,
|
||||
"p50_ms": 4.459,
|
||||
"p95_ms": 11.146,
|
||||
"p99_ms": 20.673,
|
||||
"p999_ms": 45.508,
|
||||
"max_ms": 55.237,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
17,
|
||||
937,
|
||||
318,
|
||||
252,
|
||||
215,
|
||||
157,
|
||||
103,
|
||||
39,
|
||||
31,
|
||||
18,
|
||||
19,
|
||||
14,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1467,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 122.213,
|
||||
"low_1pct_fps": 22.563,
|
||||
"low_01pct_fps": 12.41,
|
||||
"p50_ms": 6.78,
|
||||
"p95_ms": 16.592,
|
||||
"p99_ms": 29.728,
|
||||
"p999_ms": 69.574,
|
||||
"max_ms": 91.584,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
103,
|
||||
211,
|
||||
221,
|
||||
252,
|
||||
240,
|
||||
143,
|
||||
108,
|
||||
67,
|
||||
50,
|
||||
27,
|
||||
20,
|
||||
13,
|
||||
6,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 576,
|
||||
"seconds": 12.022,
|
||||
"avg_fps": 47.911,
|
||||
"low_1pct_fps": 5.317,
|
||||
"low_01pct_fps": 4.15,
|
||||
"p50_ms": 8.575,
|
||||
"p95_ms": 82.916,
|
||||
"p99_ms": 163.996,
|
||||
"p999_ms": 240.959,
|
||||
"max_ms": 240.959,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
23,
|
||||
27,
|
||||
61,
|
||||
78,
|
||||
89,
|
||||
37,
|
||||
30,
|
||||
27,
|
||||
35,
|
||||
28,
|
||||
26,
|
||||
24,
|
||||
13,
|
||||
13,
|
||||
18,
|
||||
29,
|
||||
12,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 2355,
|
||||
"seconds": 12.013,
|
||||
"avg_fps": 196.041,
|
||||
"low_1pct_fps": 31.945,
|
||||
"low_01pct_fps": 17.776,
|
||||
"p50_ms": 3.966,
|
||||
"p95_ms": 11.292,
|
||||
"p99_ms": 20.229,
|
||||
"p999_ms": 47.014,
|
||||
"max_ms": 71.034,
|
||||
"histogram": [
|
||||
0,
|
||||
13,
|
||||
561,
|
||||
715,
|
||||
325,
|
||||
189,
|
||||
178,
|
||||
126,
|
||||
90,
|
||||
57,
|
||||
35,
|
||||
27,
|
||||
15,
|
||||
8,
|
||||
9,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 1141,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 95.064,
|
||||
"low_1pct_fps": 13.19,
|
||||
"low_01pct_fps": 9.845,
|
||||
"p50_ms": 7.072,
|
||||
"p95_ms": 27.933,
|
||||
"p99_ms": 58.816,
|
||||
"p999_ms": 101.246,
|
||||
"max_ms": 101.894,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
7,
|
||||
186,
|
||||
126,
|
||||
119,
|
||||
128,
|
||||
102,
|
||||
98,
|
||||
80,
|
||||
62,
|
||||
70,
|
||||
53,
|
||||
38,
|
||||
32,
|
||||
12,
|
||||
10,
|
||||
11,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 2379,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 198.232,
|
||||
"low_1pct_fps": 46.093,
|
||||
"low_01pct_fps": 30.932,
|
||||
"p50_ms": 4.062,
|
||||
"p95_ms": 10.38,
|
||||
"p99_ms": 16.968,
|
||||
"p999_ms": 26.767,
|
||||
"max_ms": 39.249,
|
||||
"histogram": [
|
||||
0,
|
||||
5,
|
||||
442,
|
||||
805,
|
||||
307,
|
||||
243,
|
||||
186,
|
||||
160,
|
||||
91,
|
||||
57,
|
||||
32,
|
||||
24,
|
||||
16,
|
||||
5,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "pub-vanilla"
|
||||
}
|
||||
@@ -12,6 +12,7 @@ horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
@@ -22,6 +23,23 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
cpal = "0.16"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["rust_backend"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input).
|
||||
gilrs = "0.11"
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
[features]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
@@ -29,3 +47,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"] }
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon
|
||||
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
|
||||
fn main() {
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
println!("cargo:rerun-if-changed={ico}");
|
||||
#[cfg(windows)]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
resource.set_icon(&ico);
|
||||
if let Err(e) = resource.compile() {
|
||||
panic!("embedding {ico}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Arrows over the client's resident scene (`arrows::tick`), for
|
||||
//! tools/test_arrow_parity.py: one line of state and one of events per operation.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
Result,
|
||||
arrows::{
|
||||
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, Pickup, Touch,
|
||||
},
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
collisions,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
motion::{LAVA, MotionResources, WATER},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
fn number(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn vector(v: &Value) -> Vec3 {
|
||||
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
|
||||
}
|
||||
fn bits(v: Vec3) -> String {
|
||||
format!(
|
||||
"{:016x},{:016x},{:016x}",
|
||||
v.x.to_bits(),
|
||||
v.y.to_bits(),
|
||||
v.z.to_bits()
|
||||
)
|
||||
}
|
||||
fn seed(v: &Value) -> i64 {
|
||||
v.as_str().unwrap().parse().unwrap()
|
||||
}
|
||||
fn particle(k: ArrowParticle) -> &'static str {
|
||||
match k {
|
||||
ArrowParticle::Bubble => "bubble",
|
||||
ArrowParticle::Crit => "crit",
|
||||
ArrowParticle::Splash => "splash",
|
||||
}
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("usage: arrow_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
|
||||
}
|
||||
let root = PathBuf::from(&args[0]);
|
||||
let dir = PathBuf::from(&args[1]);
|
||||
let assets = Assets::load(&root)?;
|
||||
let mut scene = World::test_scene(&assets, 1)?;
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
serde_json::to_vec(
|
||||
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":assets.model_gallery}),
|
||||
)?,
|
||||
)?;
|
||||
if args[2] == "--describe" {
|
||||
return Ok(());
|
||||
}
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let motion = MotionResources::load(&root, &assets)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let side = &args[2];
|
||||
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
|
||||
let f: Value = serde_json::from_str(&line?)?;
|
||||
let kind = ProjectileKind::Arrow {
|
||||
base_damage: 2.0,
|
||||
critical: f["crit"].as_bool().unwrap(),
|
||||
effect: None,
|
||||
};
|
||||
let mut arrow = Projectile::new(kind, vector(&f["position"]), None)
|
||||
.with_arrow_random(LegacyRandom::new(seed(&f["seed"])));
|
||||
arrow.velocity = vector(&f["velocity"]);
|
||||
arrow.arrow.as_mut().unwrap().no_physics = f["no_physics"].as_bool().unwrap();
|
||||
// ProjectileWeaponItem.useAmmo's stack, intangible when no ammo was used.
|
||||
arrow.arrow.as_mut().unwrap().set_item(&arrows::drawn_ammo(
|
||||
&ItemStack::new("minecraft:arrow", 1),
|
||||
f["intangible"].as_bool().unwrap(),
|
||||
));
|
||||
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" => {
|
||||
if !arrow.arrow.as_ref().unwrap().removed {
|
||||
let no_rain = |_| false;
|
||||
let view = ArrowScene {
|
||||
world: &scene,
|
||||
registry: assets.registry,
|
||||
facts: &facts,
|
||||
collision: &binding,
|
||||
fluids: &motion.fluids,
|
||||
raining: &no_rain,
|
||||
mob_griefing: true,
|
||||
tnt_explodes: true,
|
||||
};
|
||||
arrows::tick(&mut arrow, &view, &[]);
|
||||
}
|
||||
for event in arrow.arrow.as_mut().unwrap().events.drain(..) {
|
||||
match event {
|
||||
ArrowEvent::Particle {
|
||||
kind,
|
||||
position,
|
||||
velocity,
|
||||
} => events.push(format!(
|
||||
"particle {} {} {}",
|
||||
particle(kind),
|
||||
bits(position),
|
||||
bits(velocity)
|
||||
)),
|
||||
ArrowEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
} => events.push(format!(
|
||||
"sound {} {:08x} {:08x}",
|
||||
match sound {
|
||||
ArrowSound::HitGround => "hit",
|
||||
ArrowSound::Splash => "splash",
|
||||
ArrowSound::Extinguish => "extinguish",
|
||||
},
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
)),
|
||||
ArrowEvent::BubbleColumnParticles { pos } => {
|
||||
for _ in 0..2 {
|
||||
let x = f64::from(pos.x) + level_random.next_double();
|
||||
let z = f64::from(pos.z) + level_random.next_double();
|
||||
let at = Vec3::new(x, f64::from(pos.y + 1), z);
|
||||
events.push(format!(
|
||||
"sent splash {} {} {:016x}",
|
||||
bits(at),
|
||||
bits(Vec3::ZERO),
|
||||
1.0f64.to_bits()
|
||||
));
|
||||
let x = f64::from(pos.x) + level_random.next_double();
|
||||
let z = f64::from(pos.z) + level_random.next_double();
|
||||
let at = Vec3::new(x, f64::from(pos.y + 1), z);
|
||||
events.push(format!(
|
||||
"sent bubble {} {} {:016x}",
|
||||
bits(at),
|
||||
bits(Vec3::new(0.0, 0.01, 0.0)),
|
||||
0.2f64.to_bits()
|
||||
));
|
||||
}
|
||||
}
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
// TntBlock.prime: PrimedTnt draws its push from the
|
||||
// level random, then removeBlock(pos, false).
|
||||
let _ = level_random.next_double();
|
||||
let at = Vec3::new(
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z) + 0.5,
|
||||
);
|
||||
events.push(format!("spawn tnt {} 80", bits(at)));
|
||||
events.push(format!(
|
||||
"sound tnt_primed {:08x} {:08x}",
|
||||
1.0f32.to_bits(),
|
||||
1.0f32.to_bits()
|
||||
));
|
||||
events.push("prime-event".into());
|
||||
let air =
|
||||
assets.registry.default_state("minecraft:air").unwrap();
|
||||
scene.set(pos.x, pos.y, pos.z, air)?;
|
||||
events.push(format!(
|
||||
"write {},{},{} {} 3",
|
||||
pos.x, pos.y, pos.z, air.0
|
||||
));
|
||||
}
|
||||
BlockReaction::Light(lit) => {
|
||||
scene.set(pos.x, pos.y, pos.z, lit)?;
|
||||
events.push(format!(
|
||||
"write {},{},{} {} 11",
|
||||
pos.x, pos.y, pos.z, lit.0
|
||||
));
|
||||
}
|
||||
},
|
||||
ArrowEvent::SplashEvent => events.push("splash-event".into()),
|
||||
ArrowEvent::ShootEvent => events.push("shoot-event".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
"block" => {
|
||||
let p = &op["position"];
|
||||
let mut state = assets
|
||||
.registry
|
||||
.default_state(op["name"].as_str().unwrap())
|
||||
.ok_or("missing fixture block")?;
|
||||
if let Some(properties) = op["properties"].as_object() {
|
||||
let mut definition = assets.registry.state(state).unwrap().clone();
|
||||
for (name, value) in properties {
|
||||
definition
|
||||
.properties
|
||||
.insert(name.clone(), value.as_str().unwrap().into());
|
||||
}
|
||||
state = assets
|
||||
.registry
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
}
|
||||
scene.set(
|
||||
p[0].as_i64().unwrap() as i32,
|
||||
p[1].as_i64().unwrap() as i32,
|
||||
p[2].as_i64().unwrap() as i32,
|
||||
state,
|
||||
)?;
|
||||
}
|
||||
"velocity" => arrow.velocity = vector(&op["value"]),
|
||||
"ignite" => arrow
|
||||
.arrow
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.ignite_for_seconds(f32::from_bits(
|
||||
u32::from_str_radix(op["value"].as_str().unwrap(), 16).unwrap(),
|
||||
)),
|
||||
"level_seed" => level_random.set_seed(seed(&op["value"])),
|
||||
"owner" => arrow
|
||||
.arrow
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_owner(match op["value"].as_str() {
|
||||
Some("player") => Some(Who::Player),
|
||||
Some("mob") => Some(Who::Mob(1)),
|
||||
_ => None,
|
||||
}),
|
||||
"pickup" => {
|
||||
arrow.arrow.as_mut().unwrap().pickup =
|
||||
Pickup::from_byte(op["value"].as_i64().unwrap())
|
||||
}
|
||||
// The game's touch: the inventory takes the arrow unless full,
|
||||
// and a creative player takes it anyway.
|
||||
"touch" => {
|
||||
let creative = op["creative"].as_bool().unwrap();
|
||||
let full = op["inventory"] == "full";
|
||||
let a = arrow.arrow.as_mut().unwrap();
|
||||
let (taken, arrows) = match a.player_touch(creative) {
|
||||
Touch::Ignore => (false, 0),
|
||||
Touch::Take => (true, 0),
|
||||
Touch::Give(_) => (!full || creative, u32::from(!full)),
|
||||
};
|
||||
if taken {
|
||||
a.removed = true;
|
||||
}
|
||||
touched = format!("{taken} {arrows}");
|
||||
}
|
||||
other => return Err(format!("unknown operation {other}").into()),
|
||||
}
|
||||
let a = arrow.arrow.as_ref().unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
"arrow {} {} {:08x} {:08x} {} {} {} {} {} {} {} {} {} {:016x} {:016x} {} {} {:016x}",
|
||||
bits(arrow.position),
|
||||
bits(arrow.velocity),
|
||||
arrow.pitch.to_bits(),
|
||||
arrow.yaw.to_bits(),
|
||||
a.in_ground,
|
||||
a.in_ground_time,
|
||||
a.life,
|
||||
a.shake,
|
||||
a.last_state.map_or("none".into(), |s| s.0.to_string()),
|
||||
a.fire_ticks,
|
||||
a.was_touching_water,
|
||||
a.was_eye_in_water,
|
||||
a.first_tick,
|
||||
a.fluids.fluid_height(&Tags, WATER).to_bits(),
|
||||
a.fluids.fluid_height(&Tags, LAVA).to_bits(),
|
||||
a.removed,
|
||||
arrow.critical(),
|
||||
a.fall_distance.to_bits()
|
||||
)?;
|
||||
writeln!(out, "events {}", events.join(" | "))?;
|
||||
let a = arrow.arrow.as_ref().unwrap();
|
||||
let intangible = a
|
||||
.pickup_item
|
||||
.data()
|
||||
.and_then(|d| d.other(arrows::INTANGIBLE))
|
||||
.is_some();
|
||||
writeln!(out, "pickup {} {intangible} {touched}", a.pickup.byte())?;
|
||||
}
|
||||
writeln!(out, "end")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
struct Tags;
|
||||
impl horizon_world::entity_fluid::FluidMembership for Tags {
|
||||
fn fluid_in_tag(
|
||||
&self,
|
||||
kind: horizon_world::fluid::FluidKind,
|
||||
tag: horizon_world::entity_fluid::FluidTagId,
|
||||
) -> bool {
|
||||
use horizon_world::fluid::FluidKind;
|
||||
if tag == WATER {
|
||||
matches!(kind, FluidKind::Water | FluidKind::FlowingWater)
|
||||
} else {
|
||||
matches!(kind, FluidKind::Lava | FluidKind::FlowingLava)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Transport for ClientLevel shape-aware terrain-particle placement.
|
||||
use horizon_client::{assets::Assets, blocks::BlockFacts, mesher::MeshTables, models, particles};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
let mut output = BufWriter::new(fs::File::create(&args[3])?);
|
||||
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
|
||||
let pos: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
|
||||
let offset = if facts.get(state).outline_offset {
|
||||
tables.offset(state, pos[0], pos[2]).unwrap_or([0.0; 3])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
let boxes: Vec<[f64; 6]> = facts
|
||||
.get(state)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
|
||||
.collect();
|
||||
let mut hits = Vec::new();
|
||||
let mut next = Vec::new();
|
||||
for face in [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
] {
|
||||
let mut random = LegacyRandom::new(row["seed"].as_i64().unwrap());
|
||||
hits.push(particles::breaking_position(&mut random, pos, face, &boxes));
|
||||
next.push(random.next_long());
|
||||
}
|
||||
let mut destroy = Vec::new();
|
||||
particles::destroy_positions(pos, &boxes, |p, _| destroy.push(p));
|
||||
serde_json::to_writer(
|
||||
&mut output,
|
||||
&json!({"case":row["case"],"hits":hits,"next":next,"destroy":destroy}),
|
||||
)?;
|
||||
output.write_all(&[10])?;
|
||||
}
|
||||
output.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Headless transport for real model-shaped breaking overlays.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
breaking,
|
||||
mesher::MeshTables,
|
||||
models,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
let mut output = BufWriter::new(fs::File::create(&args[3])?);
|
||||
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
|
||||
let p: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
|
||||
let camera: [f64; 3] = serde_json::from_value(row["camera"].clone())?;
|
||||
let mut vertices = Vec::new();
|
||||
let mut translucent = false;
|
||||
if facts.get(state).render_shape == RenderShape::Model {
|
||||
let models = assets
|
||||
.model_catalog
|
||||
.collect(state, BlockPos::new(p[0], p[1], p[2]))?;
|
||||
let offset = tables.offset(state, p[0], p[2]).unwrap_or([0.0; 3]);
|
||||
let origin = (glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
|
||||
- glam::DVec3::from(camera))
|
||||
.as_vec3()
|
||||
+ glam::DVec3::from(offset).as_vec3();
|
||||
translucent = models
|
||||
.iter()
|
||||
.any(|(_, m)| m.faces.iter().any(|f| f.translucent));
|
||||
for (_, m) in models {
|
||||
breaking::geometry(m, origin, translucent, &mut vertices);
|
||||
}
|
||||
}
|
||||
let quads: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| {
|
||||
q.iter()
|
||||
.map(|v| {
|
||||
assert_eq!(v.color, 0xffff_ffff);
|
||||
assert_eq!(v.light, 240 | (240 << 8));
|
||||
assert_eq!(v.layer, if translucent { 12 } else { 11 });
|
||||
[v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_writer(
|
||||
&mut output,
|
||||
&json!({"case":row["case"],"translucent":translucent,"quads":quads}),
|
||||
)?;
|
||||
output.write_all(&[10])?;
|
||||
}
|
||||
output.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use horizon_client::{
|
||||
Result,
|
||||
camera::{
|
||||
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
|
||||
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
|
||||
LivingCamera, UseItem, bob_matrix, field_of_view_modifier, perspective,
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -258,6 +258,15 @@ fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
fb(fov),
|
||||
fb(hud)
|
||||
)?;
|
||||
let (w, h, far) = (fl(&op[4]), fl(&op[5]), fl(&op[6]));
|
||||
let matrix =
|
||||
|m: [f32; 16]| m.iter().map(|&x| format!(" {}", fb(x))).collect::<String>();
|
||||
writeln!(
|
||||
out,
|
||||
"projection{}{}",
|
||||
matrix(perspective(fov, w, h, far, 0.05, true)),
|
||||
matrix(perspective(hud, w, h, far, 0.05, true))
|
||||
)?;
|
||||
}
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! CloudRenderer face selection and mesh traversal, without a GPU.
|
||||
use horizon_client::clouds;
|
||||
use std::{fmt::Write, fs};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let mut output = String::new();
|
||||
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
|
||||
let values: Vec<i32> = line
|
||||
.split_whitespace()
|
||||
.map(|v| v.parse().unwrap())
|
||||
.collect();
|
||||
let [kind, x, z, bottom, neighbors] = values.as_slice() else {
|
||||
panic!("invalid case")
|
||||
};
|
||||
write!(output, "{case}")?;
|
||||
let mut cell = |x, z, neighbors| {
|
||||
let empty = std::array::from_fn(|i| neighbors & (1 << i) != 0);
|
||||
for (direction, inside) in clouds::faces(clouds::face_mask(x, z, *bottom as f32, empty))
|
||||
{
|
||||
write!(output, " {x},{z},{direction},{}", u8::from(inside)).unwrap();
|
||||
}
|
||||
};
|
||||
let radius = if *kind == 2 {
|
||||
clouds::radius_cells(*x)
|
||||
} else {
|
||||
*x
|
||||
};
|
||||
if *kind == 0 {
|
||||
cell(*x, *z, *neighbors);
|
||||
} else {
|
||||
for (x, z) in clouds::cells(radius) {
|
||||
cell(x, z, 0);
|
||||
}
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_container_screen_parity.py's screens and mouse events
|
||||
//! through `horizon_client::container_screen`.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
container_screen::{Book, Button, Clicks, Image, Release, Target, clicked_outside},
|
||||
menu::Input,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn input_name(input: Input) -> &'static str {
|
||||
match input {
|
||||
Input::Pickup => "PICKUP",
|
||||
Input::QuickMove => "QUICK_MOVE",
|
||||
Input::Swap => "SWAP",
|
||||
Input::Clone => "CLONE",
|
||||
Input::Throw => "THROW",
|
||||
Input::QuickCraft => "QUICK_CRAFT",
|
||||
Input::PickupAll => "PICKUP_ALL",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let int = |v: &Value| v.as_i64().unwrap() as i32;
|
||||
let image = Image {
|
||||
left: int(&c["image"][0]),
|
||||
top: int(&c["image"][1]),
|
||||
width: int(&c["image"][2]),
|
||||
height: int(&c["image"][3]),
|
||||
};
|
||||
let book = c.get("book").map(|b| Book {
|
||||
visible: b[0].as_bool().unwrap(),
|
||||
selected_tab_hovered: b[1].as_bool().unwrap(),
|
||||
});
|
||||
let mut clicks = Clicks::default();
|
||||
for ev in c["events"].as_array().unwrap() {
|
||||
let name = ev[0].as_str().unwrap();
|
||||
let (x, y) = (ev[1].as_f64().unwrap(), ev[2].as_f64().unwrap());
|
||||
let outside = clicked_outside(x, y, image, book);
|
||||
let mut line = format!("{name} {outside}");
|
||||
if name != "point" {
|
||||
// The case says whether the event is over the menu's slot (7).
|
||||
let on_slot = ev[6].as_bool().unwrap();
|
||||
line += &format!(" {on_slot}");
|
||||
let target = Target::of(on_slot.then_some(7), outside);
|
||||
let button = match ev[3].as_i64().unwrap() {
|
||||
1 => Button::Left,
|
||||
3 => Button::Right,
|
||||
_ => Button::Middle,
|
||||
};
|
||||
let shift = ev[4].as_i64().unwrap() & 3 != 0;
|
||||
let carried_empty = !ev[5].as_bool().unwrap();
|
||||
let click = if name == "press" {
|
||||
clicks.press(&target, button, carried_empty, shift, false)
|
||||
} else {
|
||||
match clicks.release(&target, button, carried_empty, false, shift, false) {
|
||||
Release::Click(input) => Some(input),
|
||||
Release::QuickCraft => panic!("no drags in these cases"),
|
||||
Release::Nothing => None,
|
||||
}
|
||||
};
|
||||
if let Some(input) = click {
|
||||
let id = match target {
|
||||
Target::Slot(s) => s,
|
||||
Target::Outside => -999,
|
||||
Target::Nothing => -1,
|
||||
};
|
||||
line += &format!(" click {id} {} {}", button.menu_button(), input_name(input));
|
||||
}
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! The creative inventory, for tools/test_creative_parity.py: loads an
|
||||
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
|
||||
//! and prints the tabs it shows (canonical stacks) and the search tab's
|
||||
//! results for every query of the vanilla oracle's output.
|
||||
//!
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
|
||||
use horizon_client::{
|
||||
creative::{Creative, Names},
|
||||
item_data::canon_stack,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
|
||||
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
|
||||
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
args.get(2).ok_or(usage)?,
|
||||
)?)?)?;
|
||||
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
|
||||
let tabs: Vec<Value> = creative
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|t| {
|
||||
json!({
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"icon": t.icon,
|
||||
"row": if t.top { "TOP" } else { "BOTTOM" },
|
||||
"column": t.column,
|
||||
"items": t.items.iter().map(|s| canon_stack(Some(s))).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let searches: Vec<Value> = vanilla["searches"]
|
||||
.as_array()
|
||||
.ok_or("searches")?
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let query = s["query"].as_str().unwrap_or_default();
|
||||
json!({"query": query, "results": creative.search_results(query, &names)})
|
||||
})
|
||||
.collect();
|
||||
let search: Vec<String> = creative
|
||||
.search_items()
|
||||
.iter()
|
||||
.map(|s| canon_stack(Some(s)))
|
||||
.collect();
|
||||
let out = json!({"tabs": tabs, "search": search, "searches": searches});
|
||||
fs::write(args.get(4).ok_or(usage)?, out.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_emitter_particles_parity.py's emitters through
|
||||
//! `particles::on_block_faces`, `particles::along_axis` and
|
||||
//! `particles::item_use_particles`.
|
||||
use horizon_client::{Result, particles};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut random = LegacyRandom::new(c["seed"].as_i64().ok_or("seed")?);
|
||||
let mut lines = Vec::new();
|
||||
let record = |kind: &str| {
|
||||
let kind = kind.to_owned();
|
||||
move |p: [f64; 3], v: [f64; 3]| {
|
||||
format!(
|
||||
"+ {kind} {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(v[0]),
|
||||
d(v[1]),
|
||||
d(v[2])
|
||||
)
|
||||
}
|
||||
};
|
||||
if c["kind"] == "item_use" {
|
||||
let p: [f64; 3] = std::array::from_fn(|i| c["pos"][i].as_f64().unwrap_or(0.0));
|
||||
let f = |k: &str| c[k].as_f64().unwrap_or(0.0) as f32;
|
||||
let line = record("item");
|
||||
particles::item_use_particles(
|
||||
&mut random,
|
||||
f("pitch"),
|
||||
f("yaw"),
|
||||
p,
|
||||
p[1] + f64::from(f("eye")),
|
||||
c["count"].as_u64().unwrap_or(0) as u32,
|
||||
|p, v| lines.push(line(p, v)),
|
||||
);
|
||||
} else {
|
||||
let p: [i32; 3] = std::array::from_fn(|i| c["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
match c["axis"].as_i64() {
|
||||
Some(axis @ 0..=2) => {
|
||||
let line = record("electric_spark");
|
||||
particles::along_axis(&mut random, axis as usize, p, |p, v| {
|
||||
lines.push(line(p, v))
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let line = record("wax_on");
|
||||
particles::on_block_faces(&mut random, p, |p, v| lines.push(line(p, v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in lines {
|
||||
writeln!(out, "{l}")?;
|
||||
}
|
||||
writeln!(out, "next {}", random.next_long())?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Replays the render states of the vanilla entity-animation oracle through
|
||||
//! Horizon's `mobs::anim::setup` and writes every part's pose.
|
||||
//! Horizon's `mobs::anim::setup` and writes every part's pose, then draws the
|
||||
//! posed model with `mobs::render::draw` under the case's living-entity root
|
||||
//! transform and writes each drawn part's packed vertex normals.
|
||||
//!
|
||||
//! cargo run --profile fast -p horizon-client --example entity_anim_fixture -- \
|
||||
//! RESOURCES VANILLA.jsonl OUT.jsonl
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::{
|
||||
Mob, MobData, MobTypes, Part, Species, allay,
|
||||
anim::{self, Poses, State},
|
||||
aquatic, body_anim as b_anim, equine, guardian,
|
||||
keyframes::AnimationState,
|
||||
shulker, sniffer, species, tame,
|
||||
render, shulker, sniffer, species, tame,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
@@ -44,6 +47,61 @@ fn walk(part: &Part, path: &str, poses: &Poses, out: &mut BTreeMap<String, Value
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits the vertices `render::draw` emitted for `part` into its drawn parts,
|
||||
/// following draw's walk: a hidden or zero-scaled part ends its branch, a
|
||||
/// skip-draw part keeps its children. Per part: n = packed normal bytes per
|
||||
/// vertex, p = sum of x + 2y + 3z over the positions.
|
||||
fn drawn(
|
||||
part: &Part,
|
||||
path: &str,
|
||||
poses: &Poses,
|
||||
vertices: &[Vertex],
|
||||
cursor: &mut usize,
|
||||
out: &mut BTreeMap<String, Value>,
|
||||
) {
|
||||
let p = poses.pose(part);
|
||||
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
|
||||
return;
|
||||
}
|
||||
let count = if poses.skips(part) {
|
||||
0
|
||||
} else {
|
||||
part.polygons.len() * 4
|
||||
};
|
||||
if count > 0 {
|
||||
let own = &vertices[*cursor..*cursor + count];
|
||||
let normals: Vec<i8> = own.iter().flat_map(|v| v.normal[..3].to_vec()).collect();
|
||||
let sum: f64 = own
|
||||
.iter()
|
||||
.map(|v| f64::from(v.pos[0]) + 2.0 * f64::from(v.pos[1]) + 3.0 * f64::from(v.pos[2]))
|
||||
.sum();
|
||||
out.insert(path.to_owned(), json!({"n": normals, "p": sum}));
|
||||
}
|
||||
*cursor += count;
|
||||
for c in &part.children {
|
||||
let child = if path.is_empty() {
|
||||
c.name.clone()
|
||||
} else {
|
||||
format!("{path}/{}", c.name)
|
||||
};
|
||||
drawn(c, &child, poses, vertices, cursor, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// LivingEntityRenderer's root transform without the entity's position:
|
||||
/// rotate 180 - yaw about Y, scale(-1, -1, 1), the renderer's own scale, then
|
||||
/// translate(0, -1.501, 0) (as `render::base_transform`).
|
||||
fn root_transform(case: &Value) -> glam::Mat4 {
|
||||
let yaw = case["@rootYaw"].as_f64().unwrap_or(0.0) as f32;
|
||||
let scale = case["@rootScale"].as_array().map_or(glam::Vec3::ONE, |s| {
|
||||
glam::Vec3::from_array(std::array::from_fn(|i| s[i].as_f64().unwrap_or(1.0) as f32))
|
||||
});
|
||||
glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_scale(scale)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0))
|
||||
}
|
||||
|
||||
/// The entity-side values behind the render-state fields the case sets, for
|
||||
/// the species whose Horizon animation reads them from the `Mob`.
|
||||
fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
@@ -236,12 +294,26 @@ fn main() -> horizon_client::Result<()> {
|
||||
let types = MobTypes::load(&root)?;
|
||||
let animations = MobData::load_animations(&root);
|
||||
let mut out = String::new();
|
||||
let crystal = horizon_client::mobs::load_model(&root, "minecraft:end_crystal#main");
|
||||
for line in fs::read_to_string(&args[1])?.lines() {
|
||||
let v: Value = serde_json::from_str(line)?;
|
||||
let layer = v["layer"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.trim_start_matches("minecraft:");
|
||||
// EndCrystalModel is not a mob's: horizon_client::end_crystal poses it.
|
||||
if layer == "end_crystal#main"
|
||||
&& let Some(model) = &crystal
|
||||
{
|
||||
let case = &v["case"];
|
||||
let mut poses = horizon_client::end_crystal::poses(
|
||||
model,
|
||||
case["ageInTicks"].as_f64().unwrap_or(0.0) as f32,
|
||||
case["showsBottom"].as_bool().unwrap_or(false),
|
||||
);
|
||||
out.push_str(&format!("{}\n", emit(model, &mut poses, case, layer)));
|
||||
continue;
|
||||
}
|
||||
let baby = layer.contains("_baby#");
|
||||
let adult = layer.replace("_baby#", "#");
|
||||
// Variant layers (sizes, hatless villagers, climate cows...) animate
|
||||
@@ -518,10 +590,38 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
_ => anim::setup(&mob, s, p, &animations),
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
walk(&model, "", &poses, &mut parts);
|
||||
out.push_str(&format!("{}\n", json!({"parts": parts})));
|
||||
out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every part's pose, and the vertices drawn under the harness's root transform.
|
||||
fn emit(model: &Part, poses: &mut Poses, case: &Value, layer: &str) -> Value {
|
||||
// The harness's part-scale override, set after setupAnim as the oracle does.
|
||||
if let Some(scale) = case["@partScale"].as_array() {
|
||||
let pose = poses.get(scale[0].as_str().unwrap_or_default());
|
||||
for i in 0..3 {
|
||||
pose[6 + i] = scale[1 + i].as_f64().unwrap_or(1.0) as f32;
|
||||
}
|
||||
}
|
||||
let poses = &*poses;
|
||||
let mut parts = BTreeMap::new();
|
||||
walk(model, "", poses, &mut parts);
|
||||
let mut emitted = Vec::new();
|
||||
render::draw(
|
||||
model,
|
||||
root_transform(case),
|
||||
poses,
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
0xFFFF_FFFF,
|
||||
240 | 240 << 8,
|
||||
0,
|
||||
&mut emitted,
|
||||
);
|
||||
let mut vertices = BTreeMap::new();
|
||||
let mut cursor = 0;
|
||||
drawn(model, "", poses, &emitted, &mut cursor, &mut vertices);
|
||||
assert_eq!(cursor, emitted.len(), "{layer}: draw's walk changed");
|
||||
json!({"parts": parts, "vertices": vertices})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_entity_components_parity.py's entities and advancement
|
||||
//! entity predicates through `horizon_client::advancements::entity`, with the
|
||||
//! variant component `mobs::finalize::species_variant_component` reports.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
advancements::{EntityView, World, entity},
|
||||
mobs::{
|
||||
Species,
|
||||
finalize::{species_variant_component, variant_index},
|
||||
},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
/// Nothing but the entity: these predicates read only its type and components.
|
||||
struct Empty;
|
||||
impl World for Empty {
|
||||
fn dimension(&self) -> &str {
|
||||
"minecraft:overworld"
|
||||
}
|
||||
fn biome(&self, _: [i32; 3]) -> String {
|
||||
"minecraft:plains".into()
|
||||
}
|
||||
fn structures(&self, _: [i32; 3]) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
|
||||
("minecraft:air".into(), BTreeMap::new())
|
||||
}
|
||||
fn fluid(&self, _: [i32; 3]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn can_see_sky(&self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn light(&self, _: [i32; 3]) -> i32 {
|
||||
15
|
||||
}
|
||||
fn smokey(&self, _: [i32; 3]) -> bool {
|
||||
false
|
||||
}
|
||||
fn in_tag(&self, _: &str, _: &str, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn species(id: &str) -> Species {
|
||||
match id {
|
||||
"minecraft:wolf" => Species::Wolf,
|
||||
"minecraft:cat" => Species::Cat,
|
||||
"minecraft:frog" => Species::Frog,
|
||||
"minecraft:pig" => Species::Pig,
|
||||
"minecraft:cow" => Species::Cow,
|
||||
"minecraft:chicken" => Species::Chicken,
|
||||
other => panic!("no species for {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let kind = c["type"].as_str().unwrap();
|
||||
let s = species(kind);
|
||||
let variant = variant_index(s, c["variant"].as_str().unwrap());
|
||||
let view = EntityView {
|
||||
kind: kind.to_owned(),
|
||||
position: [0.5, 64.0, 0.5],
|
||||
components: species_variant_component(s, variant)
|
||||
.map(|(id, v)| (id.to_owned(), v))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for p in c["predicates"].as_array().unwrap() {
|
||||
writeln!(out, "{}", entity(p, &view, &Empty, [2.5, 64.0, 0.5]))?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -21,6 +21,7 @@ fn stack(spec: &str) -> LootStack {
|
||||
enchantments: Vec::new(),
|
||||
potion: None,
|
||||
map: false,
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
};
|
||||
for e in parts {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Isolated production mob-model builder benchmark; no assets, Game, GPU or full-frame work.
|
||||
//! CARGO_TARGET_DIR=/home/bob/Documents/horizon/target CARGO_INCREMENTAL=0 \
|
||||
//! cargo run --profile fast -p horizon-client --example entity_geometry_bench
|
||||
use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::{Part, Polygon, anim::Poses, render},
|
||||
};
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
fn cube(name: String, position: f32) -> Part {
|
||||
let polygons = (0..6)
|
||||
.map(|face| {
|
||||
let axis = face / 2;
|
||||
let side = (face % 2) as f32;
|
||||
let mut normal = [0.0; 3];
|
||||
normal[axis] = side * 2.0 - 1.0;
|
||||
Polygon {
|
||||
normal,
|
||||
vertices: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]].map(|uv| {
|
||||
let mut vertex = [0.0; 5];
|
||||
vertex[axis] = side * 8.0;
|
||||
vertex[(axis + 1) % 3] = uv[0] * 8.0;
|
||||
vertex[(axis + 2) % 3] = uv[1] * 8.0;
|
||||
vertex[3] = uv[0];
|
||||
vertex[4] = uv[1];
|
||||
vertex
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Part {
|
||||
name,
|
||||
pose: [position, 0.0, 0.0, 0.1, 0.2, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build(model: &Part, poses: &Poses, count: usize, out: &mut Vec<Vertex>) {
|
||||
for i in 0..count {
|
||||
render::draw(
|
||||
black_box(model),
|
||||
glam::Mat4::from_translation(glam::Vec3::new((i % 32) as f32, 0.0, (i / 32) as f32)),
|
||||
black_box(poses),
|
||||
[0.125, 0.25, 0.5, 0.75],
|
||||
0xffc0_d0e0,
|
||||
0xf0f0,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
black_box(out.as_slice());
|
||||
}
|
||||
|
||||
fn measure(model: &Part, poses: &Poses, count: usize, iterations: usize, reuse: bool) -> f64 {
|
||||
let mut retained = Vec::new();
|
||||
build(model, poses, count, &mut retained); // Untimed warmup.
|
||||
let start = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
let mut out = if reuse {
|
||||
std::mem::take(&mut retained)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
out.clear();
|
||||
build(model, poses, count, &mut out);
|
||||
if reuse {
|
||||
retained = out;
|
||||
}
|
||||
}
|
||||
start.elapsed().as_secs_f64() * 1e6 / iterations as f64
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut model = cube("body".into(), 0.0);
|
||||
model.children = (1..6).map(|i| cube(format!("part{i}"), i as f32)).collect();
|
||||
let poses = Poses::new(&model);
|
||||
for count in [32, 256, 1024, 4096, 8192] {
|
||||
let mut expected = Vec::new();
|
||||
build(&model, &poses, count, &mut expected);
|
||||
let mut retained = Vec::new();
|
||||
build(&model, &poses, count, &mut retained);
|
||||
let (pointer, capacity) = (retained.as_ptr(), retained.capacity());
|
||||
// Populated -> empty/reset -> smaller -> populated must never leave stale vertices.
|
||||
for n in [0, count / 2, count, count] {
|
||||
retained.clear();
|
||||
build(&model, &poses, n, &mut retained);
|
||||
let mut fresh = Vec::new();
|
||||
build(&model, &poses, n, &mut fresh);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<Vertex, u8>(&retained),
|
||||
bytemuck::cast_slice::<Vertex, u8>(&fresh)
|
||||
);
|
||||
assert_eq!(
|
||||
(retained.as_ptr(), retained.capacity()),
|
||||
(pointer, capacity)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<Vertex, u8>(&retained),
|
||||
bytemuck::cast_slice::<Vertex, u8>(&expected)
|
||||
);
|
||||
let iterations = (100_000 / count).clamp(40, 1000);
|
||||
let mut fresh = Vec::new();
|
||||
let mut reused = Vec::new();
|
||||
let mut paired_speedups = Vec::new();
|
||||
// Alternate order, reporting medians over eleven warmed batches.
|
||||
for batch in 0..11 {
|
||||
let mut pair = [0.0; 2];
|
||||
for reuse in if batch % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let time = measure(&model, &poses, count, iterations, reuse);
|
||||
pair[usize::from(reuse)] = time;
|
||||
if reuse {
|
||||
reused.push(time);
|
||||
} else {
|
||||
fresh.push(time);
|
||||
}
|
||||
}
|
||||
paired_speedups.push(pair[0] / pair[1]);
|
||||
}
|
||||
paired_speedups.sort_by(f64::total_cmp);
|
||||
fresh.sort_by(f64::total_cmp);
|
||||
reused.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"entities={count} vertices={} capacity={capacity} retained_bytes={} fresh_us={:.3} reused_us={:.3} speedup={:.3}x paired_speedup_median={:.3}x paired_speedup_range={:.3}..{:.3}x iterations={iterations} batches=11 byte_equivalence=pass pointer_capacity_reuse=pass",
|
||||
retained.len(),
|
||||
capacity * size_of::<Vertex>(),
|
||||
fresh[5],
|
||||
reused[5],
|
||||
fresh[5] / reused[5],
|
||||
paired_speedups[5],
|
||||
paired_speedups[0],
|
||||
paired_speedups[10]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_entity_particles_parity.py's entity events through
|
||||
//! `particles::entity_event_burst` and `particles::around_self`.
|
||||
use horizon_client::{Result, particles};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut random = LegacyRandom::new(c["seed"].as_i64().ok_or("seed")?);
|
||||
let feet: [f64; 3] = std::array::from_fn(|i| c["feet"][i].as_f64().unwrap_or(0.0));
|
||||
// EntityDimensions hold floats.
|
||||
let width = f64::from(c["width"].as_f64().unwrap_or(0.0) as f32);
|
||||
let height = f64::from(c["height"].as_f64().unwrap_or(0.0) as f32);
|
||||
let entity = c["entity"].as_str().unwrap_or("");
|
||||
if let Some((kind, burst)) =
|
||||
particles::entity_event_burst(entity, c["event"].as_u64().unwrap_or(0) as u8)
|
||||
{
|
||||
let mut lines = Vec::new();
|
||||
particles::around_self(&mut random, feet, width, height, burst, |p, v| {
|
||||
lines.push(format!(
|
||||
"+ {kind} {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(v[0]),
|
||||
d(v[1]),
|
||||
d(v[2])
|
||||
));
|
||||
});
|
||||
for l in lines {
|
||||
writeln!(out, "{l}")?;
|
||||
}
|
||||
}
|
||||
writeln!(out, "next {}", random.next_long())?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,6 +64,10 @@ fn main() -> horizon_client::Result<()> {
|
||||
);
|
||||
put("visual/fog_start_distance", format!("{:?}", sky.fog_start));
|
||||
put("visual/fog_end_distance", format!("{:?}", sky.fog_end));
|
||||
put(
|
||||
"visual/cloud_fog_end_distance",
|
||||
format!("{:?}", sky.cloud_fog_end),
|
||||
);
|
||||
out.push_str(&row);
|
||||
out.push_str("}\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_fire_starter_sound_parity.py's level random seeds through
|
||||
//! the fire charge's use sound (`horizon_client::items::fire_starter_pitch`).
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::{fire_starter_pitch, fire_starter_sound},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in input.lines() {
|
||||
let mut random = LegacyRandom::new(line?.trim().parse()?);
|
||||
let pitch = fire_starter_pitch(true, &mut random);
|
||||
writeln!(
|
||||
out,
|
||||
"{} {} {} {}",
|
||||
fire_starter_sound(true),
|
||||
1.0f32.to_bits() as i32,
|
||||
pitch.to_bits() as i32,
|
||||
random.next_long()
|
||||
)?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Probe for test_first_person_parity.py: drives horizon_client::first_person
|
||||
//! and attack_strength over the harness's scripted clicks, targets and use
|
||||
//! results, printing what VanillaFirstPersonOracle prints.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
attack_strength::{AttackStrength, HeldStack},
|
||||
first_person::{
|
||||
Clicks, Hand, HandHeights, HandSway, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
|
||||
swing_duration,
|
||||
},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn fb(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
fn fl(v: &Value) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn dl(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
|
||||
/// An `ItemStack` object: the hands hold indices into `Probe::stacks`, so
|
||||
/// shrinking keeps its identity as vanilla's in-place `shrink` does.
|
||||
#[derive(Clone)]
|
||||
struct Stack {
|
||||
item: String,
|
||||
count: i32,
|
||||
}
|
||||
impl Stack {
|
||||
fn empty(&self) -> bool {
|
||||
self.item == "minecraft:air" || self.count <= 0
|
||||
}
|
||||
/// `getItem` and `getCount` of an empty stack are air and 0.
|
||||
fn effective(&self) -> (&str, i32) {
|
||||
if self.empty() {
|
||||
("minecraft:air", 0)
|
||||
} else {
|
||||
(&self.item, self.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Probe<'a> {
|
||||
case: &'a Value,
|
||||
step: &'a Value,
|
||||
creative: bool,
|
||||
stacks: Vec<Stack>,
|
||||
/// The stacks in the main and off hand.
|
||||
hands: [usize; 2],
|
||||
blocks: HashMap<[i32; 3], String>,
|
||||
swing: SwingState,
|
||||
events: Vec<String>,
|
||||
heights: HandHeights,
|
||||
}
|
||||
fn hand_name(hand: Hand) -> &'static str {
|
||||
match hand {
|
||||
Hand::Main => "main",
|
||||
Hand::Off => "off",
|
||||
}
|
||||
}
|
||||
impl Probe<'_> {
|
||||
fn stack(&self, hand: Hand) -> &Stack {
|
||||
&self.stacks[self.hands[hand as usize]]
|
||||
}
|
||||
fn new_stack(&mut self, v: &Value) -> usize {
|
||||
// ItemStack.EMPTY is index 0.
|
||||
if v.is_null() {
|
||||
return 0;
|
||||
}
|
||||
self.stacks.push(Stack {
|
||||
item: v[0].as_str().unwrap().into(),
|
||||
count: v[1].as_i64().unwrap() as i32,
|
||||
});
|
||||
self.stacks.len() - 1
|
||||
}
|
||||
fn table<'b>(&'b self, name: &str) -> &'b Value {
|
||||
&self.case["rust"][name]
|
||||
}
|
||||
fn attack_speed(&self) -> f64 {
|
||||
let item = self.stack(Hand::Main).effective().0;
|
||||
self.table("attack_speed").get(item).map_or(4.0, dl)
|
||||
}
|
||||
fn scripted(&mut self, kind: &str, hand: Hand) -> UseResult {
|
||||
let name = self.step["uses"][kind].as_str().unwrap().to_string();
|
||||
self.events
|
||||
.push(format!("use {kind} {} {name}", hand_name(hand)));
|
||||
let result = match name.as_str() {
|
||||
"success" => UseResult::Success { swing: true },
|
||||
"server" | "consume" => UseResult::Success { swing: false },
|
||||
"fail" => UseResult::Fail,
|
||||
_ => UseResult::Pass,
|
||||
};
|
||||
if matches!(result, UseResult::Success { .. }) && self.step["shrink"].as_bool().unwrap() {
|
||||
let i = self.hands[hand as usize];
|
||||
if i != 0 {
|
||||
self.stacks[i].count -= 1;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
impl HandWorld for Probe<'_> {
|
||||
fn hit(&mut self) -> Hit {
|
||||
let h = &self.step["hit"];
|
||||
match h[0].as_str().unwrap() {
|
||||
"block" => Hit::Block {
|
||||
pos: [0, 1, 2].map(|i| h[i + 1].as_i64().unwrap() as i32),
|
||||
face: h[4].as_u64().unwrap() as u8,
|
||||
},
|
||||
"entity" => Hit::Entity,
|
||||
_ => Hit::Miss,
|
||||
}
|
||||
}
|
||||
fn is_air(&mut self, pos: [i32; 3]) -> bool {
|
||||
self.blocks.get(&pos).is_none_or(|b| b == "minecraft:air")
|
||||
}
|
||||
fn creative(&self) -> bool {
|
||||
self.creative
|
||||
}
|
||||
fn spectator(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn instabuild(&self) -> bool {
|
||||
self.creative
|
||||
}
|
||||
fn hands_busy(&self) -> bool {
|
||||
self.step["busy"].as_bool().unwrap()
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
self.step["using"].as_bool().unwrap()
|
||||
}
|
||||
fn item_enabled(&mut self, _: Hand) -> bool {
|
||||
true
|
||||
}
|
||||
fn held_empty(&mut self, hand: Hand) -> bool {
|
||||
self.stack(hand).empty()
|
||||
}
|
||||
fn held_count(&mut self, hand: Hand) -> i32 {
|
||||
self.stack(hand).effective().1
|
||||
}
|
||||
fn infinite_materials(&self) -> bool {
|
||||
self.creative
|
||||
}
|
||||
fn cannot_attack(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
fn piercing_weapon(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
fn attack_animation(&mut self) -> SwingAnimation {
|
||||
SwingAnimation::DEFAULT
|
||||
}
|
||||
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
|
||||
SwingAnimation::DEFAULT
|
||||
}
|
||||
fn piercing_attack(&mut self, _: SwingAnimation) {
|
||||
unreachable!()
|
||||
}
|
||||
fn attack_entity(&mut self) -> bool {
|
||||
self.events.push("attack".into());
|
||||
true
|
||||
}
|
||||
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
|
||||
false
|
||||
}
|
||||
fn within_border(&mut self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn attack_block(&mut self, _: [i32; 3]) {}
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed` for
|
||||
/// a player on the ground, out of water, with no mining efficiency.
|
||||
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
|
||||
let block = self.blocks.get(&pos).cloned().unwrap_or_default();
|
||||
let b = &self.table("blocks")[block.as_str()];
|
||||
let hardness = fl(&b[0]);
|
||||
if hardness == -1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let item = self.stack(Hand::Main).effective().0.to_string();
|
||||
let tool = &self.table("tools")[format!("{item} {block}").as_str()];
|
||||
let (mut speed, correct) = if tool.is_null() {
|
||||
(1.0f32, false)
|
||||
} else {
|
||||
(fl(&tool[0]), tool[1].as_bool().unwrap())
|
||||
};
|
||||
if let Some(amp) = self.case["haste"].as_i64() {
|
||||
speed *= 1.0 + (amp as i32 + 1) as f32 * 0.2;
|
||||
}
|
||||
if let Some(amp) = self.case["fatigue"].as_i64() {
|
||||
speed *= horizon_core::jmath::pow(0.3, f64::from(amp as i32 + 1)) as f32;
|
||||
}
|
||||
let harvest = !b[1].as_bool().unwrap() || correct;
|
||||
speed / hardness / if harvest { 30.0 } else { 100.0 }
|
||||
}
|
||||
fn destroy_block(&mut self, pos: [i32; 3]) -> bool {
|
||||
let item = self.stack(Hand::Main).effective().0;
|
||||
// Item.canDestroyBlock: swords' tools cannot destroy in creative.
|
||||
let sword = item.ends_with("_sword");
|
||||
let ok = !(sword && self.creative) && !self.is_air(pos);
|
||||
if ok {
|
||||
self.blocks.insert(pos, "minecraft:air".into());
|
||||
}
|
||||
self.events
|
||||
.push(format!("destroy {} {} {} {ok}", pos[0], pos[1], pos[2]));
|
||||
ok
|
||||
}
|
||||
/// The stack object itself: `destroyingItem` is a reference.
|
||||
fn main_hand_key(&mut self) -> String {
|
||||
self.hands[0].to_string()
|
||||
}
|
||||
fn same_destroying_item(&mut self, key: &str) -> bool {
|
||||
// ItemStack.EMPTY (index 0) before any break.
|
||||
let then = &self.stacks[key.parse::<usize>().unwrap_or(0)];
|
||||
then.effective().0 == self.stack(Hand::Main).effective().0
|
||||
}
|
||||
fn destroy_stage(&mut self, pos: [i32; 3], stage: Option<i32>) {
|
||||
self.events.push(format!(
|
||||
"stage {} {} {} {}",
|
||||
pos[0],
|
||||
pos[1],
|
||||
pos[2],
|
||||
stage.unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
|
||||
let duration = swing_duration(
|
||||
animation,
|
||||
self.case["haste"].as_i64().map(|a| a as i32),
|
||||
self.case["fatigue"].as_i64().map(|a| a as i32),
|
||||
);
|
||||
let started = self.swing.start_if_able(hand, animation, duration);
|
||||
self.events.push(format!(
|
||||
"swing {} {} {started}",
|
||||
hand_name(hand),
|
||||
animation.duration
|
||||
));
|
||||
}
|
||||
fn reset_attack_strength(&mut self) {
|
||||
unreachable!("ResetWorld resets the tickers")
|
||||
}
|
||||
fn entity_within_border(&mut self) -> bool {
|
||||
true
|
||||
}
|
||||
fn use_on_entity(&mut self, hand: Hand) -> Option<UseResult> {
|
||||
self.step["hit"][1]
|
||||
.as_bool()
|
||||
.unwrap()
|
||||
.then(|| self.scripted("entity", hand))
|
||||
}
|
||||
fn use_on_block(&mut self, hand: Hand) -> UseResult {
|
||||
self.scripted("block", hand)
|
||||
}
|
||||
fn use_item(&mut self, hand: Hand) -> UseResult {
|
||||
self.scripted("item", hand)
|
||||
}
|
||||
fn item_used(&mut self, hand: Hand) {
|
||||
self.events.push(format!("used {}", hand_name(hand)));
|
||||
self.heights.item_used(hand);
|
||||
}
|
||||
fn release_using(&mut self) {
|
||||
self.events.push("release".into());
|
||||
}
|
||||
}
|
||||
fn held(stack: &Stack) -> Option<HeldStack> {
|
||||
(!stack.empty()).then(|| HeldStack {
|
||||
item: stack.item.clone(),
|
||||
rest: stack.count.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let empty = Value::Null;
|
||||
let mut probe = Probe {
|
||||
case,
|
||||
step: &empty,
|
||||
creative: case["creative"].as_bool().unwrap(),
|
||||
stacks: vec![Stack {
|
||||
item: "minecraft:air".into(),
|
||||
count: 0,
|
||||
}],
|
||||
hands: [0, 0],
|
||||
blocks: case["blocks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
(
|
||||
[0, 1, 2].map(|i| b[i].as_i64().unwrap() as i32),
|
||||
b[3].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
swing: SwingState::default(),
|
||||
events: Vec::new(),
|
||||
heights: HandHeights::default(),
|
||||
};
|
||||
probe.hands[0] = probe.new_stack(&case["main"]);
|
||||
probe.hands[1] = probe.new_stack(&case["off"]);
|
||||
writeln!(out, "attack_speed {:x}", probe.attack_speed().to_bits())?;
|
||||
let mut clicks = Clicks::default();
|
||||
let mut strength = AttackStrength::default();
|
||||
// FirstPersonHandsAndItems' visible items start as ItemStack.EMPTY.
|
||||
let mut visible = [0usize, 0usize];
|
||||
// The player's xRot and yRot, and the hands' lag behind them.
|
||||
let mut rot = [0.0f32; 2];
|
||||
let mut sway = HandSway::default();
|
||||
for (s, step) in case["steps"].as_array().unwrap().iter().enumerate() {
|
||||
probe.step = step;
|
||||
probe.events.clear();
|
||||
if !step["set"].is_null() {
|
||||
let i = probe.new_stack(&step["set"]["stack"]);
|
||||
let hand = if step["set"]["hand"] == "main" { 0 } else { 1 };
|
||||
probe.hands[hand] = i;
|
||||
}
|
||||
clicks.tick(
|
||||
&mut ResetWorld {
|
||||
probe: &mut probe,
|
||||
strength: &mut strength,
|
||||
},
|
||||
step["ac"].as_u64().unwrap() as u32,
|
||||
step["uc"].as_u64().unwrap() as u32,
|
||||
step["ad"].as_bool().unwrap(),
|
||||
step["ud"].as_bool().unwrap(),
|
||||
);
|
||||
probe.swing.tick();
|
||||
if let Some(r) = step["rot"].as_array() {
|
||||
rot = [fl(&r[0]), fl(&r[1])];
|
||||
}
|
||||
sway.tick(rot[0], rot[1]);
|
||||
let main = probe.stack(Hand::Main).clone();
|
||||
strength.tick(held(&main));
|
||||
// FirstPersonHandsAndItems.tick: shouldInstantlyReplaceVisibleItem
|
||||
// (ItemStack.matchesIgnoringComponents; every item plays the swap
|
||||
// animation), then the heights.
|
||||
let mut changed = [false; 2];
|
||||
for h in 0..2 {
|
||||
let (a, b) = (visible[h], probe.hands[h]);
|
||||
let same = a == b || probe.stacks[a].effective() == probe.stacks[b].effective();
|
||||
if same {
|
||||
visible[h] = b;
|
||||
}
|
||||
changed[h] = visible[h] != b;
|
||||
}
|
||||
let speed = probe.attack_speed();
|
||||
let replace = probe.heights.tick(
|
||||
probe.step["busy"].as_bool().unwrap(),
|
||||
strength.swap_scale(1.0, speed),
|
||||
changed,
|
||||
);
|
||||
for h in 0..2 {
|
||||
if replace[h] {
|
||||
visible[h] = probe.hands[h];
|
||||
}
|
||||
}
|
||||
for e in &probe.events {
|
||||
writeln!(out, "{e}")?;
|
||||
}
|
||||
let sw = &probe.swing;
|
||||
let current = sw.current.map_or("- 0".to_string(), |c| {
|
||||
format!("{} {}", hand_name(c.hand), c.duration_ticks)
|
||||
});
|
||||
let h = &probe.heights;
|
||||
let d = &clicks.destroying;
|
||||
writeln!(
|
||||
out,
|
||||
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
sw.ticks,
|
||||
fb(sw.animation),
|
||||
fb(sw.old_animation),
|
||||
fb(sw.animation(0.5)),
|
||||
strength.ticker,
|
||||
strength.swap_ticker,
|
||||
fb(strength.swap_scale(1.0, speed)),
|
||||
fb(h.main),
|
||||
fb(h.old_main),
|
||||
fb(h.off),
|
||||
fb(h.old_off),
|
||||
clicks.miss_time,
|
||||
clicks.right_click_delay,
|
||||
d.delay,
|
||||
d.active,
|
||||
fb(d.progress),
|
||||
fb(sway.x_bob),
|
||||
fb(sway.y_bob),
|
||||
fb(sway.x_bob_o),
|
||||
fb(sway.y_bob_o)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The probe with the attack-strength tickers it resets.
|
||||
struct ResetWorld<'p, 'c> {
|
||||
probe: &'p mut Probe<'c>,
|
||||
strength: &'p mut AttackStrength,
|
||||
}
|
||||
macro_rules! forward {
|
||||
($($name:ident($($arg:ident: $t:ty),*) -> $r:ty;)*) => {
|
||||
$(fn $name(&mut self, $($arg: $t),*) -> $r { self.probe.$name($($arg),*) })*
|
||||
};
|
||||
}
|
||||
impl HandWorld for ResetWorld<'_, '_> {
|
||||
forward! {
|
||||
hit() -> Hit;
|
||||
is_air(pos: [i32; 3]) -> bool;
|
||||
item_enabled(hand: Hand) -> bool;
|
||||
held_empty(hand: Hand) -> bool;
|
||||
held_count(hand: Hand) -> i32;
|
||||
cannot_attack() -> bool;
|
||||
piercing_weapon() -> bool;
|
||||
attack_animation() -> SwingAnimation;
|
||||
interact_animation(hand: Hand) -> SwingAnimation;
|
||||
piercing_attack(a: SwingAnimation) -> ();
|
||||
attack_entity() -> bool;
|
||||
block_action_restricted(pos: [i32; 3]) -> bool;
|
||||
within_border(pos: [i32; 3]) -> bool;
|
||||
attack_block(pos: [i32; 3]) -> ();
|
||||
destroy_progress(pos: [i32; 3]) -> f32;
|
||||
destroy_block(pos: [i32; 3]) -> bool;
|
||||
main_hand_key() -> String;
|
||||
same_destroying_item(key: &str) -> bool;
|
||||
destroy_stage(pos: [i32; 3], stage: Option<i32>) -> ();
|
||||
swing(hand: Hand, a: SwingAnimation) -> ();
|
||||
entity_within_border() -> bool;
|
||||
use_on_entity(hand: Hand) -> Option<UseResult>;
|
||||
use_on_block(hand: Hand) -> UseResult;
|
||||
use_item(hand: Hand) -> UseResult;
|
||||
item_used(hand: Hand) -> ();
|
||||
release_using() -> ();
|
||||
}
|
||||
fn creative(&self) -> bool {
|
||||
self.probe.creative()
|
||||
}
|
||||
fn spectator(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn instabuild(&self) -> bool {
|
||||
self.probe.instabuild()
|
||||
}
|
||||
fn hands_busy(&self) -> bool {
|
||||
self.probe.hands_busy()
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
self.probe.using_item()
|
||||
}
|
||||
fn infinite_materials(&self) -> bool {
|
||||
self.probe.infinite_materials()
|
||||
}
|
||||
fn reset_attack_strength(&mut self) {
|
||||
self.strength.reset();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
run(&c, &mut out)?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -93,8 +93,9 @@ fn encode(draws: &[GuiDraw]) -> Vec<Value> {
|
||||
h,
|
||||
region,
|
||||
color,
|
||||
blend,
|
||||
} => {
|
||||
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32})
|
||||
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32, "pipeline": blend.pipeline()})
|
||||
}
|
||||
GuiDraw::AtlasSprite {
|
||||
sprite,
|
||||
@@ -190,7 +191,12 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
// Spectators only see it over a spectatable target (none here).
|
||||
if c["crosshair"].as_bool().unwrap_or(false) && mode != "spectator" {
|
||||
let attack = (indicator == "CROSSHAIR").then(|| float("attackScale", 1.0));
|
||||
draws.extend(hud_layout::crosshair(w, h, attack, false));
|
||||
// crosshairPickEntity: absent, or a living entity alive or not.
|
||||
let target = c["target"].as_bool();
|
||||
let full = attack.is_some_and(|a| {
|
||||
hud_layout::full_attack_indicator(a, float("attackDelay", 5.0), target)
|
||||
});
|
||||
draws.extend(hud_layout::crosshair(w, h, attack, full));
|
||||
}
|
||||
if mode != "spectator" {
|
||||
let pop = |v: &Value| v.as_f64().map(|p| p as f32 - partial);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
|
||||
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
hint::black_box,
|
||||
io::BufReader,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn reader(path: &Path) -> Result<Value> {
|
||||
Ok(serde_json::from_reader(BufReader::new(File::open(path)?))?)
|
||||
}
|
||||
|
||||
fn slice(path: &Path) -> Result<Value> {
|
||||
Ok(serde_json::from_slice(&fs::read(path)?)?)
|
||||
}
|
||||
|
||||
fn measure(path: &Path, load: fn(&Path) -> Result<Value>) -> Result<Duration> {
|
||||
let start = Instant::now();
|
||||
let value = black_box(load(black_box(path))?);
|
||||
let elapsed = start.elapsed();
|
||||
// Value destruction is excluded; the temporary byte buffer is released by slice().
|
||||
drop(value);
|
||||
Ok(elapsed)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let root = args
|
||||
.next()
|
||||
.ok_or("usage: json_load_bench ROOT [REPETITIONS]")?;
|
||||
let repetitions: usize = args.next().map_or(Ok(15), |s| s.parse())?;
|
||||
if repetitions == 0 {
|
||||
return Err("REPETITIONS must be positive".into());
|
||||
}
|
||||
for name in ["gameplay-facts.json", "facts.json"] {
|
||||
let path = Path::new(&root).join(name);
|
||||
// Validate complete parsed trees on the exact files being timed.
|
||||
assert_eq!(reader(&path)?, slice(&path)?);
|
||||
for _ in 0..2 {
|
||||
measure(&path, reader)?;
|
||||
measure(&path, slice)?;
|
||||
}
|
||||
let (mut before, mut after) = (Vec::new(), Vec::new());
|
||||
for i in 0..repetitions {
|
||||
// Alternate order to reduce filesystem cache / frequency bias.
|
||||
if i % 2 == 0 {
|
||||
before.push(measure(&path, reader)?);
|
||||
after.push(measure(&path, slice)?);
|
||||
} else {
|
||||
after.push(measure(&path, slice)?);
|
||||
before.push(measure(&path, reader)?);
|
||||
}
|
||||
}
|
||||
before.sort_unstable();
|
||||
after.sort_unstable();
|
||||
let median = |samples: &[Duration]| {
|
||||
(samples[(samples.len() - 1) / 2].as_secs_f64()
|
||||
+ samples[samples.len() / 2].as_secs_f64())
|
||||
* 500.0
|
||||
};
|
||||
let (before, after) = (median(&before), median(&after));
|
||||
println!(
|
||||
"{name}: {} bytes; {repetitions} samples; reader {before:.3} ms; slice {after:.3} ms; {:.2}x",
|
||||
fs::metadata(&path)?.len(),
|
||||
before / after
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reader_and_slice_preserve_values_and_error_categories() -> Result<()> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("horizon-json-load-{}.json", std::process::id()));
|
||||
let check = || -> Result<()> {
|
||||
fs::write(
|
||||
&path,
|
||||
br#"{"states":[{"id":0,"hardness":-1.5,"name":"escaped\n\u2603","outline":[[0,0,0,1,1,1]],"enabled":true}],"defaults":null}"#,
|
||||
)?;
|
||||
assert_eq!(reader(&path)?, slice(&path)?);
|
||||
for invalid in [
|
||||
&b"{\"states\":["[..],
|
||||
&b"{\"states\":tru}"[..],
|
||||
&b"{} trailing"[..],
|
||||
&b"{\"name\":\"\xff\"}"[..],
|
||||
] {
|
||||
fs::write(&path, invalid)?;
|
||||
let before = reader(&path).unwrap_err();
|
||||
let after = slice(&path).unwrap_err();
|
||||
assert_eq!(
|
||||
before
|
||||
.downcast_ref::<serde_json::Error>()
|
||||
.unwrap()
|
||||
.classify(),
|
||||
after
|
||||
.downcast_ref::<serde_json::Error>()
|
||||
.unwrap()
|
||||
.classify()
|
||||
);
|
||||
}
|
||||
fs::remove_file(&path)?;
|
||||
for load in [reader, slice] {
|
||||
let error = load(&path).unwrap_err();
|
||||
assert_eq!(
|
||||
error.downcast_ref::<std::io::Error>().unwrap().kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let result = check();
|
||||
let _ = fs::remove_file(path);
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Compare the exact read/deserialize sequences used by the model loaders.
|
||||
//! Run with `cargo run -p horizon-client --profile fast --example json_readers_bench`.
|
||||
//! Optional arguments: resource directory, repetitions (default 9).
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt::Debug,
|
||||
fs::{self, File},
|
||||
hint::black_box,
|
||||
io::BufReader,
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
type Result<T> = horizon_client::Result<T>;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Reader {
|
||||
File,
|
||||
Buffered,
|
||||
Slice,
|
||||
}
|
||||
const READERS: [Reader; 3] = [Reader::File, Reader::Buffered, Reader::Slice];
|
||||
|
||||
// These match the map types and reader expressions at all three production sites.
|
||||
macro_rules! reader {
|
||||
($name:ident, $map:ty) => {
|
||||
fn $name(path: &Path, reader: Reader) -> Result<$map> {
|
||||
Ok(match reader {
|
||||
Reader::File => serde_json::from_reader(File::open(path)?)?,
|
||||
Reader::Buffered => serde_json::from_reader(BufReader::new(File::open(path)?))?,
|
||||
Reader::Slice => serde_json::from_slice(&fs::read(path)?)?,
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
reader!(prepare_facts, Map<String, Value>);
|
||||
reader!(catalog_facts, BTreeMap<String, Value>);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Result<Self> {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-json-readers-{}", std::process::id()));
|
||||
fs::create_dir(&root)?;
|
||||
Ok(Self(root))
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn fixtures(root: &Path) -> Result<()> {
|
||||
let states: Vec<_> = (0..56)
|
||||
.map(|i| {
|
||||
json!({"facing": (["north", "east", "south", "west"][i % 4]),
|
||||
"powered": if i / 4 % 2 == 0 { "false" } else { "true" },
|
||||
"age": (i / 8).to_string()})
|
||||
})
|
||||
.collect();
|
||||
let mut models = Map::new();
|
||||
let mut render = Map::new();
|
||||
for i in 0..1024 {
|
||||
let name = format!("minecraft:fixture_block_{i:04}");
|
||||
models.insert(
|
||||
name.clone(),
|
||||
json!({
|
||||
"schema": {"facing": {"named": ["north", "east", "south", "west"]},
|
||||
"powered": {"named": ["false", "true"]}, "age": {"integer": [0, 6]}},
|
||||
"states": states
|
||||
}),
|
||||
);
|
||||
render.insert(
|
||||
name,
|
||||
json!({"rule": "never", "can_occlude": true, "bars_tag": false}),
|
||||
);
|
||||
}
|
||||
fs::write(root.join("model-states.json"), serde_json::to_vec(&models)?)?;
|
||||
fs::write(
|
||||
root.join("render-definitions.json"),
|
||||
serde_json::to_vec(&render)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn benchmark<T: PartialEq + Debug>(
|
||||
label: &str,
|
||||
path: &Path,
|
||||
repetitions: usize,
|
||||
read: impl Fn(&Path, Reader) -> Result<T>,
|
||||
) -> Result<()> {
|
||||
let expected = read(path, Reader::File)?;
|
||||
for reader in READERS {
|
||||
assert_eq!(expected, read(path, reader)?);
|
||||
for _ in 0..2 {
|
||||
black_box(read(black_box(path), reader)?);
|
||||
}
|
||||
}
|
||||
drop(expected);
|
||||
let mut samples: [Vec<f64>; 3] = std::array::from_fn(|_| Vec::new());
|
||||
for repetition in 0..repetitions {
|
||||
// Rotate reader order to distribute drift across implementations.
|
||||
for offset in 0..3 {
|
||||
let index = (repetition + offset) % 3;
|
||||
let start = Instant::now();
|
||||
let value = black_box(read(black_box(path), READERS[index])?);
|
||||
samples[index].push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
drop(value); // Match the loaders: retain the parsed tree after loading.
|
||||
}
|
||||
}
|
||||
let median: Vec<_> = samples
|
||||
.iter_mut()
|
||||
.map(|s| {
|
||||
s.sort_by(f64::total_cmp);
|
||||
s[s.len() / 2]
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{label}: {} bytes, {repetitions} samples/reader, median ms: File={:.3}, BufReader={:.3}, read+slice={:.3}; slice speedup={:.2}x",
|
||||
fs::metadata(path)?.len(),
|
||||
median[0],
|
||||
median[1],
|
||||
median[2],
|
||||
median[0] / median[2]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let fixture = if args.is_empty() {
|
||||
Some(Fixture::new()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let root = fixture
|
||||
.as_ref()
|
||||
.map(|f| f.0.clone())
|
||||
.unwrap_or_else(|| PathBuf::from(&args[0]));
|
||||
if fixture.is_some() {
|
||||
fixtures(&root)?;
|
||||
println!(
|
||||
"Generated representative fixtures: 1024 blocks, 57344 model states; warm filesystem cache."
|
||||
);
|
||||
}
|
||||
let repetitions = args.get(1).map(|s| s.parse()).transpose()?.unwrap_or(9);
|
||||
assert!(repetitions > 0, "repetitions must be positive");
|
||||
benchmark(
|
||||
"prepare model states",
|
||||
&root.join("model-states.json"),
|
||||
repetitions,
|
||||
prepare_facts,
|
||||
)?;
|
||||
benchmark(
|
||||
"catalog model states",
|
||||
&root.join("model-states.json"),
|
||||
repetitions,
|
||||
catalog_facts,
|
||||
)?;
|
||||
benchmark(
|
||||
"render definitions",
|
||||
&root.join("render-definitions.json"),
|
||||
repetitions,
|
||||
catalog_facts,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reader_equivalence_and_errors() -> Result<()> {
|
||||
let fixture = Fixture::new()?;
|
||||
let path = fixture.0.join("test.json");
|
||||
fs::write(&path, br#"{"minecraft:stone":{"schema":{},"states":[{}]},"unicode":"\u2603","duplicate":1,"duplicate":2}"#)?;
|
||||
let expected = prepare_facts(&path, Reader::File)?;
|
||||
for reader in READERS {
|
||||
assert_eq!(expected, prepare_facts(&path, reader)?);
|
||||
assert_eq!(
|
||||
expected.iter().collect::<BTreeMap<_, _>>(),
|
||||
catalog_facts(&path, reader)?.iter().collect()
|
||||
);
|
||||
}
|
||||
for invalid in [b"{\"a\": ".as_slice(), b"{} trailing", b"{\"a\":\"\xff\"}"] {
|
||||
fs::write(&path, invalid)?;
|
||||
for reader in READERS {
|
||||
let error = prepare_facts(&path, reader).unwrap_err();
|
||||
assert!(error.downcast_ref::<serde_json::Error>().is_some());
|
||||
assert!(catalog_facts(&path, reader).is_err());
|
||||
}
|
||||
}
|
||||
fs::remove_file(&path)?;
|
||||
for reader in READERS {
|
||||
let error = prepare_facts(&path, reader).unwrap_err();
|
||||
assert_eq!(
|
||||
error.downcast_ref::<std::io::Error>().unwrap().kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Menu clicks for tools/test_menu_click_parity.py: reads one JSON case per
|
||||
//! line (menu, creative, slots, carried, data, ops) and prints the oracle's
|
||||
//! state lines after every click.
|
||||
//! state lines after every click. The `*_be` furnace menus run over a furnace
|
||||
//! block entity: its data slots follow `Furnace::input_clicked`.
|
||||
//!
|
||||
//! usage: menu_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
containers::{Furnace, FurnaceKind},
|
||||
crafting::Recipes,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
@@ -41,11 +43,25 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let kind = Kind::parse(case["menu"].as_str().unwrap_or_default()).ok_or("menu kind")?;
|
||||
let name = case["menu"].as_str().unwrap_or_default();
|
||||
// FurnaceMenu over the server's AbstractFurnaceBlockEntity.
|
||||
let furnace_kind = match name {
|
||||
"furnace_be" => Some(FurnaceKind::Furnace),
|
||||
"blast_furnace_be" => Some(FurnaceKind::BlastFurnace),
|
||||
"smoker_be" => Some(FurnaceKind::Smoker),
|
||||
_ => None,
|
||||
};
|
||||
let kind = Kind::parse(
|
||||
name.strip_suffix("_be")
|
||||
.filter(|_| furnace_kind.is_some())
|
||||
.unwrap_or(name),
|
||||
)
|
||||
.ok_or("menu kind")?;
|
||||
let mut menu = Menu::new(kind);
|
||||
menu.creative = case["creative"].as_bool().unwrap_or(false);
|
||||
for (i, slot) in case["slots"].as_array().ok_or("slots")?.iter().enumerate() {
|
||||
@@ -56,6 +72,15 @@ fn main() -> horizon_client::Result<()> {
|
||||
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
|
||||
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
|
||||
}
|
||||
let mut furnace = furnace_kind.map(|kind| Furnace {
|
||||
kind,
|
||||
items: [0, 1, 2].map(|i| menu.slot(i).cloned()),
|
||||
lit_remaining: menu.data[0],
|
||||
lit_total: menu.data[1],
|
||||
cooking_timer: menu.data[2],
|
||||
cooking_total: menu.data[3],
|
||||
speed: 1.0,
|
||||
});
|
||||
menu.drops.clear();
|
||||
out += &format!("init {}\n", state(&menu));
|
||||
for op in case["ops"].as_array().ok_or("ops")? {
|
||||
@@ -79,7 +104,15 @@ fn main() -> horizon_client::Result<()> {
|
||||
_ => {}
|
||||
}
|
||||
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
|
||||
match menu.click(&env, slot, button, kind) {
|
||||
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());
|
||||
f.input_clicked(&recipes, before);
|
||||
menu.data[2] = f.cooking_timer;
|
||||
menu.data[3] = f.cooking_total;
|
||||
}
|
||||
match clicked {
|
||||
Ok(()) => out += &format!("{}\n", state(&menu)),
|
||||
Err(name) => out += &format!("error {name}\n"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Horizon's sprite mip levels for tools/test_mipmap_parity.py: each sprite
|
||||
//! loads through `sprites::Sprites` (its .mcmeta settings and transparency)
|
||||
//! and `mipmap::generate` makes its levels down to 1x1, printed as RGBA hex
|
||||
//! rows like VanillaMipmapOracle.
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let input = fs::read_to_string(&args[0])?;
|
||||
let mut lines = input.lines();
|
||||
let root = Path::new(lines.next().ok_or("no resources root")?);
|
||||
let names: Vec<&str> = lines.collect();
|
||||
let mut sprites = horizon_client::sprites::Sprites::default();
|
||||
let layers = names
|
||||
.iter()
|
||||
.map(|name| sprites.insert(root, name))
|
||||
.collect::<horizon_client::Result<Vec<u32>>>()?;
|
||||
sprites.finish();
|
||||
let size = sprites.size;
|
||||
let bytes = (size * size * 4) as usize;
|
||||
let mut out = String::new();
|
||||
for (name, layer) in names.iter().zip(layers) {
|
||||
let start = layer as usize * bytes;
|
||||
let base = horizon_client::mipmap::Image {
|
||||
width: size,
|
||||
height: size,
|
||||
pixels: sprites.pixels[start..start + bytes]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.to_vec(),
|
||||
};
|
||||
let s = sprites.mips[layer as usize];
|
||||
writeln!(out, "== {name}")?;
|
||||
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(' ');
|
||||
for p in row {
|
||||
write!(out, "{:02x}{:02x}{:02x}{:02x}", p[0], p[1], p[2], p[3])?;
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
fs::write(&args[1], out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -56,6 +56,9 @@ struct Scenario {
|
||||
spawning: bool,
|
||||
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
|
||||
hurts: Vec<(i32, usize)>,
|
||||
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
|
||||
/// (tick, mob index); the scenario only feeds each animal its food.
|
||||
feeds: Vec<(i32, usize)>,
|
||||
hand: Option<String>,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
@@ -92,6 +95,7 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
}
|
||||
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
@@ -680,6 +684,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let motion = horizon_client::motion::MotionResources::load(root, &assets)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let types = MobTypes::load(root)?;
|
||||
let biomes = Biomes::load(root)?;
|
||||
@@ -893,6 +898,9 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
// The level's random for eating sounds: only goats and sniffers draw
|
||||
// from it, and the scenarios feed neither.
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m, s.creative);
|
||||
}
|
||||
@@ -904,6 +912,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
movement: &motion.blocks,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: player.clone(),
|
||||
@@ -996,6 +1005,16 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
events.append(&mut mobs[i].outbox);
|
||||
}
|
||||
for &(tick, i) in &s.feeds {
|
||||
if tick != t || i >= mobs.len() || mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
{
|
||||
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
|
||||
}
|
||||
}
|
||||
#[allow(unused_mut)] // filled only with the rng-trace feature
|
||||
let mut traces: Vec<String> = Vec::new();
|
||||
let mut pushes = Vec::new();
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
|
||||
//! are small, slimes and magma cubes keep size 1, tropical fish school.
|
||||
//!
|
||||
//! Spawner cases (`mode=spawner`, tools/test_spawner_parity.py) tick a
|
||||
//! `mobs::spawner::Spawner` loaded from the case's SNBT on the same world and
|
||||
//! print its events tick by tick.
|
||||
//!
|
||||
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
@@ -25,6 +29,7 @@ use horizon_client::{
|
||||
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
spawn_box,
|
||||
},
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
|
||||
species::{self, Baby},
|
||||
},
|
||||
};
|
||||
@@ -73,6 +78,8 @@ struct Shared<'a> {
|
||||
structures: &'a StructureSpawns,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
air: BlockStateId,
|
||||
/// Every entity type id (`entity-types.json`).
|
||||
entity_types: &'a HashSet<String>,
|
||||
}
|
||||
struct World<'a> {
|
||||
s: &'a Shared<'a>,
|
||||
@@ -95,8 +102,10 @@ struct World<'a> {
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
/// Boxes of living entities (case entities, then spawned mobs).
|
||||
entities: RefCell<Vec<Aabb>>,
|
||||
/// The `spawner_blocks_work` game rule.
|
||||
spawners_work: bool,
|
||||
/// Types and boxes of living entities (case entities, then spawned mobs).
|
||||
entities: RefCell<Vec<(String, Aabb)>>,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
|
||||
@@ -280,10 +289,21 @@ impl NaturalLevel for World<'_> {
|
||||
block: &lookup,
|
||||
binding: self.s.collision,
|
||||
};
|
||||
// getEntityCollisions(null, box): living shulkers are solid.
|
||||
let grown = bounds.inflate(horizon_core::geometry::Vec3::new(1e-7, 1e-7, 1e-7));
|
||||
no_collision_empty(&mut world, bounds)
|
||||
&& !self
|
||||
.entities
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|(id, b)| id == "minecraft:shulker" && b.intersects(grown))
|
||||
}
|
||||
fn unobstructed(&self, bounds: Aabb) -> bool {
|
||||
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
|
||||
!self
|
||||
.entities
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|(_, b)| b.intersects(bounds))
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.spawn_mobs
|
||||
@@ -299,6 +319,28 @@ impl NaturalLevel for World<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SpawnerLevel for World<'_> {
|
||||
fn alive_player_near(&self, x: f64, y: f64, z: f64, range: f64) -> bool {
|
||||
self.players.iter().any(|(p, mode)| {
|
||||
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
mode != "spectator" && (range < 0.0 || d < range * range)
|
||||
})
|
||||
}
|
||||
fn spawner_blocks_work(&self) -> bool {
|
||||
self.spawners_work
|
||||
}
|
||||
fn entity_exists(&self, id: &str) -> bool {
|
||||
self.s.types.types.contains_key(id) || self.s.entity_types.contains(id)
|
||||
}
|
||||
fn count_entities(&self, id: &str, bounds: Aabb) -> usize {
|
||||
self.entities
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(k, b)| k == id && b.intersects(bounds))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
|
||||
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
|
||||
let t = &l.s.types.types[r.entity];
|
||||
@@ -369,6 +411,17 @@ fn main() -> horizon_client::Result<()> {
|
||||
let biomes = Biomes::load(root)?;
|
||||
let structures = StructureSpawns::load(root);
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let entity_types: HashSet<String> = fs::read(root.join("entity-types.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|v| {
|
||||
v.as_object().map(|o| {
|
||||
o.keys()
|
||||
.map(|k| horizon_client::command::args::normalize(k))
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let shared = Shared {
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
@@ -378,6 +431,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
structures: &structures,
|
||||
collision: &collision,
|
||||
air: facts.air,
|
||||
entity_types: &entity_types,
|
||||
};
|
||||
let mut adaptation: HashMap<String, bool> = HashMap::new();
|
||||
let mut out = String::new();
|
||||
@@ -538,12 +592,18 @@ fn main() -> horizon_client::Result<()> {
|
||||
respawn,
|
||||
unloaded,
|
||||
spawn_mobs: get("rules", "1") != "0",
|
||||
spawners_work: get("spawners", "1") != "0",
|
||||
entities: RefCell::new(
|
||||
case_entities
|
||||
.iter()
|
||||
.filter_map(|(t, p, _)| {
|
||||
let t = types.types.get(t)?;
|
||||
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
|
||||
.filter_map(|(id, p, _)| {
|
||||
let t = types.types.get(id)?;
|
||||
// Entity.setPos (a shulker sits at its block's centre).
|
||||
let p = horizon_client::mobs::spawner::entity_position(t.species, *p);
|
||||
Some((
|
||||
id.clone(),
|
||||
spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32),
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
@@ -560,14 +620,16 @@ fn main() -> horizon_client::Result<()> {
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
|
||||
let bounds = neutral_box(l, r);
|
||||
l.entities.borrow_mut().push(bounds);
|
||||
l.entities.borrow_mut().push((r.entity.to_owned(), bounds));
|
||||
Some(Spawned {
|
||||
bounds,
|
||||
school: true,
|
||||
})
|
||||
};
|
||||
let mode = get("mode", "chunk");
|
||||
if mode == "gen" {
|
||||
if mode == "spawner" {
|
||||
spawner_case(&world, &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
|
||||
// draws from it (the oracle's answers fixed values).
|
||||
@@ -686,3 +748,86 @@ fn main() -> horizon_client::Result<()> {
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `SpawnerBlockEntity.serverTick` `ticks` times at `spos` for the spawner in
|
||||
/// `snbt` (base64 SNBT), as the oracle logs it.
|
||||
fn spawner_case(
|
||||
world: &World,
|
||||
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 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 spawner = Spawner::load(&tag);
|
||||
let ticks: i32 = get("ticks", "1").parse()?;
|
||||
for tick in 0..ticks {
|
||||
let mut events = Vec::new();
|
||||
let mut spawned = std::collections::VecDeque::new();
|
||||
let mut spawn = |l: &World, r: &SpawnerSpawn| -> Option<SpawnerAdded> {
|
||||
let t = l.s.types.types.get(r.id)?;
|
||||
let [x, y, z] = r.position;
|
||||
let bounds = spawn_box(x, y, z, t.width as f32, t.height as f32);
|
||||
l.entities.borrow_mut().push((r.id.to_owned(), bounds));
|
||||
spawned.push_back(format!(
|
||||
"{tick} spawn {} {} {} {} {}",
|
||||
r.id,
|
||||
bits(x),
|
||||
bits(y),
|
||||
bits(z),
|
||||
// Shulker.finalizeSpawn faces it south.
|
||||
yaw_text(
|
||||
r.id,
|
||||
if r.finalize && r.id == "minecraft:shulker" {
|
||||
0.0
|
||||
} else {
|
||||
r.yaw
|
||||
}
|
||||
)
|
||||
));
|
||||
Some(SpawnerAdded {
|
||||
id: spawned.len() as u32,
|
||||
bounds,
|
||||
mob: true,
|
||||
})
|
||||
};
|
||||
spawner.server_tick(world, random, pos, &mut spawn, &mut events);
|
||||
for e in events {
|
||||
match e {
|
||||
SpawnerEvent::BlockUpdated => log.push(format!("{tick} update")),
|
||||
SpawnerEvent::BlockEvent(id) => log.push(format!("{tick} bevent {id} 0")),
|
||||
// Each spawn's level event follows its tryAddFreshEntityWithPassengers.
|
||||
SpawnerEvent::LevelEvent(id) => {
|
||||
log.extend(spawned.pop_front());
|
||||
log.push(format!("{tick} levent {id} 0"));
|
||||
}
|
||||
SpawnerEvent::EntityPlace(_, p) => log.push(format!(
|
||||
"{tick} gevent minecraft:entity_place {} {} {}",
|
||||
p[0], p[1], p[2]
|
||||
)),
|
||||
SpawnerEvent::SpawnAnim(_) => log.push(format!("{tick} eevent 20")),
|
||||
}
|
||||
}
|
||||
}
|
||||
let next = spawner
|
||||
.next_data()
|
||||
.and_then(|d| d.id())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let pots: Vec<String> = spawner
|
||||
.potentials
|
||||
.iter()
|
||||
.map(|(d, w)| format!("{}*{w}", d.id().unwrap_or_else(|| "-".into())))
|
||||
.collect();
|
||||
log.push(format!(
|
||||
"end delay={} next={next} pots={}",
|
||||
spawner.spawn_delay as i16,
|
||||
pots.join(",")
|
||||
));
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The resource root Horizon builds for a pack selection (`packs::build_overlay`)
|
||||
//! and what its loaders read from it, for VanillaPackResourcesOracle.
|
||||
use horizon_client::{lang::Language, packs, sound_events::SoundRegistry};
|
||||
use std::{fmt::Write, fs, path::PathBuf};
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
|
||||
(h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)
|
||||
})
|
||||
}
|
||||
/// `namespace:path` to a file under the root's `assets/`.
|
||||
fn asset(root: &std::path::Path, id: &str, prefix: &str, suffix: &str) -> PathBuf {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
root.join("assets")
|
||||
.join(namespace)
|
||||
.join(format!("{prefix}{path}{suffix}"))
|
||||
}
|
||||
fn types(root: &std::path::Path, file: PathBuf, key: &str) -> String {
|
||||
let _ = root;
|
||||
let value: serde_json::Value = fs::read(file)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
value[key]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|v| {
|
||||
let t = v["type"].as_str().unwrap_or("?");
|
||||
format!(" {}", t.strip_prefix("minecraft:").unwrap_or(t))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let input = fs::read_to_string(&args[1])?;
|
||||
let mut lines = input.lines();
|
||||
let root: Vec<&str> = lines.next().ok_or("empty input")?.split(' ').collect();
|
||||
let vanilla = PathBuf::from(root[1]);
|
||||
let ids: Vec<String> = root[3..].iter().map(|s| (*s).to_owned()).collect();
|
||||
let all = packs::discover(&vanilla, &PathBuf::from(root[2]));
|
||||
let enabled = packs::enabled(&all, &ids);
|
||||
let overlay = PathBuf::from(&args[2]).with_extension("overlay");
|
||||
packs::build_overlay(&vanilla, &enabled, &overlay)?;
|
||||
let language = Language::load(&[&overlay], "en_us");
|
||||
let sounds = SoundRegistry::load(&overlay);
|
||||
let mut out = String::new();
|
||||
for line in lines {
|
||||
let (kind, arg) = line.split_once(' ').unwrap_or((line, ""));
|
||||
write!(out, "{line} =>")?;
|
||||
match kind {
|
||||
"file" => match fs::read(asset(&overlay, arg, "", "")) {
|
||||
Ok(bytes) => write!(out, " {:x}", fnv(&bytes))?,
|
||||
Err(_) => out.push_str(" missing"),
|
||||
},
|
||||
"lang" => write!(out, " {}", language.get(arg).unwrap_or(arg))?,
|
||||
"sound" => {
|
||||
let event = if arg.contains(':') {
|
||||
arg.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{arg}")
|
||||
};
|
||||
match sounds.get(&event) {
|
||||
None => out.push_str(" missing"),
|
||||
Some(e) => {
|
||||
write!(out, " subtitle={}", e.subtitle.as_deref().unwrap_or("none"))?;
|
||||
for s in &e.sounds {
|
||||
if s.event {
|
||||
out.push_str(" event");
|
||||
} else {
|
||||
write!(out, " {}*{}", s.location, s.weight)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"font" => out.push_str(&types(
|
||||
&overlay,
|
||||
asset(&overlay, arg, "font/", ".json"),
|
||||
"providers",
|
||||
)),
|
||||
"atlas" => out.push_str(&types(
|
||||
&overlay,
|
||||
asset(&overlay, arg, "atlases/", ".json"),
|
||||
"sources",
|
||||
)),
|
||||
_ => return Err(format!("unknown query {line}").into()),
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_particle_providers_parity.py's particles through
|
||||
//! `ParticleEngine`: built with the seeds the vanilla oracle recovered, then
|
||||
//! ticked over air or a stone floor at y 63, printing their state each tick.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
chunk::Chunk,
|
||||
collisions,
|
||||
particles::{ParticleEngine, ParticleLevel, ParticleOptions, ParticleSnapshot, ParticleType},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
fn f(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
fn color(v: &Value, default: u32) -> u32 {
|
||||
v.as_u64().map_or(default, |c| c as u32)
|
||||
}
|
||||
fn rgb(c: u32) -> [f32; 3] {
|
||||
[
|
||||
((c >> 16) & 255) as f32 / 255.0,
|
||||
((c >> 8) & 255) as f32 / 255.0,
|
||||
(c & 255) as f32 / 255.0,
|
||||
]
|
||||
}
|
||||
fn options(case: &Value) -> Option<ParticleOptions> {
|
||||
let ty = ParticleType::from_name(case["type"].as_str()?)?;
|
||||
let o = &case["options"];
|
||||
let scale = o["scale"].as_f64().unwrap_or(1.0) as f32;
|
||||
let c = color(&o["color"], 0xFFFF_FFFF);
|
||||
let argb = {
|
||||
let [r, g, b] = rgb(c);
|
||||
[r, g, b, ((c >> 24) & 255) as f32 / 255.0]
|
||||
};
|
||||
Some(match ty {
|
||||
ParticleType::Block
|
||||
| ParticleType::BlockCrumble
|
||||
| ParticleType::DustPillar
|
||||
| ParticleType::BlockMarker => ParticleOptions::block(ty, "terrain".into(), 0xFF_FFFF),
|
||||
// Stone's map colour: FallingDustParticle.Provider's tint without a tint source.
|
||||
ParticleType::FallingDust => ParticleOptions::block(ty, "terrain".into(), 0x70_7070),
|
||||
ParticleType::Dust => ParticleOptions::dust(rgb(c), scale),
|
||||
ParticleType::DustColorTransition => {
|
||||
ParticleOptions::dust_transition(rgb(c), rgb(color(&o["to"], 0)), scale)
|
||||
}
|
||||
ParticleType::Effect | ParticleType::InstantEffect => ParticleOptions {
|
||||
scale,
|
||||
..ParticleOptions::colored(ty, argb)
|
||||
},
|
||||
// The oracle's item models: one layer whose sprite is named after the item.
|
||||
ParticleType::Item => {
|
||||
let item = o["item"].as_str()?;
|
||||
ParticleOptions::item(vec![format!(
|
||||
"item_{}",
|
||||
item.strip_prefix("minecraft:").unwrap_or(item)
|
||||
)])
|
||||
}
|
||||
ParticleType::EntityEffect | ParticleType::TintedLeaves | ParticleType::Flash => {
|
||||
ParticleOptions::colored(ty, argb)
|
||||
}
|
||||
ParticleType::DragonBreath => ParticleOptions {
|
||||
scale,
|
||||
..ParticleOptions::simple(ty)
|
||||
},
|
||||
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
|
||||
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
|
||||
ParticleType::Trail => {
|
||||
let t = &o["target"];
|
||||
ParticleOptions::trail(
|
||||
[t[0].as_f64()?, t[1].as_f64()?, t[2].as_f64()?],
|
||||
c & 0xFF_FFFF,
|
||||
o["duration"].as_i64()? as i32,
|
||||
)
|
||||
}
|
||||
_ => ParticleOptions::simple(ty),
|
||||
})
|
||||
}
|
||||
fn state(s: &ParticleSnapshot) -> String {
|
||||
let mut out = format!(
|
||||
"{} {} {} {}",
|
||||
s.age,
|
||||
s.lifetime,
|
||||
u8::from(s.removed),
|
||||
u8::from(s.on_ground)
|
||||
);
|
||||
for v in s.position.iter().chain(&s.velocity).chain(&s.previous) {
|
||||
out += &format!(" {}", d(*v));
|
||||
}
|
||||
if let Some(q) = &s.quad {
|
||||
out += &format!(" | {} {}", f(q.size[0]), f(q.size[1]));
|
||||
for v in q.rgba.iter().chain(&q.roll) {
|
||||
out += &format!(" {}", f(*v));
|
||||
}
|
||||
out += &format!(" {}", q.sprite);
|
||||
for v in q.uv {
|
||||
out += &format!(" {}", f(v));
|
||||
}
|
||||
out += &format!(" {:x}", q.light);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn world(assets: &Assets, facts: &BlockFacts, floor: bool) -> Result<World> {
|
||||
let mut world = World::new(facts.air, 0);
|
||||
let stone = assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.ok_or("missing stone")?;
|
||||
for cz in -3..=2 {
|
||||
for cx in -3..=2 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
if floor {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
world.set(cx * 16 + x, 63, cz * 16 + z, stone)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(".local/horizon-client/26.3");
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let collision = collisions::load(root, assets.registry)?;
|
||||
let worlds = [
|
||||
world(&assets, &facts, false)?,
|
||||
world(&assets, &facts, true)?,
|
||||
];
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new());
|
||||
for (ty, item) in [
|
||||
(ParticleType::ItemSlime, "item_slime_ball"),
|
||||
(ParticleType::ItemSnowball, "item_snowball"),
|
||||
(ParticleType::ItemCobweb, "item_cobweb"),
|
||||
] {
|
||||
engine.item_sprites.insert(ty, vec![item.to_owned()]);
|
||||
}
|
||||
// The oracle's output: the seed line of each case.
|
||||
let mut seeds = Vec::new();
|
||||
for line in BufReader::new(File::open(&args[2])?).lines() {
|
||||
let line = line?;
|
||||
if line.starts_with("seed ") || line.starts_with("null ") || line == "no provider" {
|
||||
seeds.push(line);
|
||||
}
|
||||
}
|
||||
let mut out = BufWriter::new(File::create(&args[3])?);
|
||||
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
|
||||
let case: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let Some(options) = options(&case) else {
|
||||
writeln!(out, "no provider")?;
|
||||
continue;
|
||||
};
|
||||
let seed = case["seed"].as_i64().ok_or("seed")?;
|
||||
engine.seed_randoms(seed, seed ^ 0x6C_6576_656C, seed ^ 0x73_7065_6C6C);
|
||||
engine.requested = Some(Vec::new());
|
||||
engine.sounds.clear();
|
||||
let level = ParticleLevel {
|
||||
world: &worlds[usize::from(case["floor"].as_bool() == Some(true))],
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
};
|
||||
let v = |key: &str| -> [f64; 3] {
|
||||
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
|
||||
};
|
||||
let seed_line = seeds.get(n).cloned().unwrap_or_default();
|
||||
let local = seed_line
|
||||
.split(' ')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
let Some(mut particle) = engine.probe_create(&options, v("pos"), v("vel"), local, &level)
|
||||
else {
|
||||
writeln!(out, "null {}", engine.next_randoms()[0])?;
|
||||
continue;
|
||||
};
|
||||
writeln!(out, "{seed_line}")?;
|
||||
let [a, b, c] = engine.next_randoms();
|
||||
writeln!(out, "next {a} {b} {c}")?;
|
||||
let events = |engine: &mut ParticleEngine, out: &mut BufWriter<File>| -> Result<()> {
|
||||
for (o, p, v) in engine.requested.replace(Vec::new()).unwrap_or_default() {
|
||||
write!(out, "+ {}", o.ty.name())?;
|
||||
for x in p.iter().chain(&v) {
|
||||
write!(out, " {}", d(*x))?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
for (name, p, volume, pitch) in std::mem::take(&mut engine.sounds) {
|
||||
writeln!(
|
||||
out,
|
||||
"~ {name} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
f(volume),
|
||||
f(pitch)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
writeln!(out, "0 {}", state(&engine.snapshot(&particle)))?;
|
||||
events(&mut engine, &mut out)?;
|
||||
let ticks = case["ticks"].as_i64().unwrap_or(0);
|
||||
let mut t = 1;
|
||||
while t <= ticks && particle.is_alive() {
|
||||
engine.probe_tick(&mut particle, &level);
|
||||
writeln!(out, "{t} {}", state(&engine.snapshot(&particle)))?;
|
||||
events(&mut engine, &mut out)?;
|
||||
t += 1;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -43,6 +43,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (line, order) in cases.lines().zip(orders.lines()) {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Synthetic village/storage save batches, using the production snapshot and
|
||||
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
|
||||
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
|
||||
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
|
||||
use horizon_client::{
|
||||
biome::Biomes,
|
||||
chunk::{BLOCK_STRATEGY, Chunk},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
save::{self, ChunkExtras},
|
||||
};
|
||||
use horizon_core::paletted::PalettedContainer;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
nbt::{self, Limits, Tag},
|
||||
registry::BlockRegistry,
|
||||
};
|
||||
use std::{collections::HashMap, hint::black_box, sync::Arc, time::Instant};
|
||||
|
||||
type Entities = HashMap<(i32, i32, i32), BlockEntity>;
|
||||
type Names = HashMap<(i32, i32, i32), String>;
|
||||
const BLOCKS: [&str; 5] = [
|
||||
"minecraft:air",
|
||||
"minecraft:stone",
|
||||
"minecraft:chest",
|
||||
"minecraft:furnace",
|
||||
"minecraft:oak_sign",
|
||||
];
|
||||
|
||||
// The pre-change with_entities path, with the same Chunk clone and outputs.
|
||||
fn baseline(chunks: &[Arc<Chunk>], entities: &Entities, names: &Names) -> Vec<Chunk> {
|
||||
chunks
|
||||
.iter()
|
||||
.map(|chunk| {
|
||||
let mut c = (**chunk).clone();
|
||||
c.block_entities = entities
|
||||
.iter()
|
||||
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
|
||||
.map(|(p, e)| (*p, e.clone()))
|
||||
.collect();
|
||||
c.block_names = names
|
||||
.iter()
|
||||
.filter(|(p, _)| p.0 >> 4 == c.x && p.2 >> 4 == c.z)
|
||||
.map(|(p, n)| (*p, n.clone()))
|
||||
.collect();
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fixture(per_chunk: usize) -> (Vec<Arc<Chunk>>, Entities, Names) {
|
||||
let mut chunks = Vec::new();
|
||||
let mut entities = Entities::new();
|
||||
let mut names = Names::new();
|
||||
let stack = ItemStack {
|
||||
item: "minecraft:stone".into(),
|
||||
count: 64,
|
||||
damage: 0,
|
||||
extra: horizon_client::item_data::decode_components(Some(&Tag::compound([(
|
||||
"minecraft:custom_data",
|
||||
Tag::compound([("fixture", Tag::String("save batch".into()))]),
|
||||
)])))
|
||||
.1,
|
||||
};
|
||||
let mut template = Chunk::empty(0, 0);
|
||||
for s in &mut template.sections[..8] {
|
||||
Arc::make_mut(s).blocks = PalettedContainer::new(BLOCK_STRATEGY, 1);
|
||||
}
|
||||
template.heights = Arc::new([64; 256]);
|
||||
for i in 0..256 {
|
||||
let x = i % 16 - 8;
|
||||
let z = i / 16 - 8;
|
||||
let mut c = template.clone();
|
||||
c.x = x;
|
||||
c.z = z;
|
||||
let mut extras = ChunkExtras::default();
|
||||
extras.root.insert("InhabitedTime".into(), Tag::Long(12000));
|
||||
for j in 0..per_chunk {
|
||||
let p = (
|
||||
x * 16 + (j % 16) as i32,
|
||||
64 + (j / 256) as i32,
|
||||
z * 16 + ((j / 16) % 16) as i32,
|
||||
);
|
||||
let (block, mut e) = match j % 4 {
|
||||
0 => (4, BlockEntity::for_block(BLOCKS[4]).unwrap()),
|
||||
1 => (3, BlockEntity::for_block(BLOCKS[3]).unwrap()),
|
||||
_ => (2, BlockEntity::Chest(vec![Some(stack.clone()); 27])),
|
||||
};
|
||||
if let BlockEntity::Furnace(f) = &mut e {
|
||||
f.items[0] = Some(stack.clone());
|
||||
f.lit_remaining = 120;
|
||||
f.cooking_timer = 45;
|
||||
}
|
||||
if let BlockEntity::Sign(s) = &mut e {
|
||||
s.front.lines[0] = "Village storage".into();
|
||||
}
|
||||
c.set(p.0 & 15, p.1, p.2 & 15, block);
|
||||
if j % 4 == 2 {
|
||||
names.insert(p, format!("Storage {x},{z}/{j}"));
|
||||
}
|
||||
// Exercise the existing NBT overlay and saved list order as well.
|
||||
if j % 8 == 2 {
|
||||
let mut raw = save::encode_block_entity(p, &e, BLOCKS[block as usize]);
|
||||
if let Tag::Compound(map) = &mut raw {
|
||||
map.insert(
|
||||
"LootTable".into(),
|
||||
Tag::String("minecraft:chests/simple_dungeon".into()),
|
||||
);
|
||||
}
|
||||
extras.block_entity_sources.push((p, raw));
|
||||
}
|
||||
extras.block_entity_order.push(p);
|
||||
entities.insert(p, e);
|
||||
}
|
||||
// An unmodelled block entity, retained from the original chunk.
|
||||
extras.block_entities.push(Tag::compound([
|
||||
("id", Tag::String("minecraft:end_gateway".into())),
|
||||
("x", Tag::Int(x * 16)),
|
||||
("y", Tag::Int(80)),
|
||||
("z", Tag::Int(z * 16)),
|
||||
("Age", Tag::Long(200)),
|
||||
]));
|
||||
c.saved = Some(Arc::new(extras));
|
||||
chunks.push(Arc::new(c));
|
||||
}
|
||||
(chunks, entities, names)
|
||||
}
|
||||
|
||||
fn encoding_fixture() -> horizon_client::Result<(BlockRegistry, Biomes)> {
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(BLOCKS.iter().map(|n| Tag::String((*n).into())).collect()),
|
||||
),
|
||||
(
|
||||
"defaults",
|
||||
Tag::compound(
|
||||
BLOCKS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (*n, Tag::Int(i as i32))),
|
||||
),
|
||||
),
|
||||
]))?;
|
||||
let temp = std::env::temp_dir().join(format!("horizon-save-batch-{}", std::process::id()));
|
||||
let result = (|| {
|
||||
let textures = temp.join("assets/minecraft/textures/colormap");
|
||||
let biomes = temp.join("data/minecraft/worldgen/biome");
|
||||
std::fs::create_dir_all(&textures)?;
|
||||
std::fs::create_dir_all(&biomes)?;
|
||||
for name in ["grass", "foliage", "dry_foliage"] {
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::fs::File::create(textures.join(format!("{name}.png")))?,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.write_header()?.write_image_data(&[0, 128, 0])?;
|
||||
}
|
||||
std::fs::write(biomes.join("plains.json"), b"{}")?;
|
||||
Biomes::load(&temp)
|
||||
})();
|
||||
std::fs::remove_dir_all(&temp)?;
|
||||
Ok((registry, result?))
|
||||
}
|
||||
|
||||
fn serialize(chunks: &[Chunk], registry: &BlockRegistry, biomes: &Biomes) -> Vec<Vec<u8>> {
|
||||
chunks
|
||||
.iter()
|
||||
.map(|c| nbt::write_named(&save::encode(c, registry, biomes), Limits::default()).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn measure(mut f: impl FnMut(), repetitions: usize) -> f64 {
|
||||
let start = Instant::now();
|
||||
for _ in 0..repetitions {
|
||||
f();
|
||||
}
|
||||
start.elapsed().as_secs_f64() * 1e6 / repetitions as f64
|
||||
}
|
||||
|
||||
fn compare(mut before: impl FnMut(), mut after: impl FnMut()) -> (f64, f64, usize) {
|
||||
for _ in 0..5 {
|
||||
before();
|
||||
after();
|
||||
}
|
||||
let estimate = measure(&mut before, 1).max(measure(&mut after, 1));
|
||||
let repetitions = (10_000.0 / estimate.max(0.01)).ceil().clamp(1.0, 10_000.0) as usize;
|
||||
let mut old = Vec::new();
|
||||
let mut new = Vec::new();
|
||||
for i in 0..21 {
|
||||
if i % 2 == 0 {
|
||||
old.push(measure(&mut before, repetitions));
|
||||
new.push(measure(&mut after, repetitions));
|
||||
} else {
|
||||
new.push(measure(&mut after, repetitions));
|
||||
old.push(measure(&mut before, repetitions));
|
||||
}
|
||||
}
|
||||
old.sort_by(f64::total_cmp);
|
||||
new.sort_by(f64::total_cmp);
|
||||
(old[10], new[10], repetitions)
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let include_nbt = std::env::args().any(|arg| arg == "--nbt");
|
||||
let (registry, biomes) = encoding_fixture()?;
|
||||
println!(
|
||||
"stage,fixture,chunks,world_entities,world_names,saved_entities,bytes,before_us,after_us,speedup,samples,reps"
|
||||
);
|
||||
for (name, density) in [("village", 12), ("storage", 48)] {
|
||||
let (chunks, entities, names) = fixture(density);
|
||||
for count in [0, 1, 16, 64, 256] {
|
||||
let batch = &chunks[..count];
|
||||
let before = baseline(batch, &entities, &names);
|
||||
let after = save::chunks_with_block_entities(batch, &entities, &names);
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert_eq!(a.block_names, b.block_names);
|
||||
}
|
||||
let encoded = serialize(&before, ®istry, &biomes);
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serialize(&after, ®istry, &biomes),
|
||||
"exact NBT bytes: {name}/{count}"
|
||||
);
|
||||
let bytes: usize = encoded.iter().map(Vec::len).sum();
|
||||
for encode in [false, true] {
|
||||
if encode && !include_nbt {
|
||||
continue;
|
||||
}
|
||||
let run = |optimized| {
|
||||
let snapshots = if optimized {
|
||||
save::chunks_with_block_entities(
|
||||
black_box(batch),
|
||||
black_box(&entities),
|
||||
black_box(&names),
|
||||
)
|
||||
} else {
|
||||
baseline(black_box(batch), black_box(&entities), black_box(&names))
|
||||
};
|
||||
if encode {
|
||||
black_box(serialize(black_box(&snapshots), ®istry, &biomes));
|
||||
}
|
||||
black_box(snapshots);
|
||||
};
|
||||
let (old, new, reps) = compare(|| run(false), || run(true));
|
||||
println!(
|
||||
"{},{name},{count},{},{},{},{bytes},{old:.3},{new:.3},{:.3},21,{reps}",
|
||||
if encode { "snapshot+nbt" } else { "snapshot" },
|
||||
entities.len(),
|
||||
names.len(),
|
||||
count * density,
|
||||
old / new
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -40,6 +40,7 @@ fn main() -> Result<()> {
|
||||
let mut scene = collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
|
||||
@@ -4,7 +4,7 @@ use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
collisions,
|
||||
motion::{MotionEvent, MotionResources, MotionWorld, PlayerMotion},
|
||||
motion::{LAVA, MotionEvent, MotionResources, MotionWorld, PlayerMotion, WATER},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -165,6 +165,64 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::Fall(e) => Some(e.trace()),
|
||||
MotionEvent::Health(e) => Some(e.trace()),
|
||||
MotionEvent::Snowflake { position, velocity } => Some(format!(
|
||||
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
|
||||
position.x.to_bits(),
|
||||
position.y.to_bits(),
|
||||
position.z.to_bits(),
|
||||
velocity.x.to_bits(),
|
||||
velocity.y.to_bits(),
|
||||
velocity.z.to_bits()
|
||||
)),
|
||||
MotionEvent::Splash {
|
||||
high_speed,
|
||||
volume,
|
||||
pitch,
|
||||
} => Some(format!(
|
||||
"sound {} entity {:08x} {:08x}",
|
||||
if *high_speed { "splash-high" } else { "splash" },
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
)),
|
||||
MotionEvent::SplashParticle {
|
||||
bubble,
|
||||
position,
|
||||
velocity,
|
||||
} => Some(format!(
|
||||
"splash-particle {} {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
|
||||
if *bubble { "bubble" } else { "splash" },
|
||||
position.x.to_bits(),
|
||||
position.y.to_bits(),
|
||||
position.z.to_bits(),
|
||||
velocity.x.to_bits(),
|
||||
velocity.y.to_bits(),
|
||||
velocity.z.to_bits()
|
||||
)),
|
||||
MotionEvent::SplashEvent => Some("splash-event".into()),
|
||||
MotionEvent::Swim { volume, pitch } => Some(format!(
|
||||
"sound swim entity {:08x} {:08x}",
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
)),
|
||||
MotionEvent::Emission {
|
||||
state,
|
||||
in_water: true,
|
||||
..
|
||||
} => Some(format!("muffled-step {}", state.0)),
|
||||
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
|
||||
"column-particle {} {:016x},{:016x},{:016x} 1 {:016x},{:016x},{:016x} {:016x}",
|
||||
if *bubble { "bubble" } else { "splash" },
|
||||
position.x.to_bits(),
|
||||
position.y.to_bits(),
|
||||
position.z.to_bits(),
|
||||
0f64.to_bits(),
|
||||
if *bubble { 0.01f64 } else { 0.0 }.to_bits(),
|
||||
0f64.to_bits(),
|
||||
if *bubble { 0.2f64 } else { 1.0 }.to_bits()
|
||||
)),
|
||||
MotionEvent::Drown => Some("broadcast 67".into()),
|
||||
// The oracle marks every getAdvancements call (ENTER_BLOCK.trigger) so.
|
||||
MotionEvent::EnterBlock { .. } => Some("hurt-criterion".into()),
|
||||
MotionEvent::Waypoint { receiver, enabled } => Some(format!(
|
||||
"waypoint {} {enabled}",
|
||||
if *receiver { "receive" } else { "transmit" }
|
||||
@@ -232,6 +290,12 @@ fn main() -> Result<()> {
|
||||
instances.next().expect("unexpected instance allocation")
|
||||
}),
|
||||
)?;
|
||||
// The oracle runs block-inside effects only in "snow_tick".
|
||||
player.block_effects = false;
|
||||
// Entity.portalCooldown and portalProcess, and the entry of the last
|
||||
// portal_tick that went through.
|
||||
let mut portal = horizon_client::nether_portal::PortalState::default();
|
||||
let mut travel: Option<horizon_core::position::BlockPos> = None;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
let mut world = MotionWorld {
|
||||
player: &mut player,
|
||||
@@ -261,6 +325,7 @@ fn main() -> Result<()> {
|
||||
jump: mask & 16 != 0,
|
||||
shift: mask & 32 != 0,
|
||||
sprint: mask & 64 != 0,
|
||||
analog: None,
|
||||
})?;
|
||||
}
|
||||
"movement_statistics" => world.check_movement_statistics(vector(&op["value"]))?,
|
||||
@@ -356,7 +421,8 @@ fn main() -> Result<()> {
|
||||
Some(vector(&op["value"]))
|
||||
},
|
||||
),
|
||||
"block" => {
|
||||
// A block, or every block of the box from position to "to".
|
||||
"block" | "fill" => {
|
||||
let p = &op["position"];
|
||||
let mut state = assets
|
||||
.registry
|
||||
@@ -374,12 +440,15 @@ fn main() -> Result<()> {
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
}
|
||||
world.scene.set(
|
||||
p[0].as_i64().unwrap() as i32,
|
||||
p[1].as_i64().unwrap() as i32,
|
||||
p[2].as_i64().unwrap() as i32,
|
||||
state,
|
||||
)?;
|
||||
let at = |v: &Value, i: usize| v[i].as_i64().unwrap() as i32;
|
||||
let to = if op["to"].is_array() { &op["to"] } else { p };
|
||||
for x in at(p, 0)..=at(to, 0) {
|
||||
for y in at(p, 1)..=at(to, 1) {
|
||||
for z in at(p, 2)..=at(to, 2) {
|
||||
world.scene.set(x, y, z, state)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"landing" => {
|
||||
let p = &op["position"];
|
||||
@@ -397,6 +466,26 @@ fn main() -> Result<()> {
|
||||
p,
|
||||
)?;
|
||||
}
|
||||
// Entity.baseTick's fluid phase alone (wasEyeInWater,
|
||||
// updateFluidInteraction, lava fall halving, firstTick).
|
||||
"fluid_tick" => world.fluid_base_tick()?,
|
||||
// LivingEntity.baseTick's air supply alone.
|
||||
"air_tick" => world.tick_environment_damage()?,
|
||||
"air" => world.player.air = op["value"].as_i64().unwrap() as i32,
|
||||
"pitch" => world.player.pitch = single(&op["value"]),
|
||||
"swimming" => world.player.swimming = op["value"].as_bool().unwrap(),
|
||||
"effect" => {
|
||||
let on = op["value"].as_bool().unwrap();
|
||||
let status = &mut world.player.status;
|
||||
match op["name"].as_str().unwrap() {
|
||||
"minecraft:dolphins_grace" => status.dolphins_grace = on,
|
||||
"minecraft:water_breathing" | "minecraft:conduit_power" => {
|
||||
status.water_breathing = on
|
||||
}
|
||||
"minecraft:breath_of_the_nautilus" => status.breath_of_the_nautilus = on,
|
||||
other => return Err(format!("unknown fixture effect {other}").into()),
|
||||
}
|
||||
}
|
||||
"travel" => world.travel_in_air(vector(&op["value"]))?,
|
||||
"tick" => {
|
||||
let v = vector(&op["value"]);
|
||||
@@ -409,6 +498,40 @@ fn main() -> Result<()> {
|
||||
op["jumping"].as_bool().unwrap(),
|
||||
)?;
|
||||
}
|
||||
"snow_tick" => {
|
||||
// The clock, then the player tick: baseTick's powder snow
|
||||
// flags lead PlayerMotion::player_movement_tick.
|
||||
world.player.health.advance_clock();
|
||||
let v = vector(&op["value"]);
|
||||
world.player.block_effects = true;
|
||||
let ticked = world.player_movement_tick(
|
||||
[v.x as f32, v.z as f32],
|
||||
op["jumping"].as_bool().unwrap(),
|
||||
);
|
||||
world.player.block_effects = false;
|
||||
ticked?;
|
||||
}
|
||||
"boots" => {
|
||||
// Leather boots are also freeze-immune wearables.
|
||||
let boots = op["value"].as_bool().unwrap();
|
||||
world.player.leather_boots = boots;
|
||||
world.player.freeze_immune = boots;
|
||||
}
|
||||
"frozen" => world.player.ticks_frozen = op["value"].as_i64().unwrap() as i32,
|
||||
"portal_tick" => {
|
||||
// Entity.handlePortal without the destination: players wait
|
||||
// the creative delay (0) when invulnerable, else 80 ticks.
|
||||
let creative = op["creative"].as_bool().unwrap();
|
||||
let transition = if creative { 0 } else { 80 };
|
||||
let alive = !world.player.health.dead_or_dying();
|
||||
travel = portal.handle(alive, transition, 10);
|
||||
}
|
||||
"weaving" => world.player.status.weaving = op["value"].as_bool().unwrap(),
|
||||
"flying" => {
|
||||
let flying = op["value"].as_bool().unwrap();
|
||||
world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying);
|
||||
world.player.flying = flying;
|
||||
}
|
||||
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
|
||||
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
|
||||
"move" => world.move_self(vector(&op["value"]))?,
|
||||
@@ -476,9 +599,49 @@ fn main() -> Result<()> {
|
||||
}
|
||||
_ => return Err("unknown motion operation".into()),
|
||||
}
|
||||
// NetherPortalBlock.entityInside: canUsePortal, setAsInsidePortal.
|
||||
for e in &player.events {
|
||||
if let MotionEvent::InsidePortal { pos } = e
|
||||
&& !player.health.dead_or_dying()
|
||||
{
|
||||
portal.set_inside(*pos, 10);
|
||||
}
|
||||
}
|
||||
let check_block = scene.block(25, 3, 25);
|
||||
snapshot(&mut out, &player)?;
|
||||
writeln!(out, "block {}", check_block.0)?;
|
||||
let stuck = player.stuck_multiplier;
|
||||
writeln!(
|
||||
out,
|
||||
"block {} frozen {} {} {} {:016x},{:016x},{:016x} fluid {} {} {} {} {:016x} {:016x} swim {} {} air {}",
|
||||
check_block.0,
|
||||
player.ticks_frozen,
|
||||
player.in_powder_snow,
|
||||
player.was_in_powder_snow,
|
||||
stuck.x.to_bits(),
|
||||
stuck.y.to_bits(),
|
||||
stuck.z.to_bits(),
|
||||
player.fluids.was_touching_water,
|
||||
player.fluids.was_eye_in_water,
|
||||
player.is_eye_in_fluid(WATER),
|
||||
player.fluids.first_tick,
|
||||
player.fluid_height(WATER).to_bits(),
|
||||
player.fluid_height(LAVA).to_bits(),
|
||||
player.swimming,
|
||||
player.fluids.was_eye_in_water,
|
||||
player.air
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"portal {} {} {}",
|
||||
portal.cooldown,
|
||||
portal.process.map_or("none".into(), |p| format!(
|
||||
"{},{},{} {} {}",
|
||||
p.entry.x, p.entry.y, p.entry.z, p.time, p.inside
|
||||
)),
|
||||
travel
|
||||
.take()
|
||||
.map_or("-".into(), |t| format!("{},{},{}", t.x, t.y, t.z))
|
||||
)?;
|
||||
let h = &player.health;
|
||||
let canonical = |v: f32| {
|
||||
if v.is_nan() {
|
||||
|
||||
@@ -7,7 +7,7 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
mesher::{MeshTables, Mesher, OPAQUE_LAYER, Vertex},
|
||||
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
@@ -33,7 +33,8 @@ fn argb(color: u32) -> i32 {
|
||||
}
|
||||
/// Horizon keeps sky*16 in the low byte and block*16 above it; vanilla's light
|
||||
/// coords keep block in the low half-word and sky in the high one.
|
||||
fn light_coords(light: u32) -> i32 {
|
||||
fn light_coords(light: u16) -> i32 {
|
||||
let light = u32::from(light);
|
||||
((light & 0xFF) << 16 | (light >> 8 & 0xFF)) as i32
|
||||
}
|
||||
/// Scenes cover the 3x3 chunk region around chunk (0, 0).
|
||||
@@ -146,9 +147,12 @@ fn scene(
|
||||
}
|
||||
for quad in vertices {
|
||||
let sprite = names
|
||||
.get(&(quad[0].layer & !OPAQUE_LAYER))
|
||||
.get(&u32::from(quad[0].layer & !OPAQUE_LAYER))
|
||||
.ok_or("unknown sprite layer")?;
|
||||
if quad.iter().any(|v: &Vertex| v.layer != quad[0].layer) {
|
||||
if quad
|
||||
.iter()
|
||||
.any(|v: &TerrainVertex| v.layer != quad[0].layer)
|
||||
{
|
||||
return Err("quad mixes sprite layers".into());
|
||||
}
|
||||
let vertices: Vec<Value> = quad
|
||||
|
||||
@@ -17,6 +17,7 @@ fn keys(mask: u64) -> Keys {
|
||||
jump: mask & 16 != 0,
|
||||
shift: mask & 32 != 0,
|
||||
sprint: mask & 64 != 0,
|
||||
analog: None,
|
||||
}
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
//! Trading for tools/test_trading_parity.py: runs each case of INPUT as
|
||||
//! VanillaTradingOracle does (offer generation, special prices, restocks,
|
||||
//! the merchant menu, NBT) and prints the same lines.
|
||||
//!
|
||||
//! usage: trading_fixture RESOURCES INPUT VANILLA_OUTPUT OUTPUT
|
||||
use horizon_client::{
|
||||
enchant::Enchantments,
|
||||
entity_nbt,
|
||||
gossip::{GossipType, Gossips},
|
||||
items::{ItemStack, Items},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
merchant::{self, Event, MerchantOffer, Session, TradeContext, TradeEnv, Trader, Trades},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
|
||||
|
||||
const PLAYER: [i32; 4] = [0, 0x1234, 0, 0x5678];
|
||||
|
||||
/// Canonical NBT text, as VanillaTradingOracle prints it.
|
||||
fn canon(t: &Tag, out: &mut String) {
|
||||
match t {
|
||||
Tag::Compound(c) => {
|
||||
out.push('{');
|
||||
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
|
||||
keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
quote(k, out);
|
||||
out.push(':');
|
||||
canon(t.get(k).expect("key"), out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Tag::List(l) => {
|
||||
out.push('[');
|
||||
for (i, v) in l.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canon(v, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Tag::String(s) => quote(&s.to_string_lossy(), out),
|
||||
Tag::Byte(v) => write!(out, "{v}b").unwrap(),
|
||||
Tag::Short(v) => write!(out, "{v}s").unwrap(),
|
||||
Tag::Int(v) => write!(out, "{v}").unwrap(),
|
||||
Tag::Long(v) => write!(out, "{v}L").unwrap(),
|
||||
Tag::Float(v) => write!(out, "f:{v:x}").unwrap(),
|
||||
Tag::Double(v) => write!(out, "d:{v:x}").unwrap(),
|
||||
Tag::ByteArray(v) => {
|
||||
out.push_str("[B;");
|
||||
let s: Vec<String> = v.iter().map(|b| (*b as i8).to_string()).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::IntArray(v) => {
|
||||
out.push_str("[I;");
|
||||
let s: Vec<String> = v.iter().map(i32::to_string).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::LongArray(v) => {
|
||||
out.push_str("[L;");
|
||||
let s: Vec<String> = v.iter().map(i64::to_string).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::End => out.push_str("?End"),
|
||||
}
|
||||
}
|
||||
fn quote(s: &str, out: &mut String) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
if c == '"' || c == '\\' {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
fn canon_str(t: &Tag) -> String {
|
||||
let mut s = String::new();
|
||||
canon(t, &mut s);
|
||||
s
|
||||
}
|
||||
fn stack(s: Option<&ItemStack>) -> String {
|
||||
match s {
|
||||
None => "-".into(),
|
||||
Some(s) => canon_str(&horizon_client::save::item_to_nbt(s)),
|
||||
}
|
||||
}
|
||||
|
||||
struct World<'a> {
|
||||
items: &'a Items,
|
||||
enchantments: &'a Enchantments,
|
||||
trades: &'a Trades,
|
||||
sequences: RandomSequences,
|
||||
seed: i64,
|
||||
}
|
||||
/// A merchant from PROF:TYPE:LEVEL or "wt", generated level by level.
|
||||
struct Merchant {
|
||||
villager: bool,
|
||||
profession: String,
|
||||
variant: String,
|
||||
level: i32,
|
||||
trader: Trader,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn reset(&mut self, seed: i64) {
|
||||
self.sequences = RandomSequences::default();
|
||||
self.seed = seed;
|
||||
}
|
||||
fn add_set(
|
||||
&mut self,
|
||||
offers: &mut Vec<MerchantOffer>,
|
||||
set: &str,
|
||||
variant: &str,
|
||||
origin: [f64; 3],
|
||||
) {
|
||||
let Some(sequence) = self.trades.sequence(set) else {
|
||||
return;
|
||||
};
|
||||
let ctx = TradeContext {
|
||||
items: self.items,
|
||||
enchantments: self.enchantments,
|
||||
variant,
|
||||
origin,
|
||||
maps: None,
|
||||
};
|
||||
let random = self.sequences.get(&sequence, self.seed);
|
||||
offers.extend(self.trades.offers_from_set(&ctx, set, random));
|
||||
}
|
||||
fn merchant(&mut self, spec: &str, origin: [f64; 3]) -> Merchant {
|
||||
let mut offers = Vec::new();
|
||||
if spec == "wt" {
|
||||
for set in ["buying", "uncommon", "common"] {
|
||||
self.add_set(
|
||||
&mut offers,
|
||||
&format!("minecraft:wandering_trader/{set}"),
|
||||
"minecraft:plains",
|
||||
origin,
|
||||
);
|
||||
}
|
||||
return Merchant {
|
||||
villager: false,
|
||||
profession: "minecraft:wandering_trader".into(),
|
||||
variant: "minecraft:plains".into(),
|
||||
level: 1,
|
||||
trader: Trader {
|
||||
offers,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
}
|
||||
let p: Vec<&str> = spec.split(':').collect();
|
||||
let (profession, variant) = (format!("minecraft:{}", p[0]), format!("minecraft:{}", p[1]));
|
||||
let max: i32 = p[2].parse().unwrap_or(1);
|
||||
for level in 1..=max {
|
||||
let set = Trades::set_for(&profession, level);
|
||||
if self.trades.sets.contains_key(&set) {
|
||||
self.add_set(&mut offers, &set, &variant, origin);
|
||||
}
|
||||
}
|
||||
Merchant {
|
||||
villager: true,
|
||||
profession,
|
||||
variant,
|
||||
level: max,
|
||||
trader: Trader {
|
||||
offers,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kv(fields: &[&str]) -> HashMap<String, String> {
|
||||
let mut m: HashMap<String, String> = HashMap::new();
|
||||
for f in &fields[2..] {
|
||||
if let Some((k, v)) = f.split_once('=') {
|
||||
m.entry(k.to_owned())
|
||||
.and_modify(|e| {
|
||||
e.push(' ');
|
||||
e.push_str(v);
|
||||
})
|
||||
.or_insert_with(|| v.to_owned());
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Uses, demand, gossip, xp and restock state, as the oracle's `prepare`.
|
||||
fn prepare(m: &mut Merchant, gossips: &mut Gossips, kv: &HashMap<String, String>) {
|
||||
for key in ["uses", "demand"] {
|
||||
let Some(list) = kv.get(key) else { continue };
|
||||
for s in list.split(' ') {
|
||||
let mut f = s.split(':');
|
||||
let i: usize = f.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let n: i32 = f.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let Some(o) = m.trader.offers.get_mut(i) else {
|
||||
continue;
|
||||
};
|
||||
if key == "uses" {
|
||||
o.uses += n;
|
||||
} else {
|
||||
o.demand = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(list) = kv.get("gossip") {
|
||||
for s in list.split(' ') {
|
||||
let (kind, n) = s.split_once(':').unwrap_or((s, "0"));
|
||||
if let Some(kind) = GossipType::from_id(kind) {
|
||||
gossips.add(PLAYER, kind, n.parse().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(xp) = kv.get("xp") {
|
||||
m.trader.xp = xp.parse().unwrap_or(0);
|
||||
}
|
||||
if let Some(v) = kv.get("lastrestock") {
|
||||
m.trader.last_restock = v.parse().unwrap_or(0);
|
||||
}
|
||||
if let Some(v) = kv.get("restocks") {
|
||||
m.trader.restocks_today = v.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: trading_fixture RESOURCES INPUT VANILLA_OUTPUT OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let trades = Trades::load(root);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, std::sync::Arc::new(v)))
|
||||
.collect();
|
||||
// The vanilla villagers the nbt cases read.
|
||||
let snbt: HashMap<&str, &str> = vanilla
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let (name, rest) = l.split_once(' ')?;
|
||||
Some((name, rest.strip_prefix("snbt ")?))
|
||||
})
|
||||
.collect();
|
||||
let mut world = World {
|
||||
items: &items,
|
||||
enchantments: &enchantments,
|
||||
trades: &trades,
|
||||
sequences: RandomSequences::default(),
|
||||
seed: 0,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let fields: Vec<&str> = line.split(' ').collect();
|
||||
let (kind, name) = (fields[0], fields[1]);
|
||||
let kv = kv(&fields);
|
||||
let seed: i64 = kv.get("seed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
world.reset(seed);
|
||||
let origin = [0.5, -60.0, 0.5];
|
||||
match kind {
|
||||
"gen" => {
|
||||
let specs: Vec<String> = kv["v"].split(' ').map(str::to_owned).collect();
|
||||
for (k, spec) in specs.iter().enumerate() {
|
||||
let m = world.merchant(spec, origin);
|
||||
writeln!(
|
||||
out,
|
||||
"{name} v{k} {}",
|
||||
canon_str(&entity_nbt::offers_to_nbt(&m.trader.offers))
|
||||
)?;
|
||||
}
|
||||
let mut line = format!("{name} draws");
|
||||
for id in trades.sequences() {
|
||||
write!(line, " {}", world.sequences.get(&id, seed).next_int())?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
"price" => {
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
prepare(&mut m, &mut gossips, &kv);
|
||||
let hero = kv.get("hero").and_then(|h| h.parse().ok());
|
||||
let rep = gossips.reputation(PLAYER);
|
||||
merchant::update_special_prices(&mut m.trader.offers, rep, hero);
|
||||
let mut line = format!("{name} rep {rep}");
|
||||
for o in &m.trader.offers {
|
||||
write!(line, " {}/{}", o.cost_a_count(&items), o.special_price_diff)?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
"restock" => {
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
prepare(&mut m, &mut gossips, &kv);
|
||||
let steps = kv.get("steps").cloned().unwrap_or_default();
|
||||
for (k, s) in steps.split(',').filter(|s| !s.is_empty()).enumerate() {
|
||||
let f: Vec<&str> = s.split(':').collect();
|
||||
let mut line = format!("{name} {k} {s}");
|
||||
match f[0] {
|
||||
"use" => {
|
||||
let i: usize = f[1].parse().unwrap_or(0);
|
||||
if let Some(o) = m.trader.offers.get_mut(i) {
|
||||
o.uses += f[2].parse::<i32>().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
"check" => {
|
||||
let time: i64 = f[1].parse().unwrap_or(0);
|
||||
let day: i64 = f[2].parse().unwrap_or(0);
|
||||
let should = m.trader.should_restock(time, day);
|
||||
if should {
|
||||
m.trader.restock(time);
|
||||
}
|
||||
write!(line, " {should}")?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
write!(
|
||||
line,
|
||||
" lr {} rt {} cd {}",
|
||||
m.trader.last_restock, m.trader.restocks_today, m.trader.check_day
|
||||
)?;
|
||||
for o in &m.trader.offers {
|
||||
write!(line, " {}/{}", o.uses, o.demand)?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
"nbt" => {
|
||||
let text = snbt.get(name).ok_or("missing vanilla snbt")?;
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(text)?;
|
||||
let id = if kv["v"] == "wt" {
|
||||
"minecraft:wandering_trader"
|
||||
} else {
|
||||
"minecraft:villager"
|
||||
};
|
||||
let mut tag = tag;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("id".into(), Tag::String(id.into()));
|
||||
}
|
||||
let mob = entity_nbt::mob_from_nbt(&tag, &types, 1).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None);
|
||||
writeln!(out, "{name} snbt {text}")?;
|
||||
let mut kept = horizon_world::nbt::Compound::new();
|
||||
for key in [
|
||||
"Offers",
|
||||
"Xp",
|
||||
"LastRestock",
|
||||
"RestocksToday",
|
||||
"VillagerData",
|
||||
] {
|
||||
if let Some(v) = back.get(key) {
|
||||
kept.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
writeln!(out, "{name} canon {}", canon_str(&Tag::Compound(kept)))?;
|
||||
}
|
||||
"trade" => {
|
||||
let env_trade = TradeEnv {
|
||||
trades: &trades,
|
||||
enchantments: &enchantments,
|
||||
maps: None,
|
||||
};
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: Some(&env_trade),
|
||||
};
|
||||
trade(&mut out, name, &kv, &mut world, &env, origin)?;
|
||||
}
|
||||
_ => return Err(format!("unknown case {kind}").into()),
|
||||
}
|
||||
}
|
||||
fs::write(args.get(4).ok_or(usage)?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trade(
|
||||
out: &mut String,
|
||||
name: &str,
|
||||
kv: &HashMap<String, String>,
|
||||
world: &mut World,
|
||||
env: &Env,
|
||||
origin: [f64; 3],
|
||||
) -> horizon_client::Result<()> {
|
||||
let vseed: i64 = kv.get("vseed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
if m.villager {
|
||||
prepare(&mut m, &mut gossips, kv);
|
||||
}
|
||||
let hero: Option<i32> = kv.get("hero").and_then(|h| h.parse().ok());
|
||||
let mut menu = Menu::new(Kind::Merchant);
|
||||
if let Some(inv) = kv.get("inv") {
|
||||
for s in inv.split(',') {
|
||||
let (slot, rest) = s.split_once(':').ok_or("inv")?;
|
||||
let (item, count) = rest.rsplit_once('*').ok_or("inv")?;
|
||||
let item = if item.contains(':') {
|
||||
item.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{item}")
|
||||
};
|
||||
menu.set_player_item(slot.parse()?, Some(ItemStack::new(&item, count.parse()?)));
|
||||
}
|
||||
}
|
||||
menu.selected = kv.get("hand").and_then(|h| h.parse().ok()).unwrap_or(8);
|
||||
// Villager.mobInteract / WanderingTrader.mobInteract.
|
||||
let open = !m.trader.offers.is_empty();
|
||||
let unhappy = if m.villager && !open { 40 } else { 0 };
|
||||
let mut voice = LegacyRandom::new(vseed);
|
||||
if unhappy > 0 {
|
||||
// setUnhappy: makeSound(VILLAGER_NO) draws the voice pitch.
|
||||
voice.next_float();
|
||||
voice.next_float();
|
||||
}
|
||||
writeln!(out, "{name} open {open} unhappy {unhappy}")?;
|
||||
let mut session = Session::new(m.trader.offers.clone(), m.villager);
|
||||
session.xp = m.trader.xp;
|
||||
session.level = m.level;
|
||||
session.profession = m.profession.clone();
|
||||
session.variant = m.variant.clone();
|
||||
session.random = voice.clone();
|
||||
session.origin = origin;
|
||||
session.reputation = gossips.reputation(PLAYER);
|
||||
session.hero = hero;
|
||||
for id in Session::sequences_needed(world.trades, &m.profession, m.level) {
|
||||
let r = world.sequences.lend(&id, world.seed);
|
||||
session.sequences.insert(id, r);
|
||||
}
|
||||
session.update_special_prices();
|
||||
let mut regen = false;
|
||||
let mut open_menu = open;
|
||||
if open_menu {
|
||||
menu.merchant = Some(Box::new(session));
|
||||
}
|
||||
let steps = kv.get("steps").cloned().unwrap_or_default();
|
||||
let mut session_after: Option<Box<Session>> = None;
|
||||
for (k, s) in steps.split(',').filter(|s| !s.is_empty()).enumerate() {
|
||||
let f: Vec<&str> = s.split(':').collect();
|
||||
match f[0] {
|
||||
"select" if open_menu => menu.merchant_select(env, f[1].parse().unwrap_or(0)),
|
||||
"click" if open_menu => {
|
||||
let input = Input::parse(&f[3].to_uppercase()).ok_or("input")?;
|
||||
let _ = menu.click(env, f[1].parse()?, f[2].parse()?, input);
|
||||
}
|
||||
"tick" => {
|
||||
let sess = menu.merchant.as_mut().or(session_after.as_mut());
|
||||
if let Some(sess) = sess
|
||||
&& sess.villager
|
||||
&& sess.last_traded
|
||||
{
|
||||
sess.last_traded = false;
|
||||
gossips.add(PLAYER, GossipType::Trading, 2);
|
||||
sess.reputation = gossips.reputation(PLAYER);
|
||||
if open_menu {
|
||||
sess.update_special_prices();
|
||||
menu.merchant_update(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
"close" if open_menu => {
|
||||
menu.merchant_close(env);
|
||||
open_menu = false;
|
||||
session_after = Some(menu.merchant.take().ok_or("session")?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut line = format!("{name} {k} {s} menu {open_menu}");
|
||||
if open_menu {
|
||||
for i in 0..3 {
|
||||
write!(line, " {}", stack(menu.slot(i)))?;
|
||||
}
|
||||
write!(
|
||||
line,
|
||||
" fxp {}",
|
||||
menu.merchant.as_ref().map_or(0, |s| s.future_xp)
|
||||
)?;
|
||||
}
|
||||
write!(line, " carried {}", stack(menu.carried.as_ref()))?;
|
||||
line.push_str(" inv");
|
||||
for i in 0..36 {
|
||||
if let Some(st) = menu.player_item(i) {
|
||||
write!(line, " {i}={}", stack(Some(st)))?;
|
||||
}
|
||||
}
|
||||
let sess = menu
|
||||
.merchant
|
||||
.as_deref_mut()
|
||||
.or(session_after.as_deref_mut());
|
||||
let (xp, level, offers, orbs) = match sess {
|
||||
Some(sess) => {
|
||||
let mut orbs = Vec::new();
|
||||
for e in sess.events.drain(..) {
|
||||
match e {
|
||||
Event::Orb(v) => orbs.push(v),
|
||||
Event::LevelUp(_) => regen = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(sess.xp, sess.level, sess.offers.clone(), orbs)
|
||||
}
|
||||
None => (m.trader.xp, m.level, m.trader.offers.clone(), Vec::new()),
|
||||
};
|
||||
write!(line, " xp {xp}")?;
|
||||
if m.villager {
|
||||
write!(
|
||||
line,
|
||||
" lvl {level} rep {} regen {regen}",
|
||||
gossips.reputation(PLAYER)
|
||||
)?;
|
||||
}
|
||||
line.push_str(" offers");
|
||||
for o in &offers {
|
||||
write!(
|
||||
line,
|
||||
" {}/{}/{}",
|
||||
o.uses,
|
||||
o.special_price_diff,
|
||||
o.cost_a_count(env.items)
|
||||
)?;
|
||||
}
|
||||
line.push_str(" orbs");
|
||||
for v in orbs {
|
||||
write!(line, " {v}")?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
let random = match menu.merchant.as_mut().or(session_after.as_mut()) {
|
||||
Some(s) => s.random.next_int(),
|
||||
None => voice.next_int(),
|
||||
};
|
||||
writeln!(out, "{name} next {random}")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! WeatherEffectRenderer column instances and quads, without a GPU.
|
||||
use horizon_client::weather;
|
||||
use std::{fmt::Write, fs};
|
||||
fn bits(value: f32) -> String {
|
||||
if value.is_nan() {
|
||||
"nan".into()
|
||||
} else {
|
||||
format!("{:x}", value.to_bits())
|
||||
}
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let mut output = String::new();
|
||||
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
|
||||
let p: Vec<&str> = line.split_whitespace().collect();
|
||||
let int = |i: usize| p[i].parse::<i32>().unwrap();
|
||||
let kind = int(0);
|
||||
let ticks: i64 = p[1].parse()?;
|
||||
let (x, bottom, top, z, light) = (int(2), int(3), int(4), int(5), int(6));
|
||||
let partial: f32 = p[7].parse()?;
|
||||
let camera = [p[8].parse()?, p[9].parse()?, p[10].parse()?];
|
||||
let radius = int(11);
|
||||
let intensity: f32 = p[12].parse()?;
|
||||
let mut random = weather::column_random(x, z);
|
||||
let column = if kind == 0 {
|
||||
weather::rain_column(&mut random, ticks, x, bottom, top, z, light, partial)
|
||||
} else {
|
||||
weather::snow_column(&mut random, ticks, x, bottom, top, z, light, partial)
|
||||
};
|
||||
write!(
|
||||
output,
|
||||
"{case} {} {} {} {} {} {} {}",
|
||||
column.x,
|
||||
column.z,
|
||||
column.bottom_y,
|
||||
column.top_y,
|
||||
bits(column.u_offset),
|
||||
bits(column.v_offset),
|
||||
column.light_coords
|
||||
)?;
|
||||
let max_alpha = if kind == 0 { 1.0 } else { 0.8 };
|
||||
for v in weather::column_quad(&column, camera, max_alpha, radius, intensity) {
|
||||
write!(
|
||||
output,
|
||||
" |{},{},{},{},{},{:x},{}",
|
||||
bits(v.pos[0]),
|
||||
bits(v.pos[1]),
|
||||
bits(v.pos[2]),
|
||||
bits(v.uv[0]),
|
||||
bits(v.uv[1]),
|
||||
v.color,
|
||||
v.light_coords
|
||||
)?;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -294,6 +294,8 @@ pub struct EntityView {
|
||||
pub struck: Vec<EntityView>,
|
||||
pub fishing_open_water: Option<bool>,
|
||||
pub variant: Option<String>,
|
||||
/// The data components the entity reports (`Entity.get`): id and value.
|
||||
pub components: Vec<(String, String)>,
|
||||
}
|
||||
fn range_f(v: &Value, x: f64) -> bool {
|
||||
match v {
|
||||
@@ -310,7 +312,7 @@ fn range_i(v: &Value, x: i64) -> bool {
|
||||
range_f(v, x as f64)
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
match v {
|
||||
Value::Null => true,
|
||||
Value::String(s) => match s.strip_prefix('#') {
|
||||
@@ -442,6 +444,26 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `DataComponentExactPredicate.test` on an entity: every expected
|
||||
/// component equals what the entity reports (absent ones never match).
|
||||
fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
let full = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
expected.as_object().is_some_and(|o| {
|
||||
o.iter().all(|(id, want)| {
|
||||
let id = full(id);
|
||||
want.as_str().is_some_and(|want| {
|
||||
have.iter()
|
||||
.any(|(k, v)| full(k) == id && full(v) == full(want))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `EntityPredicate` (26.x keyed sub-predicates).
|
||||
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -521,7 +543,8 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:type_specific/fishing_hook" => p["in_open_water"]
|
||||
.as_bool()
|
||||
.is_none_or(|want| e.fishing_open_water == Some(want)),
|
||||
"minecraft:components" | "minecraft:predicates" => true,
|
||||
"minecraft:components" => components(p, &e.components),
|
||||
"minecraft:predicates" => true,
|
||||
_ => false,
|
||||
};
|
||||
if !ok {
|
||||
@@ -571,6 +594,7 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
w,
|
||||
)
|
||||
}
|
||||
"minecraft:match_block" => ctx.block_pos.is_some_and(|b| block(v, b, w)),
|
||||
"minecraft:block_state_property" => ctx.block_pos.is_some_and(|b| {
|
||||
let mut pred = serde_json::Map::new();
|
||||
pred.insert("blocks".into(), v["block"].clone());
|
||||
@@ -929,6 +953,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bucket_axolotl_block_advancements_require_the_placed_block() {
|
||||
let ctx = Entities {
|
||||
this: None,
|
||||
origin: [0.0; 3],
|
||||
tool: None,
|
||||
block_pos: Some([0; 3]),
|
||||
};
|
||||
let stone = serde_json::json!({"type":"minecraft:match_block","blocks":"minecraft:stone"});
|
||||
let wheat = serde_json::json!({"type":"minecraft:match_block","blocks":"minecraft:wheat"});
|
||||
assert!(conditions(&stone, &ctx, &Flat));
|
||||
assert!(!conditions(&wheat, &ctx, &Flat));
|
||||
let absent = Entities {
|
||||
block_pos: None,
|
||||
..ctx
|
||||
};
|
||||
assert!(!conditions(&stone, &absent, &Flat));
|
||||
}
|
||||
#[test]
|
||||
fn predicates_match_tags_ranges_and_locations() {
|
||||
let w = Flat;
|
||||
let stone = ItemView {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@ use horizon_world::{
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs::File,
|
||||
fs::{self, File},
|
||||
io::{self, BufReader},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
@@ -55,6 +55,7 @@ pub struct Assets {
|
||||
pub properties: Properties,
|
||||
pub material_states: [BlockStateId; 6],
|
||||
pub sprite_size: u32,
|
||||
pub sprite_minimum_size: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
pub texture_layers: u32,
|
||||
pub model_catalog: crate::models::StateModels,
|
||||
@@ -63,6 +64,8 @@ pub struct Assets {
|
||||
pub animations: Vec<crate::sprites::SpriteAnimation>,
|
||||
pub sprite_layers: std::collections::BTreeMap<String, u32>,
|
||||
pub translucent_layers: Vec<bool>,
|
||||
/// Each layer's `MipmapGenerator` settings.
|
||||
pub sprite_mips: Vec<crate::mipmap::Settings>,
|
||||
/// Block names in `#minecraft:blocks_fluid_flow` (fluid flow vectors).
|
||||
pub blocks_fluid_flow: BTreeSet<String>,
|
||||
}
|
||||
@@ -95,8 +98,7 @@ impl Assets {
|
||||
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
|
||||
return Err(invalid("resources do not match Horizon's pinned data version").into());
|
||||
}
|
||||
let facts: Value =
|
||||
serde_json::from_reader(BufReader::new(File::open(root.join("facts.json"))?))?;
|
||||
let facts: Value = serde_json::from_slice(&fs::read(root.join("facts.json"))?)?;
|
||||
let states = facts["states"]
|
||||
.as_array()
|
||||
.ok_or_else(|| invalid("missing registry state facts"))?;
|
||||
@@ -180,15 +182,43 @@ impl Assets {
|
||||
.collect::<Result<_>>()?,
|
||||
));
|
||||
let mut material_states = [BlockStateId(0); 6];
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for (layer, name) in MATERIALS.iter().enumerate() {
|
||||
let state = registry
|
||||
material_states[layer] = registry
|
||||
.default_state(&format!("minecraft:{name}"))
|
||||
.ok_or_else(|| invalid("missing test-scene material"))?;
|
||||
material_states[layer] = state;
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
Self::with_models(
|
||||
root,
|
||||
registry,
|
||||
properties,
|
||||
material_states,
|
||||
blocks_fluid_flow,
|
||||
)
|
||||
}
|
||||
/// The same blocks with the models and sprites of another resource root
|
||||
/// (resource packs changed): the registry and block facts come from the
|
||||
/// game data, which resource packs do not touch.
|
||||
pub fn reload(&self, root: &Path) -> Result<Self> {
|
||||
Self::with_models(
|
||||
root,
|
||||
self.registry,
|
||||
self.properties.clone(),
|
||||
self.material_states,
|
||||
self.blocks_fluid_flow.clone(),
|
||||
)
|
||||
}
|
||||
fn with_models(
|
||||
root: &Path,
|
||||
registry: &'static BlockRegistry,
|
||||
properties: Properties,
|
||||
material_states: [BlockStateId; 6],
|
||||
blocks_fluid_flow: BTreeSet<String>,
|
||||
) -> Result<Self> {
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
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.
|
||||
@@ -204,11 +234,13 @@ impl Assets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.finish();
|
||||
Ok(Self {
|
||||
registry,
|
||||
properties,
|
||||
material_states,
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
texture_layers: u32::try_from(sprites.layers.len())?,
|
||||
pixels: sprites.pixels,
|
||||
model_catalog: models.catalog,
|
||||
@@ -217,6 +249,7 @@ impl Assets {
|
||||
animations: sprites.animations,
|
||||
sprite_layers: sprites.layers,
|
||||
translucent_layers: sprites.translucent,
|
||||
sprite_mips: sprites.mips,
|
||||
blocks_fluid_flow,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
*out = T::from_sample(*s);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
|e| log::warn!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
@@ -486,7 +486,7 @@ impl Audio {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
if output {
|
||||
eprintln!("Horizon: no sound: {e}");
|
||||
log::warn!("Horizon: no sound: {e}");
|
||||
}
|
||||
(None, false)
|
||||
}
|
||||
@@ -533,7 +533,7 @@ impl Audio {
|
||||
tried.push(name.clone());
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
eprintln!(
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
@@ -600,6 +600,16 @@ impl Audio {
|
||||
pub fn stop_all(&mut self) {
|
||||
self.engine.stop_all(&mut self.backend);
|
||||
}
|
||||
/// `SoundManager.reload` (resource packs changed): stop every sound, then
|
||||
/// read sound events and sound files from the new resource root.
|
||||
pub fn reload(&mut self, root: &Path) {
|
||||
self.stop_all();
|
||||
self.engine.registry = SoundRegistry::load(root);
|
||||
self.backend.root = root.to_owned();
|
||||
let mut cache = self.backend.cache.lock().unwrap();
|
||||
cache.entries.clear();
|
||||
cache.bytes = 0;
|
||||
}
|
||||
/// Play a sound event at a world position, as `ClientLevel.playLocalSound`
|
||||
/// (a `SimpleSoundInstance` seeded from the level random), or relative to
|
||||
/// the listener without attenuation (`None`).
|
||||
@@ -607,6 +617,23 @@ impl Audio {
|
||||
let seed = self.random.next_long();
|
||||
self.play_seeded(event, at, volume, pitch, seed);
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
|
||||
/// client (the entity's own random there, never the server's world random).
|
||||
pub fn hurt_pitch(&mut self) -> f32 {
|
||||
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
|
||||
}
|
||||
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
|
||||
pub fn play_button_click(&mut self) {
|
||||
self.play_instance(
|
||||
SoundInstance::for_ui(
|
||||
"minecraft:ui.button.click",
|
||||
1.0,
|
||||
0.25,
|
||||
SoundEngine::unseeded(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
@@ -739,6 +766,38 @@ impl Sounds for Audio {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn button_click_uses_vanilla_ui_parameters_without_advancing_world_random() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"ui.button.click":{"sounds":["gui/button"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// Exercise the mixer without opening an output device. The missing
|
||||
// sample is harmless: this test checks the source the engine creates.
|
||||
audio.engine.loaded = true;
|
||||
audio.backend.live = true;
|
||||
let mut random = audio.random.clone();
|
||||
audio.play_button_click();
|
||||
let instance = audio.engine.instance(1).expect("click reached the engine");
|
||||
assert_eq!(instance.identifier, "minecraft:ui.button.click");
|
||||
assert_eq!(instance.source, crate::sound_engine::UI);
|
||||
assert_eq!(instance.volume, 0.25);
|
||||
assert_eq!(instance.pitch, 1.0);
|
||||
assert_eq!(instance.attenuation, Attenuation::None);
|
||||
assert_eq!(instance.position, [0.0; 3]);
|
||||
assert!(instance.relative);
|
||||
assert!(!instance.looping);
|
||||
assert_eq!(audio.random.next_long(), random.next_long());
|
||||
audio.pause();
|
||||
assert_eq!(
|
||||
audio.backend.mixer.lock().unwrap().channels[&1].state,
|
||||
State::Initial
|
||||
);
|
||||
audio.stop_all();
|
||||
}
|
||||
|
||||
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
|
||||
Channel {
|
||||
samples: Some((Arc::new(samples), 48_000)),
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The Microsoft account the launcher signed in, handed over in a session
|
||||
//! file (docs/microsoft-account.md), and the Minecraft services call
|
||||
//! online-mode servers need: authlib's MinecraftServicesSessionService.joinServer
|
||||
//! through the services discovery document. The access token stays in this
|
||||
//! process's memory: it is never logged, printed or written anywhere.
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
/// MinecraftServicesEnvironment.PROD.
|
||||
pub const DEFAULT_DISCOVERY: &str = "https://discovery.minecraftservices.com/minecraft/client";
|
||||
/// A session file older than this is refused: it can't be replayed later.
|
||||
const MAX_SESSION_FILE_AGE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A signed-in account (vanilla's User with type msa).
|
||||
pub struct AuthSession {
|
||||
pub name: String,
|
||||
pub uuid: u128,
|
||||
pub xuid: Option<String>,
|
||||
access_token: String,
|
||||
}
|
||||
impl std::fmt::Debug for AuthSession {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthSession")
|
||||
.field("name", &self.name)
|
||||
.field("uuid", &horizon_protocol::crypto::uuid_string(self.uuid))
|
||||
.field("access_token", &"<hidden>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A UUID with or without dashes.
|
||||
pub fn parse_uuid(text: &str) -> Option<u128> {
|
||||
let hex: String = text.chars().filter(|c| *c != '-').collect();
|
||||
(hex.len() == 32)
|
||||
.then(|| u128::from_str_radix(&hex, 16).ok())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
impl AuthSession {
|
||||
/// For tests and harnesses.
|
||||
pub fn new(name: &str, uuid: u128, access_token: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
uuid,
|
||||
xuid: None,
|
||||
access_token: access_token.to_owned(),
|
||||
}
|
||||
}
|
||||
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
|
||||
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
|
||||
let name = field("name").ok_or("the session file has no name")?;
|
||||
if name.is_empty() || name.chars().count() > 16 {
|
||||
return Err("the session file's name is not a valid player name".into());
|
||||
}
|
||||
let uuid = field("uuid")
|
||||
.and_then(parse_uuid)
|
||||
.ok_or("the session file has no valid uuid")?;
|
||||
let access_token = field("access_token")
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or("the session file has no access token")?;
|
||||
if field("user_type").is_some_and(|t| t != "msa") {
|
||||
return Err("the session file is not a Microsoft account session".into());
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
uuid,
|
||||
xuid: field("xuid").map(str::to_owned),
|
||||
access_token: access_token.to_owned(),
|
||||
})
|
||||
}
|
||||
/// Read the session file the launcher wrote and delete it at once. It
|
||||
/// must be fresh and, on Unix, readable by its owner only.
|
||||
pub fn load(path: &Path) -> Result<Self, String> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let refuse = |why: &str| {
|
||||
let _ = std::fs::remove_file(path);
|
||||
Err(format!("{}: {why}", path.display()))
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if meta.permissions().mode() & 0o077 != 0 {
|
||||
return refuse("other users can read it");
|
||||
}
|
||||
}
|
||||
let age = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|m| SystemTime::now().duration_since(m).ok())
|
||||
.unwrap_or_default();
|
||||
if age > MAX_SESSION_FILE_AGE {
|
||||
return refuse("it is too old (start the game from the launcher)");
|
||||
}
|
||||
let text = std::fs::read_to_string(path);
|
||||
let _ = std::fs::remove_file(path);
|
||||
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
|
||||
}
|
||||
}
|
||||
|
||||
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
|
||||
|
||||
/// The account this game was started with (set once at start).
|
||||
pub fn set_current(session: AuthSession) {
|
||||
let _ = CURRENT.set(Arc::new(session));
|
||||
}
|
||||
pub fn current() -> Option<Arc<AuthSession>> {
|
||||
CURRENT.get().cloned()
|
||||
}
|
||||
|
||||
/// Why joining a server through the session service failed, like the
|
||||
/// AuthenticationException subclasses authlib throws.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum JoinError {
|
||||
/// No answer, or a 5xx (AuthenticationUnavailableException).
|
||||
Unavailable,
|
||||
/// 401 or ForbiddenOperationException (InvalidCredentialsException).
|
||||
InvalidSession,
|
||||
/// InsufficientPrivilegesException: multiplayer is off for the account.
|
||||
InsufficientPrivileges,
|
||||
/// multiplayer.access.banned or FORCED_USERNAME_CHANGE.
|
||||
Banned,
|
||||
/// Anything else, with the service's message.
|
||||
Other(String),
|
||||
}
|
||||
impl JoinError {
|
||||
/// MinecraftClientHttpException.toAuthenticationException.
|
||||
pub fn from_response(status: u16, body: &str) -> Self {
|
||||
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
|
||||
let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
|
||||
match error {
|
||||
"ForbiddenOperationException" => return Self::InvalidSession,
|
||||
"multiplayer.access.banned" | "FORCED_USERNAME_CHANGE" => return Self::Banned,
|
||||
"InsufficientPrivilegesException" => return Self::InsufficientPrivileges,
|
||||
_ => {}
|
||||
}
|
||||
match status {
|
||||
401 => Self::InvalidSession,
|
||||
500.. => Self::Unavailable,
|
||||
_ => Self::Other(
|
||||
v.get("errorMessage")
|
||||
.and_then(|m| m.as_str())
|
||||
.map_or_else(|| format!("Status: {status}"), str::to_owned),
|
||||
),
|
||||
}
|
||||
}
|
||||
/// ClientHandshakePacketListenerImpl.authenticateServer's message: the
|
||||
/// key of the reason inside "disconnect.loginFailedInfo", or its text.
|
||||
pub fn reason(&self) -> Result<&'static str, &str> {
|
||||
match self {
|
||||
Self::Unavailable => Ok("disconnect.loginFailedInfo.serversUnavailable"),
|
||||
Self::InvalidSession => Ok("disconnect.loginFailedInfo.invalidSession"),
|
||||
Self::InsufficientPrivileges => Ok("disconnect.loginFailedInfo.insufficientPrivileges"),
|
||||
Self::Banned => Ok("disconnect.loginFailedInfo.userBanned"),
|
||||
Self::Other(message) => Err(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The services discovery document URL: `HORIZON_API_DISCOVERY` overrides
|
||||
/// it like authlib's `-Dminecraft.api.discovery.host`.
|
||||
pub fn discovery_url() -> String {
|
||||
std::env::var("HORIZON_API_DISCOVERY")
|
||||
.ok()
|
||||
.filter(|u| !u.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
|
||||
}
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// The discovery document, fetched once per game (a failure is retried next time).
|
||||
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
|
||||
if let Some(d) = CACHE.lock().unwrap().clone() {
|
||||
return Ok(d);
|
||||
}
|
||||
let doc: serde_json::Value = agent()
|
||||
.get(&discovery_url())
|
||||
.call()
|
||||
.ok()
|
||||
.and_then(|r| r.into_string().ok())
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.ok_or(JoinError::Unavailable)?;
|
||||
let doc = Arc::new(doc);
|
||||
*CACHE.lock().unwrap() = Some(doc.clone());
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// MinecraftServicesDiscoveryService.getUrl(service, endpoint).
|
||||
pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<String> {
|
||||
doc.pointer(&format!("/discovery/{service}/endpoints/{name}/uri"))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
|
||||
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
|
||||
serde_json::json!({
|
||||
"accessToken": session.access_token,
|
||||
"selectedProfile": format!("{:032x}", session.uuid),
|
||||
"serverId": server_hash,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// MinecraftServicesSessionService.joinServer: tell the session service this
|
||||
/// account is joining the server whose hash is `server_hash`.
|
||||
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
|
||||
let doc = discovery()?;
|
||||
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
|
||||
match agent()
|
||||
.post(&url)
|
||||
.set("Content-Type", "application/json; charset=utf-8")
|
||||
.send_string(&join_request(session, server_hash))
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
|
||||
status,
|
||||
&response.into_string().unwrap_or_default(),
|
||||
)),
|
||||
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
|
||||
|
||||
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
|
||||
let path = dir.join("session.json");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
}
|
||||
let _ = mode;
|
||||
path
|
||||
}
|
||||
|
||||
#[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()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let text = r#"{"name":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","access_token":"mock-token","user_type":"msa"}"#;
|
||||
let path = write(&dir, text, 0o600);
|
||||
let s = AuthSession::load(&path).unwrap();
|
||||
assert_eq!((s.name.as_str(), s.uuid), ("Notch", UUID));
|
||||
assert!(!path.exists(), "deleted after reading");
|
||||
assert!(!format!("{s:?}").contains("mock-token"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = write(&dir, text, 0o644);
|
||||
assert!(
|
||||
AuthSession::load(&path)
|
||||
.unwrap_err()
|
||||
.contains("other users")
|
||||
);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_file_fields() {
|
||||
assert!(
|
||||
AuthSession::parse(r#"{"name":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5"}"#)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
AuthSession::parse(
|
||||
r#"{"name":"","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t"}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(AuthSession::parse(r#"{"name":"A","uuid":"nope","access_token":"t"}"#).is_err());
|
||||
assert!(AuthSession::parse(r#"{"name":"A","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t","user_type":"legacy"}"#).is_err());
|
||||
let s = AuthSession::parse(r#"{"name":"A","uuid":"069a79f444e94726a5befca90e38aaf5","access_token":"t","xuid":"25"}"#).unwrap();
|
||||
assert_eq!(s.xuid.as_deref(), Some("25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_request_like_authlib() {
|
||||
let s = AuthSession::new("Notch", UUID, "tok");
|
||||
let v: serde_json::Value = serde_json::from_str(&join_request(&s, "-7c9d")).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
serde_json::json!({"accessToken": "tok", "selectedProfile": "069a79f444e94726a5befca90e38aaf5", "serverId": "-7c9d"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_map_like_authlib() {
|
||||
let e = |status, body| JoinError::from_response(status, body);
|
||||
assert_eq!(
|
||||
e(
|
||||
403,
|
||||
r#"{"error":"ForbiddenOperationException","errorMessage":"Invalid token"}"#
|
||||
),
|
||||
JoinError::InvalidSession
|
||||
);
|
||||
assert_eq!(e(401, ""), JoinError::InvalidSession);
|
||||
assert_eq!(
|
||||
e(403, r#"{"error":"InsufficientPrivilegesException"}"#),
|
||||
JoinError::InsufficientPrivileges
|
||||
);
|
||||
assert_eq!(
|
||||
e(403, r#"{"error":"multiplayer.access.banned"}"#),
|
||||
JoinError::Banned
|
||||
);
|
||||
assert_eq!(e(503, "down"), JoinError::Unavailable);
|
||||
assert_eq!(
|
||||
e(429, r#"{"errorMessage":"Too many"}"#),
|
||||
JoinError::Other("Too many".into())
|
||||
);
|
||||
assert_eq!(
|
||||
JoinError::InvalidSession.reason(),
|
||||
Ok("disconnect.loginFailedInfo.invalidSession")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_endpoints() {
|
||||
let doc = serde_json::json!({"discovery": {"session": {"endpoints": {"join": {"uri": "https://s/join"}}}}});
|
||||
assert_eq!(
|
||||
endpoint(&doc, "session", "join").as_deref(),
|
||||
Some("https://s/join")
|
||||
);
|
||||
assert_eq!(endpoint(&doc, "session", "verify"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! horizon-ctl: drive a running Horizon client through its control socket.
|
||||
#![forbid(unsafe_code)]
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::control::UnixStream;
|
||||
|
||||
const USAGE: &str = "usage: horizon-ctl [--socket PATH] COMMAND [ARGS...]
|
||||
|
||||
Local commands:
|
||||
launch [GAME ARGS...] start horizon-client with a control socket and wait for it
|
||||
hold KEY SECONDS press KEY, wait, release it
|
||||
wait-ready [SECONDS] wait until the player has spawned and no chunks are pending
|
||||
- run commands from stdin, one per line (\"sleep S\" waits locally)
|
||||
|
||||
Game commands (run \"horizon-ctl help\" against a running game for the full list):
|
||||
status, key, mouse, look, turn, tp, hotbar, give, render-distance, time,
|
||||
screenshot PATH, stats, bench SECONDS, open, save, quit
|
||||
|
||||
Replies are JSON lines; the exit status is 1 when the reply has an \"error\".
|
||||
The socket defaults to $HORIZON_CONTROL or $XDG_RUNTIME_DIR/horizon-control.sock.";
|
||||
|
||||
struct Client {
|
||||
writer: UnixStream,
|
||||
reader: BufReader<UnixStream>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn connect(path: &Path) -> Result<Self, String> {
|
||||
let stream = UnixStream::connect(path).map_err(|e| {
|
||||
format!(
|
||||
"cannot connect to {} ({e}); is the game running with --control?",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
|
||||
Ok(Self {
|
||||
writer: stream,
|
||||
reader,
|
||||
})
|
||||
}
|
||||
fn send(&mut self, line: &str) -> Result<String, String> {
|
||||
writeln!(self.writer, "{line}").map_err(|e| e.to_string())?;
|
||||
let mut reply = String::new();
|
||||
if self
|
||||
.reader
|
||||
.read_line(&mut reply)
|
||||
.map_err(|e| e.to_string())?
|
||||
== 0
|
||||
{
|
||||
return Err("the game closed the connection".into());
|
||||
}
|
||||
Ok(reply.trim_end().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_error(reply: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(reply).map_or(true, |v| v.get("error").is_some())
|
||||
}
|
||||
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn run_line(client: &mut Client, words: &mut [String]) -> Result<bool, String> {
|
||||
match words.first().map(String::as_str) {
|
||||
Some("sleep") => {
|
||||
let seconds: f64 = words
|
||||
.get(1)
|
||||
.ok_or("sleep SECONDS")?
|
||||
.parse()
|
||||
.map_err(|_| "sleep SECONDS")?;
|
||||
thread::sleep(Duration::from_secs_f64(seconds));
|
||||
Ok(true)
|
||||
}
|
||||
Some("hold") => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, seconds] => (
|
||||
key.clone(),
|
||||
seconds.parse::<f64>().map_err(|_| "hold KEY SECONDS")?,
|
||||
),
|
||||
_ => return Err("hold KEY SECONDS".into()),
|
||||
};
|
||||
let down = client.send(&format!("key {key} down"))?;
|
||||
if is_error(&down) {
|
||||
println!("{down}");
|
||||
return Ok(false);
|
||||
}
|
||||
thread::sleep(Duration::from_secs_f64(seconds));
|
||||
let up = client.send(&format!("key {key} up"))?;
|
||||
println!("{up}");
|
||||
Ok(!is_error(&up))
|
||||
}
|
||||
Some("wait-ready") => {
|
||||
let limit = words
|
||||
.get(1)
|
||||
.map_or(Ok(120.0), |s| s.parse::<f64>())
|
||||
.map_err(|_| "wait-ready [SECONDS]")?;
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(limit);
|
||||
loop {
|
||||
let reply = client.send("status")?;
|
||||
let status: serde_json::Value =
|
||||
serde_json::from_str(&reply).map_err(|e| e.to_string())?;
|
||||
if !status["player"].is_null() && status["pending_chunks"] == 0 {
|
||||
println!("{reply}");
|
||||
return Ok(true);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
println!(
|
||||
"{{\"error\":\"not ready after {limit} seconds\",\"status\":{reply}}}"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
absolute_paths(words);
|
||||
let reply = client.send(&words.join(" "))?;
|
||||
println!("{reply}");
|
||||
Ok(!is_error(&reply))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn launch(socket: &Path, args: &[String]) -> Result<bool, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let game = exe.with_file_name("horizon-client");
|
||||
let log = socket.with_extension("log");
|
||||
let file = std::fs::File::create(&log).map_err(|e| format!("{}: {e}", log.display()))?;
|
||||
let child = Command::new(&game)
|
||||
.args(args)
|
||||
.arg("--control-socket")
|
||||
.arg(socket)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(file.try_clone().map_err(|e| e.to_string())?)
|
||||
.stderr(file)
|
||||
.spawn()
|
||||
.map_err(|e| format!("{}: {e}", game.display()))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(300);
|
||||
while Instant::now() < deadline {
|
||||
if UnixStream::connect(socket).is_ok() {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "ok": true, "pid": child.id(), "socket": socket, "log": log })
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "error": "control socket did not open within 300 seconds", "log": log })
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut socket = horizon_client::control::default_path();
|
||||
if args.first().map(String::as_str) == Some("--socket") && args.len() >= 2 {
|
||||
socket = PathBuf::from(args.remove(1));
|
||||
args.remove(0);
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
None | Some("-h" | "--help") => {
|
||||
println!("{USAGE}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
Some("launch") => launch(&socket, &args[1..]),
|
||||
Some("-") => Client::connect(&socket).and_then(|mut client| {
|
||||
let mut all_ok = true;
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let line = line.map_err(|e| e.to_string())?;
|
||||
let mut words: Vec<String> = line.split_whitespace().map(str::to_owned).collect();
|
||||
if words.is_empty() || words[0].starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
all_ok &= run_line(&mut client, &mut words)?;
|
||||
}
|
||||
Ok(all_ok)
|
||||
}),
|
||||
Some(_) => Client::connect(&socket).and_then(|mut client| run_line(&mut client, &mut args)),
|
||||
};
|
||||
match result {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::from(1),
|
||||
Err(e) => {
|
||||
eprintln!("horizon-ctl: {e}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//! Every command horizon-ctl knows: its usage, help, examples and arguments.
|
||||
//! The same table prints `help`, checks `COMMAND --help` and describes the
|
||||
//! MCP tools, so the CLI and the MCP server always offer the same commands.
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Ty {
|
||||
Int,
|
||||
Num,
|
||||
Str,
|
||||
Bool,
|
||||
/// One of these words.
|
||||
Enum(&'static [&'static str]),
|
||||
/// The rest of the line, spaces included.
|
||||
Rest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Param {
|
||||
pub name: &'static str,
|
||||
pub ty: Ty,
|
||||
pub required: bool,
|
||||
/// Sent as `--name value` (`--name` alone for booleans), not by position.
|
||||
pub flag: bool,
|
||||
pub help: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Group {
|
||||
Session,
|
||||
Observe,
|
||||
Move,
|
||||
Act,
|
||||
Screens,
|
||||
World,
|
||||
Scripting,
|
||||
}
|
||||
impl Group {
|
||||
pub const ALL: [Group; 7] = [
|
||||
Group::Session,
|
||||
Group::Observe,
|
||||
Group::Move,
|
||||
Group::Act,
|
||||
Group::Screens,
|
||||
Group::World,
|
||||
Group::Scripting,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Group::Session => "Session",
|
||||
Group::Observe => "Observe (JSON state)",
|
||||
Group::Move => "Move and look",
|
||||
Group::Act => "Act on the world",
|
||||
Group::Screens => "Screens, menus and chat",
|
||||
Group::World => "World and cheats",
|
||||
Group::Scripting => "Scripting and benchmarks",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Cmd {
|
||||
pub name: &'static str,
|
||||
/// What the game receives instead of `name` (aliases such as look-at-block).
|
||||
pub wire: Option<&'static str>,
|
||||
pub group: Group,
|
||||
pub usage: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub details: &'static str,
|
||||
pub examples: &'static [&'static str],
|
||||
pub params: &'static [Param],
|
||||
/// Offered as an MCP tool.
|
||||
pub mcp: bool,
|
||||
/// Runs in horizon-ctl itself, not in the game.
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
const fn p(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: true,
|
||||
flag: false,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const fn opt(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: false,
|
||||
flag: false,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const fn flag(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: false,
|
||||
flag: true,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const XYZ: [Param; 3] = [
|
||||
p("x", Ty::Num, "X coordinate"),
|
||||
p("y", Ty::Num, "Y coordinate"),
|
||||
p("z", Ty::Num, "Z coordinate"),
|
||||
];
|
||||
const BLOCK_XYZ: [Param; 3] = [
|
||||
p("x", Ty::Int, "block X"),
|
||||
p("y", Ty::Int, "block Y"),
|
||||
p("z", Ty::Int, "block Z"),
|
||||
];
|
||||
const OVER: Param = flag(
|
||||
"over",
|
||||
Ty::Num,
|
||||
"turn with mouse motion spread over this many seconds (eased), as a player would",
|
||||
);
|
||||
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
|
||||
|
||||
macro_rules! cmd {
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
|
||||
Cmd {
|
||||
name: $name,
|
||||
wire: cmd!(@wire $($wire)?),
|
||||
group: Group::$group,
|
||||
usage: $usage,
|
||||
summary: $summary,
|
||||
details: $details,
|
||||
examples: &[$($ex),*],
|
||||
params: $params,
|
||||
mcp: $mcp,
|
||||
local: $local,
|
||||
}
|
||||
};
|
||||
(@wire) => { None };
|
||||
(@wire $w:expr) => { Some($w) };
|
||||
}
|
||||
|
||||
pub const COMMANDS: &[Cmd] = &[
|
||||
// Session
|
||||
cmd!("launch", Session, "launch [--world NAME] [--seed N] [--creative] [--version TAG] [--game PATH] [--wait] [GAME ARGS...]",
|
||||
"start Horizon with the control socket and wait until it answers",
|
||||
"Finds the game in this order: --game PATH; a horizon-client next to horizon-ctl (a build folder, a release folder or a launcher install); the newest version installed by the Horizon launcher (--version TAG picks one). Launcher installs run with their prepared resources and keep saves in the game home ($HORIZON_HOME, else ~/.local/share/horizon). --wait also waits until the player has spawned and chunks are loaded. Other arguments go to the game (horizon-client --help lists them). The game's output goes to a .log file beside the socket.",
|
||||
["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)")],
|
||||
mcp: true, local: true),
|
||||
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),
|
||||
cmd!("logs", Session, "logs [--lines N] [--crash]", "the end of the game's log, or its newest crash report",
|
||||
"Reads the game home ($HORIZON_HOME, else ~/.local/share/horizon): logs/latest.log (the last N lines, default 100) or, with --crash, the newest file in crash-reports. Read-only; works whether the game runs or not.",
|
||||
["horizon-ctl logs", "horizon-ctl logs --lines 300", "horizon-ctl logs --crash"],
|
||||
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"", ["horizon-ctl status"], &[], mcp: false, local: false),
|
||||
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
|
||||
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
|
||||
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
|
||||
&[p("world", Ty::Str, "world folder"), opt("seed", Ty::Int, "seed for a new world"), opt("mode", Ty::Enum(&["creative"]), "creative for a creative world")], mcp: true, local: false),
|
||||
cmd!("save", Session, "save", "save the world", "", ["horizon-ctl save"], &[], mcp: true, local: false),
|
||||
cmd!("quit", Session, "quit", "save and exit the game", "", ["horizon-ctl quit"], &[], mcp: true, local: false),
|
||||
cmd!("screenshot", Session, "screenshot PATH", "write the next rendered frame as a PNG",
|
||||
"Relative paths are taken from horizon-ctl's directory. Over MCP the image is returned directly.",
|
||||
["horizon-ctl screenshot view.png"], &[], mcp: true, local: false),
|
||||
// Observe
|
||||
cmd!("state", Observe, "state", "player, world, screen and crosshair target in one reply",
|
||||
"position, block_pos, eye, yaw, pitch, velocity, on_ground, health, food, saturation, xp, effects, gamemode, dimension, time, weather, the screen or title menu, the held item, what the crosshair points at and whether an action is running.",
|
||||
["horizon-ctl state"], &[], mcp: true, local: false),
|
||||
cmd!("looking-at", Observe, "looking-at", "the block or entity under the crosshair",
|
||||
"kind is block, entity or none; a block has pos, state, face, point and distance; an entity has id, type, health and distance.",
|
||||
["horizon-ctl looking-at"], &[], mcp: true, local: false),
|
||||
cmd!("entities", Observe, "entities [RADIUS] [TYPE]", "mobs, dropped items and experience orbs near the player, nearest first",
|
||||
"RADIUS defaults to 32. TYPE filters by entity type (pig, minecraft:item, ...). Mobs have an id for look-at entity and attack.",
|
||||
["horizon-ctl entities", "horizon-ctl entities 16 cow", "horizon-ctl entities 8 item"],
|
||||
&[opt("radius", Ty::Num, "search radius in blocks (default 32)"), opt("type", Ty::Str, "entity type filter")], mcp: true, local: false),
|
||||
cmd!("block", Observe, "block X Y Z", "the block state at a position", "",
|
||||
["horizon-ctl block 10 64 -3"], &BLOCK_XYZ, mcp: true, local: false),
|
||||
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
|
||||
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
|
||||
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
|
||||
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
|
||||
&[p("block", Ty::Str, "block id such as oak_log"), opt("radius", Ty::Int, "search radius (default 32)")], mcp: true, local: false),
|
||||
cmd!("inventory", Observe, "inventory", "hotbar, main inventory, armor, offhand, carried stack and item counts",
|
||||
"Slots carry the ids click-slot takes: 0-8 hotbar, 9-35 inventory, 500-503 armor (head to feet), 504 offhand. Stacks with components list them as SNBT.",
|
||||
["horizon-ctl inventory"], &[], mcp: true, local: false),
|
||||
cmd!("screen", Observe, "screen", "the open screen or title menu, its buttons and its slots",
|
||||
"buttons are what click-button takes; slots are what click-slot takes (crafting grid 100-103 in the inventory, 100-108 at a crafting table, result 200, containers from 300, stations from 600). On the title screen, menu has the mode, worlds and the create-world fields.",
|
||||
["horizon-ctl screen"], &[], mcp: true, local: false),
|
||||
cmd!("messages", Observe, "messages [N]", "recent chat and system messages, the title and the action bar",
|
||||
"N defaults to 20; chat is newest first.", ["horizon-ctl messages 5"], &[opt("n", Ty::Int, "how many chat lines (default 20)")], mcp: true, local: false),
|
||||
cmd!("menu", Observe, "menu", "the open menu's slots and carried stack, with station details", "",
|
||||
["horizon-ctl menu"], &[], mcp: false, local: false),
|
||||
// Move
|
||||
cmd!("move", Move, "move forward|back|left|right SECONDS [--sprint] [--sneak] [--jump]", "walk in a direction relative to the view for a time",
|
||||
"Replies when done with the start and end positions.",
|
||||
["horizon-ctl move forward 2", "horizon-ctl move forward 1.5 --sprint --jump"],
|
||||
&[p("direction", Ty::Enum(&["forward", "back", "left", "right"]), "direction relative to the view"), p("seconds", Ty::Num, "how long to walk"),
|
||||
flag("sprint", Ty::Bool, "sprint"), flag("sneak", Ty::Bool, "sneak"), flag("jump", Ty::Bool, "keep jumping")], mcp: true, local: false),
|
||||
cmd!("goto", Move, "goto X Y Z [--near R] [--sprint] [--direct] [--timeout S]", "walk to a position, finding a path through loaded terrain",
|
||||
"A* over standable blocks (steps up one block, drops up to three, avoids lava, swims when it must), then steering with jumps when stuck. Without a path it fails at once (--direct walks straight at the target anyway, falls included). --near R stops within R blocks (use 1.5-3 to reach a block you then break). Replies with arrived, position and distance, or an error when stuck or out of time (default 60 s).",
|
||||
["horizon-ctl goto 120 70 -40", "horizon-ctl goto 8 64 3 --near 2 --sprint"],
|
||||
&[XYZ[0], XYZ[1], XYZ[2], flag("near", Ty::Num, "arrive within this many blocks (default 0.6)"), flag("sprint", Ty::Bool, "sprint"), flag("direct", Ty::Bool, "walk straight when no path is found"), flag("timeout", Ty::Num, "give up after this many seconds (default 60)")], mcp: true, local: false),
|
||||
cmd!("jump", Move, "jump", "jump once and reply when landed", "", ["horizon-ctl jump"], &[], mcp: true, local: false),
|
||||
cmd!("look", Move, "look YAW PITCH", "set the view direction in degrees",
|
||||
"Yaw 0 faces south (+Z), 90 west (-X), 180 north, -90 east; pitch 90 looks straight down.",
|
||||
["horizon-ctl look 90 10"], &[p("yaw", Ty::Num, "yaw in degrees"), p("pitch", Ty::Num, "pitch in degrees (-90 up .. 90 down)")], mcp: true, local: false),
|
||||
cmd!("turn", Move, "turn DYAW DPITCH [--over S]", "rotate the view by an amount",
|
||||
"Without --over the view jumps. With --over S the turn is mouse motion fed over S seconds with ease-in-out, through the same path as a real mouse (sensitivity, inverted axes, cinematic camera), and the reply comes once it is done.",
|
||||
["horizon-ctl turn 45 0", "horizon-ctl turn -90 10 --over 0.5"],
|
||||
&[p("dyaw", Ty::Num, "yaw change in degrees"), p("dpitch", Ty::Num, "pitch change in degrees"), OVER], mcp: true, local: false),
|
||||
cmd!("mouse-move", Move, "mouse-move DX DY", "move the mouse: relative motion that turns the view like a player's mouse",
|
||||
"DX and DY are mouse units (pixels of relative motion) added to the frame's accumulated movement and turned by MouseHandler.turnPlayer: degrees = units x 0.15 x (sensitivity x 0.6 + 0.2)^3 x 8 (without the 8 while scoping), inverted axes and the cinematic camera included. Positive DX turns right, positive DY looks down. Works in the world with no screen open; replies with yaw and pitch after the frame that turned.",
|
||||
["horizon-ctl mouse-move 100 0", "horizon-ctl mouse-move -25 40"],
|
||||
&[p("dx", Ty::Num, "horizontal motion (positive turns right)"), p("dy", Ty::Num, "vertical motion (positive looks down)")], mcp: true, local: false),
|
||||
cmd!("look-at", Move, "look-at X Y Z | look-at block X Y Z [FACE] | look-at entity ID [--over S]", "turn to face a point, a block (a visible face of it) or an entity",
|
||||
"look-at block aims at a face the crosshair can reach and fails when none is visible. --over S turns there with mouse motion over S seconds (the shorter way round) instead of jumping.",
|
||||
["horizon-ctl look-at 10.5 65 3.5", "horizon-ctl look-at block 10 64 3 up", "horizon-ctl look-at entity 42 --over 0.4"],
|
||||
&[XYZ[0], XYZ[1], XYZ[2], OVER], mcp: true, local: false),
|
||||
cmd!("look-at-block", Move, "look-at-block X Y Z [FACE] [--over S]", "aim the crosshair at a block (same as look-at block)", "",
|
||||
["horizon-ctl look-at-block 10 64 3", "horizon-ctl look-at-block 10 64 3 up --over 0.3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to aim at"), OVER], mcp: true, local: false, wire: "look-at block"),
|
||||
cmd!("look-at-entity", Move, "look-at-entity ID [--over S]", "face an entity (same as look-at entity)", "",
|
||||
["horizon-ctl look-at-entity 42"], &[p("id", Ty::Int, "entity id from entities"), OVER], mcp: true, local: false, wire: "look-at entity"),
|
||||
cmd!("tp", Move, "tp X Y Z", "teleport the player", "", ["horizon-ctl tp 0 80 0"], &XYZ, mcp: true, local: false),
|
||||
// Act
|
||||
cmd!("break", Act, "break X Y Z [--timeout S]", "mine a block, holding the attack button until it breaks",
|
||||
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops and picked_up (inventory change).",
|
||||
["horizon-ctl break 10 64 3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("timeout", Ty::Num, "give up after this many seconds (default 30)")], mcp: true, local: false),
|
||||
cmd!("place", Act, "place X Y Z [--item ITEM]", "place the held item (or ITEM from the hotbar) at an empty position",
|
||||
"Clicks the face of a solid neighbour touching X Y Z. Replies with the block placed and the inventory change.",
|
||||
["horizon-ctl place 10 64 4 --item oak_planks"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("item", Ty::Str, "hotbar item to select first")], mcp: true, local: false),
|
||||
cmd!("interact", Act, "interact X Y Z [FACE]", "right-click a block: open a chest or crafting table, press a button, open a door",
|
||||
"Replies with the screen afterwards and whether the block changed.", ["horizon-ctl interact 5 64 2"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to click")], mcp: true, local: false),
|
||||
cmd!("use-item", Act, "use-item [--hold SECONDS]", "right-click with the held item: eat, drink, throw, draw a bow",
|
||||
"Hold for 1.7 s to eat.", ["horizon-ctl use-item --hold 1.7"], &[flag("hold", Ty::Num, "hold the button this long")], mcp: true, local: false),
|
||||
cmd!("attack", Act, "attack [ID]", "swing at what the crosshair targets (after facing entity ID)",
|
||||
"Replies with the target's health before and after.", ["horizon-ctl attack", "horizon-ctl attack 42"],
|
||||
&[opt("id", Ty::Int, "entity id to face first")], mcp: true, local: false),
|
||||
cmd!("select", Act, "select SLOT|ITEM", "select a hotbar slot 1-9, or the hotbar slot holding ITEM", "",
|
||||
["horizon-ctl select 3", "horizon-ctl select oak_planks"], &[p("slot", Ty::Str, "1-9 or an item id")], mcp: true, local: false),
|
||||
cmd!("drop", Act, "drop [all]", "drop one of the held item, or the whole stack", "", ["horizon-ctl drop", "horizon-ctl drop all"],
|
||||
&[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", "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",
|
||||
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
|
||||
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
|
||||
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
|
||||
// Screens
|
||||
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
|
||||
["horizon-ctl open-inventory"], &[], mcp: true, local: false),
|
||||
cmd!("click-slot", Screens, "click-slot SLOT [right] [shift]", "click a slot of the open inventory or menu",
|
||||
"SLOT ids as listed by screen: 0-8 hotbar, 9-35 inventory, 100+ crafting grid, 200 result, 300+ container, 500-503 armor, 504 offhand, 600+ station. A left click picks up or puts down the carried stack; right puts down one item; shift moves the stack across.",
|
||||
["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!("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!("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),
|
||||
cmd!("chat", Screens, "chat TEXT", "send a chat line (or a /command) as if typed", "", ["horizon-ctl chat hello"],
|
||||
&[p("text", Ty::Rest, "message")], mcp: true, local: false),
|
||||
cmd!("command", Screens, "command TEXT", "run a command (the leading / is optional)", "",
|
||||
["horizon-ctl command time set day", "horizon-ctl command /give @s oak_log 4"], &[p("command", Ty::Rest, "command line")], mcp: true, local: false),
|
||||
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
|
||||
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
|
||||
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
|
||||
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
|
||||
// World
|
||||
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
|
||||
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
|
||||
cmd!("time", World, "time TICKS", "set the time of day (0 sunrise, 6000 noon, 13000 night)", "", ["horizon-ctl time 6000"],
|
||||
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
|
||||
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
|
||||
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)", "", ["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",
|
||||
"Lines are split like a shell (quotes keep spaces); # starts a comment; sleep SECONDS waits. Stops at the first error unless --keep-going. horizon-ctl - is the older form: read stdin and keep going.",
|
||||
["horizon-ctl script play.txt", "printf 'state\\ngoto 0 64 0\\n' | horizon-ctl script"], &[], mcp: false, local: true),
|
||||
cmd!("sleep", Scripting, "sleep SECONDS", "wait (in scripts)", "", ["sleep 0.5"], &[], mcp: false, local: true),
|
||||
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
];
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static Cmd> {
|
||||
COMMANDS.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// `horizon-ctl help`.
|
||||
pub fn overview() -> String {
|
||||
let mut out = String::from(
|
||||
"horizon-ctl: play and script a running Horizon from the command line.\n\n\
|
||||
usage: horizon-ctl [--socket PATH] [--timeout SECONDS] [--pretty] COMMAND [ARGS...]\n\
|
||||
\x20 horizon-ctl COMMAND --help (or: horizon-ctl help COMMAND)\n",
|
||||
);
|
||||
for group in Group::ALL {
|
||||
out.push_str(&format!("\n{}:\n", group.title()));
|
||||
for c in COMMANDS.iter().filter(|c| c.group == group) {
|
||||
out.push_str(&format!(" {:<16} {}\n", c.name, c.summary));
|
||||
}
|
||||
}
|
||||
out.push_str(
|
||||
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
|
||||
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
|
||||
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
|
||||
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
|
||||
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
|
||||
uses that transport).\n\n\
|
||||
First steps:\n\
|
||||
\x20 horizon-ctl launch --world Test --seed 1 --wait\n\
|
||||
\x20 horizon-ctl state\n\
|
||||
\x20 horizon-ctl find-block oak_log\n\
|
||||
\x20 horizon-ctl goto X Y Z --near 2 && horizon-ctl break X Y Z\n\
|
||||
\x20 horizon-ctl inventory\n",
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// `horizon-ctl COMMAND --help`.
|
||||
pub fn detail(c: &Cmd) -> String {
|
||||
let mut out = format!(
|
||||
"usage: horizon-ctl {}\n\n{}.\n",
|
||||
c.usage,
|
||||
capitalize(c.summary)
|
||||
);
|
||||
if !c.details.is_empty() {
|
||||
out.push('\n');
|
||||
out.push_str(&wrap(c.details, 78));
|
||||
}
|
||||
if !c.examples.is_empty() {
|
||||
out.push_str("\nExamples:\n");
|
||||
for e in c.examples {
|
||||
out.push_str(&format!(" {e}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
chars
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().chain(chars).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn wrap(text: &str, width: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if !line.is_empty() && line.len() + 1 + word.len() > width {
|
||||
out.push_str(&line);
|
||||
out.push('\n');
|
||||
line.clear();
|
||||
}
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
}
|
||||
if !line.is_empty() {
|
||||
out.push_str(&line);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The MCP tool name of a command (`look-at-block` gives `look_at_block`).
|
||||
pub fn tool_name(c: &Cmd) -> String {
|
||||
c.name.replace('-', "_")
|
||||
}
|
||||
|
||||
/// The JSON schema of a command's arguments.
|
||||
pub fn schema(c: &Cmd) -> Value {
|
||||
let mut props = Map::new();
|
||||
let mut required = Vec::new();
|
||||
for p in c.params {
|
||||
let mut s = match p.ty {
|
||||
Ty::Int => json!({ "type": "integer" }),
|
||||
Ty::Num => json!({ "type": "number" }),
|
||||
Ty::Str | Ty::Rest => json!({ "type": "string" }),
|
||||
Ty::Bool => json!({ "type": "boolean" }),
|
||||
Ty::Enum(values) => json!({ "type": "string", "enum": values }),
|
||||
};
|
||||
s["description"] = json!(p.help);
|
||||
props.insert(p.name.to_owned(), s);
|
||||
if p.required {
|
||||
required.push(p.name);
|
||||
}
|
||||
}
|
||||
json!({ "type": "object", "properties": props, "required": required })
|
||||
}
|
||||
|
||||
/// The command line for an MCP call's arguments: positional values in order,
|
||||
/// then `--flag value` options.
|
||||
pub fn line_for(c: &Cmd, args: &Value) -> Result<Vec<String>, String> {
|
||||
let mut words: Vec<String> = c
|
||||
.wire
|
||||
.unwrap_or(c.name)
|
||||
.split(' ')
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut flags = Vec::new();
|
||||
let mut gap: Option<&str> = None;
|
||||
let mut rest: Option<String> = None;
|
||||
for p in c.params {
|
||||
let value = args.get(p.name).filter(|v| !v.is_null());
|
||||
let text = match value {
|
||||
None => {
|
||||
if p.required {
|
||||
return Err(format!("{} is required", p.name));
|
||||
}
|
||||
if !p.flag {
|
||||
gap.get_or_insert(p.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Value::Bool(b)) => {
|
||||
if p.ty != Ty::Bool {
|
||||
return Err(format!("{} must be a {}", p.name, ty_name(p.ty)));
|
||||
}
|
||||
if *b {
|
||||
if p.flag {
|
||||
flags.push(format!("--{}", p.name));
|
||||
} else {
|
||||
words.push(p.name.to_owned());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(other) => {
|
||||
return Err(format!(
|
||||
"{} must be a {}, not {other}",
|
||||
p.name,
|
||||
ty_name(p.ty)
|
||||
));
|
||||
}
|
||||
};
|
||||
match p.ty {
|
||||
Ty::Int if text.parse::<i64>().is_err() => {
|
||||
return Err(format!("{} must be an integer, not {text}", p.name));
|
||||
}
|
||||
Ty::Num if text.parse::<f64>().is_err() => {
|
||||
return Err(format!("{} must be a number, not {text}", p.name));
|
||||
}
|
||||
Ty::Enum(values) if !values.contains(&text.as_str()) => {
|
||||
return Err(format!("{} must be one of {values:?}, not {text}", p.name));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if p.flag {
|
||||
flags.push(format!("--{}", p.name));
|
||||
flags.push(text);
|
||||
} else {
|
||||
if let Some(missing) = gap {
|
||||
return Err(format!("{} needs {missing} to be given too", p.name));
|
||||
}
|
||||
if p.ty == Ty::Rest {
|
||||
rest = Some(text);
|
||||
} else if text.contains(char::is_whitespace) || text.is_empty() {
|
||||
return Err(format!("{} must be a single word", p.name));
|
||||
} else {
|
||||
words.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Options go before free text, which runs to the end of the line.
|
||||
words.extend(flags);
|
||||
words.extend(rest);
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
fn ty_name(ty: Ty) -> &'static str {
|
||||
match ty {
|
||||
Ty::Int => "integer",
|
||||
Ty::Num => "number",
|
||||
Ty::Bool => "boolean",
|
||||
_ => "string",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_are_unique_and_documented() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for c in COMMANDS {
|
||||
assert!(seen.insert(c.name), "duplicate command {}", c.name);
|
||||
assert!(
|
||||
c.usage.starts_with(c.name.split('-').next().unwrap()),
|
||||
"{}",
|
||||
c.name
|
||||
);
|
||||
assert!(
|
||||
!c.summary.is_empty() && !c.examples.is_empty(),
|
||||
"{}",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_arguments_become_command_lines() {
|
||||
let goto = find("goto").unwrap();
|
||||
let words = line_for(
|
||||
goto,
|
||||
&json!({ "x": 1.5, "y": 64, "z": -3, "near": 2, "sprint": true }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
words,
|
||||
["goto", "1.5", "64", "-3", "--near", "2", "--sprint"]
|
||||
);
|
||||
assert!(
|
||||
line_for(goto, &json!({ "x": 1, "y": 2 }))
|
||||
.unwrap_err()
|
||||
.contains("z is required")
|
||||
);
|
||||
assert!(line_for(goto, &json!({ "x": "a", "y": 2, "z": 3 })).is_err());
|
||||
let block = find("look-at-block").unwrap();
|
||||
assert_eq!(
|
||||
line_for(block, &json!({ "x": 1, "y": 2, "z": 3, "face": "up" })).unwrap(),
|
||||
["look-at", "block", "1", "2", "3", "up"]
|
||||
);
|
||||
assert!(
|
||||
line_for(
|
||||
block,
|
||||
&json!({ "x": 1, "y": 2, "z": 3, "face": "sideways" })
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let turn = find("turn").unwrap();
|
||||
assert_eq!(
|
||||
line_for(turn, &json!({ "dyaw": -90, "dpitch": 10.5, "over": 0.5 })).unwrap(),
|
||||
["turn", "-90", "10.5", "--over", "0.5"]
|
||||
);
|
||||
let mouse = find("mouse-move").unwrap();
|
||||
assert_eq!(tool_name(mouse), "mouse_move");
|
||||
assert_eq!(
|
||||
line_for(mouse, &json!({ "dx": -25, "dy": 40 })).unwrap(),
|
||||
["mouse-move", "-25", "40"]
|
||||
);
|
||||
assert_eq!(
|
||||
line_for(block, &json!({ "x": 1, "y": 2, "z": 3, "over": 0.3 })).unwrap(),
|
||||
["look-at", "block", "1", "2", "3", "--over", "0.3"]
|
||||
);
|
||||
let button = find("click-button").unwrap();
|
||||
assert_eq!(
|
||||
line_for(button, &json!({ "label": "Create New World" })).unwrap(),
|
||||
["click-button", "Create New World"]
|
||||
);
|
||||
let slot = find("click-slot").unwrap();
|
||||
assert_eq!(
|
||||
line_for(slot, &json!({ "slot": 200, "shift": true })).unwrap(),
|
||||
["click-slot", "200", "shift"]
|
||||
);
|
||||
let open = find("open").unwrap();
|
||||
assert!(
|
||||
line_for(open, &json!({ "world": "A", "mode": "creative" }))
|
||||
.unwrap_err()
|
||||
.contains("seed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_list_required_parameters() {
|
||||
let s = schema(find("break").unwrap());
|
||||
assert_eq!(s["required"], json!(["x", "y", "z"]));
|
||||
assert_eq!(s["properties"]["timeout"]["type"], "number");
|
||||
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_every_command() {
|
||||
let text = overview();
|
||||
for c in COMMANDS {
|
||||
assert!(text.contains(c.name));
|
||||
assert!(detail(c).contains("usage: horizon-ctl"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! `horizon-ctl launch`: find a Horizon to start and start it with a control
|
||||
//! socket. Mirrors where the launcher puts things (launcher/src/backend.rs):
|
||||
//! versions under `<launcher home>/versions/<tag>`, each with its prepared
|
||||
//! resources in `.local/horizon-client/<minecraft version>` and run from the
|
||||
//! game home, where saves and options.txt live.
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::logging::{data_dir, game_home};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CLIENT: &str = "horizon-client.exe";
|
||||
#[cfg(not(windows))]
|
||||
const CLIENT: &str = "horizon-client";
|
||||
|
||||
pub fn launcher_home() -> PathBuf {
|
||||
match std::env::var_os("HORIZON_LAUNCHER_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon-launcher"),
|
||||
}
|
||||
}
|
||||
/// `v2026.10.13.1` gives [2026, 10, 13, 1]; a pre-release suffix is ignored.
|
||||
pub fn version_parts(tag: &str) -> Vec<u64> {
|
||||
tag.trim_start_matches('v')
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split('.')
|
||||
.map(|p| p.parse().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Installed launcher versions, newest first.
|
||||
pub fn installed(versions: &Path) -> Vec<String> {
|
||||
let mut tags: Vec<String> = std::fs::read_dir(versions)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().join(".installed").exists() && e.path().join(CLIENT).exists())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
tags.sort_by_key(|t| std::cmp::Reverse(version_parts(t)));
|
||||
tags
|
||||
}
|
||||
|
||||
/// A folder's prepared resources, as the launcher prepares them.
|
||||
pub fn prepared_resources(dir: &Path) -> Option<PathBuf> {
|
||||
let version = std::fs::read_to_string(dir.join("research-lock.json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
|
||||
.and_then(|v| v["minecraft"]["version"].as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "26.3".into());
|
||||
let resources = dir.join(".local/horizon-client").join(version);
|
||||
resources
|
||||
.join("horizon-resources.json")
|
||||
.exists()
|
||||
.then_some(resources)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Install {
|
||||
pub game: PathBuf,
|
||||
/// Passed as --resources unless the caller gave their own.
|
||||
pub resources: Option<PathBuf>,
|
||||
/// The working directory (the game home for prepared installs).
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub source: &'static str,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Which game to start: an explicit binary, the one beside horizon-ctl, or a
|
||||
/// launcher install.
|
||||
pub fn find(
|
||||
game: Option<&Path>,
|
||||
version: Option<&str>,
|
||||
beside: Option<&Path>,
|
||||
launcher: &Path,
|
||||
) -> Result<Install, String> {
|
||||
let prepared = |dir: &Path, source, version| {
|
||||
let resources = prepared_resources(dir);
|
||||
Install {
|
||||
game: dir.join(CLIENT),
|
||||
cwd: resources.as_ref().map(|_| game_home()),
|
||||
resources,
|
||||
source,
|
||||
version,
|
||||
}
|
||||
};
|
||||
if let Some(game) = game {
|
||||
if !game.is_file() {
|
||||
return Err(format!("{} is not a file", game.display()));
|
||||
}
|
||||
let dir = game.parent().unwrap_or(Path::new("."));
|
||||
let mut install = prepared(dir, "--game", None);
|
||||
install.game = game.to_owned();
|
||||
return Ok(install);
|
||||
}
|
||||
let versions = launcher.join("versions");
|
||||
if version.is_none()
|
||||
&& let Some(dir) = beside
|
||||
&& dir.join(CLIENT).is_file()
|
||||
{
|
||||
return Ok(prepared(dir, "beside horizon-ctl", None));
|
||||
}
|
||||
let tags = installed(&versions);
|
||||
let tag = match version {
|
||||
Some(v) => {
|
||||
let want = if v.starts_with('v') { v.to_owned() } else { format!("v{v}") };
|
||||
tags.iter()
|
||||
.find(|t| **t == want || **t == v)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("version {v} is not installed in {} (installed: {tags:?})", versions.display()))?
|
||||
}
|
||||
None => tags.first().cloned().ok_or_else(|| {
|
||||
format!(
|
||||
"no Horizon found: no horizon-client beside horizon-ctl and no version installed by the launcher in {}. Install one with the launcher (horizon-launcher install latest) or pass --game PATH.",
|
||||
versions.display()
|
||||
)
|
||||
})?,
|
||||
};
|
||||
let dir = versions.join(&tag);
|
||||
let install = prepared(&dir, "launcher", Some(tag.clone()));
|
||||
if install.resources.is_none() {
|
||||
return Err(format!(
|
||||
"{tag} is installed but not prepared; prepare it in the launcher first (horizon-launcher prepare {tag})"
|
||||
));
|
||||
}
|
||||
Ok(install)
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub game: Option<PathBuf>,
|
||||
pub version: Option<String>,
|
||||
pub wait: bool,
|
||||
pub game_args: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse(args: &[String]) -> Result<Options, String> {
|
||||
let mut o = Options {
|
||||
game: None,
|
||||
version: None,
|
||||
wait: false,
|
||||
game_args: Vec::new(),
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let value = |i: usize| {
|
||||
args.get(i + 1)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("{} needs a value", args[i]))
|
||||
};
|
||||
match args[i].as_str() {
|
||||
"--game" => {
|
||||
o.game = Some(value(i)?.into());
|
||||
i += 1;
|
||||
}
|
||||
"--version" => {
|
||||
o.version = Some(value(i)?);
|
||||
i += 1;
|
||||
}
|
||||
"--wait" | "--wait-ready" => o.wait = true,
|
||||
other => o.game_args.push(other.to_owned()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// Start the game and wait for its control endpoint; Ok(reply) or Err(reply).
|
||||
pub fn launch(socket: &Path, args: &[String]) -> Result<Value, Value> {
|
||||
let o = parse(args).map_err(|e| json!({ "error": e, "kind": "usage" }))?;
|
||||
if horizon_client::control::connect(socket).is_ok() {
|
||||
return Err(json!({
|
||||
"error": format!("a game is already listening on {}; quit it first or pass another --socket", socket.display()),
|
||||
"kind": "connection",
|
||||
}));
|
||||
}
|
||||
let exe = std::env::current_exe().ok();
|
||||
let beside = exe.as_deref().and_then(Path::parent);
|
||||
let install = find(
|
||||
o.game.as_deref(),
|
||||
o.version.as_deref(),
|
||||
beside,
|
||||
&launcher_home(),
|
||||
)
|
||||
.map_err(|e| json!({ "error": e, "kind": "launch" }))?;
|
||||
let log = socket.with_extension("log");
|
||||
let file = std::fs::File::create(&log)
|
||||
.map_err(|e| json!({ "error": format!("{}: {e}", log.display()), "kind": "launch" }))?;
|
||||
let mut cmd = Command::new(&install.game);
|
||||
let gave_resources = o.game_args.iter().any(|a| a == "--resources");
|
||||
if let Some(resources) = install.resources.as_ref().filter(|_| !gave_resources) {
|
||||
cmd.arg("--resources").arg(resources);
|
||||
}
|
||||
if let Some(cwd) = &install.cwd {
|
||||
let _ = std::fs::create_dir_all(cwd);
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let stderr = file
|
||||
.try_clone()
|
||||
.map_err(|e| json!({ "error": e.to_string(), "kind": "launch" }))?;
|
||||
let mut child = cmd
|
||||
.args(&o.game_args)
|
||||
.arg("--control-socket")
|
||||
.arg(socket)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(file)
|
||||
.stderr(stderr)
|
||||
.spawn()
|
||||
.map_err(
|
||||
|e| json!({ "error": format!("{}: {e}", install.game.display()), "kind": "launch" }),
|
||||
)?;
|
||||
let deadline = Instant::now() + Duration::from_secs(300);
|
||||
let info = json!({
|
||||
"pid": child.id(),
|
||||
"socket": socket,
|
||||
"log": log,
|
||||
"game": install.game,
|
||||
"source": install.source,
|
||||
"version": install.version,
|
||||
"resources": install.resources,
|
||||
"home": install.cwd,
|
||||
});
|
||||
while Instant::now() < deadline {
|
||||
if horizon_client::control::connect(socket).is_ok() {
|
||||
let mut reply = json!({ "ok": true });
|
||||
reply
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(info.as_object().unwrap().clone());
|
||||
return Ok(reply);
|
||||
}
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
let tail = std::fs::read_to_string(&log).unwrap_or_default();
|
||||
let tail: Vec<&str> = tail.lines().rev().take(15).collect();
|
||||
return Err(json!({
|
||||
"error": format!("the game exited ({status}) before opening the control socket"),
|
||||
"kind": "launch",
|
||||
"log_tail": tail.into_iter().rev().collect::<Vec<_>>(),
|
||||
"log": log,
|
||||
}));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(
|
||||
json!({ "error": "control socket did not open within 300 seconds", "kind": "launch", "log": log }),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-ctl-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
fn fake_version(versions: &Path, tag: &str, prepared: bool) -> PathBuf {
|
||||
let dir = versions.join(tag);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(CLIENT), "").unwrap();
|
||||
std::fs::write(dir.join(".installed"), "").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("research-lock.json"),
|
||||
r#"{"minecraft":{"version":"26.3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
if prepared {
|
||||
let res = dir.join(".local/horizon-client/26.3");
|
||||
std::fs::create_dir_all(&res).unwrap();
|
||||
std::fs::write(res.join("horizon-resources.json"), "{}").unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_sort_newest_first() {
|
||||
let home = temp("sort");
|
||||
let versions = home.join("versions");
|
||||
for tag in ["v2026.9.30", "v2026.10.13", "v2026.10.13.1", "v2026.10.2"] {
|
||||
fake_version(&versions, tag, true);
|
||||
}
|
||||
assert_eq!(
|
||||
installed(&versions),
|
||||
["v2026.10.13.1", "v2026.10.13", "v2026.10.2", "v2026.9.30"]
|
||||
);
|
||||
let install = find(None, None, None, &home).unwrap();
|
||||
assert_eq!(install.version.as_deref(), Some("v2026.10.13.1"));
|
||||
assert!(
|
||||
install
|
||||
.resources
|
||||
.unwrap()
|
||||
.ends_with(".local/horizon-client/26.3")
|
||||
);
|
||||
assert_eq!(install.cwd, Some(game_home()));
|
||||
let picked = find(None, Some("2026.10.2"), None, &home).unwrap();
|
||||
assert_eq!(picked.version.as_deref(), Some("v2026.10.2"));
|
||||
assert!(find(None, Some("v1.0"), None, &home).is_err());
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_beside_ctl_wins_and_dev_builds_keep_the_directory() {
|
||||
let home = temp("beside");
|
||||
fake_version(&home.join("versions"), "v2026.10.13", true);
|
||||
let build = temp("build");
|
||||
std::fs::write(build.join(CLIENT), "").unwrap();
|
||||
let install = find(None, None, Some(&build), &home).unwrap();
|
||||
assert_eq!(install.source, "beside horizon-ctl");
|
||||
assert_eq!((install.resources, install.cwd), (None, None));
|
||||
// --version always means a launcher install.
|
||||
assert_eq!(
|
||||
find(None, Some("v2026.10.13"), Some(&build), &home)
|
||||
.unwrap()
|
||||
.source,
|
||||
"launcher"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
let _ = std::fs::remove_dir_all(build);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unprepared_or_missing_installs_explain_themselves() {
|
||||
let home = temp("unprepared");
|
||||
assert!(
|
||||
find(None, None, None, &home)
|
||||
.unwrap_err()
|
||||
.contains("no Horizon found")
|
||||
);
|
||||
fake_version(&home.join("versions"), "v2026.10.13", false);
|
||||
assert!(
|
||||
find(None, None, None, &home)
|
||||
.unwrap_err()
|
||||
.contains("not prepared")
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_options_pass_the_rest_to_the_game() {
|
||||
let args: Vec<String> = ["--world", "A", "--version", "v1", "--wait", "--seed", "3"]
|
||||
.map(String::from)
|
||||
.to_vec();
|
||||
let o = parse(&args).unwrap();
|
||||
assert_eq!(o.version.as_deref(), Some("v1"));
|
||||
assert!(o.wait);
|
||||
assert_eq!(o.game_args, ["--world", "A", "--seed", "3"]);
|
||||
assert!(parse(&["--game".to_owned()]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
//! horizon-ctl: play and script a running Horizon client through its control
|
||||
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
|
||||
#![forbid(unsafe_code)]
|
||||
mod commands;
|
||||
mod launch;
|
||||
mod mcp;
|
||||
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::ExitCode,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::control::Stream;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Exit statuses.
|
||||
const GAME_ERROR: u8 = 1;
|
||||
const USAGE: u8 = 2;
|
||||
const UNREACHABLE: u8 = 3;
|
||||
|
||||
/// A failure, with the exit status it maps to.
|
||||
#[derive(Debug)]
|
||||
pub struct Failure {
|
||||
pub reply: Value,
|
||||
pub status: u8,
|
||||
}
|
||||
impl Failure {
|
||||
fn usage(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "usage" }),
|
||||
status: USAGE,
|
||||
}
|
||||
}
|
||||
fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "connection" }),
|
||||
status: UNREACHABLE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
writer: Stream,
|
||||
reader: BufReader<Stream>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn connect(path: &Path, timeout: Option<Duration>) -> Result<Self, Failure> {
|
||||
let stream = horizon_client::control::connect(path).map_err(|e| {
|
||||
Failure::unreachable(format!(
|
||||
"cannot connect to {} ({e}); is the game running with --control? Start one with: horizon-ctl launch --world NAME",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
stream
|
||||
.set_read_timeout(timeout)
|
||||
.map_err(|e| Failure::unreachable(e.to_string()))?;
|
||||
let reader = BufReader::new(
|
||||
stream
|
||||
.try_clone()
|
||||
.map_err(|e| Failure::unreachable(e.to_string()))?,
|
||||
);
|
||||
Ok(Self {
|
||||
writer: stream,
|
||||
reader,
|
||||
})
|
||||
}
|
||||
/// Send one line and read its JSON reply.
|
||||
fn send(&mut self, line: &str) -> Result<Value, Failure> {
|
||||
writeln!(self.writer, "{line}").map_err(|e| Failure::unreachable(e.to_string()))?;
|
||||
let mut reply = String::new();
|
||||
let n = self.reader.read_line(&mut reply).map_err(|e| {
|
||||
Failure::unreachable(
|
||||
if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
) {
|
||||
"timed out waiting for the game's reply (--timeout)".to_owned()
|
||||
} else {
|
||||
e.to_string()
|
||||
},
|
||||
)
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Err(Failure::unreachable("the game closed the connection"));
|
||||
}
|
||||
serde_json::from_str(reply.trim_end()).map_err(|_| Failure {
|
||||
reply: json!({ "error": "the game sent a reply that is not JSON", "reply": reply.trim_end() }),
|
||||
status: GAME_ERROR,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Global options and the connection, opened on first use.
|
||||
pub struct Session {
|
||||
pub socket: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
client: Option<Client>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(socket: PathBuf, timeout: Option<Duration>) -> Self {
|
||||
Self {
|
||||
socket,
|
||||
timeout,
|
||||
client: None,
|
||||
}
|
||||
}
|
||||
fn client(&mut self) -> Result<&mut Client, Failure> {
|
||||
if self.client.is_none() {
|
||||
self.client = Some(Client::connect(&self.socket, self.timeout)?);
|
||||
}
|
||||
Ok(self.client.as_mut().unwrap())
|
||||
}
|
||||
/// Send a line to the game; a reply with "error" is a failure.
|
||||
pub fn game(&mut self, line: &str) -> Result<Value, Failure> {
|
||||
let result = self.client()?.send(line);
|
||||
if result.as_ref().is_err_and(|f| f.status == UNREACHABLE) {
|
||||
// Reconnect next time (the game may have restarted).
|
||||
self.client = None;
|
||||
}
|
||||
let reply = result?;
|
||||
if reply.get("error").is_some() {
|
||||
return Err(Failure {
|
||||
reply,
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// Run one command (CLI words); Ok(reply) or a failure.
|
||||
pub fn run(&mut self, words: &[String]) -> Result<Value, Failure> {
|
||||
let Some(name) = words.first().map(String::as_str) else {
|
||||
return Err(Failure::usage("no command; try horizon-ctl help"));
|
||||
};
|
||||
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
|
||||
return Err(Failure::usage(
|
||||
"use horizon-ctl COMMAND --help outside scripts",
|
||||
));
|
||||
}
|
||||
let number = |i: usize, what: &str| -> Result<f64, Failure> {
|
||||
words
|
||||
.get(i)
|
||||
.ok_or_else(|| Failure::usage(format!("missing {what}; usage: {}", usage(name))))?
|
||||
.parse::<f64>()
|
||||
.map_err(|_| {
|
||||
Failure::usage(format!("{what} must be a number; usage: {}", usage(name)))
|
||||
})
|
||||
};
|
||||
match name {
|
||||
"sleep" => {
|
||||
thread::sleep(Duration::from_secs_f64(number(1, "SECONDS")?.max(0.0)));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"launch" => {
|
||||
let socket = self.socket.clone();
|
||||
let wait = words.iter().any(|w| w == "--wait" || w == "--wait-ready");
|
||||
let reply = launch::launch(&socket, &words[1..]).map_err(|reply| Failure {
|
||||
status: if reply["kind"] == "usage" {
|
||||
USAGE
|
||||
} else {
|
||||
UNREACHABLE
|
||||
},
|
||||
reply,
|
||||
})?;
|
||||
if !wait {
|
||||
return Ok(reply);
|
||||
}
|
||||
let mut ready = self.wait_ready(300.0)?;
|
||||
ready["launch"] = reply;
|
||||
Ok(ready)
|
||||
}
|
||||
"hold" => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
|
||||
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
};
|
||||
self.game(&format!("key {key} down"))?;
|
||||
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
|
||||
self.game(&format!("key {key} up"))
|
||||
}
|
||||
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
|
||||
"wait-ready" => {
|
||||
let limit = if words.len() > 1 {
|
||||
number(1, "SECONDS")?
|
||||
} else {
|
||||
120.0
|
||||
};
|
||||
self.wait_ready(limit)
|
||||
}
|
||||
"help" if words.get(1).is_some_and(|w| w == "--game") => self.game("help"),
|
||||
_ => {
|
||||
let mut words = words.to_vec();
|
||||
if let Some(cmd) = commands::find(name) {
|
||||
if cmd.local {
|
||||
return Err(Failure::usage(format!(
|
||||
"{name} runs only as horizon-ctl {name}, not inside a script"
|
||||
)));
|
||||
}
|
||||
if let Some(wire) = cmd.wire {
|
||||
words.splice(0..1, wire.split(' ').map(str::to_owned));
|
||||
}
|
||||
}
|
||||
absolute_paths(&mut words);
|
||||
self.game(&words.join(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_ready(&mut self, limit: f64) -> Result<Value, Failure> {
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(limit.max(0.0));
|
||||
loop {
|
||||
let status = self.game("status")?;
|
||||
if !status["player"].is_null() && status["pending_chunks"] == 0 {
|
||||
return Ok(status);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err(Failure {
|
||||
reply: json!({ "error": format!("not ready after {limit} seconds"), "status": status }),
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `logs [--lines N] [--crash]` for the game in `home`: the reply's `text` is the log's last
|
||||
/// lines or the report.
|
||||
fn logs(home: &Path, words: &[String]) -> Result<Value, Failure> {
|
||||
use horizon_client::logging;
|
||||
let mut lines = 100;
|
||||
let mut crash = false;
|
||||
let mut it = words.iter();
|
||||
while let Some(word) = it.next() {
|
||||
match word.as_str() {
|
||||
"--crash" => crash = true,
|
||||
"--lines" => {
|
||||
lines = it
|
||||
.next()
|
||||
.and_then(|n| n.parse::<usize>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.ok_or_else(|| Failure::usage("--lines takes a number above 0"))?;
|
||||
}
|
||||
other => {
|
||||
return Err(Failure::usage(format!(
|
||||
"unknown argument {other}; usage: {}",
|
||||
usage("logs")
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let missing = |what: String| Failure {
|
||||
reply: json!({ "error": what }),
|
||||
status: GAME_ERROR,
|
||||
};
|
||||
if crash {
|
||||
let path = logging::newest_crash_report(home).ok_or_else(|| {
|
||||
missing(format!(
|
||||
"no crash report in {}",
|
||||
logging::crash_reports_dir(home).display()
|
||||
))
|
||||
})?;
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|e| missing(format!("{}: {e}", path.display())))?;
|
||||
return Ok(json!({ "path": path, "text": text }));
|
||||
}
|
||||
let path = logging::logs_dir(home).join("latest.log");
|
||||
let tail =
|
||||
logging::tail(&path, lines).map_err(|e| missing(format!("{}: {e}", path.display())))?;
|
||||
Ok(json!({ "path": path, "text": tail.join("\n") + "\n" }))
|
||||
}
|
||||
|
||||
fn usage(name: &str) -> String {
|
||||
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
|
||||
}
|
||||
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a script line like a shell: quotes keep spaces, backslash escapes.
|
||||
pub fn split_line(line: &str) -> Result<Vec<String>, String> {
|
||||
let mut words = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut in_word = false;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = line.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match (quote, c) {
|
||||
(Some(q), c) if c == q => quote = None,
|
||||
(Some('"'), '\\') => word.push(chars.next().unwrap_or('\\')),
|
||||
(Some(_), c) => word.push(c),
|
||||
(None, '"' | '\'') => {
|
||||
quote = Some(c);
|
||||
in_word = true;
|
||||
}
|
||||
(None, '\\') => {
|
||||
word.push(chars.next().unwrap_or('\\'));
|
||||
in_word = true;
|
||||
}
|
||||
(None, '#') if !in_word => break,
|
||||
(None, c) if c.is_whitespace() => {
|
||||
if in_word {
|
||||
words.push(std::mem::take(&mut word));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
(None, c) => {
|
||||
word.push(c);
|
||||
in_word = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if quote.is_some() {
|
||||
return Err("unterminated quote".into());
|
||||
}
|
||||
if in_word {
|
||||
words.push(word);
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
fn print(value: &Value, pretty: bool) {
|
||||
let text = if pretty {
|
||||
serde_json::to_string_pretty(value).unwrap_or_default()
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
println!("{text}");
|
||||
}
|
||||
|
||||
/// Run a script; the exit status of the first failure (or the last one with --keep-going).
|
||||
fn script(session: &mut Session, reader: impl BufRead, keep_going: bool, pretty: bool) -> u8 {
|
||||
let mut status = 0;
|
||||
for (n, line) in reader.lines().enumerate() {
|
||||
let Ok(line) = line else { break };
|
||||
let words = match split_line(&line) {
|
||||
Ok(w) if w.is_empty() => continue,
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print(
|
||||
&json!({ "error": e, "kind": "usage", "line": n + 1 }),
|
||||
pretty,
|
||||
);
|
||||
status = USAGE;
|
||||
if keep_going {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
match session.run(&words) {
|
||||
Ok(reply) => print(&reply, pretty),
|
||||
Err(mut f) => {
|
||||
f.reply["line"] = json!(n + 1);
|
||||
print(&f.reply, pretty);
|
||||
status = f.status;
|
||||
if !keep_going || f.status == UNREACHABLE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut socket = horizon_client::control::default_path();
|
||||
let mut timeout = None;
|
||||
let mut pretty = false;
|
||||
// Global options before the command.
|
||||
loop {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("--socket") if args.len() >= 2 => {
|
||||
socket = PathBuf::from(args.remove(1));
|
||||
args.remove(0);
|
||||
}
|
||||
Some("--timeout") if args.len() >= 2 => {
|
||||
let Ok(s) = args[1].parse::<f64>() else {
|
||||
print(
|
||||
&json!({ "error": "--timeout takes seconds", "kind": "usage" }),
|
||||
false,
|
||||
);
|
||||
return ExitCode::from(USAGE);
|
||||
};
|
||||
timeout = Some(Duration::from_secs_f64(s.max(0.001)));
|
||||
args.drain(0..2);
|
||||
}
|
||||
Some("--pretty") => {
|
||||
pretty = true;
|
||||
args.remove(0);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let mut session = Session::new(socket, timeout);
|
||||
let name = args.first().cloned();
|
||||
let status = match name.as_deref() {
|
||||
None | Some("-h" | "--help") => {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
|
||||
None => {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some(c) => match commands::find(c) {
|
||||
Some(cmd) => {
|
||||
print!("{}", commands::detail(cmd));
|
||||
0
|
||||
}
|
||||
None => {
|
||||
print(
|
||||
&json!({ "error": format!("unknown command {c}; horizon-ctl help lists them, horizon-ctl help --game lists the game's own"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
},
|
||||
},
|
||||
Some(c) if args.len() == 2 && matches!(args[1].as_str(), "--help" | "-h") => {
|
||||
match commands::find(c) {
|
||||
Some(cmd) => {
|
||||
print!("{}", commands::detail(cmd));
|
||||
0
|
||||
}
|
||||
None => {
|
||||
print(
|
||||
&json!({ "error": format!("no help for {c}; horizon-ctl help --game lists the game's own commands"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("mcp") => match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("horizon-ctl mcp: {e}");
|
||||
UNREACHABLE
|
||||
}
|
||||
},
|
||||
// The original form: stdin, keep going after errors.
|
||||
Some("-") => script(&mut session, std::io::stdin().lock(), true, pretty),
|
||||
Some("script") => {
|
||||
let keep_going = args.iter().any(|a| a == "--keep-going");
|
||||
match args.iter().skip(1).find(|a| !a.starts_with("--")) {
|
||||
Some(file) if file != "-" => match std::fs::File::open(file) {
|
||||
Ok(f) => script(&mut session, BufReader::new(f), keep_going, pretty),
|
||||
Err(e) => {
|
||||
print(
|
||||
&json!({ "error": format!("{file}: {e}"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
},
|
||||
_ => script(&mut session, std::io::stdin().lock(), keep_going, pretty),
|
||||
}
|
||||
}
|
||||
// Text for people; scripts and MCP get the JSON reply.
|
||||
Some("logs") => match session.run(&args) {
|
||||
Ok(reply) => {
|
||||
print!("{}", reply["text"].as_str().unwrap_or_default());
|
||||
0
|
||||
}
|
||||
Err(f) => {
|
||||
print(&f.reply, pretty);
|
||||
f.status
|
||||
}
|
||||
},
|
||||
Some(_) => match session.run(&args) {
|
||||
Ok(reply) => {
|
||||
print(&reply, pretty);
|
||||
0
|
||||
}
|
||||
Err(f) => {
|
||||
print(&f.reply, pretty);
|
||||
f.status
|
||||
}
|
||||
},
|
||||
};
|
||||
ExitCode::from(status)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn logs_reads_the_end_of_latest_log_and_the_newest_crash_report() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-ctl-logs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
let words = |s: &str| -> Vec<String> { s.split_whitespace().map(String::from).collect() };
|
||||
let failure = logs(&home, &words("")).unwrap_err();
|
||||
assert!(
|
||||
failure.reply["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("latest.log")
|
||||
);
|
||||
std::fs::create_dir_all(home.join("logs")).unwrap();
|
||||
let text: String = (1..=150)
|
||||
.map(|i| format!("[10:00:00] [Render thread/INFO]: line {i}\n"))
|
||||
.collect();
|
||||
std::fs::write(home.join("logs/latest.log"), text).unwrap();
|
||||
let reply = logs(&home, &words("")).unwrap();
|
||||
let tail = reply["text"].as_str().unwrap();
|
||||
assert_eq!(tail.lines().count(), 100);
|
||||
assert!(tail.starts_with("[10:00:00] [Render thread/INFO]: line 51\n"));
|
||||
assert!(tail.ends_with("line 150\n"));
|
||||
let reply = logs(&home, &words("--lines 2")).unwrap();
|
||||
assert_eq!(
|
||||
reply["text"],
|
||||
"[10:00:00] [Render thread/INFO]: line 149\n[10:00:00] [Render thread/INFO]: line 150\n"
|
||||
);
|
||||
assert_eq!(logs(&home, &words("--lines 0")).unwrap_err().status, USAGE);
|
||||
assert_eq!(logs(&home, &words("--tail")).unwrap_err().status, USAGE);
|
||||
assert!(logs(&home, &words("--crash")).is_err());
|
||||
std::fs::create_dir_all(home.join("crash-reports")).unwrap();
|
||||
std::fs::write(
|
||||
home.join("crash-reports/crash-2026-10-13_10.00.00-client.txt"),
|
||||
"old",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
home.join("crash-reports/crash-2026-10-14_10.00.00-client.txt"),
|
||||
"new",
|
||||
)
|
||||
.unwrap();
|
||||
let reply = logs(&home, &words("--crash")).unwrap();
|
||||
assert_eq!(reply["text"], "new");
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_lines_split_like_a_shell() {
|
||||
assert_eq!(
|
||||
split_line("goto 1 2 3 --near 2").unwrap(),
|
||||
["goto", "1", "2", "3", "--near", "2"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_line("click-button \"Create New World\"").unwrap(),
|
||||
["click-button", "Create New World"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_line("type 'it''s' # comment").unwrap(),
|
||||
["type", "its"]
|
||||
);
|
||||
assert_eq!(split_line("chat a\\ b").unwrap(), ["chat", "a b"]);
|
||||
assert_eq!(split_line("chat issue#4").unwrap(), ["chat", "issue#4"]);
|
||||
assert!(split_line(" # only a comment").unwrap().is_empty());
|
||||
assert!(split_line("type \"open").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_games_fail_with_status_3() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
let f = s.run(&["state".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, UNREACHABLE);
|
||||
assert_eq!(f.reply["kind"], "connection");
|
||||
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_paths_become_absolute() {
|
||||
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
|
||||
absolute_paths(&mut words);
|
||||
assert!(Path::new(&words[1]).is_absolute());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! `horizon-ctl mcp`: the Model Context Protocol over stdin and stdout
|
||||
//! (JSON-RPC 2.0, one message per line, like the launcher's `mcp`), so AI
|
||||
//! agents can play Horizon. Each tool is a horizon-ctl command from the
|
||||
//! shared table, run through the same `Session` as the CLI.
|
||||
use crate::{Session, commands};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
const PROTOCOL: &str = "2025-06-18";
|
||||
|
||||
fn tools() -> Vec<Value> {
|
||||
commands::COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.mcp)
|
||||
.map(|c| {
|
||||
let mut description = format!("{}. Usage: horizon-ctl {}", capital(c.summary), c.usage);
|
||||
if !c.details.is_empty() {
|
||||
description.push_str(". ");
|
||||
description.push_str(c.details);
|
||||
}
|
||||
let mut schema = commands::schema(c);
|
||||
if c.name == "screenshot" {
|
||||
schema = json!({ "type": "object", "properties": {} });
|
||||
description = "Capture the game window and return it as a PNG image.".into();
|
||||
}
|
||||
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn capital(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
c.next()
|
||||
.map(|f| f.to_uppercase().chain(c).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648) for image content.
|
||||
pub fn base64(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = (u32::from(chunk[0]) << 16)
|
||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
if i <= chunk.len() {
|
||||
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// One tool call: (content blocks, is_error).
|
||||
fn call(&mut self, name: &str, args: &Value) -> (Vec<Value>, bool) {
|
||||
let text = |v: &Value| json!({ "type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default() });
|
||||
let Some(cmd) = commands::COMMANDS
|
||||
.iter()
|
||||
.find(|c| c.mcp && commands::tool_name(c) == name)
|
||||
else {
|
||||
return (
|
||||
vec![text(&json!({ "error": format!("unknown tool {name}") }))],
|
||||
true,
|
||||
);
|
||||
};
|
||||
if cmd.name == "screenshot" {
|
||||
let path = std::env::temp_dir().join(format!("horizon-ctl-{}.png", std::process::id()));
|
||||
let result = self
|
||||
.run(&["screenshot".to_owned(), path.display().to_string()])
|
||||
.and_then(|reply| {
|
||||
std::fs::read(&path)
|
||||
.map(|png| (reply, png))
|
||||
.map_err(|e| crate::Failure {
|
||||
reply: json!({ "error": format!("{}: {e}", path.display()) }),
|
||||
status: 1,
|
||||
})
|
||||
});
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return match result {
|
||||
Ok((reply, png)) => (
|
||||
vec![
|
||||
json!({ "type": "image", "data": base64(&png), "mimeType": "image/png" }),
|
||||
text(&reply),
|
||||
],
|
||||
false,
|
||||
),
|
||||
Err(f) => (vec![text(&f.reply)], true),
|
||||
};
|
||||
}
|
||||
if cmd.name == "logs" {
|
||||
let words = match commands::line_for(cmd, args) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return (vec![text(&json!({ "error": e, "kind": "usage" }))], true),
|
||||
};
|
||||
return match self.run(&words) {
|
||||
Ok(reply) => (
|
||||
vec![
|
||||
json!({ "type": "text", "text": format!("{}\n\n{}", reply["path"].as_str().unwrap_or_default(), reply["text"].as_str().unwrap_or_default()) }),
|
||||
],
|
||||
false,
|
||||
),
|
||||
Err(f) => (vec![text(&f.reply)], true),
|
||||
};
|
||||
}
|
||||
let mut words = match commands::line_for(cmd, args) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return (vec![text(&json!({ "error": e, "kind": "usage" }))], true),
|
||||
};
|
||||
if cmd.name == "launch" {
|
||||
// An agent wants a world it can play once the call returns.
|
||||
if args.get("wait").and_then(Value::as_bool) != Some(false)
|
||||
&& !words.contains(&"--wait".to_owned())
|
||||
{
|
||||
words.push("--wait".into());
|
||||
}
|
||||
words.push("--no-pause".into());
|
||||
}
|
||||
match self.run(&words) {
|
||||
Ok(reply) => (vec![text(&reply)], false),
|
||||
Err(f) => (vec![text(&f.reply)], true),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": env!("CARGO_PKG_VERSION") },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({ "tools": tools() })),
|
||||
"tools/call" => {
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or((-32602, "name is required".to_string()))?;
|
||||
if !tools().iter().any(|t| t["name"] == name) {
|
||||
return Err((-32602, format!("Unknown tool {name}")));
|
||||
}
|
||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
let (content, error) = self.call(name, &args);
|
||||
Ok(json!({ "content": content, "isError": error }))
|
||||
}
|
||||
_ => Err((-32601, format!("Method not found: {method}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdout = std::io::stdout();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Err(e) => Some(
|
||||
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
|
||||
),
|
||||
Ok(msg) => {
|
||||
let method = msg
|
||||
.get("method")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// Notifications (no id) get no reply.
|
||||
msg.get("id").cloned().map(|id| match session.handle(method, ¶ms) {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
writeln!(stdout, "{reply}")
|
||||
.and_then(|_| stdout.flush())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_have_schemas_and_unique_names() {
|
||||
let list = tools();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for t in &list {
|
||||
assert!(names.insert(t["name"].as_str().unwrap().to_owned()));
|
||||
assert_eq!(t["inputSchema"]["type"], "object");
|
||||
}
|
||||
for want in [
|
||||
"launch",
|
||||
"state",
|
||||
"goto",
|
||||
"break_block",
|
||||
"place",
|
||||
"click_slot",
|
||||
"click_button",
|
||||
"screenshot",
|
||||
"inventory",
|
||||
"logs",
|
||||
] {
|
||||
let want = if want == "break_block" { "break" } else { want };
|
||||
assert!(names.contains(want), "{want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_basics() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
|
||||
let init = s
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
|
||||
.unwrap();
|
||||
assert_eq!(init["protocolVersion"], "2025-03-26");
|
||||
assert!(s.handle("nope", &json!({})).is_err());
|
||||
let r = s
|
||||
.handle(
|
||||
"tools/call",
|
||||
&json!({ "name": "goto", "arguments": { "x": 1 } }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["isError"], true);
|
||||
assert!(
|
||||
r["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("y is required")
|
||||
);
|
||||
// No game: a connection error, reported as a tool error.
|
||||
let r = s
|
||||
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
|
||||
.unwrap();
|
||||
assert_eq!(r["isError"], true);
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,7 @@ pub fn draw_part(
|
||||
let color = pack_color(tint, 255, diffuse(normal, lights));
|
||||
for (v, position) in poly.iter().zip(pos) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: position.to_array(),
|
||||
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
|
||||
layer: 0,
|
||||
@@ -637,6 +638,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, 0.0))
|
||||
@@ -1587,6 +1589,7 @@ impl BeamPart {
|
||||
(at(b, self.end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
@@ -1656,6 +1659,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
|
||||
for &d in faces {
|
||||
for c in PORTAL_FACES[d as usize] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(Vec3::from(c)).to_array(),
|
||||
uv: [0.0; 2],
|
||||
layer,
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
use crate::Result;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs::File, io::BufReader, path::Path, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File},
|
||||
io::BufReader,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderShape {
|
||||
@@ -54,6 +60,8 @@ pub struct StateFacts {
|
||||
pub waterlogged: bool,
|
||||
/// `hasOffsetFunction`: the model is jittered per column.
|
||||
pub random_offset: bool,
|
||||
/// Whether the outline, not just the model, follows BlockState.getOffset.
|
||||
pub outline_offset: bool,
|
||||
/// `BlockState.isSolid()` (legacy solidity; fluid surfaces read it).
|
||||
pub legacy_solid: bool,
|
||||
/// `isFaceSturdy(.., SupportType.FULL)` by Direction ordinal.
|
||||
@@ -62,8 +70,8 @@ pub struct StateFacts {
|
||||
pub classes: Arc<[Arc<str>]>,
|
||||
/// `getShape` (the outline used for picking) as boxes in block space,
|
||||
/// without the random offset; and its edges as LevelRenderer draws them.
|
||||
pub outline: Arc<[[f32; 6]]>,
|
||||
pub outline_edges: Arc<[[f32; 6]]>,
|
||||
pub outline: Arc<[[f64; 6]]>,
|
||||
pub outline_edges: Arc<[[f64; 6]]>,
|
||||
/// `getMapColor(..).id` (0 = NONE).
|
||||
pub map_color: u8,
|
||||
}
|
||||
@@ -78,11 +86,11 @@ pub const MAP_COLORS: [u32; 62] = [
|
||||
14200723, 8365974,
|
||||
];
|
||||
/// The 12 edges of the unit cube (outline data predating the export).
|
||||
fn cube_edges() -> Vec<[f32; 6]> {
|
||||
fn cube_edges() -> Vec<[f64; 6]> {
|
||||
let mut edges = Vec::new();
|
||||
for a in 0..3 {
|
||||
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
|
||||
let mut from = [0.0f32; 3];
|
||||
let mut from = [0.0f64; 3];
|
||||
let (b, c) = ((a + 1) % 3, (a + 2) % 3);
|
||||
from[b] = u;
|
||||
from[c] = v;
|
||||
@@ -104,9 +112,7 @@ pub struct BlockFacts {
|
||||
}
|
||||
impl BlockFacts {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry) -> Result<Self> {
|
||||
let raw: Value = serde_json::from_reader(BufReader::new(File::open(
|
||||
root.join("gameplay-facts.json"),
|
||||
)?))?;
|
||||
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")?;
|
||||
@@ -147,8 +153,8 @@ impl BlockFacts {
|
||||
.or_insert_with(|| Arc::from(s))
|
||||
.clone()
|
||||
};
|
||||
let mut shapes: HashMap<String, Arc<[[f32; 6]]>> = HashMap::new();
|
||||
let mut shape = |v: &Value, fallback: &dyn Fn() -> Vec<[f32; 6]>| -> Arc<[[f32; 6]]> {
|
||||
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();
|
||||
};
|
||||
@@ -158,8 +164,7 @@ impl BlockFacts {
|
||||
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) as f32;
|
||||
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()
|
||||
@@ -263,6 +268,9 @@ impl BlockFacts {
|
||||
fluid_falling,
|
||||
waterlogged: prop("waterlogged") == Some("true"),
|
||||
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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! BlockModelFeatureRenderer breaking quads and SheetedDecalTextureGenerator UVs.
|
||||
use crate::{camera::joml, mesher::Vertex, models::catalog::BakedModel};
|
||||
use horizon_assets::model::Direction;
|
||||
use std::f32::consts::{FRAC_PI_2, PI};
|
||||
|
||||
/// Project in model space: the inverse decal pose cancels the block translation/offset.
|
||||
pub fn decal_uv([x, y, z]: [f32; 3], direction: Direction) -> [f32; 2] {
|
||||
let sy = joml::sin(PI);
|
||||
let cy = joml::cos_from_sin(sy, PI);
|
||||
let sx = joml::sin(-FRAC_PI_2);
|
||||
let cx = joml::cos_from_sin(sx, -FRAC_PI_2);
|
||||
let p = [cy * x + sy * z, y, -sy * x + cy * z];
|
||||
let p = [p[0], cx * p[1] - sx * p[2], sx * p[1] + cx * p[2]];
|
||||
let (angle_x, angle_z) = match direction {
|
||||
Direction::Down => (PI, 0.0),
|
||||
Direction::Up => (0.0, 0.0),
|
||||
Direction::North => (FRAC_PI_2, PI),
|
||||
Direction::South => (FRAC_PI_2, 0.0),
|
||||
Direction::West => (FRAC_PI_2, FRAC_PI_2),
|
||||
Direction::East => (FRAC_PI_2, -FRAC_PI_2),
|
||||
};
|
||||
let p = joml::Quat::rotation_yxz(0.0, angle_x, angle_z).transform(p);
|
||||
[-p[0], -p[1]]
|
||||
}
|
||||
|
||||
pub fn geometry(model: &BakedModel, origin: glam::Vec3, translucent: bool, out: &mut Vec<Vertex>) {
|
||||
// Vanilla visits all six cull groups and then the unculled faces, even beside solid neighbours.
|
||||
for cull in Direction::ALL.map(Some).into_iter().chain([None]) {
|
||||
for face in model.faces.iter().filter(|face| face.quad.cull == cull) {
|
||||
for pos in face.quad.positions {
|
||||
out.push(Vertex {
|
||||
pos: (origin + glam::Vec3::from(pos)).to_array(),
|
||||
uv: decal_uv(pos, face.quad.facing),
|
||||
layer: if translucent { 12 } else { 11 },
|
||||
color: 0xffff_ffff,
|
||||
light: 240 | (240 << 8),
|
||||
normal: [0; 4],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::catalog::BakedFace;
|
||||
use horizon_assets::{bake::ModelQuad, model::Material};
|
||||
fn slab_top() -> BakedModel {
|
||||
BakedModel {
|
||||
ambient_occlusion: true,
|
||||
cube: None,
|
||||
faces: vec![BakedFace {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
quad: ModelQuad {
|
||||
positions: [
|
||||
[0.0, 0.5, 0.0],
|
||||
[0.0, 0.5, 1.0],
|
||||
[1.0, 0.5, 1.0],
|
||||
[1.0, 0.5, 0.0],
|
||||
],
|
||||
uv: [[0.0; 2]; 4],
|
||||
facing: Direction::Up,
|
||||
cull: None,
|
||||
material: Material {
|
||||
sprite: "block/stone".into(),
|
||||
force_translucent: false,
|
||||
},
|
||||
tint_index: -1,
|
||||
shade_direction_override: None,
|
||||
light_emission: 0,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn slab_overlay_follows_baked_top_not_unit_cube() {
|
||||
let model = slab_top();
|
||||
let mut vertices = Vec::new();
|
||||
geometry(&model, glam::Vec3::new(2.0, 3.0, 4.0), false, &mut vertices);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
for (v, p) in vertices.iter().zip(model.faces[0].quad.positions) {
|
||||
assert_eq!(v.pos, [p[0] + 2.0, p[1] + 3.0, p[2] + 4.0]);
|
||||
assert_eq!(v.color, 0xffff_ffff);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn small_faces_use_projected_not_stretched_uvs() {
|
||||
let a = decal_uv([0.0, 0.0, 0.0], Direction::North);
|
||||
let b = decal_uv([0.0, 0.5, 0.0], Direction::North);
|
||||
assert!(((b[1] - a[1]).abs() - 0.5).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn translucent_overlay_has_its_own_pass() {
|
||||
let mut vertices = Vec::new();
|
||||
geometry(&slab_top(), glam::Vec3::ZERO, true, &mut vertices);
|
||||
assert!(vertices.iter().all(|v| v.layer == 12));
|
||||
}
|
||||
#[test]
|
||||
fn empty_model_has_no_cube_overlay() {
|
||||
let mut model = slab_top();
|
||||
model.faces.clear();
|
||||
let mut vertices = Vec::new();
|
||||
geometry(&model, glam::Vec3::ZERO, false, &mut vertices);
|
||||
assert!(vertices.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,13 @@ pub mod joml {
|
||||
}
|
||||
use joml::{Mat4, Quat};
|
||||
|
||||
/// Camera-relative world view used by terrain, sky and the first-person hand.
|
||||
pub fn view_matrix(yaw: f32, pitch: f32) -> glam::Mat4 {
|
||||
// Vanilla uses the conjugate of Camera.rotation, not a world-up look-at.
|
||||
let q = Quat::rotation_yxz(PI - yaw * DEG_TO_RAD, -pitch * DEG_TO_RAD, 0.0);
|
||||
glam::Mat4::from_quat(glam::Quat::from_xyzw(-q.x, -q.y, -q.z, q.w))
|
||||
}
|
||||
|
||||
/// `CameraType`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CameraType {
|
||||
@@ -583,6 +590,50 @@ impl Camera {
|
||||
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
|
||||
}
|
||||
}
|
||||
/// `Camera.update`: the far plane covers four times the render distance and
|
||||
/// the whole cloud range (`depthFar`).
|
||||
pub fn depth_far(render_distance_chunks: i32, cloud_range_chunks: i32) -> f32 {
|
||||
(render_distance_chunks as f32 * 16.0 * 4.0).max((cloud_range_chunks * 16) as f32)
|
||||
}
|
||||
|
||||
/// `Projection.getMatrix` for a perspective set up by `setupPerspective`:
|
||||
/// JOML's `Matrix4f.setPerspective(fov * DEG_TO_RAD, width / height, near,
|
||||
/// far, zZeroToOne)`, column-major. Vanilla passes its planes reversed
|
||||
/// (`near` is `zFar`); the hand pass here keeps them in order.
|
||||
pub fn perspective(
|
||||
fov_degrees: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
near: f32,
|
||||
far: f32,
|
||||
zero_to_one: bool,
|
||||
) -> [f32; 16] {
|
||||
let h = horizon_core::jmath::tan(f64::from(fov_degrees * DEG_TO_RAD * 0.5)) as f32;
|
||||
let aspect = width / height;
|
||||
let mut m = [0.0f32; 16];
|
||||
m[0] = 1.0 / (h * aspect);
|
||||
m[5] = 1.0 / h;
|
||||
let e = 1.0e-6f32;
|
||||
if far > 0.0 && far.is_infinite() {
|
||||
m[10] = e - 1.0;
|
||||
m[14] = (e - if zero_to_one { 1.0 } else { 2.0 }) * near;
|
||||
} else if near > 0.0 && near.is_infinite() {
|
||||
m[10] = (if zero_to_one { 0.0 } else { 1.0 }) - e;
|
||||
m[14] = ((if zero_to_one { 1.0 } else { 2.0 }) - e) * far;
|
||||
} else {
|
||||
m[10] = (if zero_to_one { far } else { far + near }) / (near - far);
|
||||
m[14] = (if zero_to_one { far } else { far + far }) * near / (near - far);
|
||||
}
|
||||
m[11] = -1.0;
|
||||
m
|
||||
}
|
||||
/// `GameRenderer.render3dHud`: the first-person hand and held items are
|
||||
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
|
||||
/// death and fluids), never the FOV option or the sprint, speed and bow
|
||||
/// modifier.
|
||||
pub fn hand_projection(hud_fov: f32, width: f32, height: f32, far: f32) -> [f32; 16] {
|
||||
perspective(hud_fov, width, height, 0.05, far, true)
|
||||
}
|
||||
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
|
||||
/// `deathTime` while it is a living entity that is dead or dying.
|
||||
pub fn modify_fov_based_on_death_or_fluid(
|
||||
@@ -723,8 +774,36 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn depth_far_covers_the_cloud_range() {
|
||||
// renderDistance 2 with the default Cloud Distance (128 chunks).
|
||||
assert_eq!(super::depth_far(2, 128), 2048.0);
|
||||
assert_eq!(super::depth_far(32, 2), 2048.0);
|
||||
assert_eq!(super::depth_far(12, 12), 768.0);
|
||||
}
|
||||
use super::*;
|
||||
#[test]
|
||||
fn view_keeps_yaw_at_vertical_pitch_limits() {
|
||||
for yaw in [0.0, 45.0, 90.0, 180.0, -90.0, 360.0] {
|
||||
for pitch in [-90.0, -89.999, 0.0, 89.999, 90.0] {
|
||||
let view = view_matrix(yaw, pitch);
|
||||
let mut camera = Camera::default();
|
||||
camera.set_rotation(yaw, pitch);
|
||||
for (world, expected) in [
|
||||
(camera.forwards, -glam::Vec3::Z),
|
||||
(camera.up, glam::Vec3::Y),
|
||||
(camera.left, -glam::Vec3::X),
|
||||
] {
|
||||
let got = view.transform_vector3(glam::Vec3::from(world));
|
||||
assert!(
|
||||
(got - expected).length() < 1e-5,
|
||||
"yaw={yaw}, pitch={pitch}: {got:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn camera_eases_eye_height_and_fov() {
|
||||
let mut camera = Camera::default();
|
||||
camera.tick(Some(1.62), 1.0);
|
||||
|
||||
@@ -9,9 +9,13 @@ use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
hud_layout::{self, GuiDraw, PoseOp},
|
||||
screen_draw::{self as d, Draw, Measure, TextField},
|
||||
text::{self, Style, Text},
|
||||
text::{self, ClickEvent, Style, Text},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "chat_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
@@ -113,6 +117,8 @@ impl Chat {
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
// ChatComponent.logChatMessage: every message is in the log.
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
|
||||
if self.log {
|
||||
println!("[CHAT] {}", content.plain());
|
||||
}
|
||||
@@ -291,6 +297,35 @@ impl Chat {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
/// The click action under a GUI-space cursor while chat is focused.
|
||||
pub fn click_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<ClickEvent> {
|
||||
let scale = self.options.scale as f32;
|
||||
let mut x = x / f64::from(scale) - 4.0;
|
||||
let bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let from_bottom = f64::from(bottom) - y / f64::from(scale);
|
||||
if x < 0.0 || x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
|
||||
if row >= self.lines_per_page(true) {
|
||||
return None;
|
||||
}
|
||||
let line = self.lines.get(self.scroll + row)?;
|
||||
let content = line.content.resolve_legacy();
|
||||
for (c, style) in content.chars() {
|
||||
x -= f64::from(font.advance(c, style));
|
||||
if x < 0.0 {
|
||||
return style.click.clone();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
self.messages
|
||||
@@ -418,6 +453,11 @@ impl ChatScreen {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.input.value
|
||||
}
|
||||
/// Replace the input for a suggest-command link and refresh completions.
|
||||
pub fn suggest_command(&mut self, m: &Measure, c: &Commands, command: &str) {
|
||||
self.input.set_value(m, command);
|
||||
self.edited(m, c);
|
||||
}
|
||||
/// `ChatScreen.resize`: a new screen with the current text.
|
||||
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
|
||||
if (width, height) != (self.width, self.height) {
|
||||
@@ -432,6 +472,18 @@ impl ChatScreen {
|
||||
self.allow_suggestions = true;
|
||||
self.update_command_info(m, c);
|
||||
}
|
||||
/// Ctrl+A/C/V/X in the input box.
|
||||
pub fn shortcut(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
shortcut: crate::clipboard::EditShortcut,
|
||||
clipboard: &mut dyn crate::clipboard::Clipboard,
|
||||
) {
|
||||
if self.input.shortcut(m, shortcut, clipboard) {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
/// Typed text (`charTyped` per character).
|
||||
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
|
||||
for ch in text.chars() {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
use super::*;
|
||||
use crate::{font::Font, hud::Rect};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
Font::load(
|
||||
Path::new("/nonexistent-horizon-chat-test-resources"),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
for content in font.wrap_components(&content, chat.max_width()) {
|
||||
chat.lines.insert(
|
||||
0,
|
||||
Line {
|
||||
added: 0,
|
||||
content,
|
||||
indicator: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
|
||||
let font = font();
|
||||
for seed in [1, -42, i64::MIN, i64::MAX] {
|
||||
let mut chat = Chat::default();
|
||||
let seed_text = Text::copyable(seed.to_string()).with_color(text::GOLD);
|
||||
assert_eq!(seed_text.plain(), format!("[{seed}]"));
|
||||
assert_eq!(seed_text.spans[0].style.color, Some(text::GOLD));
|
||||
assert_eq!(seed_text.spans[1].style.color, Some(text::GREEN));
|
||||
assert_eq!(seed_text.spans[2].style.color, Some(text::GOLD));
|
||||
add(&mut chat, &font, Text::literal("Seed: ").append(seed_text));
|
||||
let expected = Some(ClickEvent::CopyToClipboard(seed.to_string()));
|
||||
let start = 4.0 + f64::from(font.width("Seed: ["));
|
||||
let end = start + f64::from(font.width(&seed.to_string()));
|
||||
assert_eq!(chat.click_at(&font, 360, start, 315.0), expected);
|
||||
assert_eq!(chat.click_at(&font, 360, end - 0.5, 315.0), expected);
|
||||
for (x, y) in [
|
||||
(start - 0.5, 315.0),
|
||||
(end, 315.0),
|
||||
(3.0, 315.0),
|
||||
(start, 320.0),
|
||||
(start, 310.0),
|
||||
] {
|
||||
assert_eq!(chat.click_at(&font, 360, x, y), None, "{x}, {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
|
||||
let font = font();
|
||||
let screenshot = ClickEvent::OpenFile("/tmp/My screenshots/世界.png".into());
|
||||
for scale in [0.5, 0.75, 1.0] {
|
||||
for line_spacing in [0.0, 0.5, 1.0] {
|
||||
let mut chat = Chat::default();
|
||||
chat.options.scale = scale;
|
||||
chat.options.width = 0.0;
|
||||
chat.options.line_spacing = line_spacing;
|
||||
for _ in 0..20 {
|
||||
add(
|
||||
&mut chat,
|
||||
&font,
|
||||
Text::literal("Saved screenshot as ").append(Text::styled(
|
||||
"2026-10-14_18.55.14.png",
|
||||
Style {
|
||||
click: Some(screenshot.clone()),
|
||||
bold: Some(true),
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
for scroll in [0, 3, 12] {
|
||||
chat.scroll = scroll;
|
||||
let mut links = 0;
|
||||
for draw in chat.layout(361, 0, true) {
|
||||
if let GuiDraw::Text { text, x, y, .. } = draw {
|
||||
let mut at = x as f64;
|
||||
for (c, style) in text.chars() {
|
||||
let width = f64::from(font.advance(c, style));
|
||||
let cursor_x = (4.0 + at + width / 2.0) * scale;
|
||||
let cursor_y = (y as f64 + 3.0) * scale;
|
||||
assert_eq!(
|
||||
chat.click_at(&font, 361, cursor_x, cursor_y),
|
||||
style.click,
|
||||
"scale={scale}, spacing={line_spacing}, scroll={scroll}, char={c}"
|
||||
);
|
||||
links += usize::from(style.click.is_some());
|
||||
at += width;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(links > 0);
|
||||
// The history above the focused viewport is not clickable.
|
||||
let top = ((361 - 40) as f64 / scale).floor()
|
||||
- chat.lines_per_page(true) as f64 * chat.options.line_height() as f64;
|
||||
assert_eq!(
|
||||
chat.click_at(&font, 361, 10.0 * scale, (top - 1.0) * scale),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let commands = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
// Structures and POIs use relative Y; biome results include absolute Y.
|
||||
for (y, expected) in [
|
||||
(None, "/tp @s -1136 ~ 848"),
|
||||
(Some(73), "/tp @s -1136 73 848"),
|
||||
] {
|
||||
let link = Text::teleport_link(-1136, y, 848);
|
||||
assert_eq!(link.component()["click_event"]["action"], "suggest_command");
|
||||
assert_eq!(link.component()["click_event"]["command"], expected);
|
||||
let mut chat = Chat::default();
|
||||
add(&mut chat, &font, Text::literal("Found at ").append(link));
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360);
|
||||
let x = 4.0 + f64::from(font.width("Found at "));
|
||||
assert!(!screen.click(&m, &commands, x, 315.0));
|
||||
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
|
||||
panic!("locate coordinates must suggest a command");
|
||||
};
|
||||
screen.suggest_command(&m, &commands, &command);
|
||||
assert_eq!(screen.value(), expected);
|
||||
assert_eq!(screen.input.cursor, expected.chars().count());
|
||||
assert_eq!(screen.input.highlight, screen.input.cursor);
|
||||
// Submission is a separate, explicit Enter press.
|
||||
assert_eq!(
|
||||
screen.key(
|
||||
&m,
|
||||
&commands,
|
||||
&[],
|
||||
winit::keyboard::KeyCode::Enter,
|
||||
false,
|
||||
false
|
||||
),
|
||||
ChatAction::Submit(expected.to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//! The system clipboard and the text-editing shortcuts of 26.3:
|
||||
//! `KeyboardHandler.getClipboard`/`setClipboard`, `InputWithModifiers.isSelectAll`,
|
||||
//! `isCopy`, `isPaste` and `isCut`, `StringUtil.filterText` and
|
||||
//! `ChatFormatting.stripFormatting`.
|
||||
|
||||
use winit::keyboard::{Key, KeyCode, PhysicalKey};
|
||||
|
||||
/// A text-editing shortcut (`InputWithModifiers.isSelectAll`, `isCopy`,
|
||||
/// `isPaste`, `isCut`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EditShortcut {
|
||||
SelectAll,
|
||||
Copy,
|
||||
Paste,
|
||||
Cut,
|
||||
}
|
||||
|
||||
/// The letter a key stands for in shortcuts (`KeyEvent.shortcutKey`, SDL's
|
||||
/// keycode): the layout's letter, or the key's US-layout letter when the
|
||||
/// layout's is not a Latin one (SDL's default `latin_letters` keycode option).
|
||||
pub fn shortcut_letter(logical: &Key, physical: PhysicalKey) -> Option<char> {
|
||||
if let Key::Character(s) = logical {
|
||||
let mut chars = s.chars();
|
||||
if let (Some(c), None) = (chars.next(), chars.next())
|
||||
&& c.is_ascii_alphabetic()
|
||||
{
|
||||
return Some(c.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
let PhysicalKey::Code(code) = physical else {
|
||||
return None;
|
||||
};
|
||||
Some(match code {
|
||||
KeyCode::KeyA => 'a',
|
||||
KeyCode::KeyC => 'c',
|
||||
KeyCode::KeyV => 'v',
|
||||
KeyCode::KeyX => 'x',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `InputWithModifiers.isSelectAll`/`isCopy`/`isPaste`/`isCut`: A, C, V or X
|
||||
/// with the edit modifier (`InputQuirks.EDIT_SHORTCUT_KEY_MODIFIER`: Cmd on
|
||||
/// macOS, Ctrl elsewhere) and neither Shift nor Alt.
|
||||
pub fn edit_shortcut(
|
||||
letter: Option<char>,
|
||||
edit_modifier: bool,
|
||||
shift: bool,
|
||||
alt: bool,
|
||||
) -> Option<EditShortcut> {
|
||||
if !edit_modifier || shift || alt {
|
||||
return None;
|
||||
}
|
||||
Some(match letter? {
|
||||
'a' => EditShortcut::SelectAll,
|
||||
'c' => EditShortcut::Copy,
|
||||
'v' => EditShortcut::Paste,
|
||||
'x' => EditShortcut::Cut,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `StringUtil.isAllowedChatCharacter`.
|
||||
pub fn is_allowed_chat_character(c: char) -> bool {
|
||||
c != '§' && c >= ' ' && c != '\u{7f}'
|
||||
}
|
||||
|
||||
/// `StringUtil.filterText`: the allowed characters, and line breaks where
|
||||
/// the field takes several lines.
|
||||
pub fn filter_text(input: &str, multiline: bool) -> String {
|
||||
input
|
||||
.chars()
|
||||
.filter(|&c| is_allowed_chat_character(c) || (multiline && c == '\n'))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ChatFormatting.stripFormatting`: `§` followed by a format code removed.
|
||||
pub fn strip_formatting(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '§'
|
||||
&& chars.peek().is_some_and(
|
||||
|n| matches!(n.to_ascii_lowercase(), '0'..='9' | 'a'..='f' | 'k'..='o' | 'r'),
|
||||
)
|
||||
{
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `TextFieldHelper.getClipboardContents`: formatting and carriage returns
|
||||
/// removed (signs).
|
||||
pub fn helper_clipboard_contents(clipboard: &str) -> String {
|
||||
strip_formatting(&clipboard.replace('\r', ""))
|
||||
}
|
||||
|
||||
/// A clipboard: the system's, or an in-memory one in tests.
|
||||
pub trait Clipboard {
|
||||
/// `KeyboardHandler.getClipboard`: empty when there is no text.
|
||||
fn get(&mut self) -> String;
|
||||
/// `KeyboardHandler.setClipboard`: empty text is not copied.
|
||||
fn set(&mut self, text: &str);
|
||||
}
|
||||
|
||||
/// The system clipboard (`ClipboardManager`). On Wayland, arboard uses the
|
||||
/// data-control protocol and falls back to X11 (XWayland) on compositors
|
||||
/// without it. When no system clipboard can be reached, text copied in the
|
||||
/// game can still be pasted in the game.
|
||||
#[derive(Default)]
|
||||
pub struct SystemClipboard {
|
||||
system: Option<arboard::Clipboard>,
|
||||
/// The text Horizon last copied (`horizon-ctl status`).
|
||||
copied: Option<String>,
|
||||
}
|
||||
|
||||
impl SystemClipboard {
|
||||
/// The text last copied by the game.
|
||||
pub fn copied(&self) -> Option<&str> {
|
||||
self.copied.as_deref()
|
||||
}
|
||||
fn system(&mut self) -> Option<&mut arboard::Clipboard> {
|
||||
if self.system.is_none() {
|
||||
self.system = arboard::Clipboard::new().ok();
|
||||
}
|
||||
self.system.as_mut()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clipboard for SystemClipboard {
|
||||
fn get(&mut self) -> String {
|
||||
let Some(system) = self.system() else {
|
||||
return self.copied.clone().unwrap_or_default();
|
||||
};
|
||||
match system.get_text() {
|
||||
Ok(text) => text,
|
||||
Err(arboard::Error::ContentNotAvailable) => String::new(),
|
||||
Err(_) => {
|
||||
// A lost connection (a restarted compositor): reconnect next time.
|
||||
self.system = None;
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
fn set(&mut self, text: &str) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.copied = Some(text.to_owned());
|
||||
if let Some(system) = self.system()
|
||||
&& system.set_text(text.to_owned()).is_err()
|
||||
{
|
||||
self.system = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A clipboard in memory.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct MemoryClipboard(pub String);
|
||||
|
||||
impl Clipboard for MemoryClipboard {
|
||||
fn get(&mut self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
fn set(&mut self, text: &str) {
|
||||
if !text.is_empty() {
|
||||
self.0 = text.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field with a cursor and a selection (`EditBox`).
|
||||
pub trait Editable {
|
||||
/// Select All: the cursor at the end, the selection from the start.
|
||||
fn select_all(&mut self);
|
||||
/// `getHighlighted`.
|
||||
fn selected_text(&self) -> String;
|
||||
/// `insertText`, with the field's own filter and length limit. True when
|
||||
/// the value changed.
|
||||
fn insert(&mut self, text: &str) -> bool;
|
||||
}
|
||||
|
||||
/// `EditBox.keyPressed`'s shortcuts. True when the value changed.
|
||||
pub fn apply(
|
||||
field: &mut dyn Editable,
|
||||
shortcut: EditShortcut,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
) -> bool {
|
||||
match shortcut {
|
||||
EditShortcut::SelectAll => {
|
||||
field.select_all();
|
||||
false
|
||||
}
|
||||
EditShortcut::Copy => {
|
||||
clipboard.set(&field.selected_text());
|
||||
false
|
||||
}
|
||||
EditShortcut::Paste => field.insert(&clipboard.get()),
|
||||
EditShortcut::Cut => {
|
||||
clipboard.set(&field.selected_text());
|
||||
field.insert("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which append-only field a Select All covers: a name and an index (a
|
||||
/// sign line, a book page).
|
||||
pub type FieldId = (&'static str, usize);
|
||||
|
||||
/// The selection of Horizon's append-only fields, whose cursor stays at the
|
||||
/// end: Select All selects the whole value, which the next copy, cut, paste,
|
||||
/// typed text or backspace replaces. It is forgotten as soon as the field or
|
||||
/// its value changes.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AppendSelection(Option<(FieldId, String)>);
|
||||
|
||||
impl AppendSelection {
|
||||
/// Whether `value` of field `id` is wholly selected.
|
||||
pub fn covers(&self, id: FieldId, value: &str) -> bool {
|
||||
self.0.as_ref().is_some_and(|(i, v)| *i == id && v == value)
|
||||
}
|
||||
/// 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);
|
||||
self.0 = None;
|
||||
if covered {
|
||||
value.clear();
|
||||
}
|
||||
covered
|
||||
}
|
||||
/// A shortcut in an append-only field. `insert` adds pasted text to a
|
||||
/// value the field's own way and says whether it accepted it (a field
|
||||
/// that rejects it keeps its value, as `TextFieldHelper.insertText`).
|
||||
/// True when the value changed.
|
||||
pub fn apply(
|
||||
&mut self,
|
||||
id: FieldId,
|
||||
value: &mut String,
|
||||
shortcut: EditShortcut,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
insert: impl FnOnce(&mut String, &str) -> bool,
|
||||
) -> bool {
|
||||
let covered = self.covers(id, value);
|
||||
match shortcut {
|
||||
EditShortcut::SelectAll => {
|
||||
self.0 = Some((id, value.clone()));
|
||||
false
|
||||
}
|
||||
EditShortcut::Copy => {
|
||||
if covered {
|
||||
clipboard.set(value);
|
||||
}
|
||||
false
|
||||
}
|
||||
EditShortcut::Cut => {
|
||||
self.0 = None;
|
||||
if !covered {
|
||||
return false;
|
||||
}
|
||||
clipboard.set(value);
|
||||
let changed = !value.is_empty();
|
||||
value.clear();
|
||||
changed
|
||||
}
|
||||
EditShortcut::Paste => {
|
||||
self.0 = None;
|
||||
let text = clipboard.get();
|
||||
let mut next = if covered {
|
||||
String::new()
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
if !insert(&mut next, &text) || next == *value {
|
||||
return false;
|
||||
}
|
||||
*value = next;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `EditBox.insertText` for a field without a cursor: the filtered text,
|
||||
/// cut to the room left by `max_chars`.
|
||||
pub fn append_limited(value: &mut String, text: &str, max_chars: usize) -> bool {
|
||||
let room = max_chars.saturating_sub(value.chars().count());
|
||||
if room == 0 {
|
||||
return false;
|
||||
}
|
||||
value.extend(filter_text(text, false).chars().take(room));
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shortcuts_need_the_edit_modifier_alone() {
|
||||
let c = Some('c');
|
||||
assert_eq!(
|
||||
edit_shortcut(c, true, false, false),
|
||||
Some(EditShortcut::Copy)
|
||||
);
|
||||
assert_eq!(
|
||||
edit_shortcut(Some('v'), true, false, false),
|
||||
Some(EditShortcut::Paste)
|
||||
);
|
||||
assert_eq!(
|
||||
edit_shortcut(Some('x'), true, false, false),
|
||||
Some(EditShortcut::Cut)
|
||||
);
|
||||
assert_eq!(
|
||||
edit_shortcut(Some('a'), true, false, false),
|
||||
Some(EditShortcut::SelectAll)
|
||||
);
|
||||
assert_eq!(edit_shortcut(c, false, false, false), None);
|
||||
assert_eq!(edit_shortcut(c, true, true, false), None);
|
||||
assert_eq!(edit_shortcut(c, true, false, true), None);
|
||||
assert_eq!(edit_shortcut(Some('b'), true, false, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layout_letter_decides_and_non_latin_layouts_use_the_position() {
|
||||
let q = PhysicalKey::Code(KeyCode::KeyQ);
|
||||
// AZERTY: the key at Q's position types an A.
|
||||
assert_eq!(shortcut_letter(&Key::Character("a".into()), q), Some('a'));
|
||||
// Russian: the key at C's position types с.
|
||||
let c = PhysicalKey::Code(KeyCode::KeyC);
|
||||
assert_eq!(shortcut_letter(&Key::Character("с".into()), c), Some('c'));
|
||||
assert_eq!(shortcut_letter(&Key::Character("C".into()), q), Some('c'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_text_keeps_line_breaks_only_for_multiline_fields() {
|
||||
let s = "a\u{7}b§c\nd\re\u{80}";
|
||||
assert_eq!(filter_text(s, false), "abcde\u{80}");
|
||||
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
|
||||
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
|
||||
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
|
||||
}
|
||||
|
||||
struct Field {
|
||||
value: String,
|
||||
cursor: usize,
|
||||
highlight: usize,
|
||||
}
|
||||
impl Editable for Field {
|
||||
fn select_all(&mut self) {
|
||||
self.cursor = self.value.len();
|
||||
self.highlight = 0;
|
||||
}
|
||||
fn selected_text(&self) -> String {
|
||||
let (a, b) = (
|
||||
self.cursor.min(self.highlight),
|
||||
self.cursor.max(self.highlight),
|
||||
);
|
||||
self.value[a..b].to_owned()
|
||||
}
|
||||
fn insert(&mut self, text: &str) -> bool {
|
||||
let (a, b) = (
|
||||
self.cursor.min(self.highlight),
|
||||
self.cursor.max(self.highlight),
|
||||
);
|
||||
self.value.replace_range(a..b, text);
|
||||
self.cursor = a + text.len();
|
||||
self.highlight = self.cursor;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_box_shortcuts() {
|
||||
let mut clip = MemoryClipboard("old".into());
|
||||
let mut f = Field {
|
||||
value: "hello".into(),
|
||||
cursor: 5,
|
||||
highlight: 5,
|
||||
};
|
||||
// Nothing selected: copying leaves the clipboard alone (setClipboard skips "").
|
||||
apply(&mut f, EditShortcut::Copy, &mut clip);
|
||||
assert_eq!(clip.0, "old");
|
||||
apply(&mut f, EditShortcut::SelectAll, &mut clip);
|
||||
apply(&mut f, EditShortcut::Copy, &mut clip);
|
||||
assert_eq!(clip.0, "hello");
|
||||
assert!(apply(&mut f, EditShortcut::Cut, &mut clip));
|
||||
assert_eq!(f.value, "");
|
||||
assert!(apply(&mut f, EditShortcut::Paste, &mut clip));
|
||||
assert!(apply(&mut f, EditShortcut::Paste, &mut clip));
|
||||
assert_eq!(f.value, "hellohello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_only_fields_select_everything_or_nothing() {
|
||||
let id = ("search", 0);
|
||||
let mut clip = MemoryClipboard("stone".into());
|
||||
let mut sel = AppendSelection::default();
|
||||
let mut value = String::from("dirt");
|
||||
let insert = |v: &mut String, t: &str| append_limited(v, t, 6);
|
||||
// Without Select All, copy and cut do nothing and paste appends.
|
||||
sel.apply(id, &mut value, EditShortcut::Copy, &mut clip, insert);
|
||||
assert_eq!(clip.0, "stone");
|
||||
assert!(!sel.apply(id, &mut value, EditShortcut::Cut, &mut clip, insert));
|
||||
assert!(sel.apply(id, &mut value, EditShortcut::Paste, &mut clip, insert));
|
||||
assert_eq!(value, "dirtst");
|
||||
// Select All then paste replaces the value.
|
||||
sel.apply(id, &mut value, EditShortcut::SelectAll, &mut clip, insert);
|
||||
assert!(sel.apply(id, &mut value, EditShortcut::Paste, &mut clip, insert));
|
||||
assert_eq!(value, "stone");
|
||||
// Select All then cut empties it and copies it.
|
||||
sel.apply(id, &mut value, EditShortcut::SelectAll, &mut clip, insert);
|
||||
clip.0 = "x".into();
|
||||
assert!(sel.apply(id, &mut value, EditShortcut::Cut, &mut clip, insert));
|
||||
assert_eq!((value.as_str(), clip.0.as_str()), ("", "stone"));
|
||||
// A selection is forgotten once the value changes or another field is used.
|
||||
value = "ab".into();
|
||||
sel.apply(id, &mut value, EditShortcut::SelectAll, &mut clip, insert);
|
||||
assert!(!sel.covers(("other", 0), &value));
|
||||
value.push('c');
|
||||
assert!(!sel.take(id, &mut value));
|
||||
assert_eq!(value, "abc");
|
||||
sel.apply(id, &mut value, EditShortcut::SelectAll, &mut clip, insert);
|
||||
assert!(sel.take(id, &mut value));
|
||||
assert_eq!(value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_paste_keeps_the_selected_value() {
|
||||
// TextFieldHelper.insertText: a line too wide is not set at all.
|
||||
let id = ("sign", 1);
|
||||
let mut clip = MemoryClipboard("far too long".into());
|
||||
let mut sel = AppendSelection::default();
|
||||
let mut value = String::from("ok");
|
||||
sel.apply(
|
||||
id,
|
||||
&mut value,
|
||||
EditShortcut::SelectAll,
|
||||
&mut clip,
|
||||
|_, _| true,
|
||||
);
|
||||
let fits = |v: &mut String, t: &str| {
|
||||
let next = format!("{v}{t}");
|
||||
next.len() <= 5 && {
|
||||
*v = next;
|
||||
true
|
||||
}
|
||||
};
|
||||
assert!(!sel.apply(id, &mut value, EditShortcut::Paste, &mut clip, fits));
|
||||
assert_eq!(value, "ok");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Cloud visibility and flat/fancy cell geometry (`CloudRenderer`).
|
||||
/// `LevelRenderer`: skip OFF clouds and colors with no packed alpha.
|
||||
pub fn visible(mode: &str, alpha: f32) -> bool {
|
||||
mode != "false" && (alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
|
||||
}
|
||||
|
||||
/// `buildFlatCell`: bottom plane, in both windings for the culled cloud pipeline.
|
||||
pub fn flat_cell(x: f32, z: f32, bottom: f32) -> [[[f32; 3]; 4]; 2] {
|
||||
let p = [
|
||||
[x + 12.0, bottom, z],
|
||||
[x + 12.0, bottom, z + 12.0],
|
||||
[x, bottom, z + 12.0],
|
||||
[x, bottom, z],
|
||||
];
|
||||
[p, [p[3], p[2], p[1], p[0]]]
|
||||
}
|
||||
|
||||
/// Direction order is DOWN, UP, NORTH, SOUTH, WEST, EAST; inward faces use bits 6..12.
|
||||
pub fn face_mask(x: i32, z: i32, bottom: f32, empty: [bool; 4]) -> u16 {
|
||||
let mut mask = 0;
|
||||
if bottom <= 0.0 {
|
||||
mask |= 1 << 1;
|
||||
}
|
||||
if bottom + 4.0 >= 0.0 {
|
||||
mask |= 1;
|
||||
}
|
||||
for (i, visible) in [z > 0, z < 0, x > 0, x < 0].into_iter().enumerate() {
|
||||
if empty[i] && visible {
|
||||
mask |= 1 << (i + 2);
|
||||
}
|
||||
}
|
||||
if x.abs() <= 1 && z.abs() <= 1 {
|
||||
mask |= 63 << 6;
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// Exterior faces first, then reversed interior faces, in vanilla emission order.
|
||||
pub fn faces(mask: u16) -> impl Iterator<Item = (u8, bool)> {
|
||||
[1, 0, 2, 3, 4, 5]
|
||||
.into_iter()
|
||||
.filter(move |d| mask & (1 << d) != 0)
|
||||
.map(|d| (d, false))
|
||||
.chain(
|
||||
(0..6)
|
||||
.filter(move |d| mask & (1 << (d + 6)) != 0)
|
||||
.map(|d| (d, true)),
|
||||
)
|
||||
}
|
||||
|
||||
/// `CloudRenderer.prepare`: the cell radius for the Cloud Distance option
|
||||
/// (in chunks), independent of the render distance.
|
||||
pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
|
||||
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
|
||||
}
|
||||
|
||||
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
|
||||
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
|
||||
(0..=2 * radius).flat_map(move |ring| {
|
||||
(-ring..=ring).flat_map(move |x| {
|
||||
let z = ring - x.abs();
|
||||
let visible = z <= radius && x * x + z * z <= radius * radius;
|
||||
(visible && z != 0)
|
||||
.then_some((x, -z))
|
||||
.into_iter()
|
||||
.chain(visible.then_some((x, z)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn settings_and_packed_alpha_gate_clouds() {
|
||||
for mode in ["false", "fast", "true"] {
|
||||
assert_eq!(visible(mode, 1.0), mode != "false");
|
||||
assert!(!visible(mode, 0.0));
|
||||
assert!(!visible(mode, 0.5 / 255.0));
|
||||
}
|
||||
assert!(visible("fast", 1.0 / 255.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_cells_are_flat_at_the_bottom_and_visible_from_both_sides() {
|
||||
for bottom in [-10.0, 0.0, 10.0] {
|
||||
let [down, up] = flat_cell(-3.0, 7.0, bottom);
|
||||
assert_eq!(
|
||||
down,
|
||||
[
|
||||
[9.0, bottom, 7.0],
|
||||
[9.0, bottom, 19.0],
|
||||
[-3.0, bottom, 19.0],
|
||||
[-3.0, bottom, 7.0],
|
||||
]
|
||||
);
|
||||
assert_eq!(up, [down[3], down[2], down[1], down[0]]);
|
||||
for (quad, sign) in [(down, -1.0), (up, 1.0)] {
|
||||
let [a, b, c] = [quad[0], quad[1], quad[2]].map(glam::Vec3::from_array);
|
||||
assert!((b - a).cross(c - a).y * sign > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_are_nearest_first_and_unique() {
|
||||
let cells: Vec<_> = cells(2).collect();
|
||||
assert_eq!(cells[0], (0, 0));
|
||||
assert_eq!(cells.len(), 13);
|
||||
assert!(
|
||||
cells
|
||||
.windows(2)
|
||||
.all(|w| w[0].0.abs() + w[0].1.abs() <= w[1].0.abs() + w[1].1.abs())
|
||||
);
|
||||
assert_eq!(
|
||||
cells.iter().collect::<std::collections::HashSet<_>>().len(),
|
||||
cells.len()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn only_camera_facing_exterior_faces() {
|
||||
assert_eq!(face_mask(2, 2, 10.0, [true; 4]), 1 | 4 | 16);
|
||||
assert_eq!(face_mask(-2, -2, -5.0, [true; 4]), 2 | 8 | 32);
|
||||
assert_eq!(face_mask(2, 2, 0.0, [false; 4]), 3);
|
||||
}
|
||||
#[test]
|
||||
fn cloud_distance_sets_the_radius() {
|
||||
assert_eq!(radius_cells(2), 3);
|
||||
assert_eq!(radius_cells(12), 16);
|
||||
assert_eq!(radius_cells(128), 171);
|
||||
}
|
||||
#[test]
|
||||
fn connected_cells_have_no_exterior_walls() {
|
||||
assert_eq!(face_mask(2, 2, 10.0, [false; 4]), 1);
|
||||
}
|
||||
#[test]
|
||||
fn nearby_cells_keep_inward_faces_for_camera_inside_clouds() {
|
||||
assert_eq!(face_mask(0, 0, 0.0, [false; 4]), 3 | (63 << 6));
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,24 @@ pub fn load<'r>(root: &Path, registry: &'r BlockRegistry) -> Result<BlockCollisi
|
||||
Ok(BlockCollisionRegistry::new(Arc::new(cached))?)
|
||||
}
|
||||
|
||||
/// What powder snow's contextual collision asks of the colliding entity
|
||||
/// (`PowderSnowBlock.getCollisionShape`).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PowderSnowSubject {
|
||||
pub fall_distance: f64,
|
||||
/// `FallingBlockEntity`.
|
||||
pub falling_block: bool,
|
||||
/// A living entity with leather boots on its feet.
|
||||
pub leather_boots: bool,
|
||||
/// `EntityTypeTags.POWDER_SNOW_WALKABLE_MOBS`.
|
||||
pub walkable_type: bool,
|
||||
}
|
||||
|
||||
pub struct SceneCollisions<'w, 'r> {
|
||||
pub world: &'w World,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
/// The entity colliding, for powder snow; None: no fall, no boots.
|
||||
pub subject: Option<PowderSnowSubject>,
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
@@ -81,6 +96,20 @@ impl SceneCollisions<'_, '_> {
|
||||
/// `noCollision(null, box)`: block collisions under the empty collision
|
||||
/// context, as entity-less queries such as spawn checks use.
|
||||
pub fn no_collision_empty<W, E>(world: &mut W, bounds: Aabb) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
{
|
||||
no_collision_in(world, bounds, false)
|
||||
}
|
||||
/// `noCollision(null, box, true)`: the same with liquids colliding as
|
||||
/// blocks (`CollisionContext.emptyWithFluidCollisions`).
|
||||
pub fn no_collision_or_liquid<W, E>(world: &mut W, bounds: Aabb) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
{
|
||||
no_collision_in(world, bounds, true)
|
||||
}
|
||||
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
{
|
||||
@@ -88,7 +117,7 @@ where
|
||||
world,
|
||||
CollisionCheckRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::empty(false),
|
||||
context: CollisionContext::empty(fluids),
|
||||
bounds,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
@@ -169,18 +198,18 @@ impl LiquidCollisionWorld for SceneCollisions<'_, '_> {
|
||||
}
|
||||
impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
// Entities here are never falling blocks and wear no leather boots.
|
||||
fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result<PowderSnowEntity> {
|
||||
let subject = self.subject.unwrap_or_default();
|
||||
Ok(PowderSnowEntity {
|
||||
fall_distance: 0.0,
|
||||
falling_block: false,
|
||||
fall_distance: subject.fall_distance,
|
||||
falling_block: subject.falling_block,
|
||||
})
|
||||
}
|
||||
fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
Ok(self.subject.is_some_and(|s| s.walkable_type))
|
||||
}
|
||||
fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
Ok(self.subject.is_some_and(|s| s.leather_boots))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! The mouse handling every survival and container screen shares
|
||||
//! (`AbstractContainerScreen.mouseClicked` / `mouseReleased`): which slot id
|
||||
//! a click targets (-999 outside the window, `hasClickedOutside` with the
|
||||
//! recipe book's own rule) and which `ContainerInput` it sends. Shift is
|
||||
//! the event's own modifiers (`InputWithModifiers.hasShiftDown`: the
|
||||
//! physical Shift keys, never the remapped or toggled sneak key).
|
||||
|
||||
use crate::menu::Input;
|
||||
|
||||
/// The screen's window: `leftPos`, `topPos`, `imageWidth`, `imageHeight`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct Image {
|
||||
pub left: i32,
|
||||
pub top: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
/// The recipe book of an `AbstractRecipeBookScreen`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct Book {
|
||||
/// `RecipeBookComponent.isVisible`.
|
||||
pub visible: bool,
|
||||
/// `selectedTab.isHoveredOrFocused()`.
|
||||
pub selected_tab_hovered: bool,
|
||||
}
|
||||
|
||||
/// `RecipeBookComponent.IMAGE_WIDTH`.
|
||||
const BOOK_WIDTH: i32 = 147;
|
||||
|
||||
fn outside(mx: f64, my: f64, image: Image) -> bool {
|
||||
mx < f64::from(image.left)
|
||||
|| my < f64::from(image.top)
|
||||
|| mx >= f64::from(image.left + image.width)
|
||||
|| my >= f64::from(image.top + image.height)
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen.hasClickedOutside`, or
|
||||
/// `AbstractRecipeBookScreen.hasClickedOutside` when the screen has a book
|
||||
/// (`RecipeBookComponent.hasClickedOutside`: a click on the book, or with
|
||||
/// its selected tab hovered, is not outside).
|
||||
pub fn clicked_outside(mx: f64, my: f64, image: Image, book: Option<Book>) -> bool {
|
||||
let out = outside(mx, my, image);
|
||||
let Some(book) = book else {
|
||||
return out;
|
||||
};
|
||||
let book_outside = !book.visible || {
|
||||
let on_book = f64::from(image.left - BOOK_WIDTH) < mx
|
||||
&& mx < f64::from(image.left)
|
||||
&& f64::from(image.top) < my
|
||||
&& my < f64::from(image.top + image.height);
|
||||
out && !on_book && !book.selected_tab_hovered
|
||||
};
|
||||
book_outside && out
|
||||
}
|
||||
|
||||
/// What a click points at: a slot, outside the window (slot id -999), or
|
||||
/// neither (slot id -1).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Target<S> {
|
||||
Slot(S),
|
||||
Outside,
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl<S> Target<S> {
|
||||
/// Outside wins over a hovered slot, as in `mouseClicked`.
|
||||
pub fn of(slot: Option<S>, outside: bool) -> Self {
|
||||
match slot {
|
||||
_ if outside => Self::Outside,
|
||||
Some(s) => Self::Slot(s),
|
||||
None => Self::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The left, right and middle buttons (SDL's 1, 3 and 2).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Button {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
impl Button {
|
||||
/// `getContainerClickButton`: the button a menu click carries.
|
||||
pub fn menu_button(self) -> i32 {
|
||||
match self {
|
||||
Self::Left => 0,
|
||||
Self::Right => 1,
|
||||
Self::Middle => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The input state `mouseClicked`/`mouseReleased` keep between events.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Clicks {
|
||||
/// `isQuickCrafting` with `quickCraftingButton`.
|
||||
quick_crafting: Option<Button>,
|
||||
skip_next_release: bool,
|
||||
}
|
||||
|
||||
/// What a release does.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Release {
|
||||
/// `slotClicked` with this input on the release's target.
|
||||
Click(Input),
|
||||
/// `quickCraftToSlots` over the dragged slots.
|
||||
QuickCraft,
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl Clicks {
|
||||
/// A quick craft drag in progress, and the button that started it.
|
||||
pub fn quick_crafting(&self) -> Option<Button> {
|
||||
self.quick_crafting
|
||||
}
|
||||
|
||||
/// A press Horizon handled itself (a double click's `PICKUP_ALL`): its
|
||||
/// release clicks nothing more.
|
||||
pub fn skip_release(&mut self) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = true;
|
||||
}
|
||||
|
||||
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
|
||||
/// `target`, if any. A press holding a stack starts a quick craft drag
|
||||
/// instead. `cloning` is `keyPickItem` with infinite materials.
|
||||
pub fn press<S>(
|
||||
&mut self,
|
||||
target: &Target<S>,
|
||||
button: Button,
|
||||
carried_empty: bool,
|
||||
shift: bool,
|
||||
cloning: bool,
|
||||
) -> Option<Input> {
|
||||
self.skip_next_release = false;
|
||||
if button == Button::Middle && !cloning {
|
||||
return None;
|
||||
}
|
||||
if matches!(target, Target::Nothing) || self.quick_crafting.is_some() {
|
||||
return None;
|
||||
}
|
||||
if !carried_empty {
|
||||
self.quick_crafting = Some(button);
|
||||
return None;
|
||||
}
|
||||
self.skip_next_release = true;
|
||||
Some(if cloning {
|
||||
Input::Clone
|
||||
} else if shift && !matches!(target, Target::Outside) {
|
||||
Input::QuickMove
|
||||
} else if matches!(target, Target::Outside) {
|
||||
Input::Throw
|
||||
} else {
|
||||
Input::Pickup
|
||||
})
|
||||
}
|
||||
|
||||
/// `mouseReleased` (no double click). `dragged` is whether the quick
|
||||
/// craft drag collected any slots; `pick_item` is the release of the
|
||||
/// `keyPickItem` button.
|
||||
pub fn release<S>(
|
||||
&mut self,
|
||||
target: &Target<S>,
|
||||
button: Button,
|
||||
carried_empty: bool,
|
||||
dragged: bool,
|
||||
shift: bool,
|
||||
pick_item: bool,
|
||||
) -> Release {
|
||||
if self.quick_crafting.is_some_and(|b| b != button) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = true;
|
||||
return Release::Nothing;
|
||||
}
|
||||
if self.skip_next_release {
|
||||
self.skip_next_release = false;
|
||||
return Release::Nothing;
|
||||
}
|
||||
let quick_crafting = self.quick_crafting.take().is_some();
|
||||
if quick_crafting && dragged {
|
||||
Release::QuickCraft
|
||||
} else if carried_empty {
|
||||
Release::Nothing
|
||||
} else if pick_item {
|
||||
Release::Click(Input::Clone)
|
||||
} else if shift && !matches!(target, Target::Outside) {
|
||||
Release::Click(Input::QuickMove)
|
||||
} else {
|
||||
Release::Click(Input::Pickup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const INV: Image = Image {
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 176,
|
||||
height: 166,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn window_edges() {
|
||||
assert!(!clicked_outside(100.0, 50.0, INV, None));
|
||||
assert!(clicked_outside(99.9, 60.0, INV, None));
|
||||
assert!(clicked_outside(276.0, 60.0, INV, None));
|
||||
assert!(!clicked_outside(275.9, 215.9, INV, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_book_is_not_outside() {
|
||||
let book = Book {
|
||||
visible: true,
|
||||
selected_tab_hovered: false,
|
||||
};
|
||||
assert!(!clicked_outside(10.0, 100.0, INV, Some(book)));
|
||||
assert!(clicked_outside(-60.0, 100.0, INV, Some(book)));
|
||||
assert!(!clicked_outside(
|
||||
-60.0,
|
||||
100.0,
|
||||
INV,
|
||||
Some(Book {
|
||||
selected_tab_hovered: true,
|
||||
..book
|
||||
})
|
||||
));
|
||||
let closed = Book {
|
||||
visible: false,
|
||||
..book
|
||||
};
|
||||
assert!(clicked_outside(10.0, 100.0, INV, Some(closed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_carried_stack_thrown_outside() {
|
||||
let mut clicks = Clicks::default();
|
||||
let out = Target::<usize>::Outside;
|
||||
assert_eq!(clicks.press(&out, Button::Left, false, false, false), None);
|
||||
assert_eq!(
|
||||
clicks.release(&out, Button::Left, false, false, false, false),
|
||||
Release::Click(Input::Pickup)
|
||||
);
|
||||
// Shift never quick-moves outside, and an empty hand throws nothing.
|
||||
assert_eq!(
|
||||
clicks.press(&out, Button::Right, true, true, false),
|
||||
Some(Input::Throw)
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.release(&out, Button::Right, true, false, true, false),
|
||||
Release::Nothing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_button_cancels_the_drag() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::Slot(3);
|
||||
clicks.press(&slot, Button::Left, false, false, false);
|
||||
assert_eq!(
|
||||
clicks.release(&slot, Button::Right, false, true, false, false),
|
||||
Release::Nothing
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.release(&slot, Button::Left, false, true, false, false),
|
||||
Release::Nothing
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.release(&slot, Button::Left, false, true, true, false),
|
||||
Release::Click(Input::QuickMove)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ pub enum BlockEntity {
|
||||
Banner(Banner),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
Spawner(Box<crate::mobs::spawner::Spawner>),
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
@@ -180,6 +182,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:creaking_heart" {
|
||||
return Some(Self::CreakingHeart(Box::default()));
|
||||
}
|
||||
if name == "minecraft:spawner" {
|
||||
return Some(Self::Spawner(Box::default()));
|
||||
}
|
||||
if name == "minecraft:lectern" {
|
||||
return Some(Self::Lectern(Lectern::default()));
|
||||
}
|
||||
@@ -255,6 +260,7 @@ impl BlockEntity {
|
||||
Self::Banner(_) => "minecraft:banner",
|
||||
Self::Crafter(_) => "minecraft:crafter",
|
||||
Self::CreakingHeart(_) => "minecraft:creaking_heart",
|
||||
Self::Spawner(_) => "minecraft:mob_spawner",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -264,7 +270,7 @@ impl BlockEntity {
|
||||
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
|
||||
Self::Sign(_) => Vec::new(),
|
||||
Self::Jukebox(j) => j.record.iter().cloned().collect(),
|
||||
Self::Beacon(_) | Self::CreakingHeart(_) => Vec::new(),
|
||||
Self::Beacon(_) | Self::CreakingHeart(_) | Self::Spawner(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
@@ -283,7 +289,7 @@ impl BlockEntity {
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::CreakingHeart(_) => &[],
|
||||
Self::CreakingHeart(_) | Self::Spawner(_) => &[],
|
||||
}
|
||||
}
|
||||
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
|
||||
@@ -298,7 +304,7 @@ impl BlockEntity {
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::CreakingHeart(_) => &mut [],
|
||||
Self::CreakingHeart(_) | Self::Spawner(_) => &mut [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,6 +433,32 @@ fn remainder(item: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
impl Furnace {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
//! Each connection sends commands as text lines and receives one JSON line per
|
||||
//! command. Commands are executed on the game thread between frames; replies
|
||||
//! that depend on a later frame (screenshots, benchmarks) arrive when ready.
|
||||
//!
|
||||
//! Two transports carry the same lines. On Linux and macOS the endpoint is a
|
||||
//! Unix domain socket. Where those don't exist (Windows), or when the endpoint
|
||||
//! path ends in `.json`, the game listens on a random localhost TCP port and
|
||||
//! writes `{"transport":"tcp","port":N,"token":"..."}` to the path, readable
|
||||
//! only by its user; a client's first line must then be `auth TOKEN`.
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{Ipv4Addr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
@@ -12,54 +19,128 @@ use std::{
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use std::os::unix::net::{UnixListener, UnixStream};
|
||||
#[cfg(not(unix))]
|
||||
pub use unsupported::{UnixListener, UnixStream};
|
||||
|
||||
/// Windows' standard library has no Unix domain sockets: there the control
|
||||
/// socket reports itself unavailable and the game runs without it.
|
||||
#[cfg(not(unix))]
|
||||
mod unsupported {
|
||||
use std::{io, path::Path};
|
||||
/// A connection to the control endpoint, over either transport.
|
||||
pub enum Stream {
|
||||
#[cfg(unix)]
|
||||
Unix(UnixStream),
|
||||
Tcp(TcpStream),
|
||||
}
|
||||
|
||||
fn unavailable() -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"the control socket needs Unix domain sockets (Linux or macOS)",
|
||||
)
|
||||
impl Stream {
|
||||
pub fn try_clone(&self) -> io::Result<Self> {
|
||||
Ok(match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => Self::Unix(s.try_clone()?),
|
||||
Self::Tcp(s) => Self::Tcp(s.try_clone()?),
|
||||
})
|
||||
}
|
||||
pub struct UnixStream;
|
||||
impl UnixStream {
|
||||
pub fn connect<P: AsRef<Path>>(_: P) -> io::Result<Self> {
|
||||
Err(unavailable())
|
||||
}
|
||||
pub fn try_clone(&self) -> io::Result<Self> {
|
||||
Err(unavailable())
|
||||
/// A read timeout (None waits forever).
|
||||
pub fn set_read_timeout(&self, timeout: Option<std::time::Duration>) -> io::Result<()> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => s.set_read_timeout(timeout),
|
||||
Self::Tcp(s) => s.set_read_timeout(timeout),
|
||||
}
|
||||
}
|
||||
impl io::Read for UnixStream {
|
||||
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
|
||||
Err(unavailable())
|
||||
}
|
||||
impl Read for Stream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => s.read(buf),
|
||||
Self::Tcp(s) => s.read(buf),
|
||||
}
|
||||
}
|
||||
impl io::Write for UnixStream {
|
||||
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
|
||||
Err(unavailable())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => s.write(buf),
|
||||
Self::Tcp(s) => s.write(buf),
|
||||
}
|
||||
}
|
||||
pub struct UnixListener;
|
||||
impl UnixListener {
|
||||
pub fn bind<P: AsRef<Path>>(_: P) -> io::Result<Self> {
|
||||
Err(unavailable())
|
||||
}
|
||||
pub fn incoming(&self) -> std::iter::Empty<io::Result<UnixStream>> {
|
||||
std::iter::empty()
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => s.flush(),
|
||||
Self::Tcp(s) => s.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the endpoint at `path` is a TCP port described by a JSON file.
|
||||
pub fn uses_tcp(path: &Path) -> bool {
|
||||
!cfg!(unix) || path.extension().is_some_and(|e| e == "json")
|
||||
}
|
||||
|
||||
/// The TCP endpoint file: (port, token).
|
||||
fn read_endpoint(path: &Path) -> io::Result<(u16, String)> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let bad = || io::Error::new(io::ErrorKind::InvalidData, "not a control endpoint file");
|
||||
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| bad())?;
|
||||
let port = v["port"]
|
||||
.as_u64()
|
||||
.and_then(|p| u16::try_from(p).ok())
|
||||
.ok_or_else(bad)?;
|
||||
let token = v["token"].as_str().ok_or_else(bad)?.to_owned();
|
||||
Ok((port, token))
|
||||
}
|
||||
|
||||
/// Connect to a running game's control endpoint (and authenticate over TCP).
|
||||
pub fn connect(path: &Path) -> io::Result<Stream> {
|
||||
#[cfg(unix)]
|
||||
if !uses_tcp(path) {
|
||||
return UnixStream::connect(path).map(Stream::Unix);
|
||||
}
|
||||
let (port, token) = read_endpoint(path)?;
|
||||
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port))?;
|
||||
stream.set_nodelay(true)?;
|
||||
writeln!(stream, "auth {token}")?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(stream.try_clone()?).read_line(&mut reply)?;
|
||||
if !reply.contains("\"ok\"") {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!("the game refused the token: {}", reply.trim()),
|
||||
));
|
||||
}
|
||||
Ok(Stream::Tcp(stream))
|
||||
}
|
||||
|
||||
/// 128 random bits as hex, from the standard library's OS-seeded hasher keys.
|
||||
fn random_token() -> String {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
(0..2)
|
||||
.map(|i| {
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
h.write_u64(i);
|
||||
h.write_u128(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos()),
|
||||
);
|
||||
h.write_u32(std::process::id());
|
||||
format!("{:016x}", h.finish())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write the endpoint file so that only this user can read it.
|
||||
fn write_private(path: &Path, text: &str) -> io::Result<()> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
options.open(path)?.write_all(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Constant-time comparison of the presented token.
|
||||
fn same_token(a: &str, b: &str) -> bool {
|
||||
a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |d, (x, y)| d | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// One command line from a client, with the channel for its JSON reply.
|
||||
pub struct Request {
|
||||
pub line: String,
|
||||
@@ -71,33 +152,55 @@ pub struct Server {
|
||||
requests: mpsc::Receiver<Request>,
|
||||
}
|
||||
|
||||
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime directory.
|
||||
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime
|
||||
/// directory (`horizon-control.json` in the temporary directory on Windows).
|
||||
pub fn default_path() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("HORIZON_CONTROL") {
|
||||
return path.into();
|
||||
}
|
||||
if !cfg!(unix) {
|
||||
return std::env::temp_dir().join("horizon-control.json");
|
||||
}
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR").map_or_else(std::env::temp_dir, PathBuf::from);
|
||||
dir.join("horizon-control.sock")
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn start(path: &Path) -> std::io::Result<Self> {
|
||||
// A socket left by a crashed game refuses connections; replace it.
|
||||
if path.exists() && UnixStream::connect(path).is_err() {
|
||||
pub fn start(path: &Path) -> io::Result<Self> {
|
||||
// An endpoint left by a crashed game refuses connections; replace it.
|
||||
if path.exists() && connect(path).is_err() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
let listener = UnixListener::bind(path)?;
|
||||
let (sender, requests) = mpsc::channel();
|
||||
thread::Builder::new()
|
||||
.name("horizon-control".into())
|
||||
.spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let sender = sender.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("horizon-control-client".into())
|
||||
.spawn(move || serve(stream, &sender));
|
||||
}
|
||||
})?;
|
||||
if uses_tcp(path) {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let token = random_token();
|
||||
let port = listener.local_addr()?.port();
|
||||
write_private(
|
||||
path,
|
||||
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
|
||||
.to_string(),
|
||||
)?;
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
move |stream: TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let mut stream = Stream::Tcp(stream);
|
||||
authenticate(&mut stream, &token).then_some(stream)
|
||||
},
|
||||
)?;
|
||||
} else {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let listener = UnixListener::bind(path)?;
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
|s| Some(Stream::Unix(s)),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
path: path.to_owned(),
|
||||
requests,
|
||||
@@ -111,13 +214,68 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept connections on a thread, one more thread per client.
|
||||
fn spawn_listener<S: Send + 'static>(
|
||||
mut accept_next: impl FnMut() -> io::Result<S> + Send + 'static,
|
||||
sender: mpsc::Sender<Request>,
|
||||
accept: impl Fn(S) -> Option<Stream> + Send + Sync + 'static,
|
||||
) -> io::Result<()> {
|
||||
let accept = std::sync::Arc::new(accept);
|
||||
thread::Builder::new()
|
||||
.name("horizon-control".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let Ok(stream) = accept_next() else {
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
continue;
|
||||
};
|
||||
let sender = sender.clone();
|
||||
let accept = accept.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("horizon-control-client".into())
|
||||
.spawn(move || {
|
||||
if let Some(stream) = accept(stream) {
|
||||
serve(stream, &sender);
|
||||
}
|
||||
});
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first line of a TCP client must be `auth TOKEN`.
|
||||
fn authenticate(stream: &mut Stream, token: &str) -> bool {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
// Read the line byte by byte so nothing after it is buffered away.
|
||||
while line.len() < 256 {
|
||||
match stream.read(&mut byte) {
|
||||
Ok(1) if byte[0] == b'\n' => break,
|
||||
Ok(1) => line.push(byte[0]),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
let line = String::from_utf8_lossy(&line);
|
||||
let ok = line
|
||||
.trim()
|
||||
.strip_prefix("auth ")
|
||||
.is_some_and(|t| same_token(t.trim(), token));
|
||||
let reply = if ok {
|
||||
r#"{"ok":true}"#
|
||||
} else {
|
||||
r#"{"error":"bad or missing token: the first line must be auth TOKEN"}"#
|
||||
};
|
||||
let _ = writeln!(stream, "{reply}");
|
||||
ok
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: UnixStream, sender: &mpsc::Sender<Request>) {
|
||||
fn serve(stream: Stream, sender: &mpsc::Sender<Request>) {
|
||||
let Ok(mut writer) = stream.try_clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -140,10 +298,86 @@ fn serve(stream: UnixStream, sender: &mpsc::Sender<Request>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame durations since the last reset, summarized like the Java recorder.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tcp_endpoint_round_trip() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("horizon-ctl-test-{}.json", std::process::id()));
|
||||
let server = Server::start(&path).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("\"tcp\""));
|
||||
let mut client = connect(&path).unwrap();
|
||||
writeln!(client, "status").unwrap();
|
||||
let request = loop {
|
||||
if let Some(r) = server.try_recv() {
|
||||
break r;
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(request.line, "status");
|
||||
request.reply.send(r#"{"ok":true}"#.into()).unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(client).read_line(&mut reply).unwrap();
|
||||
assert_eq!(reply.trim(), r#"{"ok":true}"#);
|
||||
drop(server);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_refuses_a_wrong_token() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("horizon-ctl-bad-{}.json", std::process::id()));
|
||||
let _server = Server::start(&path).unwrap();
|
||||
let (port, _) = read_endpoint(&path).unwrap();
|
||||
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
writeln!(stream, "auth nope").unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(stream).read_line(&mut reply).unwrap();
|
||||
assert!(reply.contains("error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_differ() {
|
||||
assert_ne!(random_token(), random_token());
|
||||
assert_eq!(random_token().len(), 32);
|
||||
assert!(same_token("abc", "abc") && !same_token("abc", "abd") && !same_token("a", "ab"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame durations since the last reset, summarized like the Java recorder,
|
||||
/// with the CPU part of each frame and GPU times from timestamp queries.
|
||||
#[derive(Default)]
|
||||
pub struct FrameStats {
|
||||
millis: Vec<f32>,
|
||||
cpu: Vec<f32>,
|
||||
gpu: Vec<f32>,
|
||||
/// Milliseconds per frame phase (`PHASES`), one entry per rendered frame.
|
||||
phases: Vec<[f32; PHASES.len()]>,
|
||||
}
|
||||
|
||||
/// The parts of a frame's CPU time: world streaming and uploads, game ticks,
|
||||
/// building the frame's geometry and HUD, and the renderer's own work.
|
||||
pub const PHASES: [&str; 4] = ["update", "tick", "prepare", "render"];
|
||||
|
||||
fn percentiles(values: &[f32]) -> serde_json::Value {
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
let n = sorted.len();
|
||||
if n == 0 {
|
||||
return serde_json::Value::Null;
|
||||
}
|
||||
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];
|
||||
serde_json::json!({
|
||||
"avg_ms": total / n as f64,
|
||||
"p50_ms": percentile(0.5),
|
||||
"p95_ms": percentile(0.95),
|
||||
"p99_ms": percentile(0.99),
|
||||
"max_ms": sorted[n - 1],
|
||||
})
|
||||
}
|
||||
|
||||
impl FrameStats {
|
||||
@@ -153,8 +387,52 @@ impl FrameStats {
|
||||
self.millis.push(millis);
|
||||
}
|
||||
}
|
||||
/// The frame's own work: its duration minus waits for the GPU, the
|
||||
/// display and the frame limiter.
|
||||
pub fn record_cpu(&mut self, millis: f32) {
|
||||
if self.cpu.len() < Self::LIMIT {
|
||||
self.cpu.push(millis);
|
||||
}
|
||||
}
|
||||
pub fn record_phases(&mut self, millis: [f32; PHASES.len()]) {
|
||||
if self.phases.len() < Self::LIMIT {
|
||||
self.phases.push(millis);
|
||||
}
|
||||
}
|
||||
/// Average milliseconds per phase over the frames whose CPU time is at
|
||||
/// least `min_cpu` (all frames for 0): where the slow frames go.
|
||||
fn phase_average(&self, min_cpu: f32) -> serde_json::Value {
|
||||
let mut sums = [0.0f64; PHASES.len()];
|
||||
let mut n = 0;
|
||||
for (i, phases) in self.phases.iter().enumerate() {
|
||||
if self.cpu.get(i).is_some_and(|&c| c >= min_cpu) {
|
||||
for (sum, m) in sums.iter_mut().zip(phases) {
|
||||
*sum += f64::from(*m);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
PHASES
|
||||
.iter()
|
||||
.zip(sums)
|
||||
.map(|(name, sum)| (name.to_string(), (sum / f64::from(n.max(1))).into()))
|
||||
.collect::<serde_json::Map<_, _>>()
|
||||
.into()
|
||||
}
|
||||
pub fn record_gpu(&mut self, millis: &[f32]) {
|
||||
if self.gpu.len() < Self::LIMIT {
|
||||
self.gpu.extend_from_slice(millis);
|
||||
}
|
||||
}
|
||||
/// Every recorded frame time, in milliseconds, in order.
|
||||
pub fn frame_millis(&self) -> &[f32] {
|
||||
&self.millis
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.millis.clear();
|
||||
self.cpu.clear();
|
||||
self.gpu.clear();
|
||||
self.phases.clear();
|
||||
}
|
||||
pub fn summary(&self) -> serde_json::Value {
|
||||
let mut sorted = self.millis.clone();
|
||||
@@ -172,6 +450,15 @@ impl FrameStats {
|
||||
.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();
|
||||
cpu.sort_by(f32::total_cmp);
|
||||
let p99 = cpu
|
||||
.get(cpu.len().saturating_sub(cpu.len().div_ceil(100)))
|
||||
.copied();
|
||||
p99.map_or(serde_json::Value::Null, |p| self.phase_average(p))
|
||||
};
|
||||
serde_json::json!({
|
||||
"frames": n,
|
||||
"seconds": total / 1000.0,
|
||||
@@ -181,6 +468,10 @@ impl FrameStats {
|
||||
"p95_ms": percentile(0.95),
|
||||
"p99_ms": percentile(0.99),
|
||||
"max_ms": sorted[n - 1],
|
||||
"cpu": percentiles(&self.cpu),
|
||||
"gpu": percentiles(&self.gpu),
|
||||
"phases_avg_ms": self.phase_average(0.0),
|
||||
"slow_phases_avg_ms": slow_phases,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1061,7 +1061,7 @@ impl Special {
|
||||
}
|
||||
}
|
||||
/// `DyedItemColor.applyDyes` over dye indexes.
|
||||
fn apply_dyes(current: Option<i32>, dyes: &[usize]) -> i32 {
|
||||
pub(crate) fn apply_dyes(current: Option<i32>, dyes: &[usize]) -> i32 {
|
||||
let (mut r, mut g, mut b, mut intensity, mut n) = (0i32, 0i32, 0i32, 0i32, 0i32);
|
||||
let mut add = |rgb: u32| {
|
||||
let (red, green, blue) = (
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
//! Crash reports, laid out like vanilla's CrashReport.getFriendlyReport: a comment, the time,
|
||||
//! a description, the error with its stack, then the details of the game and the machine.
|
||||
//! Written to `crash-reports/crash-<yyyy-MM-dd_HH.mm.ss>-client.txt` in the game folder when
|
||||
//! the main thread panics or `main` gets an error back. It leaves the machine only scrubbed, and
|
||||
//! only if the player allows crash reports (`telemetry`).
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
static REPORTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Sets one line of the report's details (replacing the last value under `key`).
|
||||
pub fn set_detail(key: &'static str, value: impl Into<String>) {
|
||||
let mut details = DETAILS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let value = value.into();
|
||||
match details.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some(entry) => entry.1 = value,
|
||||
None => details.push((key, value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_detail(key: &'static str) {
|
||||
DETAILS
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.retain(|(k, _)| *k != key);
|
||||
}
|
||||
|
||||
fn details() -> Vec<(&'static str, String)> {
|
||||
DETAILS.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// Vanilla picks a line from a list of jokes; these only say what happened.
|
||||
const COMMENTS: &[&str] = &[
|
||||
"Something went wrong.",
|
||||
"The game stopped here.",
|
||||
"This should not have happened.",
|
||||
"Details below.",
|
||||
];
|
||||
|
||||
/// One crash, ready to be written.
|
||||
pub struct Report {
|
||||
pub time: DateTime<Local>,
|
||||
pub description: String,
|
||||
/// The error's message, first line of the stack section.
|
||||
pub error: String,
|
||||
/// Where it happened and how it got there: one frame a line (`function (file:line:col)`).
|
||||
pub stack: String,
|
||||
pub thread: String,
|
||||
/// `client` or `server`, the end of the file name.
|
||||
pub side: &'static str,
|
||||
pub details: Vec<(String, String)>,
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
/// `crash-2026-10-14_09.05.07-client.txt`
|
||||
pub fn file_name(time: &DateTime<Local>, side: &str) -> String {
|
||||
format!("crash-{}-{side}.txt", time.format("%Y-%m-%d_%H.%M.%S"))
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// The report's text.
|
||||
pub fn text(&self) -> String {
|
||||
let comment = COMMENTS[self.time.timestamp().rem_euclid(COMMENTS.len() as i64) as usize];
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "---- Horizon Crash Report ----");
|
||||
let _ = writeln!(out, "// {comment}\n");
|
||||
let _ = writeln!(out, "Time: {}", self.time.format("%Y-%m-%d %H:%M:%S"));
|
||||
let _ = writeln!(out, "Description: {}\n", self.description);
|
||||
let _ = writeln!(out, "{}", self.error);
|
||||
for line in self.stack.lines() {
|
||||
let _ = writeln!(out, "\tat {line}");
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\n\nA detailed walkthrough of the error, its code path and all known details is as follows:"
|
||||
);
|
||||
let _ = writeln!(out, "{}\n", "-".repeat(87));
|
||||
let _ = writeln!(out, "-- Head --");
|
||||
let _ = writeln!(out, "Thread: {}\n", self.thread);
|
||||
let _ = writeln!(out, "-- System Details --");
|
||||
let _ = writeln!(out, "Details:");
|
||||
for (key, value) in &self.details {
|
||||
let _ = writeln!(out, "\t{key}: {value}");
|
||||
}
|
||||
let _ = writeln!(out, "\n-- Last {} log lines --", self.log.len());
|
||||
for line in &self.log {
|
||||
let _ = writeln!(out, "{line}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Writes the report in `home/crash-reports` and returns its path.
|
||||
pub fn save(&self, home: &Path) -> std::io::Result<PathBuf> {
|
||||
let dir = crate::logging::crash_reports_dir(home);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(file_name(&self.time, self.side));
|
||||
std::fs::write(&path, self.text())?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The release this binary came with: launcher installs live in a folder named after the tag.
|
||||
pub fn release_tag() -> Option<String> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let folder = exe.parent()?.file_name()?.to_str()?;
|
||||
(folder.starts_with('v') && folder[1..].starts_with(|c: char| c.is_ascii_digit()))
|
||||
.then(|| folder.to_owned())
|
||||
}
|
||||
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
let version = if cfg!(target_os = "linux") {
|
||||
let name = read("/etc/os-release").and_then(|t| {
|
||||
t.lines()
|
||||
.find_map(|l| l.strip_prefix("PRETTY_NAME="))
|
||||
.map(|v| v.trim_matches('"').to_owned())
|
||||
});
|
||||
let kernel = read("/proc/sys/kernel/osrelease").map(|k| k.trim().to_owned());
|
||||
match (name, kernel) {
|
||||
(Some(n), Some(k)) => Some(format!("{n}, kernel {k}")),
|
||||
(n, k) => n.or(k),
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
|
||||
} else if cfg!(windows) {
|
||||
command_output("cmd", &["/C", "ver"])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
format!(
|
||||
"{} ({arch}) version {}",
|
||||
std::env::consts::OS,
|
||||
version.unwrap_or_else(|| "unknown".into())
|
||||
)
|
||||
}
|
||||
|
||||
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| {
|
||||
t.lines()
|
||||
.find(|l| l.starts_with("model name"))
|
||||
.and_then(|l| l.split_once(':'))
|
||||
.map(|(_, v)| v.trim().to_owned())
|
||||
})
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
format!(
|
||||
"{threads}x {}",
|
||||
name.unwrap_or_else(|| std::env::consts::ARCH.into())
|
||||
)
|
||||
}
|
||||
|
||||
/// Total memory and what this process uses, in MiB where the system says.
|
||||
fn memory() -> String {
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
let kib = |text: &str, key: &str| -> Option<u64> {
|
||||
text.lines()
|
||||
.find(|l| l.starts_with(key))?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse()
|
||||
.ok()
|
||||
};
|
||||
let (total, used) = if cfg!(target_os = "linux") {
|
||||
(
|
||||
read("/proc/meminfo")
|
||||
.and_then(|t| kib(&t, "MemTotal:"))
|
||||
.map(|k| k * 1024),
|
||||
read("/proc/self/status")
|
||||
.and_then(|t| kib(&t, "VmRSS:"))
|
||||
.map(|k| k * 1024),
|
||||
)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
(
|
||||
command_output("sysctl", &["-n", "hw.memsize"]).and_then(|v| v.parse().ok()),
|
||||
None,
|
||||
)
|
||||
} else if cfg!(windows) {
|
||||
(
|
||||
command_output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)
|
||||
.and_then(|v| v.parse().ok()),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
match (total, used) {
|
||||
(Some(t), Some(u)) => format!(
|
||||
"{} MiB used by the game, {} MiB installed",
|
||||
u / MIB,
|
||||
t / MIB
|
||||
),
|
||||
(Some(t), None) => format!("{} MiB installed", t / MIB),
|
||||
_ => "unknown".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
|
||||
pub fn system_details() -> Vec<(String, String)> {
|
||||
let mut version = env!("CARGO_PKG_VERSION").to_owned();
|
||||
if let Some(tag) = release_tag() {
|
||||
version = format!("{tag} (client {version})");
|
||||
}
|
||||
let mut out = vec![
|
||||
("Horizon Version".to_owned(), version),
|
||||
("Minecraft Version".to_owned(), "26.3".to_owned()),
|
||||
("Operating System".to_owned(), operating_system()),
|
||||
("CPU".to_owned(), cpu()),
|
||||
("Memory".to_owned(), memory()),
|
||||
];
|
||||
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
|
||||
out
|
||||
}
|
||||
|
||||
fn thread_name() -> String {
|
||||
let current = std::thread::current();
|
||||
match current.name() {
|
||||
Some("main") | None => "Render thread".into(),
|
||||
Some(name) => name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
// The panic is on stderr already: the stack goes to the log only.
|
||||
crate::logging::write_only(
|
||||
log::Level::Error,
|
||||
&format!(
|
||||
"Horizon crashed: {}\n{}",
|
||||
report.error,
|
||||
report
|
||||
.stack
|
||||
.lines()
|
||||
.map(|l| format!("\tat {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
),
|
||||
);
|
||||
crate::logging::flush();
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
match report.save(home) {
|
||||
Ok(path) => {
|
||||
eprintln!("Horizon: crash report saved to {}", path.display());
|
||||
Some(path)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Horizon: could not save a crash report: {e}");
|
||||
eprintln!("{}", report.text());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let payload = info.payload();
|
||||
let text = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into());
|
||||
match info.location() {
|
||||
Some(at) => format!("panicked at {at}:\n{text}"),
|
||||
None => format!("panicked:\n{text}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `std::backtrace` as one line a frame, `function (file:line:col)`. Captured in a panic hook
|
||||
/// (`from_panic`), it starts at the frame that panicked: the hook's and the panic machinery's
|
||||
/// frames go. Paths inside the workspace lose the build machine's prefix.
|
||||
pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
|
||||
// The workspace as it was on the machine that built this binary (it need not exist here).
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
let root = manifest
|
||||
.strip_suffix("crates/horizon-client")
|
||||
.unwrap_or(manifest);
|
||||
let mut frames: Vec<(String, Option<String>)> = Vec::new();
|
||||
for line in backtrace.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(at) = line.strip_prefix("at ") {
|
||||
if let Some(frame) = frames.last_mut() {
|
||||
frame.1 = Some(short_path(at, root).to_owned());
|
||||
}
|
||||
} else if let Some((n, name)) = line.split_once(": ")
|
||||
&& n.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
frames.push((name.to_owned(), None));
|
||||
}
|
||||
}
|
||||
let machinery = |name: &str| {
|
||||
[
|
||||
"std::panicking::",
|
||||
"core::panicking::",
|
||||
"std::sys::backtrace::",
|
||||
"__rustc::",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| name.starts_with(p))
|
||||
|| name.contains("rust_begin_unwind")
|
||||
};
|
||||
// The hook's frames, then the panic machinery's, then the code that panicked.
|
||||
let top = frames.iter().take(30).position(|(name, _)| machinery(name));
|
||||
let first = match top.filter(|_| from_panic) {
|
||||
Some(start) => {
|
||||
start
|
||||
+ frames[start..]
|
||||
.iter()
|
||||
.take_while(|(name, _)| machinery(name))
|
||||
.count()
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
// Some platforms keep std's own capture frames (`<std::backtrace::Backtrace>::create`).
|
||||
let first = first
|
||||
+ frames[first..]
|
||||
.iter()
|
||||
.take_while(|(name, _)| name.trim_start_matches('<').starts_with("std::backtrace"))
|
||||
.count();
|
||||
frames[first..]
|
||||
.iter()
|
||||
.map(|(name, at)| match at {
|
||||
Some(at) => format!("{name} ({at})\n"),
|
||||
None => format!("{name}\n"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A source path without the build machine's folders: relative to the workspace, or from the
|
||||
/// crate's folder for dependencies (`winit-0.30.13/src/event_loop.rs:642:56`).
|
||||
fn short_path<'a>(path: &'a str, root: &str) -> &'a str {
|
||||
if let Some(rest) = path.strip_prefix(root) {
|
||||
return rest;
|
||||
}
|
||||
if let Some(i) = path.find("/registry/src/") {
|
||||
let rest = &path[i + "/registry/src/".len()..];
|
||||
if let Some((_index, krate)) = rest.split_once('/') {
|
||||
return krate;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// A report for a panic, with the stack captured now (RUST_BACKTRACE or not).
|
||||
pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Report {
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Unexpected error".into(),
|
||||
error: panic_message(info),
|
||||
stack: stack_lines(
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
),
|
||||
thread: thread_name(),
|
||||
side,
|
||||
details: system_details(),
|
||||
log: crate::logging::recent_lines(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
default(info);
|
||||
let main = std::thread::current().name() == Some("main");
|
||||
if main {
|
||||
deliver(&home, panic_report(info, side));
|
||||
} else {
|
||||
log::error!(
|
||||
"Caught previously unhandled exception in thread {}: {}",
|
||||
thread_name(),
|
||||
panic_message(info)
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Vanilla's debug crash (`key.debug.crash` held for ten seconds): the error `main` gets back,
|
||||
/// with the stack where it was raised.
|
||||
#[derive(Debug)]
|
||||
pub struct DebugCrash {
|
||||
stack: String,
|
||||
}
|
||||
|
||||
impl DebugCrash {
|
||||
pub const MESSAGE: &str = "Manually triggered debug crash";
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
stack: stack_lines(
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DebugCrash {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str(Self::MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DebugCrash {}
|
||||
|
||||
/// A report for an error `main` got back: the game ends with it.
|
||||
pub fn report_error(
|
||||
home: &Path,
|
||||
side: &'static str,
|
||||
error: &(dyn std::error::Error + 'static),
|
||||
) -> Option<PathBuf> {
|
||||
if let Some(debug) = error.downcast_ref::<DebugCrash>() {
|
||||
return deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: DebugCrash::MESSAGE.into(),
|
||||
error: DebugCrash::MESSAGE.into(),
|
||||
stack: debug.stack.clone(),
|
||||
thread: thread_name(),
|
||||
side,
|
||||
details: system_details(),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut message = error.to_string();
|
||||
let mut source = error.source();
|
||||
while let Some(e) = source {
|
||||
let _ = write!(message, "\nCaused by: {e}");
|
||||
source = e.source();
|
||||
}
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Fatal error".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
side,
|
||||
details: system_details(),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Called every frame: panics once `HORIZON_DEBUG_CRASH` asks (development builds only).
|
||||
pub fn debug_crash_tick() {
|
||||
static AT: std::sync::OnceLock<Option<(std::time::Instant, std::time::Duration)>> =
|
||||
std::sync::OnceLock::new();
|
||||
if let Some((first, after)) =
|
||||
AT.get_or_init(|| debug_crash_after().map(|d| (std::time::Instant::now(), d)))
|
||||
&& first.elapsed() >= *after
|
||||
{
|
||||
panic!("Manually triggered debug crash");
|
||||
}
|
||||
}
|
||||
|
||||
/// Development builds crash on purpose when `HORIZON_DEBUG_CRASH` is set (vanilla has F3+C held
|
||||
/// for ten seconds): the number is seconds after the first frame (default 5).
|
||||
pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
if !cfg!(debug_assertions) {
|
||||
return None;
|
||||
}
|
||||
let value = std::env::var("HORIZON_DEBUG_CRASH").ok()?;
|
||||
let seconds = value.parse::<f64>().unwrap_or(5.0).max(0.0);
|
||||
Some(std::time::Duration::from_secs_f64(seconds))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn report() -> Report {
|
||||
Report {
|
||||
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
|
||||
description: "Unexpected error".into(),
|
||||
error: "panicked at src/game.rs:10:5:\nboom".into(),
|
||||
stack: "horizon_client::game::frame (src/game.rs:10:5)\n".into(),
|
||||
thread: "Render thread".into(),
|
||||
side: "client",
|
||||
details: vec![
|
||||
("Horizon Version".into(), "0.1.0".into()),
|
||||
("Minecraft Version".into(), "26.3".into()),
|
||||
("Graphics".into(), "Test GPU (Vulkan), driver 1.0".into()),
|
||||
],
|
||||
log: vec!["[09:05:06] [Render thread/INFO]: load assets: 1.20s".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_are_vanillas() {
|
||||
assert_eq!(
|
||||
file_name(&report().time, "client"),
|
||||
"crash-2026-10-14_09.05.07-client.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_reads_like_vanillas() {
|
||||
let text = report().text();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines[0], "---- Horizon Crash Report ----");
|
||||
assert!(lines[1].starts_with("// "));
|
||||
assert_eq!(lines[3], "Time: 2026-10-14 09:05:07");
|
||||
assert_eq!(lines[4], "Description: Unexpected error");
|
||||
assert_eq!(lines[6], "panicked at src/game.rs:10:5:");
|
||||
assert_eq!(lines[7], "boom");
|
||||
assert_eq!(
|
||||
lines[8],
|
||||
"\tat horizon_client::game::frame (src/game.rs:10:5)"
|
||||
);
|
||||
assert!(text.contains("-- Head --\nThread: Render thread\n"));
|
||||
assert!(text.contains(
|
||||
"-- System Details --\nDetails:\n\tHorizon Version: 0.1.0\n\tMinecraft Version: 26.3\n\tGraphics: Test GPU (Vulkan), driver 1.0\n"
|
||||
));
|
||||
assert!(text.ends_with(
|
||||
"-- Last 1 log lines --\n[09:05:06] [Render thread/INFO]: load assets: 1.20s\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_start_at_the_panicking_frame() {
|
||||
let bt = format!(
|
||||
" 0: horizon_client::crash::panic_report\n at {root}/src/crash.rs:296:16\n 1: std::panicking::panic_with_hook\n at /rustc/abc/library/std/src/panicking.rs:823:13\n 2: core::panicking::panic_fmt\n at /rustc/abc/library/core/src/panicking.rs:80:14\n 3: horizon_client::crash::debug_crash_tick\n at {root}/src/crash.rs:359:9\n 4: winit::dispatch\n at /home/someone/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/event_loop.rs:642:56\n 5: main\n",
|
||||
root = env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
assert_eq!(
|
||||
stack_lines(&bt, true),
|
||||
"horizon_client::crash::debug_crash_tick (crates/horizon-client/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_debug_crash_is_reported_as_vanillas() {
|
||||
let error: Box<dyn std::error::Error> = DebugCrash::new().into();
|
||||
assert_eq!(error.to_string(), "Manually triggered debug crash");
|
||||
let debug = error.downcast_ref::<DebugCrash>().unwrap();
|
||||
// The stack starts where the crash was raised (DebugCrash::new, in this file).
|
||||
let first = debug.stack.lines().next().unwrap();
|
||||
assert!(first.contains("crash.rs"), "{}", debug.stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_a_report() {
|
||||
// A real panic through a hook, as the game's would be.
|
||||
let caught = std::sync::Arc::new(Mutex::new(None));
|
||||
let sink = caught.clone();
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if panic_message(info).contains("the test crash") {
|
||||
*sink.lock().unwrap() = Some(panic_report(info, "client"));
|
||||
}
|
||||
}));
|
||||
let _ = std::panic::catch_unwind(|| panic!("the test crash"));
|
||||
std::panic::set_hook(previous);
|
||||
let report = caught.lock().unwrap().take().expect("the hook ran");
|
||||
assert!(report.error.contains("the test crash"));
|
||||
assert!(report.error.contains("crash.rs"));
|
||||
// The first frame is the test's own code, not the panic machinery.
|
||||
let first = report.stack.lines().next().unwrap();
|
||||
assert!(first.contains("crash.rs"), "{}", report.stack);
|
||||
let text = report.text();
|
||||
assert!(text.contains("Description: Unexpected error"));
|
||||
assert!(text.contains("\tMinecraft Version: 26.3\n"));
|
||||
assert!(text.contains("\tOperating System: "));
|
||||
assert!(text.contains("\tCPU: "));
|
||||
let home = std::env::temp_dir().join(format!("horizon-crash-save-{}", std::process::id()));
|
||||
let path = report.save(&home).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
|
||||
assert!(
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.ends_with("-client.txt")
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
|
||||
//! contents as built by the game's own display generators
|
||||
//! (`creative-tabs.json`), a 9x5 scrolling grid and the search tab.
|
||||
use crate::Result;
|
||||
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
|
||||
use crate::{Result, items::ItemStack};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
@@ -10,19 +10,35 @@ pub struct Tab {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub icon: String,
|
||||
pub items: Vec<String>,
|
||||
pub items: Vec<ItemStack>,
|
||||
pub top: bool,
|
||||
pub column: u32,
|
||||
pub search: bool,
|
||||
pub inventory: bool,
|
||||
}
|
||||
/// What the client's creative search trees (SessionSearchTrees) index for one
|
||||
/// stack of the search tab: its creative tooltip lines and its item tags.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct SearchEntry {
|
||||
/// The item id's namespace and path, lower-cased.
|
||||
namespace: String,
|
||||
path: String,
|
||||
/// Tooltip lines, lower-cased; empty when the resources predate them.
|
||||
lines: Vec<String>,
|
||||
/// Item tags as (namespace, path), lower-cased.
|
||||
tags: Vec<(String, String)>,
|
||||
}
|
||||
pub struct Creative {
|
||||
pub tabs: Vec<Tab>,
|
||||
pub selected: usize,
|
||||
/// Scroll position 0..1.
|
||||
/// Scroll position 0..1 (scrollOffs).
|
||||
pub scroll: f32,
|
||||
/// The scrollbar is held (scrolling): dragging moves it.
|
||||
pub scrolling: bool,
|
||||
pub search: String,
|
||||
all_items: Vec<String>,
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
}
|
||||
/// Display names from the pinned en_us language file.
|
||||
pub struct Names {
|
||||
@@ -51,26 +67,120 @@ impl Names {
|
||||
impl Creative {
|
||||
pub const WIDTH: f32 = 195.0;
|
||||
pub const HEIGHT: f32 = 136.0;
|
||||
pub const TRASH_POSITION: (f32, f32) = (173.0, 112.0);
|
||||
|
||||
/// Visible player slots in the creative inventory tab (game slot ids).
|
||||
pub fn inventory_slots() -> impl Iterator<Item = usize> {
|
||||
(0..36).chain(500..505)
|
||||
}
|
||||
|
||||
/// CreativeModeInventoryScreen.selectTab positions its InventoryMenu wrappers.
|
||||
pub fn inventory_slot_position(slot: usize) -> Option<(f32, f32)> {
|
||||
match slot {
|
||||
0..9 => Some((9.0 + slot as f32 * 18.0, 112.0)),
|
||||
9..36 => Some((
|
||||
9.0 + ((slot - 9) % 9) as f32 * 18.0,
|
||||
54.0 + ((slot - 9) / 9) as f32 * 18.0,
|
||||
)),
|
||||
500..504 => {
|
||||
let position = slot - 500;
|
||||
Some((
|
||||
54.0 + (position / 2) as f32 * 54.0,
|
||||
6.0 + (position % 2) as f32 * 27.0,
|
||||
))
|
||||
}
|
||||
504 => Some((35.0, 20.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;
|
||||
pub const SCROLL_WIDTH: f32 = 14.0;
|
||||
pub const SCROLL_HEIGHT: f32 = 112.0;
|
||||
/// `creative-inventory.json` (VanillaCreativeTabsExporter); resources
|
||||
/// prepared before it fall back to `creative-tabs.json`'s item ids.
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let raw: Value = serde_json::from_slice(&fs::read(root.join("creative-tabs.json"))?)?;
|
||||
let path = root.join("creative-inventory.json");
|
||||
if path.exists() {
|
||||
Self::from_json(&serde_json::from_slice(&fs::read(path)?)?)
|
||||
} else {
|
||||
Self::from_legacy(&serde_json::from_slice(&fs::read(
|
||||
root.join("creative-tabs.json"),
|
||||
)?)?)
|
||||
}
|
||||
}
|
||||
pub fn from_json(raw: &Value) -> Result<Self> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut all_items: Vec<String> = Vec::new();
|
||||
for t in raw.as_array().into_iter().flatten() {
|
||||
let items: Vec<String> = t["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
for i in &items {
|
||||
if !all_items.contains(i) {
|
||||
all_items.push(i.clone());
|
||||
let mut search_items = Vec::new();
|
||||
let mut search_index = Vec::new();
|
||||
for t in raw["tabs"].as_array().into_iter().flatten() {
|
||||
let mut items = Vec::new();
|
||||
for v in t["items"].as_array().into_iter().flatten() {
|
||||
let snbt = v.as_str().unwrap_or("-");
|
||||
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
|
||||
items.push(stack);
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
if kind == "SEARCH" {
|
||||
let strings = |v: &Value| -> Vec<String> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| s.as_str().map(java_lower))
|
||||
.collect()
|
||||
};
|
||||
for (i, stack) in items.iter().enumerate() {
|
||||
let (namespace, path) = split_id(&java_lower(&stack.item));
|
||||
search_index.push(SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
lines: strings(&t["lines"][i]),
|
||||
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
|
||||
});
|
||||
}
|
||||
search_items = items.clone();
|
||||
}
|
||||
if kind == "HOTBAR" || t["visible"] == false {
|
||||
continue;
|
||||
}
|
||||
let icon = t["icon"]
|
||||
.as_str()
|
||||
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
});
|
||||
}
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
}
|
||||
/// The old export: item ids per tab, no components, tooltips or tags.
|
||||
fn from_legacy(raw: &Value) -> Result<Self> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut search_items: Vec<ItemStack> = Vec::new();
|
||||
for t in raw.as_array().into_iter().flatten() {
|
||||
let items: Vec<ItemStack> = t["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(|id| ItemStack::new(id, 1)))
|
||||
.collect();
|
||||
if t["id"] == "minecraft:hotbar" || t["id"] == "minecraft:op_blocks" {
|
||||
continue;
|
||||
}
|
||||
for i in &items {
|
||||
if !search_items.iter().any(|s| s.item == i.item) {
|
||||
search_items.push(i.clone());
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
@@ -82,31 +192,101 @@ impl Creative {
|
||||
inventory: kind == "INVENTORY",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
let search_index = search_items
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let (namespace, path) = split_id(&java_lower(&s.item));
|
||||
SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
}
|
||||
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
|
||||
Self {
|
||||
tabs,
|
||||
selected: 0,
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
search: String::new(),
|
||||
all_items,
|
||||
})
|
||||
search_items,
|
||||
search_index,
|
||||
}
|
||||
}
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
}
|
||||
/// Items shown by the selected tab (the search tab filters by name).
|
||||
pub fn items(&self, names: &Names) -> Vec<String> {
|
||||
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
|
||||
pub fn select(&mut self, tab: usize) {
|
||||
if tab != self.selected {
|
||||
self.search.clear();
|
||||
}
|
||||
self.selected = tab;
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
/// The search tab's stacks.
|
||||
pub fn search_items(&self) -> &[ItemStack] {
|
||||
&self.search_items
|
||||
}
|
||||
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
|
||||
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
/// results in the tab's order).
|
||||
pub fn search_results(&self, term: &str, names: &Names) -> Vec<usize> {
|
||||
let all = 0..self.search_index.len();
|
||||
if term.is_empty() {
|
||||
return all.collect();
|
||||
}
|
||||
if let Some(tag) = term.strip_prefix('#') {
|
||||
// IdSearchTree over the stacks' tags.
|
||||
let text = java_lower(tag);
|
||||
return all
|
||||
.filter(|&i| {
|
||||
let tags = &self.search_index[i].tags;
|
||||
match text.split_once(':') {
|
||||
None => any(tags.iter().map(|t| t.1.as_str()), &text),
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
any(tags.iter().map(|t| t.0.as_str()), ns)
|
||||
&& any(tags.iter().map(|t| t.1.as_str()), path)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
let text = java_lower(term);
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
let lines: &[String] = if e.lines.is_empty() {
|
||||
fallback = [java_lower(&names.item(&self.search_items[i].item))];
|
||||
&fallback
|
||||
} else {
|
||||
&e.lines
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => any(lines.iter().map(String::as_str), &text),
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path) || any(lines.iter().map(String::as_str), path))
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Items shown by the selected tab (the search tab filters by `search`).
|
||||
pub fn items(&self, names: &Names) -> Vec<ItemStack> {
|
||||
let tab = self.tab();
|
||||
if tab.search {
|
||||
let query = self.search.to_lowercase();
|
||||
return self
|
||||
.all_items
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
query.is_empty()
|
||||
|| names.item(i).to_lowercase().contains(&query)
|
||||
|| i.contains(&query)
|
||||
})
|
||||
.cloned()
|
||||
.search_results(&self.search, names)
|
||||
.into_iter()
|
||||
.map(|i| self.search_items[i].clone())
|
||||
.collect();
|
||||
}
|
||||
tab.items.clone()
|
||||
@@ -114,10 +294,60 @@ impl Creative {
|
||||
pub fn rows(&self, names: &Names) -> usize {
|
||||
self.items(names).len().div_ceil(9)
|
||||
}
|
||||
/// First visible grid row for the scroll position.
|
||||
/// ItemPickerMenu.calculateRowCount: rows beyond the five shown.
|
||||
fn hidden_rows(&self, names: &Names) -> i32 {
|
||||
self.rows(names) as i32 - 5
|
||||
}
|
||||
/// First visible grid row for the scroll position (getRowIndexForScroll).
|
||||
pub fn first_row(&self, names: &Names) -> usize {
|
||||
let hidden = self.rows(names).saturating_sub(5);
|
||||
(self.scroll * hidden as f32 + 0.5) as usize
|
||||
((self.scroll * self.hidden_rows(names) as f32 + 0.5) as i32).max(0) as usize
|
||||
}
|
||||
/// CreativeModeInventoryScreen.canScroll: more than five rows, on a tab with a scrollbar.
|
||||
pub fn can_scroll(&self, names: &Names) -> bool {
|
||||
!self.tab().inventory && self.items(names).len() > 45
|
||||
}
|
||||
/// insideScrollbar, for a point relative to the screen origin.
|
||||
pub fn inside_scrollbar(&self, x: f32, y: f32) -> bool {
|
||||
x >= Self::SCROLL_X
|
||||
&& y >= Self::SCROLL_Y
|
||||
&& x < Self::SCROLL_X + Self::SCROLL_WIDTH
|
||||
&& y < Self::SCROLL_Y + Self::SCROLL_HEIGHT
|
||||
&& !self.tab().inventory
|
||||
}
|
||||
/// mouseClicked on the scrollbar: it is held only if the tab can scroll
|
||||
/// (the thumb doesn't move until the mouse does).
|
||||
pub fn press_scrollbar(&mut self, x: f32, y: f32, names: &Names) -> bool {
|
||||
if !self.inside_scrollbar(x, y) {
|
||||
return false;
|
||||
}
|
||||
self.scrolling = self.can_scroll(names);
|
||||
true
|
||||
}
|
||||
/// mouseDragged while the scrollbar is held, at a cursor height relative to
|
||||
/// the screen origin, wherever the cursor is (even off the track or the window).
|
||||
pub fn drag_scrollbar(&mut self, y: f32) -> bool {
|
||||
if !self.scrolling {
|
||||
return false;
|
||||
}
|
||||
let (top, bottom) = (Self::SCROLL_Y, Self::SCROLL_Y + Self::SCROLL_HEIGHT);
|
||||
self.scroll = ((y - top - 7.5) / (bottom - top - 15.0)).clamp(0.0, 1.0);
|
||||
true
|
||||
}
|
||||
/// mouseReleased.
|
||||
pub fn release(&mut self) {
|
||||
self.scrolling = false;
|
||||
}
|
||||
/// mouseScrolled: ItemPickerMenu.subtractInputFromScroll.
|
||||
pub fn wheel(&mut self, amount: f64, names: &Names) {
|
||||
if !self.can_scroll(names) {
|
||||
return;
|
||||
}
|
||||
let rows = self.hidden_rows(names) as f64;
|
||||
self.scroll = (self.scroll - (amount / rows) as f32).clamp(0.0, 1.0);
|
||||
}
|
||||
/// The scroller sprite's offset from the top of the track.
|
||||
pub fn scroller_offset(&self) -> f32 {
|
||||
((Self::SCROLL_HEIGHT - 17.0) * self.scroll) as i32 as f32
|
||||
}
|
||||
/// Tab button position relative to the screen origin (CreativeModeInventoryScreen.getTabX/Y).
|
||||
pub fn tab_position(tab: &Tab) -> (f32, f32) {
|
||||
@@ -130,3 +360,209 @@ impl Creative {
|
||||
(x, y)
|
||||
}
|
||||
}
|
||||
/// Whether a suffix array of `strings` has a suffix starting with `text`:
|
||||
/// some string contains it (none for no strings, even when `text` is empty).
|
||||
fn any<'a>(strings: impl IntoIterator<Item = &'a str>, text: &str) -> bool {
|
||||
strings.into_iter().any(|s| s.contains(text))
|
||||
}
|
||||
/// String.toLowerCase(Locale.ROOT).
|
||||
fn java_lower(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
}
|
||||
/// String.trim: strips code points up to U+0020.
|
||||
fn java_trim(s: &str) -> &str {
|
||||
s.trim_matches(|c: char| c <= ' ')
|
||||
}
|
||||
/// Identifier.parse's namespace and path (default namespace `minecraft`).
|
||||
fn split_id(id: &str) -> (String, String) {
|
||||
match id.split_once(':') {
|
||||
Some((ns, path)) => (ns.to_owned(), path.to_owned()),
|
||||
None => ("minecraft".to_owned(), id.to_owned()),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn creative(items: usize) -> Creative {
|
||||
let mut stacks: Vec<String> = ["anvil", "crafting_table", "wooden_hoe"]
|
||||
.iter()
|
||||
.map(|i| format!("{{count:1,id:\"minecraft:{i}\"}}"))
|
||||
.collect();
|
||||
stacks.extend((0..items).map(|i| format!("{{count:1,id:\"minecraft:item_{i}\"}}")));
|
||||
let mut lines = vec![
|
||||
vec!["Anvil"],
|
||||
vec!["Crafting Table"],
|
||||
vec!["Wooden Hoe", "When in Main Hand:", "-3 Attack Speed"],
|
||||
];
|
||||
lines.extend(vec![vec!["Thing"]; items]);
|
||||
let mut tags = vec![
|
||||
vec!["minecraft:anvil"],
|
||||
vec![],
|
||||
vec!["minecraft:hoes", "minecraft:enchantable/mining"],
|
||||
];
|
||||
tags.extend(vec![vec![]; items]);
|
||||
let raw = serde_json::json!({"schema": 2, "tabs": [
|
||||
{"id": "minecraft:functional_blocks", "type": "CATEGORY", "row": "TOP", "column": 3, "title": "Functional Blocks",
|
||||
"icon": "{count:1,id:\"minecraft:oak_sign\"}", "visible": true,
|
||||
"items": ["{count:1,id:\"minecraft:anvil\"}", "{count:1,id:\"minecraft:crafting_table\"}"]},
|
||||
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
|
||||
"icon": "{count:1,id:\"minecraft:command_block\"}", "visible": false, "items": []},
|
||||
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
|
||||
"icon": "{count:1,id:\"minecraft:compass\"}", "visible": true,
|
||||
"items": stacks, "lines": lines, "tags": tags},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []}
|
||||
]});
|
||||
Creative::from_json(&raw).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_tab_slot_layout_matches_vanilla() {
|
||||
assert_eq!(Creative::inventory_slots().count(), 41);
|
||||
for (slot, expected) in [
|
||||
(0, (9.0, 112.0)),
|
||||
(8, (153.0, 112.0)),
|
||||
(9, (9.0, 54.0)),
|
||||
(35, (153.0, 90.0)),
|
||||
(500, (54.0, 6.0)),
|
||||
(501, (54.0, 33.0)),
|
||||
(502, (108.0, 6.0)),
|
||||
(503, (108.0, 33.0)),
|
||||
(504, (35.0, 20.0)),
|
||||
] {
|
||||
assert_eq!(Creative::inventory_slot_position(slot), Some(expected));
|
||||
}
|
||||
assert_eq!(Creative::inventory_slot_position(100), None);
|
||||
assert_eq!(Creative::TRASH_POSITION, (173.0, 112.0));
|
||||
let mut c = creative(90);
|
||||
c.select(2);
|
||||
assert!(c.tab().inventory);
|
||||
assert!(!c.can_scroll(&Names {
|
||||
map: HashMap::new()
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_tab_can_return_to_category_and_search() {
|
||||
let mut c = creative(90);
|
||||
for (selected, inventory, search) in [
|
||||
(2, true, false),
|
||||
(0, false, false),
|
||||
(1, false, true),
|
||||
(2, true, false),
|
||||
] {
|
||||
c.search = "anvil".into();
|
||||
c.scroll = 0.5;
|
||||
c.select(selected);
|
||||
assert_eq!(c.selected, selected);
|
||||
assert_eq!(c.tab().inventory, inventory);
|
||||
assert_eq!(c.tab().search, search);
|
||||
assert!(c.search.is_empty());
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_tabs_are_skipped() {
|
||||
let c = creative(0);
|
||||
let ids: Vec<&str> = c.tabs.iter().map(|t| t.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"minecraft:functional_blocks",
|
||||
"minecraft:search",
|
||||
"minecraft:inventory"
|
||||
]
|
||||
);
|
||||
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
|
||||
let mut c = c;
|
||||
c.select(1);
|
||||
c.search = "anvil".into();
|
||||
c.scroll = 0.5;
|
||||
c.select(1);
|
||||
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
|
||||
c.select(0);
|
||||
assert_eq!(c.search, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_vanilla_trees() {
|
||||
let c = creative(0);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
assert_eq!(q(""), [0, 1, 2]);
|
||||
assert_eq!(q("ANVIL"), [0]);
|
||||
assert_eq!(q("table"), [1]);
|
||||
assert_eq!(q("a"), [0, 1, 2]);
|
||||
// Tooltip lines, not just names; no trimming without a colon.
|
||||
assert_eq!(q("attack speed"), [2]);
|
||||
assert_eq!(q(" anvil"), Vec::<usize>::new());
|
||||
// A colon searches the id: namespace, then the path or a tooltip line.
|
||||
assert_eq!(q("minecraft:crafting_t"), [1]);
|
||||
assert_eq!(q(" minecraft : hoe "), [2]);
|
||||
assert_eq!(q("mine:anvil"), [0]);
|
||||
assert_eq!(q("other:anvil"), Vec::<usize>::new());
|
||||
assert_eq!(q(":"), [0, 1, 2]);
|
||||
// Tags after "#": only stacks with tags.
|
||||
assert_eq!(q("#"), [0, 2]);
|
||||
assert_eq!(q("#hoe"), [2]);
|
||||
assert_eq!(q("#minecraft:enchantable/"), [2]);
|
||||
assert_eq!(q("#craft"), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
c.selected = 1;
|
||||
// Pressing the track holds the scrollbar without moving it.
|
||||
assert!(c.press_scrollbar(180.0, 60.0, &names));
|
||||
assert!(c.scrolling);
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
// Dragging maps the cursor height onto the track (minus half the thumb).
|
||||
assert!(c.drag_scrollbar(18.0 + 7.5 + 48.5));
|
||||
assert!((c.scroll - 0.5).abs() < 1e-6);
|
||||
// The cursor may leave the track, the panel or the window: it keeps scrolling.
|
||||
c.drag_scrollbar(400.0);
|
||||
assert_eq!(c.scroll, 1.0);
|
||||
c.drag_scrollbar(-50.0);
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
c.drag_scrollbar(18.0 + 7.5 + 97.0 * 0.25);
|
||||
assert!((c.scroll - 0.25).abs() < 1e-6);
|
||||
let rows = (93usize.div_ceil(9) - 5) as f32;
|
||||
assert_eq!(c.first_row(&names), (0.25 * rows + 0.5) as usize);
|
||||
// Releasing stops it.
|
||||
c.release();
|
||||
assert!(!c.drag_scrollbar(400.0));
|
||||
assert!((c.scroll - 0.25).abs() < 1e-6);
|
||||
// Off the track, or with too few items, it isn't held.
|
||||
assert!(!c.press_scrollbar(170.0, 60.0, &names));
|
||||
assert!(!c.press_scrollbar(180.0, 130.0, &names));
|
||||
c.selected = 0;
|
||||
assert!(c.press_scrollbar(180.0, 60.0, &names));
|
||||
assert!(!c.scrolling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_moves_one_row_per_notch() {
|
||||
let mut c = creative(90);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
c.selected = 1;
|
||||
let rows = (93usize.div_ceil(9) - 5) as f32;
|
||||
c.wheel(-1.0, &names);
|
||||
assert!((c.scroll - 1.0 / rows).abs() < 1e-6);
|
||||
assert_eq!(c.first_row(&names), 1);
|
||||
c.wheel(5.0, &names);
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
assert_eq!(c.scroller_offset(), 0.0);
|
||||
c.scroll = 1.0;
|
||||
assert_eq!(c.scroller_offset(), 95.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
//! The debug keys (26.3 `KeyboardHandler`): `key.debug.modifier` (F3) held
|
||||
//! with another key runs a debug action (`handleDebugKeys`), released alone
|
||||
//! it toggles the debug overlay (`key.debug.overlay`, F3 too by default);
|
||||
//! with Escape it pauses without the menu; held with `key.debug.crash` (C)
|
||||
//! for ten seconds it crashes the game (`tick`).
|
||||
use crate::keybinds::KeyBinds;
|
||||
|
||||
/// A debug mapping's action, as `handleDebugKeys` tests them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DebugAction {
|
||||
ReloadChunks,
|
||||
ShowHitboxes,
|
||||
ClearChat,
|
||||
ShowChunkBorders,
|
||||
AdvancedTooltips,
|
||||
CopyRecreateCommand,
|
||||
Spectate,
|
||||
SwitchGameMode,
|
||||
DebugOptions,
|
||||
FocusPause,
|
||||
DumpDynamicTextures,
|
||||
ReloadResourcePacks,
|
||||
Profiling,
|
||||
CopyLocation,
|
||||
DumpVersion,
|
||||
ProfilingChart,
|
||||
FpsCharts,
|
||||
NetworkCharts,
|
||||
LightmapTexture,
|
||||
ImprovedTransparency,
|
||||
}
|
||||
|
||||
/// The mappings `handleDebugKeys` tests, in its order.
|
||||
pub const ACTIONS: [(&str, DebugAction); 20] = [
|
||||
("key.debug.reloadChunk", DebugAction::ReloadChunks),
|
||||
("key.debug.showHitboxes", DebugAction::ShowHitboxes),
|
||||
("key.debug.clearChat", DebugAction::ClearChat),
|
||||
("key.debug.showChunkBorders", DebugAction::ShowChunkBorders),
|
||||
(
|
||||
"key.debug.showAdvancedTooltips",
|
||||
DebugAction::AdvancedTooltips,
|
||||
),
|
||||
(
|
||||
"key.debug.copyRecreateCommand",
|
||||
DebugAction::CopyRecreateCommand,
|
||||
),
|
||||
("key.debug.spectate", DebugAction::Spectate),
|
||||
("key.debug.switchGameMode", DebugAction::SwitchGameMode),
|
||||
("key.debug.debugOptions", DebugAction::DebugOptions),
|
||||
("key.debug.focusPause", DebugAction::FocusPause),
|
||||
(
|
||||
"key.debug.dumpDynamicTextures",
|
||||
DebugAction::DumpDynamicTextures,
|
||||
),
|
||||
(
|
||||
"key.debug.reloadResourcePacks",
|
||||
DebugAction::ReloadResourcePacks,
|
||||
),
|
||||
("key.debug.profiling", DebugAction::Profiling),
|
||||
("key.debug.copyLocation", DebugAction::CopyLocation),
|
||||
("key.debug.dumpVersion", DebugAction::DumpVersion),
|
||||
("key.debug.profilingChart", DebugAction::ProfilingChart),
|
||||
("key.debug.fpsCharts", DebugAction::FpsCharts),
|
||||
("key.debug.networkCharts", DebugAction::NetworkCharts),
|
||||
("key.debug.lightmapTexture", DebugAction::LightmapTexture),
|
||||
(
|
||||
"key.debug.improvedTransparency",
|
||||
DebugAction::ImprovedTransparency,
|
||||
),
|
||||
];
|
||||
|
||||
/// GLFW's key actions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KeyAction {
|
||||
Release,
|
||||
Press,
|
||||
Repeat,
|
||||
}
|
||||
|
||||
/// The screen open when a key reaches the game (after the screen itself
|
||||
/// passed on it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OpenScreen {
|
||||
None,
|
||||
/// `PauseScreen` without its menu (F3+Escape).
|
||||
PauseWithoutMenu,
|
||||
GameModeSwitcher,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What the game does for `KeyboardHandler.keyPress`.
|
||||
pub trait DebugHost {
|
||||
fn screen(&self) -> OpenScreen;
|
||||
/// `DebugScreenEntries.toggleDebugOverlay`.
|
||||
fn toggle_overlay(&mut self);
|
||||
/// `Minecraft.pauseGame(suppressPauseMenu)`.
|
||||
fn pause_game(&mut self, without_menu: bool);
|
||||
/// One matching mapping of `handleDebugKeys`; whether its condition held
|
||||
/// (vanilla's `debugAction = true`). `shift`: `event.hasShiftDown()`.
|
||||
fn debug_action(&mut self, action: DebugAction, shift: bool) -> bool;
|
||||
}
|
||||
|
||||
/// What `KeyboardHandler.tick` reports while the debug crash keys are held.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CrashNotice {
|
||||
/// `debug.crash.message`, a second after the keys went down.
|
||||
Message,
|
||||
/// `debug.crash.warning` with the seconds left.
|
||||
Warning(i32),
|
||||
/// "Manually triggered debug crash".
|
||||
Crash,
|
||||
}
|
||||
|
||||
/// `KeyboardHandler`'s state, with the down state of `keyDebugModifier`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DebugKeys {
|
||||
pub used_debug_key_as_modifier: bool,
|
||||
/// `debugCrashKeyTime`: when the crash keys went down (milliseconds),
|
||||
/// 0 never, -1 released.
|
||||
pub crash_key_time: i64,
|
||||
pub crash_key_reported_time: i64,
|
||||
pub crash_key_reported_count: i64,
|
||||
/// `keyDebugModifier.isDown()`.
|
||||
pub modifier_down: bool,
|
||||
}
|
||||
|
||||
impl DebugKeys {
|
||||
/// `keyPress` once the open screen (if any) did not take the key. `key`
|
||||
/// is the physical key's name (`key.keyboard.f3`); `crash_key_down`
|
||||
/// whether the crash mapping's key is held (`InputConstants.isKeyDown`).
|
||||
/// Returns whether the key goes on to the game's key mappings
|
||||
/// (`KeyMapping.set(key, true)` and `click`): only without a screen and
|
||||
/// when it ran no debug action.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn key_press(
|
||||
&mut self,
|
||||
host: &mut impl DebugHost,
|
||||
binds: &KeyBinds,
|
||||
key: &str,
|
||||
action: KeyAction,
|
||||
shift: bool,
|
||||
crash_key_down: bool,
|
||||
now: i64,
|
||||
) -> bool {
|
||||
let modifier = binds.get("key.debug.modifier");
|
||||
let overlay = binds.get("key.debug.overlay");
|
||||
let same = modifier == overlay;
|
||||
let modifier_down = self.modifier_down;
|
||||
let crash = binds.get("key.debug.crash") != "key.keyboard.unknown" && crash_key_down;
|
||||
if self.crash_key_time > 0 {
|
||||
if !crash || !modifier_down {
|
||||
self.crash_key_time = -1;
|
||||
}
|
||||
} else if crash && modifier_down {
|
||||
self.used_debug_key_as_modifier = same;
|
||||
self.crash_key_time = now;
|
||||
self.crash_key_reported_time = now;
|
||||
self.crash_key_reported_count = 0;
|
||||
}
|
||||
let screen = host.screen();
|
||||
let handles_game = screen == OpenScreen::None;
|
||||
let handles_global = handles_game
|
||||
|| matches!(
|
||||
screen,
|
||||
OpenScreen::PauseWithoutMenu | OpenScreen::GameModeSwitcher
|
||||
);
|
||||
if same && key == modifier && action == KeyAction::Release {
|
||||
if self.used_debug_key_as_modifier {
|
||||
self.used_debug_key_as_modifier = false;
|
||||
} else {
|
||||
host.toggle_overlay();
|
||||
}
|
||||
} else if !same && key == overlay && action == KeyAction::Press {
|
||||
host.toggle_overlay();
|
||||
}
|
||||
if action == KeyAction::Release {
|
||||
// KeyMapping.set(key, false).
|
||||
if key == modifier {
|
||||
self.modifier_down = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let mut did = false;
|
||||
if handles_global && key == "key.keyboard.escape" {
|
||||
host.pause_game(modifier_down);
|
||||
did = modifier_down;
|
||||
} else if modifier_down {
|
||||
did = self.handle_debug_keys(host, binds, key, shift, now);
|
||||
}
|
||||
if same {
|
||||
self.used_debug_key_as_modifier |= did;
|
||||
}
|
||||
// KeyMapping.set(key, !did) for the game, setDown(!did) behind a screen.
|
||||
if key == modifier {
|
||||
self.modifier_down = !did;
|
||||
}
|
||||
handles_game && !did
|
||||
}
|
||||
/// `handleDebugKeys`: every debug mapping on this key, in order.
|
||||
fn handle_debug_keys(
|
||||
&mut self,
|
||||
host: &mut impl DebugHost,
|
||||
binds: &KeyBinds,
|
||||
key: &str,
|
||||
shift: bool,
|
||||
now: i64,
|
||||
) -> bool {
|
||||
// Once the crash keys have been down a tenth of a second.
|
||||
if self.crash_key_time > 0 && self.crash_key_time < now - 100 {
|
||||
return true;
|
||||
}
|
||||
let mut did = false;
|
||||
for (name, action) in ACTIONS {
|
||||
if binds.get(name) == key {
|
||||
did |= host.debug_action(action, shift);
|
||||
}
|
||||
}
|
||||
did
|
||||
}
|
||||
/// `KeyboardHandler.tick`.
|
||||
pub fn tick(&mut self, now: i64) -> Option<CrashNotice> {
|
||||
if self.crash_key_time <= 0 {
|
||||
return None;
|
||||
}
|
||||
let remaining = 10000 - (now - self.crash_key_time);
|
||||
let reported = now - self.crash_key_reported_time;
|
||||
if remaining < 0 {
|
||||
return Some(CrashNotice::Crash);
|
||||
}
|
||||
if reported >= 1000 {
|
||||
let notice = if self.crash_key_reported_count == 0 {
|
||||
CrashNotice::Message
|
||||
} else {
|
||||
// Mth.ceil(remainingTime / 1000.0F).
|
||||
CrashNotice::Warning((remaining as f32 / 1000.0).ceil() as i32)
|
||||
};
|
||||
self.crash_key_reported_time = now;
|
||||
self.crash_key_reported_count += 1;
|
||||
return Some(notice);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Host {
|
||||
screen: Option<OpenScreen>,
|
||||
overlay: u32,
|
||||
paused: Vec<bool>,
|
||||
actions: Vec<(DebugAction, bool)>,
|
||||
/// Actions whose condition fails (no player, reduced debug info...).
|
||||
refused: Vec<DebugAction>,
|
||||
}
|
||||
impl DebugHost for Host {
|
||||
fn screen(&self) -> OpenScreen {
|
||||
self.screen.unwrap_or(OpenScreen::None)
|
||||
}
|
||||
fn toggle_overlay(&mut self) {
|
||||
self.overlay += 1;
|
||||
}
|
||||
fn pause_game(&mut self, without_menu: bool) {
|
||||
self.paused.push(without_menu);
|
||||
}
|
||||
fn debug_action(&mut self, action: DebugAction, shift: bool) -> bool {
|
||||
self.actions.push((action, shift));
|
||||
!self.refused.contains(&action)
|
||||
}
|
||||
}
|
||||
const F3: &str = "key.keyboard.f3";
|
||||
|
||||
fn press(k: &mut DebugKeys, h: &mut Host, b: &KeyBinds, key: &str, now: i64) -> bool {
|
||||
k.key_press(h, b, key, KeyAction::Press, false, false, now)
|
||||
}
|
||||
fn release(k: &mut DebugKeys, h: &mut Host, b: &KeyBinds, key: &str) -> bool {
|
||||
k.key_press(h, b, key, KeyAction::Release, false, false, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f3_alone_toggles_the_overlay_on_release() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
// The F3 press goes to the game's mappings (key.debug.* are down now).
|
||||
assert!(press(&mut k, &mut h, &b, F3, 0));
|
||||
assert!(k.modifier_down);
|
||||
assert_eq!(h.overlay, 0);
|
||||
assert!(!release(&mut k, &mut h, &b, F3));
|
||||
assert_eq!(h.overlay, 1);
|
||||
assert!(!k.modifier_down);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_combination_runs_its_action_and_keeps_the_overlay() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
// F3+G: the action, and G does not reach the game.
|
||||
assert!(!press(&mut k, &mut h, &b, "key.keyboard.g", 10));
|
||||
assert_eq!(h.actions, [(DebugAction::ShowChunkBorders, false)]);
|
||||
release(&mut k, &mut h, &b, "key.keyboard.g");
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 0);
|
||||
// The next F3 alone toggles again.
|
||||
press(&mut k, &mut h, &b, F3, 20);
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_copies_the_location_and_both_c_mappings_share_the_key() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
k.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Press,
|
||||
true,
|
||||
true,
|
||||
5,
|
||||
);
|
||||
assert_eq!(h.actions, [(DebugAction::CopyLocation, true)]);
|
||||
// The crash keys are down: from a tenth of a second on, debug keys
|
||||
// are swallowed.
|
||||
assert_eq!(k.crash_key_time, 5);
|
||||
k.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Repeat,
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
);
|
||||
assert_eq!(h.actions.len(), 2);
|
||||
assert!(!k.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Repeat,
|
||||
false,
|
||||
true,
|
||||
200
|
||||
));
|
||||
assert_eq!(h.actions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_crash_keys_warn_every_second_then_crash() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
k.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Press,
|
||||
false,
|
||||
true,
|
||||
1000,
|
||||
);
|
||||
assert_eq!(k.tick(1500), None);
|
||||
assert_eq!(k.tick(2000), Some(CrashNotice::Message));
|
||||
assert_eq!(k.tick(2999), None);
|
||||
assert_eq!(k.tick(3000), Some(CrashNotice::Warning(8)));
|
||||
assert_eq!(k.tick(4100), Some(CrashNotice::Warning(7)));
|
||||
assert_eq!(k.tick(11_000), Some(CrashNotice::Warning(0)));
|
||||
assert_eq!(k.tick(11_001), Some(CrashNotice::Crash));
|
||||
// Releasing either key stops it on the next key event.
|
||||
let mut k2 = DebugKeys::default();
|
||||
press(&mut k2, &mut h, &b, F3, 0);
|
||||
k2.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Press,
|
||||
false,
|
||||
true,
|
||||
1000,
|
||||
);
|
||||
k2.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Release,
|
||||
false,
|
||||
false,
|
||||
1200,
|
||||
);
|
||||
assert_eq!(k2.crash_key_time, -1);
|
||||
assert_eq!(k2.tick(20_000), None);
|
||||
// The crash used F3 as a modifier: its release does not toggle the overlay.
|
||||
release(&mut k2, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_actions_do_not_count() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
h.refused.push(DebugAction::ShowHitboxes);
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
// F3+B without a player (or with reduced debug info): B reaches the
|
||||
// game and F3's release still toggles the overlay.
|
||||
assert!(press(&mut k, &mut h, &b, "key.keyboard.b", 1));
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_pauses_with_or_without_the_menu() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
assert!(press(&mut k, &mut h, &b, "key.keyboard.escape", 0));
|
||||
press(&mut k, &mut h, &b, F3, 1);
|
||||
assert!(!press(&mut k, &mut h, &b, "key.keyboard.escape", 2));
|
||||
assert_eq!(h.paused, [false, true]);
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 0);
|
||||
// Behind another screen, Escape is the screen's.
|
||||
h.screen = Some(OpenScreen::Other);
|
||||
press(&mut k, &mut h, &b, "key.keyboard.escape", 3);
|
||||
assert_eq!(h.paused.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behind_a_screen_combinations_work_but_keys_stay_with_the_screen() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
h.screen = Some(OpenScreen::Other);
|
||||
assert!(!press(&mut k, &mut h, &b, F3, 0));
|
||||
assert!(k.modifier_down);
|
||||
assert!(!press(&mut k, &mut h, &b, "key.keyboard.h", 1));
|
||||
assert_eq!(h.actions, [(DebugAction::AdvancedTooltips, false)]);
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switcher_modifier_release_does_not_toggle_the_debug_overlay() {
|
||||
let (mut k, mut h, mut b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
// Screen release handling must also work with rebound debug keys.
|
||||
b.set("key.debug.modifier", "key.keyboard.f7");
|
||||
b.set("key.debug.overlay", "key.keyboard.f7");
|
||||
b.set("key.debug.switchGameMode", "key.keyboard.f8");
|
||||
press(&mut k, &mut h, &b, "key.keyboard.f7", 0);
|
||||
assert!(!press(&mut k, &mut h, &b, "key.keyboard.f8", 1));
|
||||
assert_eq!(h.actions, [(DebugAction::SwitchGameMode, false)]);
|
||||
h.screen = Some(OpenScreen::GameModeSwitcher);
|
||||
release(&mut k, &mut h, &b, "key.keyboard.f8");
|
||||
release(&mut k, &mut h, &b, "key.keyboard.f7");
|
||||
assert!(!k.modifier_down);
|
||||
assert_eq!(h.overlay, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_separate_overlay_key_toggles_on_press() {
|
||||
let (mut k, mut h, mut b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
b.set("key.debug.overlay", "key.keyboard.f7");
|
||||
press(&mut k, &mut h, &b, "key.keyboard.f7", 0);
|
||||
assert_eq!(h.overlay, 1);
|
||||
press(&mut k, &mut h, &b, F3, 1);
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebound_mappings_follow_their_keys() {
|
||||
let (mut k, mut h, mut b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
b.set("key.debug.showChunkBorders", "key.keyboard.k");
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
assert!(press(&mut k, &mut h, &b, "key.keyboard.g", 1));
|
||||
press(&mut k, &mut h, &b, "key.keyboard.k", 2);
|
||||
assert_eq!(h.actions, [(DebugAction::ShowChunkBorders, false)]);
|
||||
}
|
||||
}
|
||||
@@ -283,8 +283,11 @@ impl PlayerEffects {
|
||||
levitation: self.amplifier("levitation"),
|
||||
resistance: self.amplifier("resistance"),
|
||||
fire_resistance: self.get("fire_resistance").is_some(),
|
||||
dolphins_grace: self.get("dolphins_grace").is_some(),
|
||||
weaving: self.get("weaving").is_some(),
|
||||
water_breathing: self.get("water_breathing").is_some()
|
||||
|| self.get("conduit_power").is_some(),
|
||||
breath_of_the_nautilus: self.get("breath_of_the_nautilus").is_some(),
|
||||
protection: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -365,12 +368,7 @@ impl PlayerEffects {
|
||||
f *= 1.0 + (a + 1) as f32 * 0.2;
|
||||
}
|
||||
if let Some(a) = self.amplifier("mining_fatigue") {
|
||||
f *= match a {
|
||||
0 => 0.3,
|
||||
1 => 0.09,
|
||||
2 => 0.0027,
|
||||
_ => 8.1e-4,
|
||||
};
|
||||
f *= horizon_core::jmath::pow(0.3, f64::from(a + 1)) as f32;
|
||||
}
|
||||
f
|
||||
}
|
||||
@@ -383,7 +381,11 @@ pub struct StatusFlags {
|
||||
pub levitation: Option<i32>,
|
||||
pub resistance: Option<i32>,
|
||||
pub fire_resistance: bool,
|
||||
pub dolphins_grace: bool,
|
||||
/// Weaving: cobwebs hold half as much (`WebBlock.entityInside`).
|
||||
pub weaving: bool,
|
||||
pub water_breathing: bool,
|
||||
pub breath_of_the_nautilus: bool,
|
||||
/// Armor enchantment protection per damage type id (`EnchantmentHelper.getDamageProtection`).
|
||||
pub protection: Vec<(String, f32)>,
|
||||
}
|
||||
|
||||
@@ -628,7 +628,8 @@ impl Enchantments {
|
||||
} else {
|
||||
spec
|
||||
};
|
||||
spec.is_null() && entity.is_some() || self.entity_matches(spec, entity)
|
||||
(spec.is_null() && entity.is_some() || self.entity_matches(spec, entity))
|
||||
&& movement_matches(&c["predicate"], role_state(c, ctx))
|
||||
}
|
||||
"minecraft:random_chance" => {
|
||||
let chance = match &c["chance"] {
|
||||
@@ -661,6 +662,38 @@ pub struct EffectContext<'a> {
|
||||
pub this: Option<&'a str>,
|
||||
pub attacker: Option<&'a str>,
|
||||
pub direct_attacker: Option<&'a str>,
|
||||
/// The direct attacker's fall distance and whether it flies (elytra or
|
||||
/// abilities), for `movement` and `flags` predicates.
|
||||
pub direct_motion: Option<(f64, bool)>,
|
||||
}
|
||||
/// The fall distance and flying flag known for an `entity_properties` role.
|
||||
fn role_state(c: &Value, ctx: &EffectContext<'_>) -> Option<(f64, bool)> {
|
||||
match c["entity"].as_str().unwrap_or("this") {
|
||||
"direct_attacker" => ctx.direct_motion,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `EntityPredicate`'s `minecraft:movement` fall distance and
|
||||
/// `minecraft:flags` is_flying (the parts enchantment requirements use); an
|
||||
/// entity whose state is unknown matches neither.
|
||||
fn movement_matches(predicate: &Value, state: Option<(f64, bool)>) -> bool {
|
||||
let fall = &predicate["minecraft:movement"]["fall_distance"];
|
||||
let flying = predicate["minecraft:flags"]["is_flying"].as_bool();
|
||||
if fall.is_null() && flying.is_none() {
|
||||
return true;
|
||||
}
|
||||
let Some((distance, is_flying)) = state else {
|
||||
return false;
|
||||
};
|
||||
// MinMaxBounds.Doubles: a number is an exact bound.
|
||||
let bound = |key: &str| {
|
||||
fall.as_f64()
|
||||
.or_else(|| fall.get(key).and_then(Value::as_f64))
|
||||
};
|
||||
// MinMaxBounds.Doubles.matches
|
||||
bound("min").is_none_or(|min| min <= distance)
|
||||
&& bound("max").is_none_or(|max| distance <= max)
|
||||
&& flying.is_none_or(|f| f == is_flying)
|
||||
}
|
||||
fn next_int<R: RandomSource>(random: &mut R, bound: i32) -> i32 {
|
||||
random.next_int_bound(bound.max(1)).unwrap_or(0)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `EndCrystalRenderer` and `EndCrystalModel.setupAnim`: the crystal's bob
|
||||
//! and the poses of its base, glass and cube.
|
||||
use crate::mobs::{Part, anim::Poses};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
|
||||
/// `EndCrystalRenderer.getY`: the crystal's bob below its resting height.
|
||||
pub fn y(age: f32) -> f32 {
|
||||
let hh = (age * 0.2).msin() / 2.0 + 0.5;
|
||||
(hh * hh + hh) * 0.4 - 1.4
|
||||
}
|
||||
|
||||
/// `EndCrystalModel.setupAnim`: the base shown or not, the glass raised by
|
||||
/// half the bob (in model pixels) and the three cubes turned with
|
||||
/// `ModelPart.rotateBy`, which stores the composed rotation as ZYX angles.
|
||||
pub fn poses(model: &Part, age: f32, show_bottom: bool) -> Poses {
|
||||
let mut poses = Poses::new(model);
|
||||
poses.visible("base", show_bottom);
|
||||
let speed = age * 3.0;
|
||||
let sin45 = std::f32::consts::FRAC_PI_4.sin();
|
||||
let tilt = glam::Quat::from_axis_angle(
|
||||
glam::Vec3::new(sin45, 0.0, sin45),
|
||||
std::f32::consts::FRAC_PI_3,
|
||||
);
|
||||
let spin = glam::Quat::from_rotation_y(speed.to_radians());
|
||||
poses.get("outer_glass")[1] += y(age) * 16.0 / 2.0;
|
||||
for (part, rotation) in [
|
||||
("outer_glass", spin * tilt),
|
||||
("inner_glass", tilt * spin),
|
||||
("cube", tilt * spin),
|
||||
] {
|
||||
let (z, y, x) = rotation.to_euler(glam::EulerRot::ZYX);
|
||||
let pose = poses.get(part);
|
||||
pose[3] = x;
|
||||
pose[4] = y;
|
||||
pose[5] = z;
|
||||
}
|
||||
poses
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemEntity {
|
||||
pub health: i32,
|
||||
pub fire_ticks: i32,
|
||||
pub id: u32,
|
||||
pub stack: ItemStack,
|
||||
pub position: Vec3,
|
||||
@@ -22,6 +24,17 @@ pub struct ItemEntity {
|
||||
pub pickup_delay: u32,
|
||||
pub bob_offset: f32,
|
||||
}
|
||||
/// Environment results needed by the entity owner, including lethal lava damage.
|
||||
#[derive(Debug)]
|
||||
pub struct ItemEnvironmentTick {
|
||||
pub alive: bool,
|
||||
pub lava_hurt: bool,
|
||||
}
|
||||
impl ItemEnvironmentTick {
|
||||
pub fn should_play_lava_hurt_sound(&self, tick: u32) -> bool {
|
||||
self.lava_hurt && (!self.alive || tick.is_multiple_of(10))
|
||||
}
|
||||
}
|
||||
impl ItemEntity {
|
||||
pub const WIDTH: f64 = 0.25;
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -35,6 +48,52 @@ impl ItemEntity {
|
||||
),
|
||||
)
|
||||
}
|
||||
pub fn environment_tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
items: &crate::items::Items,
|
||||
) -> ItemEnvironmentTick {
|
||||
let at = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y + 0.1).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
let fluid = facts.get(at).fluid;
|
||||
let fire_immune = items.resists_fire(&self.stack, "minecraft:in_fire");
|
||||
if fluid == Fluid::Water || fire_immune {
|
||||
self.fire_ticks = 0;
|
||||
}
|
||||
if self.fire_ticks > 0 {
|
||||
if self.fire_ticks % 20 == 0
|
||||
&& fluid != Fluid::Lava
|
||||
&& !items.resists_fire(&self.stack, "minecraft:on_fire")
|
||||
{
|
||||
self.health -= 1;
|
||||
}
|
||||
self.fire_ticks -= 1;
|
||||
}
|
||||
let mut lava_hurt = false;
|
||||
if fluid == Fluid::Lava && !fire_immune {
|
||||
self.fire_ticks = self.fire_ticks.max(300);
|
||||
if !items.resists_fire(&self.stack, "minecraft:lava") {
|
||||
self.health -= 4;
|
||||
lava_hurt = true;
|
||||
}
|
||||
}
|
||||
let fire_damage = crate::motion::environment::fire_contact(world, registry, self.bounds());
|
||||
if fire_damage > 0.0 && !fire_immune {
|
||||
self.fire_ticks = self.fire_ticks.max(160);
|
||||
if !items.resists_fire(&self.stack, "minecraft:in_fire") {
|
||||
self.health -= fire_damage as i32;
|
||||
}
|
||||
}
|
||||
ItemEnvironmentTick {
|
||||
alive: self.health > 0,
|
||||
lava_hurt,
|
||||
}
|
||||
}
|
||||
/// Returns false when the item should be removed.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
@@ -59,6 +118,12 @@ impl ItemEntity {
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
} else if facts.get(eye).fluid == Fluid::Lava {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.95,
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
self.velocity.z * 0.95,
|
||||
);
|
||||
} else {
|
||||
self.velocity.y -= 0.04;
|
||||
}
|
||||
@@ -67,6 +132,7 @@ impl ItemEntity {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
@@ -109,80 +175,6 @@ impl ItemEntity {
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
}
|
||||
}
|
||||
/// Arrows (`AbstractArrow.tick`): 0.99 drag, 0.05 gravity, sticking in blocks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Arrow {
|
||||
/// Shot by the player (hits mobs) rather than a mob (hits the player).
|
||||
pub from_player: bool,
|
||||
pub critical: bool,
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub stuck: bool,
|
||||
pub age: u32,
|
||||
pub base_damage: f64,
|
||||
}
|
||||
pub enum ArrowHit {
|
||||
None,
|
||||
Player(f32),
|
||||
}
|
||||
impl Arrow {
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
player: Option<Aabb>,
|
||||
) -> (bool, ArrowHit) {
|
||||
self.previous = self.position;
|
||||
self.age += 1;
|
||||
if self.stuck {
|
||||
return (self.age < 1200, ArrowHit::None);
|
||||
}
|
||||
let steps = 4;
|
||||
for _ in 0..steps {
|
||||
let next = Vec3::new(
|
||||
self.position.x + self.velocity.x / f64::from(steps),
|
||||
self.position.y + self.velocity.y / f64::from(steps),
|
||||
self.position.z + self.velocity.z / f64::from(steps),
|
||||
);
|
||||
if let Some(b) = player {
|
||||
let (min, max) = (b.min_position(), b.max_position());
|
||||
if next.x >= min.x
|
||||
&& next.x <= max.x
|
||||
&& next.y >= min.y
|
||||
&& next.y <= max.y
|
||||
&& next.z >= min.z
|
||||
&& next.z <= max.z
|
||||
{
|
||||
let speed = (self.velocity.x.powi(2)
|
||||
+ self.velocity.y.powi(2)
|
||||
+ self.velocity.z.powi(2))
|
||||
.sqrt();
|
||||
// AbstractArrow.onHitEntity: ceil(speed * baseDamage).
|
||||
let damage = (speed * self.base_damage).ceil().max(0.0) as f32;
|
||||
return (false, ArrowHit::Player(damage));
|
||||
}
|
||||
}
|
||||
let block = world.block(
|
||||
next.x.floor() as i32,
|
||||
next.y.floor() as i32,
|
||||
next.z.floor() as i32,
|
||||
);
|
||||
if facts.get(block).full_collision {
|
||||
self.stuck = true;
|
||||
self.age = 0;
|
||||
return (true, ArrowHit::None);
|
||||
}
|
||||
self.position = next;
|
||||
}
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.99,
|
||||
self.velocity.y * 0.99 - 0.05,
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
(self.position.y > -128.0 && self.age < 1200, ArrowHit::None)
|
||||
}
|
||||
}
|
||||
/// ExperienceOrb: gravity 0.03, homing towards the player within 8 blocks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExperienceOrb {
|
||||
@@ -268,6 +260,7 @@ impl ExperienceOrb {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
@@ -431,6 +424,8 @@ impl ItemFrame {
|
||||
/// An armor stand (`ArmorStand`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArmorStand {
|
||||
pub fire_ticks: i32,
|
||||
pub health: f32,
|
||||
pub id: u32,
|
||||
pub position: Vec3,
|
||||
pub velocity: Vec3,
|
||||
@@ -450,6 +445,8 @@ impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
fire_ticks: 0,
|
||||
health: 20.0,
|
||||
id,
|
||||
position,
|
||||
velocity: Vec3::ZERO,
|
||||
@@ -464,6 +461,53 @@ impl ArmorStand {
|
||||
last_hit: i64::MIN / 2,
|
||||
}
|
||||
}
|
||||
pub fn environment_tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
) -> bool {
|
||||
let (lo, hi) = (self.bounds().min_position(), self.bounds().max_position());
|
||||
let mut water = false;
|
||||
let mut lava = false;
|
||||
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
|
||||
for y in lo.y.floor() as i32..hi.y.ceil() as i32 {
|
||||
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
|
||||
match facts.get(world.block(x, y, z)).fluid {
|
||||
Fluid::Water => water = true,
|
||||
Fluid::Lava => lava = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if water {
|
||||
self.fire_ticks = 0;
|
||||
}
|
||||
if self.fire_ticks > 0 {
|
||||
if self.fire_ticks % 20 == 0
|
||||
&& !lava
|
||||
&& !self.invisible
|
||||
&& !self.marker
|
||||
&& self.health > 0.5
|
||||
{
|
||||
self.health -= 4.0;
|
||||
}
|
||||
self.fire_ticks -= 1;
|
||||
}
|
||||
if lava {
|
||||
self.fire_ticks = self.fire_ticks.max(300);
|
||||
}
|
||||
let fire = crate::motion::environment::fire_contact(world, registry, self.bounds()) > 0.0;
|
||||
if fire && !self.invisible && !self.marker {
|
||||
if self.fire_ticks > 0 {
|
||||
self.health -= 0.15;
|
||||
} else {
|
||||
self.fire_ticks = 100;
|
||||
}
|
||||
}
|
||||
self.health > 0.5
|
||||
}
|
||||
/// EntityType.ARMOR_STAND dimensions (0.5 x 1.975, half for small).
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let s = if self.small { 0.5 } else { 1.0 };
|
||||
@@ -495,3 +539,115 @@ impl ArmorStand {
|
||||
self.velocity = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fire_environment_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ordinary_items_burn_but_native_netherite_resists_lava() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let mut world = World::test_scene(&assets, 3).unwrap();
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
world
|
||||
.set(
|
||||
20,
|
||||
4,
|
||||
20,
|
||||
assets.registry.default_state("minecraft:lava").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let make = |id: &str| ItemEntity {
|
||||
id: 1,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from(id),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
};
|
||||
let mut wood = make("minecraft:oak_planks");
|
||||
let mut metal = make("minecraft:netherite_ingot");
|
||||
let first = wood.environment_tick(&world, &facts, assets.registry, &items);
|
||||
assert!(first.alive && first.lava_hurt);
|
||||
assert!(
|
||||
!first.should_play_lava_hurt_sound(1),
|
||||
"ordinary entry damage must not repeat the burn sound"
|
||||
);
|
||||
assert!(first.should_play_lava_hurt_sound(10));
|
||||
assert!(
|
||||
wood.fire_ticks > 0,
|
||||
"lava contact must ignite dropped items"
|
||||
);
|
||||
let last = wood.environment_tick(&world, &facts, assets.registry, &items);
|
||||
assert!(
|
||||
!last.alive && last.lava_hurt,
|
||||
"lethal lava damage still emits burn sound"
|
||||
);
|
||||
assert!(
|
||||
last.should_play_lava_hurt_sound(2),
|
||||
"the lethal hit must always play the sound"
|
||||
);
|
||||
for _ in 0..40 {
|
||||
let result = metal.environment_tick(&world, &facts, assets.registry, &items);
|
||||
assert!(result.alive && !result.lava_hurt);
|
||||
assert!(!result.should_play_lava_hurt_sound(10));
|
||||
}
|
||||
assert_eq!(metal.health, 5);
|
||||
assert_eq!(metal.fire_ticks, 0, "fire-resistant items must not ignite");
|
||||
world
|
||||
.set(
|
||||
20,
|
||||
4,
|
||||
20,
|
||||
assets.registry.default_state("minecraft:fire").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut burning = make("minecraft:oak_planks");
|
||||
let result = burning.environment_tick(&world, &facts, assets.registry, &items);
|
||||
assert!(result.alive && !result.lava_hurt);
|
||||
assert!(burning.fire_ticks > 0);
|
||||
world
|
||||
.set(
|
||||
20,
|
||||
4,
|
||||
20,
|
||||
assets
|
||||
.registry
|
||||
.default_state("minecraft:soul_fire")
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut soul = make("minecraft:oak_planks");
|
||||
soul.environment_tick(&world, &facts, assets.registry, &items);
|
||||
assert_eq!(soul.health, 3);
|
||||
let mut stand = ArmorStand::new(2, at, 0.0);
|
||||
world
|
||||
.set(
|
||||
20,
|
||||
4,
|
||||
20,
|
||||
assets.registry.default_state("minecraft:lava").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for _ in 0..150 {
|
||||
assert!(stand.environment_tick(&world, &facts, assets.registry));
|
||||
}
|
||||
assert_eq!(stand.health, 20.0, "lava does not hurt armor stands");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@ struct Globals {
|
||||
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: array<vec4<f32>, 2>,
|
||||
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
|
||||
// y: Nether cardinal lighting.
|
||||
time: vec4<f32>,
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
outline_params: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -27,15 +31,35 @@ struct Out {
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) spherical: f32,
|
||||
@location(3) cylindrical: f32,
|
||||
@location(5) flash: f32,
|
||||
// entity.vsh's overlayColor: the OverlayTexture texel (see overlay_texel).
|
||||
@location(5) overlay_color: vec4<f32>,
|
||||
@location(4) @interpolate(flat) overlay: u32,
|
||||
// Overlay 6: the sprite's v origin and height, for v repeated inside it.
|
||||
@location(6) @interpolate(flat) wrap: vec2<f32>,
|
||||
// Overlays 9 and 10: projection_from_position(gl_Position), the End portal's texProj0.
|
||||
@location(7) proj: vec4<f32>,
|
||||
@location(8) back_color: vec4<f32>,
|
||||
// entity.vsh's lightMapColor, multiplied in after the overlay.
|
||||
@location(9) light_color: vec4<f32>,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
let l0 = normalize(vec3(0.2, 1.0, -0.7));
|
||||
let l1 = normalize(vec3(-0.2, select(1.0, -1.0, nether), 0.7));
|
||||
return min(1.0, (max(0.0, dot(l0, normal)) + max(0.0, dot(l1, normal))) * 0.6 + 0.4);
|
||||
}
|
||||
// The OverlayTexture texel entity.vsh fetches (constants from overlay.rs):
|
||||
// the hurt red for overlays 1 and 8, the white row at column 15 for the
|
||||
// primed TNT flash (5) and at column overlay - 16 for 16..=31 (a creeper's
|
||||
// swelling). Alpha 1 changes nothing.
|
||||
fn overlay_texel(overlay: u32) -> vec4<f32> {
|
||||
if overlay == 1u || overlay == 8u { return HURT_OVERLAY; }
|
||||
if overlay == 5u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[15]); }
|
||||
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
|
||||
return vec4(1.0);
|
||||
}
|
||||
@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>) -> Out {
|
||||
@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);
|
||||
@@ -43,15 +67,22 @@ struct Out {
|
||||
let l = vec2(f32(light.y), f32(light.x));
|
||||
let lm = textureSampleLevel(lightmap, lightmap_sampler,
|
||||
clamp(l / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0);
|
||||
// overlay 2: celestial bodies, 4: clouds; both use their vertex colour unlit.
|
||||
var c = select(color * lm, color, overlay == 2u || overlay == 4u || overlay == 6u);
|
||||
// Celestial bodies, clouds, beacon beams, End portals and breaking cracks are unlit.
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|
||||
|| overlay == 11u || overlay == 12u;
|
||||
out.light_color = select(lm, vec4(1.0), unlit);
|
||||
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range.
|
||||
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
|
||||
// Hurt overlay (OverlayTexture red tint).
|
||||
if overlay == 1u || overlay == 8u { c = vec4(mix(c.rgb, vec3(1.0, 0.0, 0.0), 0.3), c.a); }
|
||||
// minecraft_mix_light: the vertex colour times the cardinal light only.
|
||||
var c = color;
|
||||
var back = c;
|
||||
if normal.w != 0.0 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
}
|
||||
out.color = c;
|
||||
// Overlay 5: OverlayTexture white flash (primed TNT), applied after texturing.
|
||||
out.flash = select(0.0, 0.75, overlay == 5u);
|
||||
out.back_color = back;
|
||||
out.overlay_color = overlay_texel(overlay);
|
||||
// Sky geometry (clouds, celestial bodies) is exempt from render-distance fog.
|
||||
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
@@ -99,10 +130,13 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d >= end { return 1.0; }
|
||||
return (d - start) / (end - start);
|
||||
}
|
||||
@fragment fn fs_entity(in: Out) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
var color = textureSample(atlas, atlas_sampler, uv) * in.color;
|
||||
if in.overlay == 11u || in.overlay == 12u {
|
||||
uv = globals.crumbling_sprite.xy + fract(in.uv) * globals.crumbling_sprite.zw;
|
||||
}
|
||||
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
|
||||
if in.overlay == 9u || in.overlay == 10u {
|
||||
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
|
||||
// for gateways) layers of end_portal.png projected from the screen.
|
||||
@@ -117,7 +151,10 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
|
||||
}
|
||||
if color.a < 0.1 { discard; }
|
||||
color = vec4(mix(color.rgb, vec3(1.0), in.flash), color.a);
|
||||
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
|
||||
// lit texture, then the lightmap, then the glint and fog.
|
||||
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
|
||||
color *= in.light_color;
|
||||
if in.overlay == 7u || in.overlay == 8u {
|
||||
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
|
||||
// enchanted_glint sheen drifting diagonally over the item's shape.
|
||||
@@ -135,6 +172,6 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
}
|
||||
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
return color;
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
//! Horizon does not model (variants, attributes, equipment, custom names)
|
||||
//! survive a Horizon save.
|
||||
use crate::{
|
||||
entities::{ArmorStand, Arrow, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
mobs::{Mob, MobType},
|
||||
arrows::Pickup,
|
||||
entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
mobs::{Mob, MobType, Who},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
save::{
|
||||
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
|
||||
uuid_from_tag,
|
||||
@@ -29,10 +31,10 @@ pub fn position(tag: &Tag) -> Option<Vec3> {
|
||||
pub fn uuid(tag: &Tag) -> Option<[i32; 4]> {
|
||||
uuid_from_tag(tag.get("UUID"))
|
||||
}
|
||||
fn motion(tag: &Tag) -> Vec3 {
|
||||
pub fn motion(tag: &Tag) -> Vec3 {
|
||||
read_doubles::<3>(tag.get("Motion")).map_or(Vec3::ZERO, |m| Vec3::new(m[0], m[1], m[2]))
|
||||
}
|
||||
fn rotation(tag: &Tag) -> [f32; 2] {
|
||||
pub fn rotation(tag: &Tag) -> [f32; 2] {
|
||||
read_doubles::<2>(tag.get("Rotation")).map_or([0.0; 2], |r| [r[0] as f32, r[1] as f32])
|
||||
}
|
||||
fn flag(tag: &Tag, key: &str) -> bool {
|
||||
@@ -98,6 +100,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
for (key, value) in [
|
||||
(
|
||||
"Fire",
|
||||
Tag::Short(m.fire_ticks.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16),
|
||||
),
|
||||
("Air", Tag::Short(m.air as i16)),
|
||||
("Health", Tag::Float(m.health.max(0.0).to_bits())),
|
||||
(
|
||||
"HurtTime",
|
||||
@@ -129,6 +136,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
for (key, on) in [
|
||||
("NoAI", m.no_ai),
|
||||
("Silent", m.silent),
|
||||
("NoGravity", m.no_gravity),
|
||||
("Glowing", m.glowing),
|
||||
("Invulnerable", m.invulnerable_flag),
|
||||
] {
|
||||
if on {
|
||||
@@ -137,6 +146,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
}
|
||||
if m.ticks_frozen > 0 {
|
||||
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
|
||||
} else {
|
||||
map.remove(&horizon_world::nbt::JavaString::from("TicksFrozen"));
|
||||
}
|
||||
for (key, value) in [
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
@@ -156,6 +170,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
if m.kind.category == "creature" && aged {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
@@ -167,14 +182,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
match id {
|
||||
"minecraft:creeper" => {
|
||||
for (key, value) in [
|
||||
("Fuse", Tag::Short(30)),
|
||||
("ExplosionRadius", Tag::Byte(3)),
|
||||
("ignited", Tag::Byte(0)),
|
||||
] {
|
||||
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
map.insert("powered".into(), Tag::Byte(i8::from(m.extra.charged)));
|
||||
map.insert("ignited".into(), Tag::Byte(i8::from(m.extra.flag)));
|
||||
}
|
||||
"minecraft:cat" => {
|
||||
if let Some(owner) = m.extra.owner {
|
||||
@@ -324,6 +336,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
crate::mobs::villager::write_nbt(m, map);
|
||||
}
|
||||
"minecraft:wandering_trader" => put_offers(map, m),
|
||||
"minecraft:axolotl" => write_axolotl_memories(m, map),
|
||||
"minecraft:allay" => {
|
||||
// InventoryCarrier: the fetched stack; the liked player memory.
|
||||
let mut inv = Vec::new();
|
||||
@@ -584,20 +597,25 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.main_hand = None;
|
||||
}
|
||||
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
|
||||
m.persistent = flag(tag, "PersistenceRequired");
|
||||
m.persistent |= flag(tag, "PersistenceRequired");
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
if m.kind.category == "creature" {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
}
|
||||
m.name_visible = flag(tag, "CustomNameVisible");
|
||||
m.no_ai = flag(tag, "NoAI");
|
||||
m.no_gravity = flag(tag, "NoGravity");
|
||||
m.glowing = flag(tag, "Glowing");
|
||||
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
|
||||
m.silent = flag(tag, "Silent");
|
||||
m.invulnerable_flag = flag(tag, "Invulnerable");
|
||||
m.no_gravity = m.no_gravity || flag(tag, "NoGravity");
|
||||
if m.species() == crate::mobs::Species::Creeper {
|
||||
m.extra.charged = flag(tag, "powered");
|
||||
// Creeper.readAdditionalSaveData: `ignited` lights the fuse.
|
||||
m.extra.flag = flag(tag, "ignited");
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -710,6 +728,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
crate::mobs::zombie_villager::read_nbt(&mut m, tag);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Axolotl {
|
||||
restore_axolotl_memories(&mut m, tag);
|
||||
}
|
||||
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
|
||||
if m.species() == crate::mobs::Species::Allay {
|
||||
if let Some(Tag::List(inv)) = tag.get("Inventory")
|
||||
&& let Some(first) = inv.first()
|
||||
@@ -836,6 +859,8 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
let mut tag = base.tag(raw);
|
||||
for (key, value) in [
|
||||
("Item", item_to_nbt(&e.stack)),
|
||||
("Health", Tag::Short(e.health as i16)),
|
||||
("Fire", Tag::Short(e.fire_ticks as i16)),
|
||||
("Age", Tag::Short(e.age.min(i16::MAX as u32) as i16)),
|
||||
(
|
||||
"PickupDelay",
|
||||
@@ -844,15 +869,14 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.entry("Health".into()).or_insert(Tag::Short(5));
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
let stack = item_from_nbt(tag.get("Item")?)?;
|
||||
let position = position(tag)?;
|
||||
Some(ItemEntity {
|
||||
health: number(tag.get("Health")).unwrap_or(5) as i32,
|
||||
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i32,
|
||||
id,
|
||||
stack,
|
||||
position,
|
||||
@@ -905,68 +929,102 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
vec![orb; count]
|
||||
}
|
||||
|
||||
/// `AbstractArrow`: damage, crit, inGround, life, pickup, item.
|
||||
pub fn arrow_to_nbt(a: &Arrow, uuid: [i32; 4], owner: Option<[i32; 4]>) -> Tag {
|
||||
let v = a.velocity;
|
||||
let horizontal = (v.x * v.x + v.z * v.z).sqrt();
|
||||
let rotation = [
|
||||
(v.x.atan2(v.z).to_degrees()) as f32,
|
||||
(v.y.atan2(horizontal).to_degrees()) as f32,
|
||||
];
|
||||
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
|
||||
/// `in_block` is its `inBlockState`, `raw` the compound it was loaded with.
|
||||
pub fn arrow_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
in_block: Option<Tag>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<Tag> {
|
||||
let a = p.arrow.as_deref()?;
|
||||
let ProjectileKind::Arrow {
|
||||
base_damage,
|
||||
critical,
|
||||
..
|
||||
} = p.kind
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let base = Base {
|
||||
id: "minecraft:arrow",
|
||||
uuid,
|
||||
position: a.position,
|
||||
motion: a.velocity,
|
||||
rotation,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: a.fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(None);
|
||||
let mut tag = base.tag(raw);
|
||||
for (key, value) in [
|
||||
("damage", Tag::Double(a.base_damage.to_bits())),
|
||||
("crit", Tag::Byte(i8::from(a.critical))),
|
||||
("inGround", Tag::Byte(i8::from(a.stuck))),
|
||||
(
|
||||
"life",
|
||||
Tag::Short(if a.stuck { a.age.min(1200) as i16 } else { 0 }),
|
||||
),
|
||||
// Pickup: allowed for the player's arrows, disallowed for mobs'.
|
||||
("pickup", Tag::Byte(i8::from(a.from_player))),
|
||||
("shake", Tag::Byte(0)),
|
||||
("PierceLevel", Tag::Byte(0)),
|
||||
("HasBeenShot", Tag::Byte(1)),
|
||||
("SoundEvent", string("minecraft:entity.arrow.hit")),
|
||||
(
|
||||
"item",
|
||||
Tag::compound([("id", string("minecraft:arrow")), ("count", Tag::Int(1))]),
|
||||
),
|
||||
("Fire", Tag::Short(a.fire_ticks as i16)),
|
||||
("life", Tag::Short(a.life as i16)),
|
||||
("shake", Tag::Byte(a.shake as i8)),
|
||||
("inGround", Tag::Byte(i8::from(a.in_ground))),
|
||||
("pickup", Tag::Byte(a.pickup.byte())),
|
||||
("damage", Tag::Double(base_damage.to_bits())),
|
||||
("crit", Tag::Byte(i8::from(critical))),
|
||||
("PierceLevel", Tag::Byte(a.pierce as i8)),
|
||||
("HasBeenShot", Tag::Byte(i8::from(a.has_been_shot))),
|
||||
("item", item_to_nbt(&a.pickup_item)),
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.entry("SoundEvent".into())
|
||||
.or_insert_with(|| string("minecraft:entity.arrow.hit"));
|
||||
// Projectile: LeftOwner only when set; Owner as the UUID.
|
||||
map.remove(&"LeftOwner".into());
|
||||
map.remove(&"inBlockState".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if let Some(state) = in_block {
|
||||
put(&mut tag, "inBlockState", state);
|
||||
}
|
||||
if let Some(owner) = owner {
|
||||
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
tag
|
||||
Some(tag)
|
||||
}
|
||||
pub fn arrow_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Arrow> {
|
||||
/// A saved arrow as a live one (`readAdditionalSaveData`), with its
|
||||
/// `inBlockState` compound for the caller to resolve. `effect` is the tipped
|
||||
/// arrow's lasting effect from its item.
|
||||
pub fn arrow_from_nbt(
|
||||
tag: &Tag,
|
||||
player: Option<[i32; 4]>,
|
||||
effect: impl FnOnce(&crate::items::ItemStack) -> Option<(&'static str, i32)>,
|
||||
) -> Option<(Projectile, Option<Tag>)> {
|
||||
let position = position(tag)?;
|
||||
let stuck = flag(tag, "inGround");
|
||||
Some(Arrow {
|
||||
from_player: player.is_some() && uuid_from_tag(tag.get("Owner")) == player
|
||||
|| number(tag.get("pickup")) == Some(1),
|
||||
critical: flag(tag, "crit"),
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
stuck,
|
||||
age: if stuck {
|
||||
number(tag.get("life")).unwrap_or(0).max(0) as u32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let item = tag
|
||||
.get("item")
|
||||
.and_then(item_from_nbt)
|
||||
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:arrow", 1));
|
||||
let kind = ProjectileKind::Arrow {
|
||||
base_damage: float(tag.get("damage")).unwrap_or(2.0),
|
||||
})
|
||||
critical: flag(tag, "crit"),
|
||||
effect: effect(&item),
|
||||
};
|
||||
let mut p = Projectile::new(kind, position, from_player.then_some(Who::Player));
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
let a = p.arrow.as_mut()?;
|
||||
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
|
||||
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
|
||||
a.in_ground = flag(tag, "inGround");
|
||||
a.pickup = Pickup::from_byte(number(tag.get("pickup")).unwrap_or(0));
|
||||
a.pierce = number(tag.get("PierceLevel")).unwrap_or(0) as u8;
|
||||
a.has_been_shot = flag(tag, "HasBeenShot");
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
a.pickup_item = item;
|
||||
p.stuck = a.in_ground;
|
||||
Some((p, tag.get("inBlockState").cloned()))
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
@@ -1141,11 +1199,8 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Marker".into());
|
||||
}
|
||||
if let Tag::Compound(map) = &tag
|
||||
&& !map.contains_key(&horizon_world::nbt::JavaString::from("Health"))
|
||||
{
|
||||
put(&mut tag, "Health", Tag::Float(20f32.to_bits()));
|
||||
}
|
||||
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
|
||||
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
|
||||
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
for (key, value) in [
|
||||
@@ -1170,6 +1225,8 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
let mut a = ArmorStand::new(id, position(tag)?, rotation(tag)[0]);
|
||||
a.velocity = motion(tag);
|
||||
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
for (i, slot) in ArmorStand::SLOTS.iter().enumerate() {
|
||||
a.equipment[i] = eq.get(slot).and_then(item_from_nbt);
|
||||
@@ -1256,20 +1313,44 @@ fn villager_data(m: &Mob) -> Tag {
|
||||
}
|
||||
/// `AbstractVillager` "Offers" (`MerchantOffers.CODEC`).
|
||||
fn put_offers(map: &mut Compound, m: &Mob) {
|
||||
let Some(t) = &m.extra.trade else { return };
|
||||
let recipes = t
|
||||
.offers
|
||||
let Some(t) = m.extra.trade.as_ref().filter(|t| !t.unset) else {
|
||||
return;
|
||||
};
|
||||
map.insert("Offers".into(), offers_to_nbt(&t.offers));
|
||||
}
|
||||
/// `ItemCost.CODEC`: id, count (always written) and the exact components.
|
||||
fn item_cost_to_nbt(stack: &crate::items::ItemStack) -> Tag {
|
||||
let mut tag = item_to_nbt(stack);
|
||||
if let Tag::Compound(c) = &mut tag
|
||||
&& !c.contains_key(&"count".into())
|
||||
{
|
||||
c.insert("count".into(), Tag::Int(stack.count as i32));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `MerchantOffers.CODEC`: `{Recipes: [...]}`, each offer without the
|
||||
/// fields at their defaults.
|
||||
pub fn offers_to_nbt(offers: &[crate::merchant::MerchantOffer]) -> Tag {
|
||||
let recipes = offers
|
||||
.iter()
|
||||
.map(|o| {
|
||||
let mut entries = vec![
|
||||
("buy", item_to_nbt(&o.base_cost_a.stack)),
|
||||
("buy", item_cost_to_nbt(&o.base_cost_a.stack)),
|
||||
("sell", item_to_nbt(&o.result)),
|
||||
("uses", Tag::Int(o.uses)),
|
||||
("maxUses", Tag::Int(o.max_uses)),
|
||||
("priceMultiplier", Tag::Float(o.price_multiplier.to_bits())),
|
||||
("xp", Tag::Int(o.xp)),
|
||||
];
|
||||
// `MerchantOffer.CODEC` omits fields at their defaults.
|
||||
if o.uses != 0 {
|
||||
entries.push(("uses", Tag::Int(o.uses)));
|
||||
}
|
||||
if o.max_uses != 4 {
|
||||
entries.push(("maxUses", Tag::Int(o.max_uses)));
|
||||
}
|
||||
if o.price_multiplier != 0.0 {
|
||||
entries.push(("priceMultiplier", Tag::Float(o.price_multiplier.to_bits())));
|
||||
}
|
||||
if o.xp != 1 {
|
||||
entries.push(("xp", Tag::Int(o.xp)));
|
||||
}
|
||||
if !o.reward_exp {
|
||||
entries.push(("rewardExp", Tag::Byte(0)));
|
||||
}
|
||||
@@ -1280,15 +1361,45 @@ fn put_offers(map: &mut Compound, m: &Mob) {
|
||||
entries.push(("demand", Tag::Int(o.demand)));
|
||||
}
|
||||
if let Some(b) = &o.cost_b {
|
||||
entries.push(("buyB", item_to_nbt(&b.stack)));
|
||||
entries.push(("buyB", item_cost_to_nbt(&b.stack)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect();
|
||||
map.insert(
|
||||
"Offers".into(),
|
||||
Tag::compound([("Recipes", Tag::List(recipes))]),
|
||||
);
|
||||
.collect::<Vec<_>>();
|
||||
// An empty list is the field's default: left out.
|
||||
if recipes.is_empty() {
|
||||
return Tag::Compound(Compound::new());
|
||||
}
|
||||
Tag::compound([("Recipes", Tag::List(recipes))])
|
||||
}
|
||||
/// The offers of a `MerchantOffers.CODEC` tag.
|
||||
pub fn offers_from_nbt(tag: &Tag) -> Vec<crate::merchant::MerchantOffer> {
|
||||
use crate::merchant::{ItemCost, MerchantOffer};
|
||||
let Some(Tag::List(recipes)) = tag.get("Recipes") else {
|
||||
return Vec::new();
|
||||
};
|
||||
recipes
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let cost = |key| {
|
||||
r.get(key)
|
||||
.and_then(item_from_nbt)
|
||||
.map(|stack| ItemCost { stack })
|
||||
};
|
||||
Some(MerchantOffer {
|
||||
base_cost_a: cost("buy")?,
|
||||
cost_b: cost("buyB"),
|
||||
result: r.get("sell").and_then(item_from_nbt)?,
|
||||
uses: number(r.get("uses")).unwrap_or(0) as i32,
|
||||
max_uses: number(r.get("maxUses")).unwrap_or(4) as i32,
|
||||
reward_exp: number(r.get("rewardExp")).unwrap_or(1) != 0,
|
||||
special_price_diff: number(r.get("specialPrice")).unwrap_or(0) as i32,
|
||||
demand: number(r.get("demand")).unwrap_or(0) as i32,
|
||||
price_multiplier: float(r.get("priceMultiplier")).unwrap_or(0.0) as f32,
|
||||
xp: number(r.get("xp")).unwrap_or(1) as i32,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The villager brain's `job_site`, `home` and `meeting_point` memories.
|
||||
fn put_memories(map: &mut Compound, m: &Mob) {
|
||||
@@ -1314,7 +1425,7 @@ fn put_memories(map: &mut Compound, m: &Mob) {
|
||||
}
|
||||
fn read_villager(m: &mut Mob, tag: &Tag) {
|
||||
use crate::{
|
||||
merchant::{ItemCost, MerchantOffer, Trader},
|
||||
merchant::Trader,
|
||||
mobs::render::{PROFESSIONS, VILLAGER_TYPES},
|
||||
};
|
||||
if let Some(data) = tag.get("VillagerData") {
|
||||
@@ -1343,32 +1454,14 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
|
||||
if m.species() == crate::mobs::Species::Villager {
|
||||
crate::mobs::villager::read_nbt(m, tag);
|
||||
}
|
||||
let Some(Tag::List(recipes)) = tag.get("Offers").and_then(|o| o.get("Recipes")) else {
|
||||
let offers_tag = tag.get("Offers");
|
||||
let xp = number(tag.get("Xp")).unwrap_or(0) as i32;
|
||||
if offers_tag.is_none() && xp == 0 && tag.get("LastRestock").is_none() {
|
||||
return;
|
||||
};
|
||||
let offers = recipes
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let cost = |key| {
|
||||
r.get(key)
|
||||
.and_then(item_from_nbt)
|
||||
.map(|stack| ItemCost { stack })
|
||||
};
|
||||
Some(MerchantOffer {
|
||||
base_cost_a: cost("buy")?,
|
||||
cost_b: cost("buyB"),
|
||||
result: r.get("sell").and_then(item_from_nbt)?,
|
||||
uses: number(r.get("uses")).unwrap_or(0) as i32,
|
||||
max_uses: number(r.get("maxUses")).unwrap_or(4) as i32,
|
||||
reward_exp: number(r.get("rewardExp")).unwrap_or(1) != 0,
|
||||
special_price_diff: number(r.get("specialPrice")).unwrap_or(0) as i32,
|
||||
demand: number(r.get("demand")).unwrap_or(0) as i32,
|
||||
price_multiplier: float(r.get("priceMultiplier")).unwrap_or(0.0) as f32,
|
||||
xp: number(r.get("xp")).unwrap_or(1) as i32,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let offers = offers_tag.map(offers_from_nbt).unwrap_or_default();
|
||||
m.extra.trade = Some(Box::new(Trader {
|
||||
unset: offers_tag.is_none(),
|
||||
xp: number(tag.get("Xp")).unwrap_or(0) as i32,
|
||||
offers,
|
||||
last_restock: number(tag.get("LastRestock")).unwrap_or(0),
|
||||
@@ -1385,6 +1478,8 @@ mod tests {
|
||||
#[test]
|
||||
fn item_entities_round_trip() {
|
||||
let e = ItemEntity {
|
||||
health: 2,
|
||||
fire_ticks: 73,
|
||||
id: 7,
|
||||
stack: ItemStack {
|
||||
item: Arc::from("minecraft:diamond_pickaxe"),
|
||||
@@ -1405,6 +1500,7 @@ mod tests {
|
||||
assert_eq!(uuid(&tag), Some([1, 2, 3, 4]));
|
||||
let back = item_entity_from_nbt(&tag, 9, 0.0).unwrap();
|
||||
assert_eq!(back.stack.damage, 5);
|
||||
assert_eq!((back.health, back.fire_ticks), (2, 73));
|
||||
assert_eq!(
|
||||
(back.age, back.pickup_delay, back.on_ground),
|
||||
(120, 10, true)
|
||||
@@ -1444,3 +1540,324 @@ mod tests {
|
||||
assert_eq!(back[0].value, 7);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
|
||||
let brain = map
|
||||
.entry("Brain".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(brain, Tag::Compound(_)) {
|
||||
*brain = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(brain) = brain else { return };
|
||||
let memories = brain
|
||||
.entry("memories".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(memories, Tag::Compound(_)) {
|
||||
*memories = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(memories) = memories else {
|
||||
return;
|
||||
};
|
||||
let mm = m.extra.animal_brain.as_deref().map(|b| &b.mem);
|
||||
let hunting = crate::mobs::animal_brain::hunting_cooldown(m)
|
||||
.map(|ttl| Tag::compound([("value", Tag::Byte(1)), ("ttl", Tag::Long(ttl))]));
|
||||
let play_dead = mm
|
||||
.and_then(|m| m.play_dead_ticks.value())
|
||||
.map(|v| Tag::compound([("value", Tag::Int(v))]));
|
||||
let temptation = mm
|
||||
.and_then(|m| m.temptation_cooldown_ticks.value())
|
||||
.map(|v| Tag::compound([("value", Tag::Int(v))]));
|
||||
for (key, value) in [
|
||||
("minecraft:has_hunting_cooldown", hunting),
|
||||
("minecraft:play_dead_ticks", play_dead),
|
||||
("minecraft:temptation_cooldown_ticks", temptation),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
memories.insert(key.into(), value);
|
||||
} else {
|
||||
memories.remove(&key.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
|
||||
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
|
||||
let ttl = memories
|
||||
.and_then(|b| b.get("minecraft:has_hunting_cooldown"))
|
||||
.and_then(|v| number(v.get("ttl")))
|
||||
.unwrap_or(0);
|
||||
crate::mobs::animal_brain::set_hunting_cooldown(m, ttl);
|
||||
if let Some(b) = m.extra.animal_brain.as_mut() {
|
||||
if let Some(v) = memories
|
||||
.and_then(|b| b.get("minecraft:play_dead_ticks"))
|
||||
.and_then(|v| number(v.get("value")))
|
||||
{
|
||||
b.mem.play_dead_ticks.set(v as i32);
|
||||
}
|
||||
if let Some(v) = memories
|
||||
.and_then(|b| b.get("minecraft:temptation_cooldown_ticks"))
|
||||
.and_then(|v| number(v.get("value")))
|
||||
{
|
||||
b.mem.temptation_cooldown_ticks.set(v as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"];
|
||||
pub fn bucket_data(m: &Mob) -> Tag {
|
||||
let mut data = Tag::compound([
|
||||
("Age", Tag::Int(m.extra.age)),
|
||||
("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))),
|
||||
("Health", Tag::Float(m.health.to_bits())),
|
||||
]);
|
||||
for (key, on) in [
|
||||
("NoAI", m.no_ai),
|
||||
("Silent", m.silent),
|
||||
("NoGravity", m.no_gravity),
|
||||
("Glowing", m.glowing),
|
||||
("Invulnerable", m.invulnerable_flag),
|
||||
("PersistenceRequired", m.persistent),
|
||||
] {
|
||||
if on {
|
||||
put(&mut data, key, Tag::Byte(1));
|
||||
}
|
||||
}
|
||||
if let Some(ttl) = crate::mobs::animal_brain::hunting_cooldown(m) {
|
||||
put(&mut data, "HuntingCooldown", Tag::Long(ttl));
|
||||
}
|
||||
data
|
||||
}
|
||||
pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
let mut data = crate::item_data::StackData {
|
||||
custom_name: m.custom_name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
data.set_other("minecraft:bucket_entity_data", Some(bucket_data(m)));
|
||||
if m.species() == crate::mobs::Species::Axolotl {
|
||||
let variant = AXOLOTL_VARIANTS
|
||||
.get(usize::from(m.extra.variant))
|
||||
.unwrap_or(&"lucy");
|
||||
data.set_other("minecraft:axolotl/variant", Some(string(variant)));
|
||||
}
|
||||
data
|
||||
}
|
||||
pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
let Some(components) = stack.data() else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = &components.custom_name {
|
||||
m.custom_name = Some(name.clone());
|
||||
}
|
||||
if let Some(data) = components.other("minecraft:bucket_entity_data") {
|
||||
if let Some(v) = number(data.get("Age")) {
|
||||
m.extra.age = v as i32;
|
||||
}
|
||||
if let Some(v) = number(data.get("AgeLocked")) {
|
||||
m.extra.age_locked = v != 0;
|
||||
}
|
||||
// Keep old Horizon buckets readable; the modern component below takes precedence.
|
||||
if let Some(v) = number(data.get("Variant")) {
|
||||
m.extra.variant = v as u8;
|
||||
}
|
||||
if let Some(v) = float(data.get("Health")) {
|
||||
m.health = v as f32;
|
||||
}
|
||||
for (key, flag) in [
|
||||
("NoAI", &mut m.no_ai),
|
||||
("Silent", &mut m.silent),
|
||||
("NoGravity", &mut m.no_gravity),
|
||||
("Glowing", &mut m.glowing),
|
||||
("Invulnerable", &mut m.invulnerable_flag),
|
||||
] {
|
||||
if let Some(v) = number(data.get(key)) {
|
||||
*flag = v != 0;
|
||||
}
|
||||
}
|
||||
m.persistent |= flag(data, "PersistenceRequired");
|
||||
if m.species() == crate::mobs::Species::Axolotl {
|
||||
crate::mobs::animal_brain::set_hunting_cooldown(
|
||||
m,
|
||||
number(data.get("HuntingCooldown")).unwrap_or(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Axolotl
|
||||
&& let Some(name) = text(components.other("minecraft:axolotl/variant"))
|
||||
&& let Some(id) = AXOLOTL_VARIANTS
|
||||
.iter()
|
||||
.position(|&v| v == name.trim_start_matches("minecraft:"))
|
||||
{
|
||||
m.extra.variant = id as u8;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod axolotl_nbt_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn bucket_cooldown_and_brain_memories_survive_and_expired_cooldown_is_removed() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let kind = Arc::new(data.types.get("minecraft:axolotl").unwrap().clone());
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 123);
|
||||
m.extra.variant = 4;
|
||||
m.extra.age = -24000;
|
||||
m.extra.age_locked = true;
|
||||
m.no_ai = true;
|
||||
m.silent = true;
|
||||
m.no_gravity = true;
|
||||
m.glowing = true;
|
||||
m.invulnerable_flag = true;
|
||||
m.persistent = true;
|
||||
m.custom_name = Some("Bucket friend".into());
|
||||
m.health = 5.0;
|
||||
|
||||
{
|
||||
let mm = &mut m.extra.animal_brain.as_mut().unwrap().mem;
|
||||
mm.play_dead_ticks.set(78);
|
||||
mm.temptation_cooldown_ticks.set(42);
|
||||
}
|
||||
let raw = Tag::compound([(
|
||||
"Brain",
|
||||
Tag::compound([(
|
||||
"memories",
|
||||
Tag::compound([("test:unrelated", Tag::compound([("value", Tag::Int(9))]))]),
|
||||
)]),
|
||||
)]);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
let memories = tag.get("Brain").unwrap().get("memories").unwrap();
|
||||
assert!(memories.get("test:unrelated").is_some());
|
||||
assert_eq!(
|
||||
number(
|
||||
memories
|
||||
.get("minecraft:play_dead_ticks")
|
||||
.unwrap()
|
||||
.get("value")
|
||||
),
|
||||
Some(78)
|
||||
);
|
||||
assert_eq!(
|
||||
number(
|
||||
memories
|
||||
.get("minecraft:temptation_cooldown_ticks")
|
||||
.unwrap()
|
||||
.get("value")
|
||||
),
|
||||
Some(42)
|
||||
);
|
||||
let mut stack = crate::items::ItemStack::new("minecraft:axolotl_bucket", 1);
|
||||
stack.extra = Some(Box::new(bucket_components(&m)));
|
||||
assert_eq!(
|
||||
text(stack.data().unwrap().other("minecraft:axolotl/variant")),
|
||||
Some("blue".into())
|
||||
);
|
||||
assert!(bucket_data(&m).get("Variant").is_none());
|
||||
let stack = item_from_nbt(&item_to_nbt(&stack)).unwrap();
|
||||
let mut restored = Mob::new(2, kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
load_bucket_data(&mut restored, &stack);
|
||||
assert_eq!(
|
||||
crate::mobs::animal_brain::hunting_cooldown(&restored),
|
||||
Some(123)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
restored.extra.variant,
|
||||
restored.extra.age,
|
||||
restored.extra.age_locked
|
||||
),
|
||||
(4, -24000, true)
|
||||
);
|
||||
assert!(
|
||||
restored.no_ai
|
||||
&& restored.silent
|
||||
&& restored.no_gravity
|
||||
&& restored.glowing
|
||||
&& restored.invulnerable_flag
|
||||
&& restored.persistent
|
||||
);
|
||||
assert_eq!(restored.custom_name.as_deref(), Some("Bucket friend"));
|
||||
assert_eq!(restored.health, 5.0);
|
||||
m.glowing = false;
|
||||
m.add_effect("glowing", 200, 0);
|
||||
assert!(m.has_effect("glowing"));
|
||||
assert!(bucket_data(&m).get("Glowing").is_none());
|
||||
m.glowing = true;
|
||||
assert_eq!(number(bucket_data(&m).get("Glowing")), Some(1));
|
||||
for (persistent, expected) in [(None, false), (Some(0), false), (Some(1), true)] {
|
||||
let mut data = Tag::Compound(Compound::new());
|
||||
if let Some(value) = persistent {
|
||||
put(&mut data, "PersistenceRequired", Tag::Byte(value));
|
||||
}
|
||||
let mut bucket = crate::items::ItemStack::new("minecraft:axolotl_bucket", 1);
|
||||
bucket
|
||||
.data_mut()
|
||||
.set_other("minecraft:bucket_entity_data", Some(data));
|
||||
restored.persistent = false;
|
||||
load_bucket_data(&mut restored, &bucket);
|
||||
assert_eq!(restored.persistent, expected);
|
||||
restored.persistent = true;
|
||||
load_bucket_data(&mut restored, &bucket);
|
||||
assert!(restored.persistent, "bucket data cannot clear persistence");
|
||||
}
|
||||
restored.ticks_frozen = 140;
|
||||
restored.ignite_for_ticks(160);
|
||||
assert_eq!(restored.ticks_frozen, 0);
|
||||
restored.ticks_frozen = 140;
|
||||
restored.ignite_for_ticks(1);
|
||||
assert_eq!(
|
||||
restored.ticks_frozen, 0,
|
||||
"even an ignition that does not extend burning clears freeze"
|
||||
);
|
||||
for (id, name) in AXOLOTL_VARIANTS.iter().enumerate() {
|
||||
let mut vanilla = crate::items::ItemStack::new("minecraft:axolotl_bucket", 1);
|
||||
vanilla.extra = Some(Box::new(crate::item_data::StackData {
|
||||
other: vec![("minecraft:axolotl/variant".into(), string(name))],
|
||||
..Default::default()
|
||||
}));
|
||||
load_bucket_data(&mut restored, &vanilla);
|
||||
assert_eq!(
|
||||
usize::from(restored.extra.variant),
|
||||
id,
|
||||
"component-only vanilla buckets work"
|
||||
);
|
||||
}
|
||||
let mut legacy = crate::items::ItemStack::new("minecraft:axolotl_bucket", 1);
|
||||
legacy.extra = Some(Box::new(crate::item_data::StackData {
|
||||
other: vec![(
|
||||
"minecraft:bucket_entity_data".into(),
|
||||
Tag::compound([("Variant", Tag::Int(3))]),
|
||||
)],
|
||||
..Default::default()
|
||||
}));
|
||||
load_bucket_data(&mut restored, &legacy);
|
||||
assert_eq!(restored.extra.variant, 3);
|
||||
legacy
|
||||
.data_mut()
|
||||
.set_other("minecraft:axolotl/variant", Some(string("gold")));
|
||||
load_bucket_data(&mut restored, &legacy);
|
||||
assert_eq!(
|
||||
restored.extra.variant, 2,
|
||||
"component wins over old Horizon data"
|
||||
);
|
||||
|
||||
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 0);
|
||||
assert!(bucket_data(&m).get("HuntingCooldown").is_none());
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag));
|
||||
assert!(
|
||||
saved
|
||||
.get("Brain")
|
||||
.unwrap()
|
||||
.get("memories")
|
||||
.unwrap()
|
||||
.get("minecraft:has_hunting_cooldown")
|
||||
.is_none()
|
||||
);
|
||||
restore_axolotl_memories(&mut restored, &saved);
|
||||
let mm = &restored.extra.animal_brain.as_ref().unwrap().mem;
|
||||
assert_eq!(mm.play_dead_ticks.value(), Some(78));
|
||||
assert_eq!(mm.temptation_cooldown_ticks.value(), Some(42));
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,8 @@ pub struct Sky {
|
||||
/// Environmental fog (`visual/fog_start_distance` / `fog_end_distance`).
|
||||
pub fog_start: f32,
|
||||
pub fog_end: f32,
|
||||
/// `visual/cloud_fog_end_distance`.
|
||||
pub cloud_fog_end: f32,
|
||||
}
|
||||
impl Environment {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
@@ -299,6 +301,10 @@ impl Environment {
|
||||
.base
|
||||
.get("minecraft:visual/fog_end_distance")
|
||||
.map_or(1024.0, |v| v[0]),
|
||||
cloud_fog_end: self
|
||||
.base
|
||||
.get("minecraft:visual/cloud_fog_end_distance")
|
||||
.map_or(2048.0, |v| v[0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`.
|
||||
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";
|
||||
/// 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;
|
||||
/// `Camera.tickFov` keeps the field-of-view modifier at 0.1 or more.
|
||||
pub const ZOOM_MAX: f32 = 10.0;
|
||||
/// Each wheel step multiplies the zoom by this.
|
||||
const ZOOM_STEP: f32 = 1.25;
|
||||
|
||||
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
|
||||
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
pub const MINIMAP_ZOOMS: [f32; 4] = [0.5, 1.0, 2.0, 4.0];
|
||||
|
||||
/// A screen corner for an overlay.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Corner {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
impl Corner {
|
||||
pub const ALL: [Corner; 4] = [
|
||||
Corner::TopLeft,
|
||||
Corner::TopRight,
|
||||
Corner::BottomLeft,
|
||||
Corner::BottomRight,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Corner::TopLeft => "top_left",
|
||||
Corner::TopRight => "top_right",
|
||||
Corner::BottomLeft => "bottom_left",
|
||||
Corner::BottomRight => "bottom_right",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Corner::TopLeft => "Top left",
|
||||
Corner::TopRight => "Top right",
|
||||
Corner::BottomLeft => "Bottom left",
|
||||
Corner::BottomRight => "Bottom right",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str, default: Corner) -> Corner {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|c| c.name() == s)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
pub fn top(self) -> bool {
|
||||
matches!(self, Corner::TopLeft | Corner::TopRight)
|
||||
}
|
||||
pub fn left(self) -> bool {
|
||||
matches!(self, Corner::TopLeft | Corner::BottomLeft)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Extras {
|
||||
pub zoom: bool,
|
||||
/// A vanilla key name (`key.keyboard.z`).
|
||||
pub zoom_key: String,
|
||||
/// The zoom the key starts at.
|
||||
pub zoom_level: u8,
|
||||
/// Ease through vanilla's FOV easing (as the spyglass does).
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
pub fps: bool,
|
||||
pub fps_corner: Corner,
|
||||
/// Also show the minimum and 1% low.
|
||||
pub fps_lows: bool,
|
||||
pub keystrokes: bool,
|
||||
pub keystrokes_corner: Corner,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub keystrokes_cps: bool,
|
||||
pub minimap: bool,
|
||||
pub minimap_corner: Corner,
|
||||
/// Side in GUI pixels (`MINIMAP_SIZES`).
|
||||
pub minimap_size: u8,
|
||||
pub minimap_round: bool,
|
||||
/// Blocks per pixel (`MINIMAP_ZOOMS`).
|
||||
pub minimap_zoom: f32,
|
||||
/// Turn with the player (forward up) instead of north up.
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
zoom: false,
|
||||
zoom_key: "key.keyboard.z".into(),
|
||||
zoom_level: 4,
|
||||
zoom_smooth: true,
|
||||
zoom_sensitivity: true,
|
||||
fps: false,
|
||||
fps_corner: Corner::TopLeft,
|
||||
fps_lows: false,
|
||||
keystrokes: false,
|
||||
keystrokes_corner: Corner::TopLeft,
|
||||
keystrokes_cps: true,
|
||||
minimap: false,
|
||||
minimap_corner: Corner::TopRight,
|
||||
minimap_size: 96,
|
||||
minimap_round: false,
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl Extras {
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut e = Self::default();
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
let flag = |d: bool| match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => d,
|
||||
};
|
||||
match key.trim() {
|
||||
"zoom" => e.zoom = flag(e.zoom),
|
||||
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
|
||||
"zoomLevel" => {
|
||||
if let Ok(v) = value.parse::<u8>() {
|
||||
e.zoom_level = v.clamp(2, ZOOM_MAX as u8);
|
||||
}
|
||||
}
|
||||
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
|
||||
"zoomSensitivity" => e.zoom_sensitivity = flag(e.zoom_sensitivity),
|
||||
"fps" => e.fps = flag(e.fps),
|
||||
"fpsCorner" => e.fps_corner = Corner::parse(value, e.fps_corner),
|
||||
"fpsLows" => e.fps_lows = flag(e.fps_lows),
|
||||
"keystrokes" => e.keystrokes = flag(e.keystrokes),
|
||||
"keystrokesCorner" => {
|
||||
e.keystrokes_corner = Corner::parse(value, e.keystrokes_corner)
|
||||
}
|
||||
"keystrokesCps" => e.keystrokes_cps = flag(e.keystrokes_cps),
|
||||
"minimap" => e.minimap = flag(e.minimap),
|
||||
"minimapCorner" => e.minimap_corner = Corner::parse(value, e.minimap_corner),
|
||||
"minimapSize" => {
|
||||
if let Some(&(v, _)) = MINIMAP_SIZES.iter().find(|s| s.0.to_string() == value) {
|
||||
e.minimap_size = v;
|
||||
}
|
||||
}
|
||||
"minimapShape" => e.minimap_round = value == "round",
|
||||
"minimapZoom" => {
|
||||
if let Some(&z) = MINIMAP_ZOOMS.iter().find(|z| z.to_string() == value) {
|
||||
e.minimap_zoom = z;
|
||||
}
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"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,
|
||||
bool_text(self.zoom_smooth),
|
||||
bool_text(self.zoom_sensitivity),
|
||||
bool_text(self.fps),
|
||||
self.fps_corner.name(),
|
||||
bool_text(self.fps_lows),
|
||||
bool_text(self.keystrokes),
|
||||
self.keystrokes_corner.name(),
|
||||
bool_text(self.keystrokes_cps),
|
||||
bool_text(self.minimap),
|
||||
self.minimap_corner.name(),
|
||||
self.minimap_size,
|
||||
if self.minimap_round {
|
||||
"round"
|
||||
} else {
|
||||
"square"
|
||||
},
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
)
|
||||
}
|
||||
/// Written only once something differs from the defaults, so a vanilla
|
||||
/// setup gains no file.
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let path = dir.join(FILE);
|
||||
if *self == Self::default() && !path.exists() {
|
||||
return;
|
||||
}
|
||||
let _ = fs::write(path, self.serialize());
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom key's state: held or not, and the zoom the wheel has set.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Zoom {
|
||||
held: bool,
|
||||
factor: f32,
|
||||
}
|
||||
impl Default for Zoom {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
held: false,
|
||||
factor: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Zoom {
|
||||
/// The key went down: start at the default level.
|
||||
pub fn press(&mut self, level: u8) {
|
||||
self.held = true;
|
||||
self.factor = f32::from(level).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
}
|
||||
pub fn release(&mut self) {
|
||||
self.held = false;
|
||||
}
|
||||
/// A press or release of the zoom key. Only a press while `playing` (no
|
||||
/// screen or text field open) zooms; anywhere else the key belongs to
|
||||
/// the screen, and the zoom lets go.
|
||||
pub fn key(&mut self, pressed: bool, repeat: bool, playing: bool, level: u8) {
|
||||
if !pressed || !playing {
|
||||
self.release();
|
||||
} else if !repeat {
|
||||
self.press(level);
|
||||
}
|
||||
}
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
/// Wheel steps while held (positive zooms in); false when not zooming.
|
||||
pub fn scroll(&mut self, steps: f32) -> bool {
|
||||
if !self.held {
|
||||
return false;
|
||||
}
|
||||
self.factor = (self.factor * ZOOM_STEP.powf(steps)).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
true
|
||||
}
|
||||
/// The zoom in effect: 1 when the key is up or the spyglass is in use
|
||||
/// (scoping keeps vanilla's spyglass view).
|
||||
pub fn factor(&self, scoping: bool) -> f32 {
|
||||
if self.held && !scoping {
|
||||
self.factor
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla key mappings bound to the zoom key. The key still reaches them
|
||||
/// (a key bound twice triggers both, as vanilla's do), so the Extras page
|
||||
/// says so.
|
||||
pub fn zoom_key_conflicts(e: &Extras, keys: &crate::keybinds::KeyBinds) -> Vec<&'static str> {
|
||||
if e.zoom_key == "key.keyboard.unknown" {
|
||||
return Vec::new();
|
||||
}
|
||||
crate::keybinds::MAPPINGS
|
||||
.iter()
|
||||
.map(|m| m.0)
|
||||
.filter(|name| keys.get(name) == e.zoom_key)
|
||||
.collect()
|
||||
}
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by the zoom. A zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
}
|
||||
/// The mouse turning scale while zoomed.
|
||||
pub fn turn_scale(zoom: f32, lower_sensitivity: bool) -> f64 {
|
||||
if lower_sensitivity && zoom > 1.0 {
|
||||
1.0 / f64::from(zoom)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame times over the last few seconds: frames per second, the minimum
|
||||
/// and the 1% low, recomputed twice a second.
|
||||
pub struct FpsCounter {
|
||||
/// (end time in seconds, frame milliseconds), oldest first.
|
||||
frames: VecDeque<(f64, f32)>,
|
||||
shown: FpsReading,
|
||||
computed_at: f64,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FpsReading {
|
||||
pub fps: u32,
|
||||
pub min: u32,
|
||||
pub low: u32,
|
||||
}
|
||||
/// Seconds the minimum and 1% low look back.
|
||||
const LOW_WINDOW: f64 = 5.0;
|
||||
impl Default for FpsCounter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frames: VecDeque::new(),
|
||||
shown: FpsReading::default(),
|
||||
computed_at: f64::NEG_INFINITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FpsCounter {
|
||||
/// A frame that ended at `now` (seconds) and took `ms`.
|
||||
pub fn record(&mut self, now: f64, ms: f32) {
|
||||
self.frames.push_back((now, ms));
|
||||
while self
|
||||
.frames
|
||||
.front()
|
||||
.is_some_and(|&(t, _)| now - t > LOW_WINDOW)
|
||||
{
|
||||
self.frames.pop_front();
|
||||
}
|
||||
if now - self.computed_at >= 0.5 {
|
||||
self.computed_at = now;
|
||||
self.shown = self.compute(now);
|
||||
}
|
||||
}
|
||||
fn compute(&self, now: f64) -> FpsReading {
|
||||
let fps = self.frames.iter().filter(|&&(t, _)| now - t < 1.0).count() as u32;
|
||||
let mut times: Vec<f32> = self.frames.iter().map(|f| f.1).collect();
|
||||
if times.is_empty() {
|
||||
return FpsReading::default();
|
||||
}
|
||||
times.sort_by(|a, b| b.total_cmp(a));
|
||||
let to_fps = |ms: f32| (1000.0 / ms.max(0.001)).round() as u32;
|
||||
// The 1% low: the frame rate of the 99th percentile frame time.
|
||||
let low = times[(times.len() - 1) / 100];
|
||||
FpsReading {
|
||||
fps,
|
||||
min: to_fps(times[0]),
|
||||
low: to_fps(low),
|
||||
}
|
||||
}
|
||||
pub fn reading(&self) -> FpsReading {
|
||||
self.shown
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks of one button in the last second.
|
||||
#[derive(Default)]
|
||||
pub struct Cps {
|
||||
clicks: VecDeque<f64>,
|
||||
}
|
||||
impl Cps {
|
||||
pub fn click(&mut self, now: f64) {
|
||||
self.clicks.push_back(now);
|
||||
self.forget(now);
|
||||
}
|
||||
fn forget(&mut self, now: f64) {
|
||||
while self.clicks.front().is_some_and(|&t| now - t >= 1.0) {
|
||||
self.clicks.pop_front();
|
||||
}
|
||||
}
|
||||
pub fn per_second(&mut self, now: f64) -> usize {
|
||||
self.forget(now);
|
||||
self.clicks.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings the Extras page shows, one per row.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Item {
|
||||
Zoom,
|
||||
ZoomKey,
|
||||
ZoomLevel,
|
||||
ZoomSmooth,
|
||||
ZoomSensitivity,
|
||||
Fps,
|
||||
FpsCorner,
|
||||
FpsLows,
|
||||
Keystrokes,
|
||||
KeystrokesCorner,
|
||||
KeystrokesCps,
|
||||
Minimap,
|
||||
MinimapCorner,
|
||||
MinimapSize,
|
||||
MinimapShape,
|
||||
MinimapZoom,
|
||||
MinimapRotate,
|
||||
AutoReconnect,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Err("Zoom"),
|
||||
Ok(Zoom),
|
||||
Ok(ZoomKey),
|
||||
Ok(ZoomLevel),
|
||||
Ok(ZoomSmooth),
|
||||
Ok(ZoomSensitivity),
|
||||
Err("Minimap"),
|
||||
Ok(Minimap),
|
||||
Ok(MinimapCorner),
|
||||
Ok(MinimapSize),
|
||||
Ok(MinimapShape),
|
||||
Ok(MinimapZoom),
|
||||
Ok(MinimapRotate),
|
||||
Err("FPS counter"),
|
||||
Ok(Fps),
|
||||
Ok(FpsCorner),
|
||||
Ok(FpsLows),
|
||||
Err("Keystrokes"),
|
||||
Ok(Keystrokes),
|
||||
Ok(KeystrokesCorner),
|
||||
Ok(KeystrokesCps),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
]
|
||||
}
|
||||
/// "1 block", "2 blocks", "½ block".
|
||||
fn block_count(n: f32) -> String {
|
||||
if n < 1.0 {
|
||||
"½ block".into()
|
||||
} else if n == 1.0 {
|
||||
"1 block".into()
|
||||
} else {
|
||||
format!("{n} blocks")
|
||||
}
|
||||
}
|
||||
fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
|
||||
let i = all.iter().position(|v| *v == current).unwrap_or(0);
|
||||
let n = all.len();
|
||||
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
|
||||
}
|
||||
impl Item {
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Zoom => "Zoom",
|
||||
Item::ZoomKey => "Zoom key",
|
||||
Item::ZoomLevel => "Starting zoom",
|
||||
Item::ZoomSmooth => "Smooth zoom",
|
||||
Item::ZoomSensitivity => "Lower sensitivity while zoomed",
|
||||
Item::Fps => "FPS counter",
|
||||
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => "Corner",
|
||||
Item::Minimap => "Minimap",
|
||||
Item::MinimapSize => "Size",
|
||||
Item::MinimapShape => "Shape",
|
||||
Item::MinimapZoom => "Zoom",
|
||||
Item::MinimapRotate => "Turn with the player",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
}
|
||||
}
|
||||
/// What the row says under its caption.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
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.",
|
||||
Item::ZoomSmooth => "Eases in and out like the spyglass.",
|
||||
Item::ZoomSensitivity => "Turns slower the more you zoom in.",
|
||||
Item::Fps => "Frames per second in a corner of the screen.",
|
||||
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => {
|
||||
"Overlays in one corner stack."
|
||||
}
|
||||
Item::Minimap => "A map of the loaded terrain around you, in map colours.",
|
||||
Item::MinimapSize => "How big the map is on screen.",
|
||||
Item::MinimapShape => "Square or round.",
|
||||
Item::MinimapZoom => "Blocks per pixel: smaller shows more detail, larger more land.",
|
||||
Item::MinimapRotate => "Forward is up; off keeps north up.",
|
||||
Item::AutoReconnect => {
|
||||
"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::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
|
||||
Item::Minimap => Some(Cost::Low),
|
||||
Item::AutoReconnect => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// On/off items and their value.
|
||||
pub fn switch(self, e: &Extras) -> Option<bool> {
|
||||
Some(match self {
|
||||
Item::Zoom => e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity,
|
||||
Item::Fps => e.fps,
|
||||
Item::FpsLows => e.fps_lows,
|
||||
Item::Keystrokes => e.keystrokes,
|
||||
Item::KeystrokesCps => e.keystrokes_cps,
|
||||
Item::Minimap => e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The value of the other items, as their button shows it.
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
|
||||
Item::ZoomLevel => format!("{}x", e.zoom_level),
|
||||
Item::FpsCorner => e.fps_corner.label().into(),
|
||||
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
|
||||
Item::MinimapCorner => e.minimap_corner.label().into(),
|
||||
Item::MinimapSize => MINIMAP_SIZES
|
||||
.iter()
|
||||
.find(|s| s.0 == e.minimap_size)
|
||||
.map_or("Medium", |s| s.1)
|
||||
.into(),
|
||||
Item::MinimapShape => if e.minimap_round { "Round" } else { "Square" }.into(),
|
||||
Item::MinimapZoom => format!("{} per pixel", block_count(e.minimap_zoom)),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// A click (`back` for the right button). The zoom key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
match self {
|
||||
Item::Zoom => e.zoom = !e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
|
||||
Item::Fps => e.fps = !e.fps,
|
||||
Item::FpsLows => e.fps_lows = !e.fps_lows,
|
||||
Item::Keystrokes => e.keystrokes = !e.keystrokes,
|
||||
Item::KeystrokesCps => e.keystrokes_cps = !e.keystrokes_cps,
|
||||
Item::ZoomLevel => e.zoom_level = next(&ZOOM_LEVELS, e.zoom_level, back),
|
||||
Item::FpsCorner => e.fps_corner = next(&Corner::ALL, e.fps_corner, back),
|
||||
Item::KeystrokesCorner => {
|
||||
e.keystrokes_corner = next(&Corner::ALL, e.keystrokes_corner, back)
|
||||
}
|
||||
Item::Minimap => e.minimap = !e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
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();
|
||||
e.minimap_size = next(&sizes, e.minimap_size, back);
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::ZoomKey => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn everything_is_off_by_default_and_round_trips() {
|
||||
let d = Extras::default();
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
e.zoom = true;
|
||||
e.zoom_key = "key.keyboard.c".into();
|
||||
e.zoom_level = 6;
|
||||
e.fps_corner = Corner::BottomRight;
|
||||
e.keystrokes = true;
|
||||
e.keystrokes_cps = false;
|
||||
e.minimap = true;
|
||||
e.minimap_size = 128;
|
||||
e.minimap_round = true;
|
||||
e.minimap_zoom = 0.5;
|
||||
e.minimap_rotate = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.auto_reconnect = true;
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_lines_keep_defaults() {
|
||||
let e = Extras::parse("zoom:maybe\nzoomLevel:99\nfpsCorner:middle\nzoomKey:z\nnoise");
|
||||
assert!(!e.zoom);
|
||||
assert_eq!(e.zoom_level, 10);
|
||||
assert_eq!(e.fps_corner, Corner::TopLeft);
|
||||
assert_eq!(e.zoom_key, "key.keyboard.z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_zoom_key_is_free_in_vanilla() {
|
||||
let key = Extras::default().zoom_key;
|
||||
assert!(crate::keybinds::key_code(&key).is_some());
|
||||
assert!(
|
||||
crate::keybinds::MAPPINGS.iter().all(|m| m.1 != key),
|
||||
"{key} is a vanilla binding"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_is_vanilla_unless_held_and_not_scoping() {
|
||||
let mut z = Zoom::default();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
assert_eq!(fov_target(1.1, z.factor(false)), 1.1);
|
||||
assert!(!z.scroll(1.0), "the wheel is the hotbar's when not zooming");
|
||||
z.press(4);
|
||||
assert_eq!(z.factor(false), 4.0);
|
||||
assert_eq!(z.factor(true), 1.0, "the spyglass wins");
|
||||
assert!((fov_target(1.0, 4.0) - 0.25).abs() < 1e-6);
|
||||
assert!(z.scroll(1.0));
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
for _ in 0..40 {
|
||||
z.scroll(1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MAX);
|
||||
for _ in 0..80 {
|
||||
z.scroll(-1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MIN);
|
||||
z.release();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
z.press(2);
|
||||
assert_eq!(z.factor(false), 2.0, "each press starts at the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_target_eases_like_the_spyglass() {
|
||||
// Camera.tickFov halves the distance to the target each tick.
|
||||
let mut camera = crate::camera::Camera::default();
|
||||
camera.tick(None, 1.0);
|
||||
for _ in 0..30 {
|
||||
camera.tick(None, 1.0);
|
||||
}
|
||||
let target = fov_target(1.0, 4.0);
|
||||
camera.tick(None, target);
|
||||
let first = camera.calculate_fov(1.0, 70, 1.0, None, Default::default(), false);
|
||||
assert!((first - 70.0 * 0.625).abs() < 1e-3, "{first}");
|
||||
for _ in 0..30 {
|
||||
camera.tick(None, target);
|
||||
}
|
||||
let settled = camera.calculate_fov(1.0, 70, 1.0, None, Default::default(), false);
|
||||
assert!((settled - 70.0 / 4.0).abs() < 1e-3, "{settled}");
|
||||
// Deeper than the 0.1 floor stops there, as vanilla clamps.
|
||||
for _ in 0..30 {
|
||||
camera.tick(None, fov_target(1.0, 20.0));
|
||||
}
|
||||
let floor = camera.calculate_fov(1.0, 70, 1.0, None, Default::default(), false);
|
||||
assert!((floor - 7.0).abs() < 1e-3, "{floor}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_zoom_key_only_zooms_while_playing() {
|
||||
let mut z = Zoom::default();
|
||||
// In chat, a sign, a text field or any screen: no zoom.
|
||||
z.key(true, false, false, 4);
|
||||
assert!(!z.held());
|
||||
z.key(true, false, true, 4);
|
||||
assert!(z.held());
|
||||
// A screen opening while held lets go (its key presses are the screen's).
|
||||
z.key(true, true, false, 4);
|
||||
assert!(!z.held());
|
||||
// Key repeats do not restart a zoom the wheel changed.
|
||||
z.key(true, false, true, 4);
|
||||
z.scroll(1.0);
|
||||
z.key(true, true, true, 4);
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
z.key(false, false, true, 4);
|
||||
assert!(!z.held());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_key_conflicts_name_the_vanilla_mappings() {
|
||||
let mut e = Extras::default();
|
||||
let mut keys = crate::keybinds::KeyBinds::default();
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
e.zoom_key = "key.keyboard.c".into();
|
||||
assert_eq!(
|
||||
zoom_key_conflicts(&e, &keys),
|
||||
[
|
||||
"key.saveToolbarActivator",
|
||||
"key.debug.crash",
|
||||
"key.debug.copyLocation"
|
||||
]
|
||||
);
|
||||
keys.set("key.drop", "key.keyboard.z");
|
||||
e.zoom_key = "key.keyboard.z".into();
|
||||
assert_eq!(zoom_key_conflicts(&e, &keys), ["key.drop"]);
|
||||
e.zoom_key = "key.keyboard.unknown".into();
|
||||
keys.set("key.smoothCamera", "key.keyboard.unknown");
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_scales_with_zoom() {
|
||||
assert_eq!(turn_scale(1.0, true), 1.0);
|
||||
assert_eq!(turn_scale(4.0, false), 1.0);
|
||||
assert_eq!(turn_scale(4.0, true), 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_counter_reports_rate_minimum_and_low() {
|
||||
let mut c = FpsCounter::default();
|
||||
let mut t = 0.0;
|
||||
// Two seconds at 100 fps with one 50 ms hitch.
|
||||
for i in 0..200 {
|
||||
let ms = if i == 150 { 50.0 } else { 10.0 };
|
||||
t += f64::from(ms) / 1000.0;
|
||||
c.record(t, ms);
|
||||
}
|
||||
c.computed_at = f64::NEG_INFINITY;
|
||||
c.record(t + 0.01, 10.0);
|
||||
let r = c.reading();
|
||||
assert!((95..=101).contains(&r.fps), "{r:?}");
|
||||
assert_eq!(r.min, 20);
|
||||
assert_eq!(r.low, 100, "one frame in 200 is under the 1% mark");
|
||||
// Hitches beyond 1% of frames pull the 1% low down.
|
||||
let mut c = FpsCounter::default();
|
||||
let mut t = 0.0;
|
||||
for i in 0..100 {
|
||||
let ms = if i % 25 == 0 { 40.0 } else { 10.0 };
|
||||
t += f64::from(ms) / 1000.0;
|
||||
c.record(t, ms);
|
||||
}
|
||||
c.computed_at = f64::NEG_INFINITY;
|
||||
c.record(t, 10.0);
|
||||
assert_eq!(c.reading().low, 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_frames_leave_the_window() {
|
||||
let mut c = FpsCounter::default();
|
||||
c.record(0.0, 100.0);
|
||||
for i in 1..=700 {
|
||||
c.record(f64::from(i) / 100.0, 10.0);
|
||||
}
|
||||
assert_eq!(c.reading().min, 100, "the 100 ms frame is 7 s old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cps_counts_the_last_second() {
|
||||
let mut cps = Cps::default();
|
||||
for i in 0..10 {
|
||||
cps.click(f64::from(i) * 0.1);
|
||||
}
|
||||
assert_eq!(cps.per_second(0.95), 10);
|
||||
assert_eq!(cps.per_second(1.25), 7);
|
||||
assert_eq!(cps.per_second(3.0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_cycle_through_their_values() {
|
||||
let mut e = Extras::default();
|
||||
Item::ZoomLevel.cycle(&mut e, false);
|
||||
assert_eq!(e.zoom_level, 6);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
assert_eq!(e.zoom_level, 3);
|
||||
Item::FpsCorner.cycle(&mut e, true);
|
||||
assert_eq!(e.fps_corner, Corner::BottomRight);
|
||||
Item::Zoom.cycle(&mut e, false);
|
||||
assert_eq!(Item::Zoom.switch(&e), Some(true));
|
||||
assert_eq!(Item::ZoomKey.value(&e), "Z");
|
||||
Item::MinimapZoom.cycle(&mut e, true);
|
||||
assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel");
|
||||
Item::MinimapSize.cycle(&mut e, false);
|
||||
assert_eq!(Item::MinimapSize.value(&e), "Large");
|
||||
assert_eq!(
|
||||
Extras::parse("minimapSize:7\nminimapZoom:3").minimap_zoom,
|
||||
1.0
|
||||
);
|
||||
for item in rows().into_iter().flatten() {
|
||||
assert!(!item.caption().is_empty() && !item.description().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! The extras' HUD overlays (FPS counter, keystrokes): small boxes in the
|
||||
//! vanilla font, in GUI pixels, stacked per corner (`docs/extras.md`).
|
||||
use crate::{
|
||||
extras::{Corner, FpsReading},
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
};
|
||||
|
||||
/// The gap between an overlay and the screen edge or the next overlay.
|
||||
const MARGIN: f32 = 2.0;
|
||||
const BACKGROUND: u32 = 0x7800_0000;
|
||||
|
||||
/// Where the next overlay goes in each corner.
|
||||
pub struct Corners {
|
||||
width: f32,
|
||||
height: f32,
|
||||
/// Distance from the corner's top or bottom edge, per `Corner::ALL`.
|
||||
used: [f32; 4],
|
||||
}
|
||||
impl Corners {
|
||||
/// `top_right` reserves the status effect icons' rows.
|
||||
pub fn new(width: f32, height: f32, top_right: f32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
used: [0.0, top_right, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
/// The top-left position of a `w` x `h` box in `corner`, below (or
|
||||
/// above) what the corner already holds.
|
||||
pub fn place(&mut self, corner: Corner, w: f32, h: f32) -> (f32, f32) {
|
||||
let i = Corner::ALL.iter().position(|c| *c == corner).unwrap_or(0);
|
||||
let edge = self.used[i] + MARGIN;
|
||||
self.used[i] = edge + h;
|
||||
let x = if corner.left() {
|
||||
MARGIN
|
||||
} else {
|
||||
self.width - MARGIN - w
|
||||
};
|
||||
let y = if corner.top() {
|
||||
edge
|
||||
} else {
|
||||
self.height - edge - h
|
||||
};
|
||||
(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the minimap shows: its corner and side in GUI pixels, its image (an
|
||||
/// atlas rect), the player arrow (the map item's decoration sprite) turned
|
||||
/// `arrow` degrees clockwise from up, and the rim's N toward `north` when the
|
||||
/// map turns.
|
||||
pub struct MinimapLook {
|
||||
pub corner: Corner,
|
||||
pub size: f32,
|
||||
pub image: Rect,
|
||||
pub arrow: f32,
|
||||
pub north: Option<(f32, f32)>,
|
||||
}
|
||||
pub fn draw_minimap(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
look: &MinimapLook,
|
||||
) {
|
||||
let MinimapLook {
|
||||
corner,
|
||||
size,
|
||||
image,
|
||||
arrow,
|
||||
north,
|
||||
} = *look;
|
||||
let (x, y) = corners.place(corner, size, size);
|
||||
batch.rect_uv(x, y, size, size, image, u32::MAX);
|
||||
let (cx, cy) = (x + size / 2.0, y + size / 2.0);
|
||||
if let Some(r) = atlas.sprites.get("map/decorations/player").copied() {
|
||||
// MapRenderer's decorations draw the sprite flipped: its arrow
|
||||
// points down the texture, so up on screen is half a turn.
|
||||
let (s, c) = (arrow + 180.0).to_radians().sin_cos();
|
||||
let p = |dx: f32, dy: f32| [cx + dx * c - dy * s, cy + dx * s + dy * c];
|
||||
let h = 4.0;
|
||||
batch.quad(
|
||||
[p(-h, h), p(h, h), p(h, -h), p(-h, -h)],
|
||||
[[r.u0, r.v0], [r.u1, r.v0], [r.u1, r.v1], [r.u0, r.v1]],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
if let Some((nx, ny)) = north {
|
||||
let reach = size / 2.0 - 6.0;
|
||||
let tw = HudBatch::text_width(atlas, "N");
|
||||
let (tx, ty) = (cx + nx * reach - tw / 2.0, cy + ny * reach - 4.0);
|
||||
batch.text(
|
||||
atlas,
|
||||
"N",
|
||||
tx.round(),
|
||||
ty.round(),
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The FPS counter's text.
|
||||
pub fn fps_text(r: FpsReading, lows: bool) -> String {
|
||||
if lows {
|
||||
format!("{} fps min {} 1% {}", r.fps, r.min, r.low)
|
||||
} else {
|
||||
format!("{} fps", r.fps)
|
||||
}
|
||||
}
|
||||
pub fn draw_fps(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
reading: FpsReading,
|
||||
lows: bool,
|
||||
) {
|
||||
let text = fps_text(reading, lows);
|
||||
let w = HudBatch::text_width(atlas, &text) + 4.0;
|
||||
let (x, y) = corners.place(corner, w, 12.0);
|
||||
batch.fill(atlas, x, y, w, 12.0, BACKGROUND);
|
||||
batch.text(
|
||||
atlas,
|
||||
&text,
|
||||
x + 2.0,
|
||||
y + 2.0,
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// What the keystroke overlay shows.
|
||||
pub struct Keystrokes {
|
||||
/// Forward, left, back, right: held, and the bound key's name.
|
||||
pub moves: [(bool, String); 4],
|
||||
pub jump: bool,
|
||||
pub attack: bool,
|
||||
pub use_: bool,
|
||||
/// Clicks per second of the left and right buttons, when shown.
|
||||
pub cps: Option<(usize, usize)>,
|
||||
}
|
||||
const KEY: f32 = 22.0;
|
||||
const GAP: f32 = 2.0;
|
||||
/// The overlay's size.
|
||||
pub fn keystrokes_size() -> (f32, f32) {
|
||||
(3.0 * KEY + 2.0 * GAP, 3.0 * KEY + 3.0 * GAP + 10.0)
|
||||
}
|
||||
fn key_box(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4], held: bool, lines: &[&str]) {
|
||||
let [x, y, w, h] = rect;
|
||||
let (fill, ink) = if held {
|
||||
(rgba(255, 255, 255, 176), rgba(20, 20, 20, 255))
|
||||
} else {
|
||||
(BACKGROUND, rgba(255, 255, 255, 255))
|
||||
};
|
||||
batch.fill(atlas, x, y, w, h, fill);
|
||||
let total = lines.len() as f32 * 9.0 - 1.0;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let tw = HudBatch::text_width(atlas, line);
|
||||
let tx = (x + (w - tw) / 2.0).round();
|
||||
let ty = (y + (h - total) / 2.0).round() + i as f32 * 9.0;
|
||||
batch.text(atlas, line, tx, ty, ink, !held);
|
||||
}
|
||||
}
|
||||
pub fn draw_keystrokes(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
k: &Keystrokes,
|
||||
) {
|
||||
let (w, h) = keystrokes_size();
|
||||
let (x, y) = corners.place(corner, w, h);
|
||||
let step = KEY + GAP;
|
||||
let [forward, left, back, right] = &k.moves;
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + step, y, KEY, KEY],
|
||||
forward.0,
|
||||
&[&forward.1],
|
||||
);
|
||||
for (i, key) in [left, back, right].into_iter().enumerate() {
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + i as f32 * step, y + step, KEY, KEY],
|
||||
key.0,
|
||||
&[&key.1],
|
||||
);
|
||||
}
|
||||
let mw = (w - GAP) / 2.0;
|
||||
let my = y + 2.0 * step;
|
||||
for (i, (held, label, cps)) in [
|
||||
(k.attack, "LMB", k.cps.map(|c| c.0)),
|
||||
(k.use_, "RMB", k.cps.map(|c| c.1)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let rate = cps.map(|n| format!("{n} CPS"));
|
||||
let lines: Vec<&str> = std::iter::once(label).chain(rate.as_deref()).collect();
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + i as f32 * (mw + GAP), my, mw, KEY],
|
||||
held,
|
||||
&lines,
|
||||
);
|
||||
}
|
||||
// The space bar: a line in a short key.
|
||||
let sy = my + step;
|
||||
key_box(batch, atlas, [x, sy, w, 10.0], k.jump, &[]);
|
||||
let ink = if k.jump {
|
||||
rgba(20, 20, 20, 255)
|
||||
} else {
|
||||
rgba(255, 255, 255, 255)
|
||||
};
|
||||
batch.fill(atlas, x + w / 2.0 - 12.0, sy + 4.0, 24.0, 2.0, ink);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn corners_stack_away_from_their_edge() {
|
||||
let mut c = Corners::new(400.0, 300.0, 26.0);
|
||||
assert_eq!(c.place(Corner::TopLeft, 50.0, 12.0), (2.0, 2.0));
|
||||
assert_eq!(c.place(Corner::TopLeft, 70.0, 20.0), (2.0, 16.0));
|
||||
// Top right starts under the effect icons.
|
||||
assert_eq!(c.place(Corner::TopRight, 50.0, 12.0), (348.0, 28.0));
|
||||
assert_eq!(c.place(Corner::BottomRight, 50.0, 12.0), (348.0, 286.0));
|
||||
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
|
||||
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_text_with_and_without_lows() {
|
||||
let r = FpsReading {
|
||||
fps: 144,
|
||||
min: 61,
|
||||
low: 98,
|
||||
};
|
||||
assert_eq!(fps_text(r, false), "144 fps");
|
||||
assert_eq!(fps_text(r, true), "144 fps min 61 1% 98");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
|
||||
//! keyed by small integers, such as chunk coordinates. Iteration order of
|
||||
//! such maps carries no meaning: the default hasher's is random anyway.
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FxHasher {
|
||||
hash: u64,
|
||||
}
|
||||
impl FxHasher {
|
||||
#[inline]
|
||||
fn add(&mut self, word: u64) {
|
||||
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
|
||||
}
|
||||
}
|
||||
impl Hasher for FxHasher {
|
||||
#[inline]
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
let (words, rest) = bytes.as_chunks::<8>();
|
||||
for word in words {
|
||||
self.add(u64::from_le_bytes(*word));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let mut word = [0u8; 8];
|
||||
word[..rest.len()].copy_from_slice(rest);
|
||||
self.add(u64::from_le_bytes(word));
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn write_u8(&mut self, i: u8) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u16(&mut self, i: u16) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u32(&mut self, i: u32) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.add(i);
|
||||
}
|
||||
#[inline]
|
||||
fn write_usize(&mut self, i: usize) {
|
||||
self.add(i as u64);
|
||||
}
|
||||
#[inline]
|
||||
fn write_i32(&mut self, i: i32) {
|
||||
self.add(u64::from(i as u32));
|
||||
}
|
||||
#[inline]
|
||||
fn finish(&self) -> u64 {
|
||||
// The multiply leaves the low bits weakest; hash tables use them.
|
||||
self.hash.rotate_left(26)
|
||||
}
|
||||
}
|
||||
pub type FxBuild = BuildHasherDefault<FxHasher>;
|
||||
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
|
||||
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearby_chunks_spread_over_buckets() {
|
||||
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
for x in -40..40 {
|
||||
for z in -40..40 {
|
||||
map.insert((x, z), x * z);
|
||||
}
|
||||
}
|
||||
assert_eq!(map.len(), 6400);
|
||||
assert_eq!(map[&(-7, 13)], -91);
|
||||
let mut low = std::collections::HashSet::new();
|
||||
for x in 0..64 {
|
||||
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
|
||||
}
|
||||
assert!(low.len() > 32, "low bits vary: {}", low.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Animated burning-entity flames; the first-person overlay uses screen_effects.
|
||||
use crate::{hud::Rect, mesher::Vertex};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn flames(
|
||||
position: [f64; 3],
|
||||
width: f32,
|
||||
height: f32,
|
||||
camera: glam::DVec3,
|
||||
yaw: f32,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
scene_light: u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let (Some(a), Some(b)) = (sprites.get("block/fire_0"), sprites.get("block/fire_1")) else {
|
||||
return;
|
||||
};
|
||||
let scale = width * 1.4;
|
||||
if scale <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let origin = glam::DVec3::from(position) - camera;
|
||||
let rotation = glam::Mat3::from_rotation_y(std::f32::consts::PI - yaw.to_radians());
|
||||
let mut remaining = height / scale;
|
||||
let mut half = 0.5f32;
|
||||
let mut y = 0.0f32;
|
||||
let mut z = 0.3 - (remaining as i32) as f32 * 0.02;
|
||||
let mut index = 0;
|
||||
while remaining > 0.0 {
|
||||
let r = if index % 2 == 0 { a } else { b };
|
||||
let (u0, u1) = if (index / 2) % 2 == 0 {
|
||||
(r.u0, r.u1)
|
||||
} else {
|
||||
(r.u1, r.u0)
|
||||
};
|
||||
for (p, uv) in [
|
||||
([-half, y, z], [u0, r.v1]),
|
||||
([half, y, z], [u1, r.v1]),
|
||||
([half, y + 1.4, z], [u1, r.v0]),
|
||||
([-half, y + 1.4, z], [u0, r.v0]),
|
||||
] {
|
||||
let p = origin.as_vec3() + rotation * (glam::Vec3::from(p) * scale);
|
||||
out.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xffff_ffff,
|
||||
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
|
||||
normal: [0; 4],
|
||||
});
|
||||
}
|
||||
remaining -= 0.45;
|
||||
y += 0.45;
|
||||
half *= 0.9;
|
||||
z -= 0.03;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn flames_cover_entity_height_and_alternate_animated_sprites() {
|
||||
let rect = |u0| super::Rect {
|
||||
u0,
|
||||
v0: 0.0,
|
||||
u1: u0 + 0.1,
|
||||
v1: 0.1,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites = super::HashMap::from([
|
||||
("block/fire_0".into(), rect(0.0)),
|
||||
("block/fire_1".into(), rect(0.5)),
|
||||
]);
|
||||
let mut out = Vec::new();
|
||||
super::flames(
|
||||
[0.0; 3],
|
||||
0.6,
|
||||
1.8,
|
||||
glam::DVec3::ZERO,
|
||||
0.0,
|
||||
&sprites,
|
||||
0,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len() % 4, 0);
|
||||
assert!(
|
||||
out[0].pos[2] < 0.0,
|
||||
"billboard depth bias must face the camera"
|
||||
);
|
||||
assert!(
|
||||
out.iter().all(|v| v.light == 0xf000),
|
||||
"fire forces block light to 15 but preserves scene sky light"
|
||||
);
|
||||
assert!(out.len() >= 20);
|
||||
assert!(out.iter().any(|v| v.pos[1] >= 1.8));
|
||||
assert!(
|
||||
out.iter().all(|v| v.layer == 0),
|
||||
"flames must not use the breaking-cracks UV-remap material"
|
||||
);
|
||||
assert!(out[..4].iter().all(|v| v.uv[0] <= 0.1));
|
||||
assert!(out[4..8].iter().all(|v| v.uv[0] >= 0.5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
//! The local player's hand: the arm swing (`LivingEntity.SwingState`,
|
||||
//! `getModifiedSwingDuration`), the first-person equip heights
|
||||
//! (`FirstPersonHandsAndItems.tick` and `itemUsed`), and the click handling
|
||||
//! that starts swings: `Minecraft.startAttack`, `continueAttack`,
|
||||
//! `startUseItem` and `handleKeybinds`' attack and use part, with
|
||||
//! `MultiPlayerGameMode`'s block breaking (`startDestroyBlock`,
|
||||
//! `continueDestroyBlock`, `stopDestroyBlock`).
|
||||
//!
|
||||
//! What those read and do in the world is behind [`HandWorld`].
|
||||
|
||||
/// The ranged item state read by the first-person hand renderer.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ItemPose {
|
||||
Swing,
|
||||
Using,
|
||||
Eat {
|
||||
remaining: f32,
|
||||
duration: f32,
|
||||
partial: f32,
|
||||
},
|
||||
Trident {
|
||||
ticks_used: f32,
|
||||
},
|
||||
Bow {
|
||||
ticks_used: f32,
|
||||
},
|
||||
Crossbow {
|
||||
ticks_used: Option<f32>,
|
||||
charge_duration: f32,
|
||||
charged: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// `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;
|
||||
let invert = if left { -1.0 } else { 1.0 };
|
||||
let pi = std::f32::consts::PI;
|
||||
let mut m = Mat4::from_translation(Vec3::new(
|
||||
invert * 0.56,
|
||||
-0.52 - inverse_height * 0.6,
|
||||
-0.72,
|
||||
));
|
||||
if let ItemPose::Eat {
|
||||
remaining,
|
||||
duration,
|
||||
partial,
|
||||
} = pose
|
||||
{
|
||||
let time = remaining - partial + 1.0;
|
||||
let fraction = time / duration;
|
||||
let mut eat = Mat4::IDENTITY;
|
||||
if fraction < 0.8 {
|
||||
eat *=
|
||||
Mat4::from_translation(Vec3::new(0.0, ((time / 4.0 * pi).mcos() * 0.1).abs(), 0.0));
|
||||
}
|
||||
let jiggle = 1.0 - horizon_core::jmath::pow(f64::from(fraction), 27.0) as f32;
|
||||
eat *= Mat4::from_translation(Vec3::new(jiggle * 0.6 * invert, jiggle * -0.5, 0.0));
|
||||
eat *= Mat4::from_rotation_y((invert * jiggle * 90.0).to_radians());
|
||||
eat *= Mat4::from_rotation_x((jiggle * 10.0).to_radians());
|
||||
eat *= Mat4::from_rotation_z((invert * jiggle * 30.0).to_radians());
|
||||
return eat * m;
|
||||
}
|
||||
if matches!(pose, ItemPose::Using) {
|
||||
return m;
|
||||
}
|
||||
let charging = match pose {
|
||||
ItemPose::Bow { ticks_used } => {
|
||||
let power = ticks_used / 20.0;
|
||||
Some((
|
||||
ticks_used,
|
||||
((power * power + power * 2.0) / 3.0).min(1.0),
|
||||
false,
|
||||
))
|
||||
}
|
||||
ItemPose::Crossbow {
|
||||
ticks_used: Some(ticks),
|
||||
charge_duration,
|
||||
charged: false,
|
||||
} => Some((ticks, (ticks / charge_duration).min(1.0), true)),
|
||||
ItemPose::Trident { ticks_used } => Some((ticks_used, (ticks_used / 10.0).min(1.0), false)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((ticks, power, crossbow)) = charging {
|
||||
let trident = matches!(pose, ItemPose::Trident { .. });
|
||||
let (x, y, z, pitch, yaw) = if trident {
|
||||
(-0.5, 0.7, 0.1, -55.0f32, 35.3f32)
|
||||
} else if crossbow {
|
||||
(-0.4785682, -0.094387, 0.05731531, -11.935f32, 65.3f32)
|
||||
} else {
|
||||
(-0.2785682, 0.18344387, 0.15731531, -13.935f32, 35.3f32)
|
||||
};
|
||||
m *= Mat4::from_translation(Vec3::new(invert * x, y, z));
|
||||
m *= Mat4::from_rotation_x(pitch.to_radians());
|
||||
m *= Mat4::from_rotation_y((invert * yaw).to_radians());
|
||||
m *= Mat4::from_rotation_z((invert * -9.785f32).to_radians());
|
||||
if power > 0.1 {
|
||||
let shake = ((ticks - 0.1) * 1.3).msin() * (power - 0.1);
|
||||
m *= Mat4::from_translation(Vec3::new(0.0, shake * 0.004, 0.0));
|
||||
}
|
||||
m *= Mat4::from_translation(Vec3::new(
|
||||
0.0,
|
||||
0.0,
|
||||
power * if trident { 0.2 } else { 0.04 },
|
||||
));
|
||||
m *= Mat4::from_scale(Vec3::new(1.0, 1.0, 1.0 + power * 0.2));
|
||||
m *= Mat4::from_rotation_y((invert * -45.0f32).to_radians());
|
||||
} else {
|
||||
let root = attack.sqrt();
|
||||
m *= Mat4::from_translation(Vec3::new(
|
||||
invert * -0.4 * (root * pi).msin(),
|
||||
0.2 * (root * std::f32::consts::TAU).msin(),
|
||||
-0.2 * (attack * pi).msin(),
|
||||
));
|
||||
m *= Mat4::from_rotation_y(
|
||||
(invert * (45.0 - (attack * attack * pi).msin() * 20.0)).to_radians(),
|
||||
);
|
||||
m *= Mat4::from_rotation_z((invert * (root * pi).msin() * -20.0).to_radians());
|
||||
m *= Mat4::from_rotation_x(((root * pi).msin() * -80.0).to_radians());
|
||||
m *= Mat4::from_rotation_y((invert * -45.0f32).to_radians());
|
||||
if matches!(pose, ItemPose::Crossbow { charged: true, .. }) && attack < 0.001 {
|
||||
m *= Mat4::from_translation(Vec3::new(invert * -0.641864, 0.0, 0.0));
|
||||
m *= Mat4::from_rotation_y((invert * 10.0f32).to_radians());
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// `InteractionHand`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hand {
|
||||
Main,
|
||||
Off,
|
||||
}
|
||||
impl Hand {
|
||||
pub const BOTH: [Hand; 2] = [Hand::Main, Hand::Off];
|
||||
}
|
||||
|
||||
/// `SwingAnimation`: its type (`SwingAnimationType` ordinal) and duration.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SwingAnimation {
|
||||
pub kind: u8,
|
||||
pub duration: i32,
|
||||
}
|
||||
impl SwingAnimation {
|
||||
/// `SwingAnimation.DEFAULT`: WHACK for 6 ticks.
|
||||
pub const DEFAULT: SwingAnimation = SwingAnimation {
|
||||
kind: 1,
|
||||
duration: 6,
|
||||
};
|
||||
}
|
||||
|
||||
/// `LivingEntity.SwingDescription`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SwingDescription {
|
||||
pub hand: Hand,
|
||||
pub animation: SwingAnimation,
|
||||
pub duration_ticks: i32,
|
||||
}
|
||||
|
||||
/// `LivingEntity.SwingState`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct SwingState {
|
||||
pub current: Option<SwingDescription>,
|
||||
pub ticks: i32,
|
||||
pub old_animation: f32,
|
||||
pub animation: f32,
|
||||
}
|
||||
impl SwingState {
|
||||
/// `startIfAble`: a swing restarts unless the current one is in its
|
||||
/// first half (past its first tick).
|
||||
pub fn start_if_able(&mut self, hand: Hand, animation: SwingAnimation, duration: i32) -> bool {
|
||||
if let Some(c) = self.current
|
||||
&& self.ticks <= c.duration_ticks / 2
|
||||
&& self.ticks > 0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let swing = SwingDescription {
|
||||
hand,
|
||||
animation,
|
||||
duration_ticks: duration,
|
||||
};
|
||||
if self.current != Some(swing) {
|
||||
self.old_animation = 0.0;
|
||||
self.animation = 0.0;
|
||||
}
|
||||
self.current = Some(swing);
|
||||
self.ticks = 0;
|
||||
true
|
||||
}
|
||||
/// `tick` (from `LivingEntity.baseTick`).
|
||||
pub fn tick(&mut self) {
|
||||
self.old_animation = self.animation;
|
||||
if let Some(c) = self.current {
|
||||
if c.duration_ticks > 0 {
|
||||
self.animation = (self.ticks as f32 / c.duration_ticks as f32).min(1.0);
|
||||
}
|
||||
let ticks = self.ticks;
|
||||
self.ticks += 1;
|
||||
if ticks > c.duration_ticks {
|
||||
self.current = None;
|
||||
self.animation = 0.0;
|
||||
}
|
||||
} else {
|
||||
self.animation = 0.0;
|
||||
}
|
||||
}
|
||||
/// `getAnimation(partialTicks)`: a restarted swing keeps moving forward.
|
||||
pub fn animation(&self, partial: f32) -> f32 {
|
||||
if self.animation >= self.old_animation {
|
||||
crate::camera::lerp(partial, self.old_animation, self.animation)
|
||||
} else {
|
||||
crate::camera::lerp(partial, self.old_animation, self.animation + 1.0)
|
||||
}
|
||||
}
|
||||
pub fn swinging(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
/// The hand of the current swing (`state.attackHand`), main by default.
|
||||
pub fn attack_hand(&self) -> Hand {
|
||||
self.current.map_or(Hand::Main, |c| c.hand)
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.getModifiedSwingDuration`: `dig_speed` is
|
||||
/// `MobEffectUtil.getDigSpeedAmplification` while haste or conduit power is
|
||||
/// active, `fatigue` the mining fatigue amplifier.
|
||||
pub fn swing_duration(
|
||||
animation: SwingAnimation,
|
||||
dig_speed: Option<i32>,
|
||||
fatigue: Option<i32>,
|
||||
) -> i32 {
|
||||
let duration = animation.duration;
|
||||
if let Some(amplification) = dig_speed {
|
||||
duration - (1 + amplification)
|
||||
} else if let Some(amplifier) = fatigue {
|
||||
duration + (1 + amplifier) * 2
|
||||
} else {
|
||||
duration
|
||||
}
|
||||
}
|
||||
|
||||
/// `FirstPersonHandsAndItems`' equip heights.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct HandHeights {
|
||||
pub main: f32,
|
||||
pub old_main: f32,
|
||||
pub off: f32,
|
||||
pub old_off: f32,
|
||||
}
|
||||
impl Default for HandHeights {
|
||||
fn default() -> Self {
|
||||
HandHeights {
|
||||
main: 0.0,
|
||||
old_main: 0.0,
|
||||
off: 0.0,
|
||||
old_off: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct HandChange {
|
||||
/// The visible item is not the hand's item after the instant replacement
|
||||
/// (`mainHandItem != nextMainHand`): the hand lowers.
|
||||
pub changed: bool,
|
||||
/// The visible item becomes the hand's item this tick.
|
||||
pub replace: bool,
|
||||
}
|
||||
impl HandHeights {
|
||||
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
|
||||
/// whether the visible item differs from the held one once
|
||||
/// `shouldInstantlyReplaceVisibleItem` has run; `swap_scale` is
|
||||
/// `getItemSwapScale(1.0F)`. Returns whether each hand's visible item
|
||||
/// is replaced at the end.
|
||||
pub fn tick(&mut self, hands_busy: bool, swap_scale: f32, changed: [bool; 2]) -> [bool; 2] {
|
||||
self.old_main = self.main;
|
||||
self.old_off = self.off;
|
||||
if hands_busy {
|
||||
self.main = (self.main - 0.4).clamp(0.0, 1.0);
|
||||
self.off = (self.off - 0.4).clamp(0.0, 1.0);
|
||||
} else {
|
||||
let attack = swap_scale;
|
||||
let main_target = if changed[0] {
|
||||
0.0
|
||||
} else {
|
||||
attack * attack * attack
|
||||
};
|
||||
let off_target = if changed[1] { 0.0 } else { 1.0 };
|
||||
self.main += (main_target - self.main).clamp(-0.4, 0.4);
|
||||
self.off += (off_target - self.off).clamp(-0.4, 0.4);
|
||||
}
|
||||
[self.main < 0.1, self.off < 0.1]
|
||||
}
|
||||
/// `itemUsed`: the hand drops to the bottom and rises again.
|
||||
pub fn item_used(&mut self, hand: Hand) {
|
||||
match hand {
|
||||
Hand::Main => self.main = 0.0,
|
||||
Hand::Off => self.off = 0.0,
|
||||
}
|
||||
}
|
||||
/// `Mth.lerp(partialTicks, oMainHandHeight, mainHandHeight)` and the
|
||||
/// off hand's.
|
||||
pub fn interpolated(&self, partial: f32) -> [f32; 2] {
|
||||
[
|
||||
crate::camera::lerp(partial, self.old_main, self.main),
|
||||
crate::camera::lerp(partial, self.old_off, self.off),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// `LocalPlayer.xBob` and `yBob`: the hands lag behind the view when it
|
||||
/// turns. `LocalPlayer.applyInput` eases them halfway to the rotation every
|
||||
/// tick; `FirstPersonHandsAndItemsRenderer` turns the hands by a tenth of
|
||||
/// the gap left.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct HandSway {
|
||||
pub x_bob: f32,
|
||||
pub y_bob: f32,
|
||||
pub x_bob_o: f32,
|
||||
pub y_bob_o: f32,
|
||||
}
|
||||
impl HandSway {
|
||||
/// `LocalPlayer.applyInput` with the player's `getXRot` and `getYRot`.
|
||||
pub fn tick(&mut self, x_rot: f32, y_rot: f32) {
|
||||
self.y_bob_o = self.y_bob;
|
||||
self.x_bob_o = self.x_bob;
|
||||
self.x_bob += (x_rot - self.x_bob) * 0.5;
|
||||
self.y_bob += (y_rot - self.y_bob) * 0.5;
|
||||
}
|
||||
/// The degrees the hands turn about X, then Y, before either is drawn:
|
||||
/// `(viewXRot - xBob) * 0.1` and `(viewYRot - yBob) * 0.1`, the bobs
|
||||
/// interpolated as `extractRenderState` does.
|
||||
pub fn rotation(&self, partial: f32, view_x_rot: f32, view_y_rot: f32) -> [f32; 2] {
|
||||
let x_bob = crate::camera::lerp(partial, self.x_bob_o, self.x_bob);
|
||||
let y_bob = crate::camera::lerp(partial, self.y_bob_o, self.y_bob);
|
||||
[(view_x_rot - x_bob) * 0.1, (view_y_rot - y_bob) * 0.1]
|
||||
}
|
||||
}
|
||||
|
||||
/// `HitResult` at the crosshair (`Minecraft.hitResult`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Hit {
|
||||
Miss,
|
||||
Block { pos: [i32; 3], face: u8 },
|
||||
Entity,
|
||||
}
|
||||
|
||||
/// `InteractionResult` as `startUseItem` reads it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UseResult {
|
||||
/// `Success` with its `SwingSource`: whether the client swings.
|
||||
Success {
|
||||
swing: bool,
|
||||
},
|
||||
Pass,
|
||||
Fail,
|
||||
}
|
||||
|
||||
impl UseResult {
|
||||
pub fn should_swing(self, before: u32, after: u32) -> bool {
|
||||
matches!(self, Self::Success { swing: true }) && before == after
|
||||
}
|
||||
}
|
||||
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
match v
|
||||
.get("animation")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("eat")
|
||||
{
|
||||
"eat" => UseAnimation::Eat,
|
||||
"drink" => UseAnimation::Drink,
|
||||
_ => UseAnimation::None,
|
||||
}
|
||||
}
|
||||
/// What the click handling reads and does in the world.
|
||||
pub trait HandWorld {
|
||||
fn hit(&mut self) -> Hit;
|
||||
fn is_air(&mut self, pos: [i32; 3]) -> bool;
|
||||
/// `MultiPlayerGameMode.localPlayerMode`.
|
||||
fn creative(&self) -> bool;
|
||||
fn spectator(&self) -> bool;
|
||||
/// `Abilities.instabuild`.
|
||||
fn instabuild(&self) -> bool;
|
||||
fn hands_busy(&self) -> bool;
|
||||
fn using_item(&self) -> bool;
|
||||
/// `ItemStack.isItemEnabled` for the item in a hand.
|
||||
fn item_enabled(&mut self, hand: Hand) -> bool;
|
||||
fn held_empty(&mut self, hand: Hand) -> bool;
|
||||
fn held_count(&mut self, hand: Hand) -> i32;
|
||||
/// `Player.hasInfiniteMaterials`.
|
||||
fn infinite_materials(&self) -> bool;
|
||||
/// `Player.cannotAttackWithItem(heldItem, 0)`.
|
||||
fn cannot_attack(&mut self) -> bool;
|
||||
/// The main-hand item is a `PIERCING_WEAPON`.
|
||||
fn piercing_weapon(&mut self) -> bool;
|
||||
fn attack_animation(&mut self) -> SwingAnimation;
|
||||
fn interact_animation(&mut self, hand: Hand) -> SwingAnimation;
|
||||
/// `MultiPlayerGameMode.piercingAttack`.
|
||||
fn piercing_attack(&mut self, animation: SwingAnimation);
|
||||
/// `Player.attack` on the targeted entity when the item's attack range
|
||||
/// allows it; false when out of range.
|
||||
fn attack_entity(&mut self) -> bool;
|
||||
/// `Player.blockActionRestricted` for the game mode.
|
||||
fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool;
|
||||
/// `WorldBorder.isWithinBounds`.
|
||||
fn within_border(&mut self, pos: [i32; 3]) -> bool;
|
||||
/// `BlockState.attack` on the first hit of a break.
|
||||
fn attack_block(&mut self, pos: [i32; 3]);
|
||||
/// `BlockState.getDestroyProgress` for the player.
|
||||
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32;
|
||||
/// `MultiPlayerGameMode.destroyBlock`.
|
||||
fn destroy_block(&mut self, pos: [i32; 3]) -> bool;
|
||||
/// A key for the main-hand stack, kept as `destroyingItem`.
|
||||
fn main_hand_key(&mut self) -> String;
|
||||
/// `ItemStack.isSameItemSameComponents(selected, destroyingItem)` for
|
||||
/// the stack `main_hand_key` gave when the break started. Vanilla keeps
|
||||
/// a reference to that stack, so it compares what it holds now.
|
||||
fn same_destroying_item(&mut self, key: &str) -> bool {
|
||||
self.main_hand_key() == key
|
||||
}
|
||||
/// Breaking progress shown (`destroyBlockProgress`), or cleared with `None`.
|
||||
fn destroy_stage(&mut self, pos: [i32; 3], stage: Option<i32>);
|
||||
/// `player.swing(hand, animation, false)`.
|
||||
fn swing(&mut self, hand: Hand, animation: SwingAnimation);
|
||||
/// `Player.resetAttackStrengthTicker`.
|
||||
fn reset_attack_strength(&mut self);
|
||||
/// The targeted entity's block position is inside the world border.
|
||||
fn entity_within_border(&mut self) -> bool;
|
||||
/// The use attempts of `startUseItem`: on the targeted entity (`None`
|
||||
/// when it is out of interaction range), the targeted block, and the
|
||||
/// item alone.
|
||||
fn use_on_entity(&mut self, hand: Hand) -> Option<UseResult>;
|
||||
fn use_on_block(&mut self, hand: Hand) -> UseResult;
|
||||
fn use_item(&mut self, hand: Hand) -> UseResult;
|
||||
/// `LocalPlayer.itemUsed`.
|
||||
fn item_used(&mut self, hand: Hand);
|
||||
/// `MultiPlayerGameMode.releaseUsingItem`.
|
||||
fn release_using(&mut self);
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode`'s block breaking.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Destroying {
|
||||
pub delay: i32,
|
||||
pub active: bool,
|
||||
pub pos: [i32; 3],
|
||||
pub face: u8,
|
||||
pub item: String,
|
||||
pub progress: f32,
|
||||
pub ticks: f32,
|
||||
}
|
||||
impl Default for Destroying {
|
||||
/// `destroyBlockPos` starts at (-1, -1, -1) and `destroyingItem` empty
|
||||
/// (an empty key).
|
||||
fn default() -> Self {
|
||||
Destroying {
|
||||
delay: 0,
|
||||
active: false,
|
||||
pos: [-1; 3],
|
||||
face: 0,
|
||||
item: String::new(),
|
||||
progress: 0.0,
|
||||
ticks: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Destroying {
|
||||
/// `getDestroyStage`.
|
||||
pub fn stage(&self) -> i32 {
|
||||
if self.progress > 0.0 {
|
||||
(self.progress * 10.0) as i32
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
fn same_target(&self, w: &mut impl HandWorld, pos: [i32; 3]) -> bool {
|
||||
pos == self.pos && w.same_destroying_item(&self.item)
|
||||
}
|
||||
/// `startDestroyBlock`.
|
||||
pub fn start(&mut self, w: &mut impl HandWorld, pos: [i32; 3], face: u8) -> bool {
|
||||
if w.block_action_restricted(pos) || !w.within_border(pos) {
|
||||
return false;
|
||||
}
|
||||
if w.instabuild() {
|
||||
w.destroy_block(pos);
|
||||
self.delay = 5;
|
||||
} else if !self.active || !self.same_target(w, pos) {
|
||||
let not_air = !w.is_air(pos);
|
||||
if not_air && self.progress == 0.0 {
|
||||
w.attack_block(pos);
|
||||
}
|
||||
if not_air && w.destroy_progress(pos) >= 1.0 {
|
||||
w.destroy_block(pos);
|
||||
} else {
|
||||
self.active = true;
|
||||
self.pos = pos;
|
||||
self.face = face;
|
||||
self.item = w.main_hand_key();
|
||||
self.progress = 0.0;
|
||||
self.ticks = 0.0;
|
||||
w.destroy_stage(pos, Some(self.stage()));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `stopDestroyBlock`.
|
||||
pub fn stop(&mut self, w: &mut impl HandWorld) {
|
||||
if self.active {
|
||||
self.active = false;
|
||||
self.progress = 0.0;
|
||||
w.destroy_stage(self.pos, None);
|
||||
w.reset_attack_strength();
|
||||
}
|
||||
}
|
||||
/// `continueDestroyBlock`: true while the hand keeps swinging.
|
||||
pub fn continue_destroy(&mut self, w: &mut impl HandWorld, pos: [i32; 3], face: u8) -> bool {
|
||||
if self.delay > 0 {
|
||||
self.delay -= 1;
|
||||
return true;
|
||||
}
|
||||
if w.instabuild() && w.within_border(pos) {
|
||||
self.delay = 5;
|
||||
w.destroy_block(pos);
|
||||
return true;
|
||||
}
|
||||
if !self.same_target(w, pos) {
|
||||
return self.start(w, pos, face);
|
||||
}
|
||||
if w.is_air(pos) {
|
||||
self.active = false;
|
||||
return false;
|
||||
}
|
||||
self.progress += w.destroy_progress(pos);
|
||||
self.ticks += 1.0;
|
||||
if self.progress >= 1.0 {
|
||||
self.active = false;
|
||||
w.destroy_block(pos);
|
||||
self.progress = 0.0;
|
||||
self.ticks = 0.0;
|
||||
self.delay = 5;
|
||||
} else if self.face != face {
|
||||
self.face = face;
|
||||
}
|
||||
w.destroy_stage(self.pos, Some(self.stage()));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft`'s attack and use handling.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Clicks {
|
||||
pub miss_time: i32,
|
||||
pub right_click_delay: i32,
|
||||
pub destroying: Destroying,
|
||||
}
|
||||
impl Clicks {
|
||||
/// `Minecraft.startAttack`: whether the click ended the attack (broke
|
||||
/// the block outright).
|
||||
pub fn start_attack(&mut self, w: &mut impl HandWorld) -> bool {
|
||||
if self.miss_time > 0 || w.hands_busy() {
|
||||
return false;
|
||||
}
|
||||
if w.spectator() {
|
||||
return true;
|
||||
}
|
||||
if !w.item_enabled(Hand::Main) || w.cannot_attack() {
|
||||
return false;
|
||||
}
|
||||
let animation = w.attack_animation();
|
||||
if w.piercing_weapon() {
|
||||
w.piercing_attack(animation);
|
||||
return true;
|
||||
}
|
||||
let mut end = false;
|
||||
let mut miss = false;
|
||||
match w.hit() {
|
||||
// MultiPlayerGameMode.attack.
|
||||
Hit::Entity => {
|
||||
if w.attack_entity() {
|
||||
w.reset_attack_strength();
|
||||
if w.instabuild() {
|
||||
self.destroying.delay = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
Hit::Block { pos, face } if !w.is_air(pos) => {
|
||||
self.destroying.start(w, pos, face);
|
||||
end = w.is_air(pos);
|
||||
}
|
||||
Hit::Block { .. } | Hit::Miss => miss = true,
|
||||
}
|
||||
if miss {
|
||||
if !w.creative() {
|
||||
self.miss_time = 10;
|
||||
}
|
||||
w.reset_attack_strength();
|
||||
}
|
||||
w.swing(Hand::Main, animation);
|
||||
end
|
||||
}
|
||||
/// `Minecraft.continueAttack`.
|
||||
pub fn continue_attack(&mut self, w: &mut impl HandWorld, down: bool) {
|
||||
if !down {
|
||||
self.miss_time = 0;
|
||||
}
|
||||
if self.miss_time > 0 || w.using_item() || w.piercing_weapon() {
|
||||
return;
|
||||
}
|
||||
let animation = w.attack_animation();
|
||||
match w.hit() {
|
||||
Hit::Block { pos, face } if down => {
|
||||
if !w.is_air(pos) && self.destroying.continue_destroy(w, pos, face) {
|
||||
w.swing(Hand::Main, animation);
|
||||
}
|
||||
}
|
||||
_ => self.destroying.stop(w),
|
||||
}
|
||||
}
|
||||
/// `Minecraft.startUseItem`.
|
||||
pub fn start_use_item(&mut self, w: &mut impl HandWorld) {
|
||||
if self.destroying.active {
|
||||
return;
|
||||
}
|
||||
self.right_click_delay = 4;
|
||||
if w.hands_busy() {
|
||||
return;
|
||||
}
|
||||
for hand in Hand::BOTH {
|
||||
if !w.item_enabled(hand) {
|
||||
return;
|
||||
}
|
||||
let animation = w.interact_animation(hand);
|
||||
match w.hit() {
|
||||
Hit::Entity => {
|
||||
if !w.entity_within_border() {
|
||||
return;
|
||||
}
|
||||
if let Some(UseResult::Success { swing }) = w.use_on_entity(hand) {
|
||||
if swing {
|
||||
w.swing(hand, animation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
Hit::Block { .. } => {
|
||||
let count = w.held_count(hand);
|
||||
match w.use_on_block(hand) {
|
||||
UseResult::Success { swing } => {
|
||||
if swing {
|
||||
w.swing(hand, animation);
|
||||
if !w.held_empty(hand)
|
||||
&& (w.held_count(hand) != count || w.infinite_materials())
|
||||
{
|
||||
w.item_used(hand);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
UseResult::Fail => return,
|
||||
UseResult::Pass => {}
|
||||
}
|
||||
}
|
||||
Hit::Miss => {}
|
||||
}
|
||||
if !w.held_empty(hand)
|
||||
&& let UseResult::Success { swing } = w.use_item(hand)
|
||||
{
|
||||
if swing {
|
||||
w.swing(hand, animation);
|
||||
}
|
||||
w.item_used(hand);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The attack and use part of `Minecraft.tick`: `rightClickDelay`, the
|
||||
/// clicks `handleKeybinds` consumes, held use and attack, then
|
||||
/// `missTime`. `attack_down` is `keyAttack.isDown()` with no screen and
|
||||
/// the mouse grabbed.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
w: &mut impl HandWorld,
|
||||
attack_clicks: u32,
|
||||
use_clicks: u32,
|
||||
attack_down: bool,
|
||||
use_down: bool,
|
||||
) {
|
||||
if self.right_click_delay > 0 {
|
||||
self.right_click_delay -= 1;
|
||||
}
|
||||
let mut instant = false;
|
||||
if w.using_item() {
|
||||
if !use_down {
|
||||
w.release_using();
|
||||
}
|
||||
} else {
|
||||
for _ in 0..attack_clicks {
|
||||
instant |= self.start_attack(w);
|
||||
}
|
||||
for _ in 0..use_clicks {
|
||||
self.start_use_item(w);
|
||||
}
|
||||
}
|
||||
if use_down && self.right_click_delay == 0 && !w.using_item() {
|
||||
self.start_use_item(w);
|
||||
}
|
||||
self.continue_attack(w, !instant && attack_down);
|
||||
if self.miss_time > 0 {
|
||||
self.miss_time -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[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));
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..9 {
|
||||
s.tick();
|
||||
seen.push(s.animation);
|
||||
}
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
0.0,
|
||||
1.0 / 6.0,
|
||||
2.0 / 6.0,
|
||||
0.5,
|
||||
4.0 / 6.0,
|
||||
5.0 / 6.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
);
|
||||
assert!(!s.swinging());
|
||||
s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6);
|
||||
s.tick();
|
||||
s.tick();
|
||||
assert!(!s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
|
||||
s.tick();
|
||||
s.tick();
|
||||
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
|
||||
s.tick();
|
||||
// Restarting the same swing wraps forward rather than snapping back.
|
||||
assert_eq!(
|
||||
s.animation(0.5),
|
||||
(s.old_animation + s.animation + 1.0) / 2.0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn haste_shortens_and_fatigue_lengthens_swings() {
|
||||
let d = SwingAnimation::DEFAULT;
|
||||
assert_eq!(swing_duration(d, None, None), 6);
|
||||
assert_eq!(swing_duration(d, Some(1), Some(0)), 4);
|
||||
assert_eq!(swing_duration(d, None, Some(2)), 12);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod item_pose_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ranged_draw_uses_native_charge_curves_and_caps() {
|
||||
for (ticks, stretch) in [
|
||||
(0.0, 1.0),
|
||||
(10.0, 1.0 + 0.2 * 1.25 / 3.0),
|
||||
(20.0, 1.2),
|
||||
(40.0, 1.2),
|
||||
] {
|
||||
let m = item_pose(false, 0.0, 0.0, ItemPose::Bow { ticks_used: ticks });
|
||||
assert!((m.determinant() - stretch).abs() < 0.000001);
|
||||
}
|
||||
for duration in [25.0, 20.0, 15.0, 10.0] {
|
||||
let m = item_pose(
|
||||
false,
|
||||
0.0,
|
||||
0.0,
|
||||
ItemPose::Crossbow {
|
||||
ticks_used: Some(duration),
|
||||
charge_duration: duration,
|
||||
charged: false,
|
||||
},
|
||||
);
|
||||
assert!((m.determinant() - 1.2).abs() < 0.000001);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn left_handed_ranged_poses_mirror_the_right_hand() {
|
||||
let reflect = glam::Mat4::from_scale(glam::Vec3::new(-1.0, 1.0, 1.0));
|
||||
for pose in [
|
||||
ItemPose::Swing,
|
||||
ItemPose::Bow { ticks_used: 10.0 },
|
||||
ItemPose::Crossbow {
|
||||
ticks_used: Some(10.0),
|
||||
charge_duration: 25.0,
|
||||
charged: false,
|
||||
},
|
||||
ItemPose::Crossbow {
|
||||
ticks_used: None,
|
||||
charge_duration: 25.0,
|
||||
charged: true,
|
||||
},
|
||||
] {
|
||||
let left = item_pose(true, 0.2, 0.0, pose).to_cols_array();
|
||||
let right = (reflect * item_pose(false, 0.2, 0.0, pose) * reflect).to_cols_array();
|
||||
for (a, b) in left.into_iter().zip(right) {
|
||||
assert!((a - b).abs() < 0.000001);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_bow_has_its_own_pose() {
|
||||
assert_ne!(
|
||||
item_pose(false, 0.0, 0.0, ItemPose::Bow { ticks_used: 10.0 }),
|
||||
item_pose(false, 0.0, 0.0, ItemPose::Swing)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn charging_crossbow_has_its_own_pose() {
|
||||
assert_ne!(
|
||||
item_pose(
|
||||
false,
|
||||
0.0,
|
||||
0.0,
|
||||
ItemPose::Crossbow {
|
||||
ticks_used: Some(10.0),
|
||||
charge_duration: 25.0,
|
||||
charged: false
|
||||
}
|
||||
),
|
||||
item_pose(false, 0.0, 0.0, ItemPose::Swing)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn charged_crossbow_is_aimed_while_idle() {
|
||||
assert_ne!(
|
||||
item_pose(
|
||||
false,
|
||||
0.0,
|
||||
0.0,
|
||||
ItemPose::Crossbow {
|
||||
ticks_used: None,
|
||||
charge_duration: 25.0,
|
||||
charged: true
|
||||
}
|
||||
),
|
||||
item_pose(false, 0.0, 0.0, ItemPose::Swing)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod continuous_use_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn eating_and_throwing_have_use_poses_not_attack_poses() {
|
||||
let swing = item_pose(false, 0.0, 0.3, ItemPose::Swing);
|
||||
assert_ne!(
|
||||
swing,
|
||||
item_pose(
|
||||
false,
|
||||
0.0,
|
||||
0.3,
|
||||
ItemPose::Eat {
|
||||
remaining: 10.0,
|
||||
duration: 32.0,
|
||||
partial: 0.5
|
||||
}
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
swing,
|
||||
item_pose(false, 0.0, 0.3, ItemPose::Trident { ticks_used: 5.0 })
|
||||
);
|
||||
assert_ne!(swing, item_pose(false, 0.0, 0.3, ItemPose::Using));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod action_result_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_successful_actions_swing_once() {
|
||||
assert!(UseResult::Success { swing: true }.should_swing(2, 2));
|
||||
for result in [
|
||||
UseResult::Fail,
|
||||
UseResult::Pass,
|
||||
UseResult::Success { swing: false },
|
||||
] {
|
||||
assert!(!result.should_swing(2, 2));
|
||||
}
|
||||
assert!(!UseResult::Success { swing: true }.should_swing(2, 3));
|
||||
}
|
||||
#[test]
|
||||
fn consumption_pose_requires_eat_or_drink_animation() {
|
||||
use horizon_world::consumable::UseAnimation::*;
|
||||
assert_eq!(consumable_animation(&serde_json::json!({})), Eat);
|
||||
assert_eq!(
|
||||
consumable_animation(&serde_json::json!({"animation":"drink"})),
|
||||
Drink
|
||||
);
|
||||
for animation in ["none", "block", "bow", "brush"] {
|
||||
assert_eq!(
|
||||
consumable_animation(&serde_json::json!({"animation":animation})),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ impl FlatTerrain {
|
||||
biome: biomes.id(&biome),
|
||||
})
|
||||
}
|
||||
/// FlatLevelSource.getSpawnHeight: the top of the layers.
|
||||
pub fn spawn_height(&self) -> i32 {
|
||||
crate::chunk::MIN_Y + self.layers.len().min(crate::chunk::HEIGHT as usize) as i32
|
||||
}
|
||||
}
|
||||
impl ChunkGenerator for FlatTerrain {
|
||||
type Worker = ();
|
||||
|
||||
@@ -196,6 +196,8 @@ pub struct FogInputs<'a> {
|
||||
pub water_fog_color: [f32; 3],
|
||||
pub biome: u16,
|
||||
pub render_distance_chunks: i32,
|
||||
/// The Cloud Distance option, in chunks.
|
||||
pub cloud_range_chunks: i32,
|
||||
pub camera_y: f64,
|
||||
pub min_y: i32,
|
||||
/// Flat worlds use a 1-block void darkness range.
|
||||
@@ -209,6 +211,9 @@ pub struct FogInputs<'a> {
|
||||
pub blindness: Option<(i32, bool)>,
|
||||
/// The darkness effect's blend factor, while the effect is on.
|
||||
pub darkness: Option<f32>,
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -335,7 +340,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
let min_end = sky.fog_end.min(96.0);
|
||||
fog.environmental_end = min_end.max(sky.fog_end + -256.0 * state.rain_fog_multiplier);
|
||||
fog.sky_end = rd.min(512.0);
|
||||
fog.cloud_end = 2048.0f32.min(128.0 * 16.0);
|
||||
fog.cloud_end = ((input.cloud_range_chunks * 16) as f32).min(sky.cloud_fog_end);
|
||||
if input.boss_world_fog {
|
||||
fog.environmental_start = fog.environmental_start.min(10.0);
|
||||
fog.environmental_end = fog.environmental_end.min(96.0);
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
@@ -373,6 +384,7 @@ mod tests {
|
||||
water_fog_color: [0.0, 0.0, 0.2],
|
||||
biome: 0,
|
||||
render_distance_chunks: 12,
|
||||
cloud_range_chunks: 128,
|
||||
camera_y: 70.0,
|
||||
min_y: -64,
|
||||
flat: false,
|
||||
@@ -382,9 +394,57 @@ mod tests {
|
||||
skybox: true,
|
||||
blindness: None,
|
||||
darkness: None,
|
||||
boss_world_fog: false,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn boss_world_fog_closes_in() {
|
||||
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
cloud_fog_end: 2048.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.boss_world_fog = true;
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(
|
||||
(fog.environmental_start, fog.environmental_end),
|
||||
(0.0, 96.0)
|
||||
);
|
||||
assert_eq!((fog.sky_end, fog.cloud_end), (96.0, 96.0));
|
||||
// Under water the water environment applies instead.
|
||||
input.fog_type = FogType::Water;
|
||||
let water = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(water.environmental_start, -8.0);
|
||||
}
|
||||
#[test]
|
||||
fn void_darkness_starts_32_above_the_floor() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
fog_color: [0.5; 3],
|
||||
sky_color: [0.5; 3],
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
// The End's floor is y 0: y 16 is halfway into the darkness.
|
||||
input.min_y = 0;
|
||||
input.camera_y = 16.0;
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color[0], 0.5 * 0.25);
|
||||
input.camera_y = 32.0;
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).color[0],
|
||||
0.5
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn render_distance_fog_span() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
@@ -402,6 +462,29 @@ mod tests {
|
||||
assert_eq!(fog.sky_end, 192.0);
|
||||
}
|
||||
#[test]
|
||||
fn cloud_fog_ends_at_the_cloud_distance() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
cloud_fog_end: 2048.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.render_distance_chunks = 2;
|
||||
input.cloud_range_chunks = 64;
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).cloud_end,
|
||||
1024.0
|
||||
);
|
||||
input.cloud_range_chunks = 128;
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).cloud_end,
|
||||
2048.0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn water_vision_ramps() {
|
||||
let mut s = FogState::default();
|
||||
for _ in 0..100 {
|
||||
|
||||
@@ -163,6 +163,17 @@ fn providers(root: &Path, id: &str, depth: u32, out: &mut Vec<serde_json::Value>
|
||||
}
|
||||
}
|
||||
}
|
||||
// FontTexture.add nudges UVs inside the glyph to avoid neighboring texels.
|
||||
fn glyph_rect(x: u32, y: u32, w: u32, h: u32, atlas: (u32, u32)) -> Rect {
|
||||
Rect {
|
||||
u0: (x as f32 + 0.01) / atlas.0 as f32,
|
||||
v0: (y as f32 + 0.01) / atlas.1 as f32,
|
||||
u1: ((x + w) as f32 - 0.01) / atlas.0 as f32,
|
||||
v1: ((y + h) as f32 - 0.01) / atlas.1 as f32,
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
impl Font {
|
||||
/// Build from the atlas holding the bitmap font sheets. `pixels` is the
|
||||
/// RGBA atlas (for glyph widths) and `region` the reserved rows.
|
||||
@@ -237,14 +248,7 @@ impl Font {
|
||||
bitmap.insert(
|
||||
*ch,
|
||||
Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: cx as f32 / atlas.0 as f32,
|
||||
v0: cy as f32 / atlas.1 as f32,
|
||||
u1: (cx + cell_w) as f32 / atlas.0 as f32,
|
||||
v1: (cy + cell_h) as f32 / atlas.1 as f32,
|
||||
w: cell_w,
|
||||
h: cell_h,
|
||||
}),
|
||||
rect: Some(glyph_rect(cx, cy, cell_w, cell_h, atlas)),
|
||||
left: 0.0,
|
||||
top,
|
||||
right: cell_w as f32 * scale,
|
||||
@@ -414,14 +418,7 @@ impl Font {
|
||||
});
|
||||
cache.cursor = (x + width + 1, y);
|
||||
Some(Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: ax as f32 / self.atlas.0 as f32,
|
||||
v0: ay as f32 / self.atlas.1 as f32,
|
||||
u1: (ax + width) as f32 / self.atlas.0 as f32,
|
||||
v1: (ay + 16) as f32 / self.atlas.1 as f32,
|
||||
w: width,
|
||||
h: 16,
|
||||
}),
|
||||
rect: Some(glyph_rect(ax, ay, width, 16, self.atlas)),
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: width as f32 / 2.0,
|
||||
@@ -609,4 +606,16 @@ mod tests {
|
||||
parse_hex(b"zz:00\n", &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_uvs_have_the_native_texel_inset() {
|
||||
for (x, y, w, h) in [(120, 48, 8, 8), (0, 0, 1, 16), (4000, 2000, 32, 16)] {
|
||||
let r = glyph_rect(x, y, w, h, (4096, 4096));
|
||||
assert_eq!((r.w, r.h), (w, h));
|
||||
assert!(r.u0 > x as f32 / 4096.0 && r.v0 > y as f32 / 4096.0);
|
||||
assert!(r.u1 < (x + w) as f32 / 4096.0 && r.v1 < (y + h) as f32 / 4096.0);
|
||||
assert_eq!(r.u0, (x as f32 + 0.01) / 4096.0);
|
||||
assert_eq!(r.v1, ((y + h) as f32 - 0.01) / 4096.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 683 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user