mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
331
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8c60b6df | ||
|
|
4693954aa1 | ||
|
|
0208c75ef4 | ||
|
|
71527fa938 | ||
|
|
d6adbe1cb5 | ||
|
|
bc93f7da59 | ||
|
|
8b843008eb | ||
|
|
8bd015284b | ||
|
|
b97e677cbc | ||
|
|
117fbcd1aa | ||
|
|
5bafa93fa0 | ||
|
|
ae4660dfdf | ||
|
|
141b1e0cf5 | ||
|
|
bf4d46df7e | ||
|
|
4bf451bd2d | ||
|
|
4c8178f4fd | ||
|
|
f38db18183 | ||
|
|
919c6803c5 | ||
|
|
abce5d7864 | ||
|
|
54ac508d90 | ||
|
|
2f6babf098 | ||
|
|
df051f7089 | ||
|
|
706fc7aa13 | ||
|
|
db0a98d645 | ||
|
|
b8eb63db68 | ||
|
|
ce18dafd4e | ||
|
|
971af532f9 | ||
|
|
bdf9cb8eb0 | ||
|
|
1b90753fab | ||
|
|
b890329854 | ||
|
|
e618347e38 | ||
|
|
dbf445db2d | ||
|
|
bf702cfe22 | ||
|
|
03805b8eeb | ||
|
|
c081525853 | ||
|
|
6a6acac27c | ||
|
|
c5e9265bf0 | ||
|
|
421834b82e | ||
|
|
a98cf88458 | ||
|
|
79aca92478 | ||
|
|
48d29b20f1 | ||
|
|
0229716f22 | ||
|
|
a3809490f6 | ||
|
|
8e49a414c2 | ||
|
|
33e56c7a56 | ||
|
|
37a1e8859f | ||
|
|
6d666c30e2 | ||
|
|
4bd1313677 | ||
|
|
6eeeb454f5 | ||
|
|
8eb2ecfa76 | ||
|
|
c3071c7b61 | ||
|
|
80cb00a494 | ||
|
|
fcea81151c | ||
|
|
14d453048e | ||
|
|
224b99b724 | ||
|
|
e8ecd77cc8 | ||
|
|
97b6e16684 | ||
|
|
fdc7593180 | ||
|
|
b1dcbdc0eb | ||
|
|
450ff69b4e | ||
|
|
49aa9dd00b | ||
|
|
b3fdebcb92 | ||
|
|
3ffaf67e22 | ||
|
|
3d68cb4d71 | ||
|
|
bd665f28ad | ||
|
|
2982f6eb9a | ||
|
|
499a08ba81 | ||
|
|
7f54b8abb4 | ||
|
|
573140cc5a | ||
|
|
bdd6275570 | ||
|
|
588c0ea77a | ||
|
|
3f08e59ad2 | ||
|
|
518ecfb511 | ||
|
|
6daad36de2 | ||
|
|
91635b6852 | ||
|
|
58cdd754cd | ||
|
|
0e15c274da | ||
|
|
3b6972a40a | ||
|
|
829c4021a3 | ||
|
|
7a12e4444a | ||
|
|
c3e9ec54a9 | ||
|
|
4b09c9d7d1 | ||
|
|
1884052eda | ||
|
|
0801c954b5 | ||
|
|
5200d36a7b | ||
|
|
2204e39515 | ||
|
|
cfff58e66f | ||
|
|
c1255761d6 | ||
|
|
a88e84f3c9 | ||
|
|
33f0fa8c94 | ||
|
|
42ff9e6482 | ||
|
|
4f3457a4dc | ||
|
|
117241eebb | ||
|
|
3d23a9ff95 | ||
|
|
4bf65778b6 | ||
|
|
cd44c4f495 | ||
|
|
768099d32c | ||
|
|
9d53e6ce35 | ||
|
|
194970773d | ||
|
|
c8012dff3a | ||
|
|
04da927a44 | ||
|
|
67c4e5eb1a | ||
|
|
12930d6237 | ||
|
|
945ad05579 | ||
|
|
9bba7215f5 | ||
|
|
f822855c64 | ||
|
|
0fff625c22 | ||
|
|
d52705984a | ||
|
|
73c8eb9b60 | ||
|
|
a305d38fc5 | ||
|
|
14b7b252ac | ||
|
|
393ff56184 | ||
|
|
e515379b04 | ||
|
|
eb19c2723b | ||
|
|
ccc323a1b7 | ||
|
|
d242616d68 | ||
|
|
d1a62e68ec | ||
|
|
6175960663 | ||
|
|
7eaa770716 | ||
|
|
7d6a05e18f | ||
|
|
fab10b1922 | ||
|
|
8a363878ee | ||
|
|
d7f9535406 | ||
|
|
c770074449 | ||
|
|
9226f507a3 | ||
|
|
8169314c66 | ||
|
|
7b581fb43c | ||
|
|
c32dddb1fe | ||
|
|
5b1d64d4f7 | ||
|
|
55db71a9ff | ||
|
|
9751bdf0ab | ||
|
|
bf0d6dd423 | ||
|
|
91523637ac | ||
|
|
d64f34c96c | ||
|
|
74904e32c1 | ||
|
|
7c8e0d8e9b | ||
|
|
8974e07eec | ||
|
|
6da84cf195 | ||
|
|
2403ca40a2 | ||
|
|
e7b19995ea | ||
|
|
f47c14d93f | ||
|
|
a5f4e32bfc | ||
|
|
ca0888663e | ||
|
|
cdd3254dbb | ||
|
|
410bde729b | ||
|
|
90aded96a3 | ||
|
|
7b33479e15 | ||
|
|
7c1e0f9794 | ||
|
|
0c9e995257 | ||
|
|
97032398df | ||
|
|
a8f0a64bd6 | ||
|
|
d3e0774ca3 | ||
|
|
c3fdf7192d | ||
|
|
59b1c33bb2 | ||
|
|
d1d99be74f | ||
|
|
cc4c49e8f9 | ||
|
|
f9e5264df7 | ||
|
|
668419b92a | ||
|
|
f5de793b38 | ||
|
|
bfda2bf165 | ||
|
|
5782df1c03 | ||
|
|
d3c6f0d937 | ||
|
|
52a380cd2f | ||
|
|
150412d353 | ||
|
|
5fb9092073 | ||
|
|
5f931e3659 | ||
|
|
b9ed9d58de | ||
|
|
e40ea748d1 | ||
|
|
9afb898e80 | ||
|
|
84c3af5122 | ||
|
|
b6b9a5a307 | ||
|
|
d8e912738b | ||
|
|
c7aca8489f | ||
|
|
67a960b045 | ||
|
|
36ca9df57b | ||
|
|
20acff0b3c | ||
|
|
1ea5ec5964 | ||
|
|
8168d96082 | ||
|
|
27e16c3e01 | ||
|
|
2fb276d5f6 | ||
|
|
7830c6de4a | ||
|
|
cf558b0c0a | ||
|
|
0f9adac090 | ||
|
|
73558e3673 | ||
|
|
bc5fd427ab | ||
|
|
2c8b7ee172 | ||
|
|
95c234cc1e | ||
|
|
3e1f39899c | ||
|
|
07cd6c90a8 | ||
|
|
01bf07dd5d | ||
|
|
1ab99bec27 | ||
|
|
2286b52f01 | ||
|
|
a11e54e7b8 | ||
|
|
8b5145eaa5 | ||
|
|
2f804ed55f | ||
|
|
52cc53a4f9 | ||
|
|
267b92182e | ||
|
|
240ac19e3c | ||
|
|
bf9928423c | ||
|
|
39b6aa46bc | ||
|
|
f9cac972ad | ||
|
|
978c42216a | ||
|
|
ddc2a6d7e6 | ||
|
|
d3e2fe8bf3 | ||
|
|
1a01f5ef59 | ||
|
|
a717d6d583 | ||
|
|
1ffe686350 | ||
|
|
217216e263 | ||
|
|
825f8f8845 | ||
|
|
0771f59bca | ||
|
|
c455bb6ec7 | ||
|
|
a91952a8c0 | ||
|
|
c5a734eace | ||
|
|
558904775f | ||
|
|
01125c3023 | ||
|
|
8537e29c92 | ||
|
|
398b1dd2d8 | ||
|
|
e0fecd8b8e | ||
|
|
a839b0cef1 | ||
|
|
2cd11c926f | ||
|
|
0d0411fc7a | ||
|
|
8c739d2bc2 | ||
|
|
b25d588c91 | ||
|
|
c566e69c6c | ||
|
|
461a8757e3 | ||
|
|
4e5b26f9fb | ||
|
|
9828599067 | ||
|
|
84185641bc | ||
|
|
c0b3b5b3e9 | ||
|
|
edd11b6103 | ||
|
|
be2d3582e7 | ||
|
|
3c6bdc2e5b | ||
|
|
99071a1160 | ||
|
|
c6fbd42aee | ||
|
|
ff0c089ac5 | ||
|
|
d9f40c6942 | ||
|
|
eaa5549a3b | ||
|
|
0bf91b8822 | ||
|
|
c01f4f646b | ||
|
|
6534db4f6d | ||
|
|
69689b3fdd | ||
|
|
c6dfae6dd7 | ||
|
|
f75896ec45 | ||
|
|
7d0e5aa953 | ||
|
|
a80973335c | ||
|
|
ace27c11a9 | ||
|
|
c91ca07115 | ||
|
|
c001f56d0f | ||
|
|
b36a63fb39 | ||
|
|
65043ba092 | ||
|
|
daa5415724 | ||
|
|
f2edc255d8 | ||
|
|
f6144df193 | ||
|
|
bd59c0bab2 | ||
|
|
d3cb2f5c29 | ||
|
|
e851af06d0 | ||
|
|
11d3f08c12 | ||
|
|
25e39a2f2e | ||
|
|
630f3feec5 | ||
|
|
84d49077c0 | ||
|
|
0c9a49390e | ||
|
|
2d98bf8dae | ||
|
|
451720bb56 | ||
|
|
5485e189f8 | ||
|
|
aded903dd2 | ||
|
|
f9494ef93e | ||
|
|
ad644778bc | ||
|
|
ee90b3b34b | ||
|
|
a72f06cfab | ||
|
|
fab9c37413 | ||
|
|
ac7e491f3b | ||
|
|
cb1c3499bf | ||
|
|
4a094bee90 | ||
|
|
85bfbc7d46 | ||
|
|
f983223354 | ||
|
|
3d39bbdaf1 | ||
|
|
2aca6fe06c | ||
|
|
81b3803017 | ||
|
|
c8f484e040 | ||
|
|
d4a1ebaa33 | ||
|
|
2ae5640704 | ||
|
|
2f95ce431c | ||
|
|
0536ebbe98 | ||
|
|
59538c7dc1 | ||
|
|
2c10febff1 | ||
|
|
a32e0ac4ec | ||
|
|
fd8f1a02a1 | ||
|
|
a1cf544de7 | ||
|
|
8d8737a266 | ||
|
|
c045245e8e | ||
|
|
91358d8088 | ||
|
|
969749da3d | ||
|
|
5a8b9687e5 | ||
|
|
0f7036f9b1 | ||
|
|
12c503e154 | ||
|
|
2dd963a539 | ||
|
|
cd42938b88 | ||
|
|
ef304f30c9 | ||
|
|
e36fd24008 | ||
|
|
818a2ec52a | ||
|
|
f852985693 | ||
|
|
41d3890a3c | ||
|
|
2f13cb3ac6 | ||
|
|
f52e94cfb9 | ||
|
|
113d694a87 | ||
|
|
1944dd45e4 | ||
|
|
e1d825312f | ||
|
|
5ea61ed3d8 | ||
|
|
b910cdd257 | ||
|
|
3934833369 | ||
|
|
231b309634 | ||
|
|
621473f60e | ||
|
|
2b2dc1929f | ||
|
|
fbab7d29a5 | ||
|
|
b901415c80 | ||
|
|
da877fe1db | ||
|
|
6143e258ab | ||
|
|
f9ca0a76cf | ||
|
|
1232745064 | ||
|
|
3382b666ce | ||
|
|
92c073de61 | ||
|
|
139de824aa | ||
|
|
523dd491c4 | ||
|
|
ed01a93df6 | ||
|
|
1a56e74324 | ||
|
|
25b84dfc2e | ||
|
|
a2098c2ffa | ||
|
|
d98dc8edc9 | ||
|
|
bf05176692 | ||
|
|
58f2ea700e | ||
|
|
9da81f9293 |
No files matched your search
@@ -4,6 +4,18 @@
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
|
||||
## Kind
|
||||
|
||||
<!-- Tick one: it sets the pull request's label (CONTRIBUTING.md "Pull requests"). -->
|
||||
|
||||
- [ ] parity: Horizon now does what vanilla 26.3 does
|
||||
- [ ] bug: a crash or a breakage
|
||||
- [ ] perf: faster, or less memory
|
||||
- [ ] feature: something new for players (listed under New in the release notes)
|
||||
- [ ] extra: Horizon-only, off by default
|
||||
- [ ] tooling: tools, CI, tests, harnesses
|
||||
- [ ] docs
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -62,7 +62,6 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
@@ -71,11 +70,11 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-mods/pack
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: claim command
|
||||
# `/claim` and `/unclaim` comments: anyone takes an issue before working on it, and a daily run releases the claims
|
||||
# older than 5 days that no open pull request links (CONTRIBUTING.md "Claim before you start",
|
||||
# tools/ci/claim_command.py).
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` and `schedule` always run this file from main, whoever comments and wherever.
|
||||
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
|
||||
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step
|
||||
# and can only write issues (assignees, comments, reactions).
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
schedule:
|
||||
- cron: "23 5 * * *"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts. Pull requests are answered by the script.
|
||||
if: >-
|
||||
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/claim') ||
|
||||
startsWith(github.event.comment.body, '/unclaim'))
|
||||
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/trusted-reviewers.txt
|
||||
tools/ci/nightly_set.py
|
||||
tools/ci/claim_command.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Run the command
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
AUTHOR: ${{ github.event.comment.user.login }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/claim_command.py command
|
||||
release:
|
||||
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/nightly_set.py
|
||||
tools/ci/claim_command.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Release stale claims
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/claim_command.py release
|
||||
@@ -0,0 +1,41 @@
|
||||
name: overlap note
|
||||
# One comment on a pull request naming the other open pull requests that close the same issue or change the same
|
||||
# functions, edited on every push (tools/ci/overlap_note.py, issue #3859).
|
||||
#
|
||||
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write:
|
||||
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
|
||||
# - The checkout is main's (`ref: main`), blobless, with the script alone on disk. The pull requests' heads are fetched
|
||||
# as git objects and only diffed, never checked out; git runs with hooks, fsmonitor, external diff and textconv off.
|
||||
# - The rest comes from the API. The token writes the comment only.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
note:
|
||||
if: github.event.pull_request.base.ref == 'main'
|
||||
# A newer push replaces the note being computed.
|
||||
concurrency:
|
||||
group: overlap-note-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/overlap_note.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Note
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/overlap_note.py event
|
||||
@@ -0,0 +1,36 @@
|
||||
name: pr labels
|
||||
# A pull request's kind label (the box ticked in the template) and area labels (the paths it changes):
|
||||
# tools/ci/pr_labels.py, CONTRIBUTING.md "Pull requests".
|
||||
#
|
||||
# Why no pull request's code runs here, though `pull_request_target` gives a token that can write to fork PRs:
|
||||
# - `pull_request_target` runs this file and the script from main, never the pull request's copy.
|
||||
# - It checks out main's script only (`ref: main`), never the pull request's head or merge commit.
|
||||
# - The script reads the event file (the description is data in JSON, never in a shell line) and the API's list of
|
||||
# changed file names; it runs, builds and imports nothing of theirs. The token writes labels only.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, edited, ready_for_review]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
label:
|
||||
# Title-only edits change nothing: skip them before a runner starts.
|
||||
if: github.event.action != 'edited' || github.event.changes.body != null
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
tools/ci/pr_labels.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 tools/ci/pr_labels.py event
|
||||
@@ -30,6 +30,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
|
||||
## Crates
|
||||
|
||||
|
||||
+20
-3
@@ -74,6 +74,19 @@ including examples. The dependency scope includes optional, platform, build and
|
||||
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
|
||||
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
|
||||
|
||||
## Claim before you start
|
||||
|
||||
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
|
||||
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
|
||||
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
|
||||
enough), then claim it. `/unclaim` gives it back.
|
||||
|
||||
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
|
||||
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. Agents claim the same way.
|
||||
|
||||
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
|
||||
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
|
||||
@@ -82,8 +95,12 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
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.
|
||||
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
|
||||
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
|
||||
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
|
||||
`label:parity` is the list of real progress toward vanilla.
|
||||
- **Release labels** (we set them if you can't): `feature` also puts it 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.
|
||||
|
||||
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
|
||||
@@ -102,4 +119,4 @@ new pushes, we add it again.
|
||||
## Talk to us
|
||||
|
||||
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
Generated
+5
-815
File diff suppressed because it is too large.
Load diff
-13
@@ -23,26 +23,13 @@ members = [
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mod-sdk",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mods/tests/guest",
|
||||
"crates/horizon-mods/tests/ui_guest",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/far-view",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
+307
-452
@@ -12,11 +12,11 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
|
||||
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
- **Vanilla parity: the player-visible gaps, closed**: Every method of the Java game is tracked with where Horizon does it, and the differences players can see are being closed, list by list: mobs, blocks, items, screens, rendering, servers.
|
||||
- **Mods**: Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.
|
||||
|
||||
**Next**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
|
||||
**Next**: Fresh benchmarks against vanilla; Faster world creation and entry on small CPUs; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; One single-player path: remove the in-frame world; Faster block lookups; Playing on servers: everything shows as it should; Shader packs, written again from scratch; A new launcher design; Splitscreen.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -30,19 +30,25 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
|
||||
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3909](https://github.com/horizon-rust/horizon/issues/3909)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/horizon-rust/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/horizon-rust/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/horizon-rust/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: no main-thread tick stutter at high render distances ([#3827](https://github.com/horizon-rust/horizon/issues/3827)), each proto chunk's light view built once ([#3815](https://github.com/horizon-rust/horizon/issues/3815)), video memory handed back by the vertex arena ([#3466](https://github.com/horizon-rust/horizon/issues/3466)), chunk batches assembled on helper threads ([#3036](https://github.com/horizon-rust/horizon/issues/3036)), free structure starts in the generation workers ([#1760](https://github.com/horizon-rust/horizon/issues/1760)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster block lookups
|
||||
#### Servers that hold 20 TPS with 50 to 100 players
|
||||
|
||||
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
|
||||
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3910](https://github.com/horizon-rust/horizon/issues/3910)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
|
||||
|
||||
More: [#3847](https://github.com/horizon-rust/horizon/issues/3847) · [#2608](https://github.com/horizon-rust/horizon/issues/2608)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -52,101 +58,27 @@ Speeding up the code that reads and writes the blocks of the world, which almost
|
||||
|
||||
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/horizon-rust/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/horizon-rust/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/horizon-rust/horizon/issues/1753), [#1757](https://github.com/horizon-rust/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Playing on servers: everything shows as it should
|
||||
|
||||
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
Owner: [ThatCakeIsAPie](https://github.com/ThatCakeIsAPie) · Tracking: [#3913](https://github.com/horizon-rust/horizon/issues/3913)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server dialogs and login screens
|
||||
|
||||
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's dialog packets and model ([#1720](https://github.com/horizon-rust/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/horizon-rust/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/horizon-rust/horizon/issues/1735)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Pre-generate an area with /pregen
|
||||
|
||||
A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/horizon-rust/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
|
||||
`horizon-port-pack` carries a pack's data-driven content over, and 26.2 data packs are upgraded to 26.3's format ([#3369](https://github.com/horizon-rust/horizon/issues/3369)). In review: worlds taller than 32 sections and each level's shape from its dimension type ([#3480](https://github.com/horizon-rust/horizon/issues/3480), [#3488](https://github.com/horizon-rust/horizon/issues/3488)), data-pack biomes in local and remote worlds ([#3491](https://github.com/horizon-rust/horizon/issues/3491)), block state providers ([#3802](https://github.com/horizon-rust/horizon/issues/3802)), surface rules by biome id ([#3366](https://github.com/horizon-rust/horizon/issues/3366)), Lithostitched's pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Horizon's mod registry
|
||||
#### Mods
|
||||
|
||||
One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
Mods for Horizon, as one line of work led by the modding team: native mods the launcher builds into your game, mods on servers, mod screens and settings, tools for mod makers, the registry and machines.
|
||||
|
||||
Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://github.com/horizon-rust/horizon/issues/3911)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/horizon-rust/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/horizon-rust/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/horizon-rust/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/horizon-rust/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/horizon-rust/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/horizon-rust/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/horizon-rust/horizon/issues/1637)), the Mods page ([#1638](https://github.com/horizon-rust/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/horizon-rust/horizon/issues/1640)).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is being removed ([#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native mods, built by the launcher
|
||||
|
||||
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods that run on the server
|
||||
|
||||
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Screens, input and drawing for mods
|
||||
|
||||
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
|
||||
|
||||
</details>
|
||||
|
||||
#### Tools for people who make mods
|
||||
|
||||
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
|
||||
|
||||
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
|
||||
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -156,103 +88,11 @@ More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/nati
|
||||
|
||||
Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Play in your language
|
||||
|
||||
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://github.com/horizon-rust/horizon/issues/3914)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/horizon-rust/horizon/issues/1774)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/horizon-rust/horizon/issues/1763).
|
||||
|
||||
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/horizon-rust/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/horizon-rust/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/horizon-rust/horizon/issues/1347).
|
||||
|
||||
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/horizon-rust/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/horizon-rust/horizon/issues/1782), after [#978](https://github.com/horizon-rust/horizon/issues/978)).
|
||||
|
||||
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/horizon-rust/horizon/issues/1706).
|
||||
|
||||
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
|
||||
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon's own shader format and kit
|
||||
|
||||
Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/horizon-rust/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/horizon-rust/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/horizon-rust/horizon/issues/1861)), the sky stage ([#1863](https://github.com/horizon-rust/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/horizon-rust/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -262,6 +102,8 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
|
||||
|
||||
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3915](https://github.com/horizon-rust/horizon/issues/3915)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
|
||||
@@ -270,37 +112,31 @@ More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
|
||||
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Discord IPC client on its own thread ([#1787](https://github.com/horizon-rust/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/horizon-rust/horizon/issues/1788)). Merged: it ships with the next release.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### A leaner codebase that builds faster
|
||||
|
||||
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3916](https://github.com/horizon-rust/horizon/issues/3916)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
|
||||
</details>
|
||||
|
||||
#### Every vanilla method, tracked
|
||||
#### Vanilla parity: the player-visible gaps, closed
|
||||
|
||||
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
|
||||
Every method of the Java game is tracked with where Horizon does it, and the differences players can see are being closed, list by list: mobs, blocks, items, screens, rendering, servers.
|
||||
|
||||
Owner: [kacpherek](https://github.com/kacpherek) · Tracking: [#3912](https://github.com/horizon-rust/horizon/issues/3912)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
|
||||
The port ledger ([tools/port_ledger.py](tools/port_ledger.py), tools/port_map/, [#3139](https://github.com/horizon-rust/horizon/issues/3139)) gives every 26.3 method a verdict and links the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)). The player-visible gaps are ten lists, one per area ([#3512](https://github.com/horizon-rust/horizon/issues/3512) to [#3521](https://github.com/horizon-rust/horizon/issues/3521)); a fix translates the vanilla method, ticks its row and carries the `parity` label. Start from .claude/skills/vanilla-parity.
|
||||
|
||||
More: [#3513](https://github.com/horizon-rust/horizon/issues/3513) · [#3514](https://github.com/horizon-rust/horizon/issues/3514) · [#3515](https://github.com/horizon-rust/horizon/issues/3515) · [#3516](https://github.com/horizon-rust/horizon/issues/3516) · [#3517](https://github.com/horizon-rust/horizon/issues/3517) · [#3518](https://github.com/horizon-rust/horizon/issues/3518) · [#3519](https://github.com/horizon-rust/horizon/issues/3519) · [#3520](https://github.com/horizon-rust/horizon/issues/3520)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -328,16 +164,6 @@ Cut the per-column generation cost (noise sampling, surface rules, aquifers) and
|
||||
|
||||
</details>
|
||||
|
||||
#### Servers that hold 20 TPS with 50 to 100 players
|
||||
|
||||
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
|
||||
|
||||
</details>
|
||||
|
||||
#### Low-end computers: Steam Deck, old Macs, 8 GB
|
||||
|
||||
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
|
||||
@@ -358,74 +184,22 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
|
||||
|
||||
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
|
||||
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
More: [#3762](https://github.com/horizon-rust/horizon/issues/3762) · [#3763](https://github.com/horizon-rust/horizon/issues/3763) · [#3764](https://github.com/horizon-rust/horizon/issues/3764)
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
#### Faster block lookups
|
||||
|
||||
#### CurseForge in the mod browser (coming soon)
|
||||
|
||||
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
|
||||
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A Convert to Rust button for what isn't rewritten yet
|
||||
|
||||
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods with code: machines, magic, mobs
|
||||
|
||||
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Data packs in the mod browser
|
||||
|
||||
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Prepare a version without Java
|
||||
|
||||
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server console, bans and a full admin API
|
||||
|
||||
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
|
||||
@@ -446,86 +220,18 @@ A Windows service wrapper instead of the logon scheduled task; `backups.before_u
|
||||
|
||||
</details>
|
||||
|
||||
#### Guided hosting with Tailscale and an invite button
|
||||
#### Playing on servers: everything shows as it should
|
||||
|
||||
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
|
||||
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
|
||||
|
||||
More: [docs/hosting.md](docs/hosting.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### An official Docker image for servers
|
||||
|
||||
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
|
||||
|
||||
More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### A server you install with one command
|
||||
|
||||
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
|
||||
Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Machines and blocks with inventories (API 0.4)
|
||||
|
||||
Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
|
||||
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
@@ -536,37 +242,31 @@ Single player will have one way to run, the server thread, so every fix works ev
|
||||
|
||||
</details>
|
||||
|
||||
#### Villages and combat, exactly as in vanilla
|
||||
#### Splitscreen
|
||||
|
||||
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
Owner: [martipops](https://github.com/martipops)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054), built by martipops as a series: up to 16 local players on the server thread's multiplayer ([#3541](https://github.com/horizon-rust/horizon/issues/3541)), each controller driving its own player ([#3794](https://github.com/horizon-rust/horizon/issues/3794)), joining with a controller ([#3801](https://github.com/horizon-rust/horizon/issues/3801)), players in windows of their own ([#3825](https://github.com/horizon-rust/horizon/issues/3825)) and the profile page ([#3826](https://github.com/horizon-rust/horizon/issues/3826)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Choose the audio output device
|
||||
|
||||
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
More: [#3777](https://github.com/horizon-rust/horizon/issues/3777)
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Well-known shader looks, rewritten for Horizon
|
||||
#### Shader packs, written again from scratch
|
||||
|
||||
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
|
||||
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -576,101 +276,13 @@ More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
|
||||
|
||||
Owner: [ludovic111](https://github.com/ludovic111)
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
#### Wayland and Hyprland polish
|
||||
|
||||
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### A versioned agent-control protocol
|
||||
|
||||
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
|
||||
|
||||
More: [docs/api/stability.md](docs/api/stability.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Port mods with your own AI agent
|
||||
|
||||
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### Horizon on X (coming soon)
|
||||
|
||||
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### Pipeline protection and a GitHub App for agents
|
||||
|
||||
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Signed updates everywhere, installers included
|
||||
|
||||
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
More: [#3635](https://github.com/horizon-rust/horizon/issues/3635) · [#3636](https://github.com/horizon-rust/horizon/issues/3636) · [#3637](https://github.com/horizon-rust/horizon/issues/3637)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -688,6 +300,18 @@ Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
|
||||
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Older Fabric and Forge mods
|
||||
@@ -730,6 +354,46 @@ Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), cl
|
||||
|
||||
</details>
|
||||
|
||||
#### CurseForge in the mod browser (coming soon)
|
||||
|
||||
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A Convert to Rust button for what isn't rewritten yet
|
||||
|
||||
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods with code: machines, magic, mobs
|
||||
|
||||
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Data packs in the mod browser
|
||||
|
||||
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Ely.by and other sign-in providers
|
||||
@@ -764,6 +428,60 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
|
||||
|
||||
</details>
|
||||
|
||||
#### Prepare a version without Java
|
||||
|
||||
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Guided hosting with Tailscale and an invite button
|
||||
|
||||
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
|
||||
|
||||
More: [docs/hosting.md](docs/hosting.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### An official Docker image for servers
|
||||
|
||||
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
|
||||
|
||||
More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### A server you install with one command
|
||||
|
||||
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Scripts for mods
|
||||
@@ -842,16 +560,6 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
|
||||
|
||||
</details>
|
||||
|
||||
#### Splitscreen
|
||||
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
@@ -862,6 +570,46 @@ Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](do
|
||||
|
||||
</details>
|
||||
|
||||
#### Pistons push players and mobs
|
||||
|
||||
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
#### Villages and combat, exactly as in vanilla
|
||||
|
||||
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Choose the audio output device
|
||||
|
||||
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
|
||||
</details>
|
||||
|
||||
#### Play in your language
|
||||
|
||||
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn.
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### A light default shader pack
|
||||
@@ -906,6 +654,18 @@ Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering ne
|
||||
|
||||
</details>
|
||||
|
||||
#### Well-known shader looks, rewritten for Horizon
|
||||
|
||||
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Lean installs that use less disk
|
||||
@@ -918,6 +678,16 @@ A shared resource store keyed by the preparer's digest, no prepare intermediates
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Linux ARM64
|
||||
@@ -952,6 +722,26 @@ No official build. Android was first closed as not planned, with touch controls,
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
#### Wayland and Hyprland polish
|
||||
|
||||
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
|
||||
</details>
|
||||
|
||||
#### AppImage and a Nix flake
|
||||
|
||||
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
|
||||
@@ -996,6 +786,30 @@ Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/hori
|
||||
|
||||
</details>
|
||||
|
||||
#### A versioned agent-control protocol
|
||||
|
||||
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
|
||||
|
||||
More: [docs/api/stability.md](docs/api/stability.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Port mods with your own AI agent
|
||||
|
||||
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### A video series about Horizon
|
||||
@@ -1008,6 +822,38 @@ Kira already shows new uploads in a read-only Discord channel (done). The series
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on X (coming soon)
|
||||
|
||||
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### Pipeline protection and a GitHub App for agents
|
||||
|
||||
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Signed updates everywhere, installers included
|
||||
|
||||
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
## Help wanted
|
||||
|
||||
### Performance
|
||||
@@ -1048,6 +894,15 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Chickens flap while they fall** (v0.2.67): Chickens should flap their wings while they fall, not stand still in the air. [#1706](https://github.com/horizon-rust/horizon/issues/1706) · [#1892](https://github.com/horizon-rust/horizon/issues/1892)
|
||||
- **Ctrl+Backspace and scroll bars in menus** (v0.2.67): Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. [#1764](https://github.com/horizon-rust/horizon/issues/1764) · [#1782](https://github.com/horizon-rust/horizon/issues/1782) · [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
|
||||
- **A pointing hand over buttons** (v0.2.67): The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. [#1347](https://github.com/horizon-rust/horizon/issues/1347) · [#1897](https://github.com/horizon-rust/horizon/issues/1897)
|
||||
- **Chat settings and team prefixes** (v0.2.67): The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat. [#1376](https://github.com/horizon-rust/horizon/issues/1376) · [#1378](https://github.com/horizon-rust/horizon/issues/1378) · [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
|
||||
- **Cooldowns drawn on items** (v0.2.67): After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla. [#1763](https://github.com/horizon-rust/horizon/issues/1763) · [#1933](https://github.com/horizon-rust/horizon/issues/1933)
|
||||
- **Pre-generate an area with /pregen** (v0.2.67): A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore. [#1805](https://github.com/horizon-rust/horizon/issues/1805)
|
||||
- **Horizon's own shader format and kit** (v0.2.70): Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them. [#1820](https://github.com/horizon-rust/horizon/issues/1820) · [#1822](https://github.com/horizon-rust/horizon/issues/1822) · [#1861](https://github.com/horizon-rust/horizon/issues/1861) · [#1863](https://github.com/horizon-rust/horizon/issues/1863) · [#1855](https://github.com/horizon-rust/horizon/issues/1855) · [docs/shaders.md](docs/shaders.md) · [docs/api/shaders.md](docs/api/shaders.md)
|
||||
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
|
||||
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:13:01Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 3158.208,
|
||||
"stdev": 215.77,
|
||||
"min": 3023.046,
|
||||
"max": 3407.049,
|
||||
"ci95": 536.046
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 7170.164,
|
||||
"stdev": 557.958,
|
||||
"min": 6703.242,
|
||||
"max": 7788.08,
|
||||
"ci95": 1386.157
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1694.647,
|
||||
"stdev": 41.285,
|
||||
"min": 1653.122,
|
||||
"max": 1735.687,
|
||||
"ci95": 102.566
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 23544.0,
|
||||
"stdev": 47.127,
|
||||
"min": 23495.0,
|
||||
"max": 23589.0,
|
||||
"ci95": 117.081
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1711.652,
|
||||
"stdev": 77.412,
|
||||
"min": 1623.403,
|
||||
"max": 1768.093,
|
||||
"ci95": 192.317
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 593.407,
|
||||
"stdev": 32.279,
|
||||
"min": 571.485,
|
||||
"max": 630.473,
|
||||
"ci95": 80.191
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 358.473,
|
||||
"stdev": 27.832,
|
||||
"min": 326.35,
|
||||
"max": 375.353,
|
||||
"ci95": 69.143
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 631.813,
|
||||
"stdev": 8.381,
|
||||
"min": 626.803,
|
||||
"max": 641.489,
|
||||
"ci95": 20.822
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2521.363,
|
||||
"stdev": 291.624,
|
||||
"min": 2336.881,
|
||||
"max": 2857.57,
|
||||
"ci95": 724.493
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 27346.667,
|
||||
"stdev": 57.934,
|
||||
"min": 27306.0,
|
||||
"max": 27413.0,
|
||||
"ci95": 143.927
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2423.783,
|
||||
"stdev": 55.746,
|
||||
"min": 2386.353,
|
||||
"max": 2487.851,
|
||||
"ci95": 138.493
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 709.165,
|
||||
"stdev": 66.309,
|
||||
"min": 638.666,
|
||||
"max": 770.286,
|
||||
"ci95": 164.734
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 401.039,
|
||||
"stdev": 40.413,
|
||||
"min": 354.472,
|
||||
"max": 426.929,
|
||||
"ci95": 100.399
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 705.008,
|
||||
"stdev": 6.062,
|
||||
"min": 698.466,
|
||||
"max": 710.435,
|
||||
"ci95": 15.06
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2094.909,
|
||||
"stdev": 521.225,
|
||||
"min": 1758.024,
|
||||
"max": 2695.274,
|
||||
"ci95": 1294.9
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 24312.667,
|
||||
"stdev": 261.934,
|
||||
"min": 24116.0,
|
||||
"max": 24610.0,
|
||||
"ci95": 650.732
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2201.125,
|
||||
"stdev": 73.595,
|
||||
"min": 2153.6,
|
||||
"max": 2285.897,
|
||||
"ci95": 182.834
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 553.968,
|
||||
"stdev": 100.955,
|
||||
"min": 445.599,
|
||||
"max": 645.355,
|
||||
"ci95": 250.806
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 325.078,
|
||||
"stdev": 131.154,
|
||||
"min": 177.4,
|
||||
"max": 427.981,
|
||||
"ci95": 325.831
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 652.944,
|
||||
"stdev": 5.177,
|
||||
"min": 647.51,
|
||||
"max": 657.818,
|
||||
"ci95": 12.862
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3304.58,
|
||||
"stdev": 134.041,
|
||||
"min": 3155.996,
|
||||
"max": 3416.409,
|
||||
"ci95": 333.003
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 26591.0,
|
||||
"stdev": 57.472,
|
||||
"min": 26525.0,
|
||||
"max": 26630.0,
|
||||
"ci95": 142.779
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1961.086,
|
||||
"stdev": 39.725,
|
||||
"min": 1921.241,
|
||||
"max": 2000.69,
|
||||
"ci95": 98.69
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 536.973,
|
||||
"stdev": 38.828,
|
||||
"min": 495.61,
|
||||
"max": 572.635,
|
||||
"ci95": 96.461
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 296.586,
|
||||
"stdev": 30.236,
|
||||
"min": 275.859,
|
||||
"max": 331.281,
|
||||
"ci95": 75.118
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 896.941,
|
||||
"stdev": 5.177,
|
||||
"min": 891.506,
|
||||
"max": 901.814,
|
||||
"ci95": 12.862
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1458.578,
|
||||
"stdev": 3.229,
|
||||
"min": 1455.32,
|
||||
"max": 1461.777,
|
||||
"ci95": 8.022
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1236.28,
|
||||
"stdev": 4.869,
|
||||
"min": 1230.701,
|
||||
"max": 1239.672,
|
||||
"ci95": 12.096
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 21218,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1768.093,
|
||||
"low_1pct_fps": 630.473,
|
||||
"low_01pct_fps": 375.353,
|
||||
"p50_ms": 0.537,
|
||||
"p95_ms": 0.846,
|
||||
"p99_ms": 1.222,
|
||||
"p999_ms": 2.081,
|
||||
"max_ms": 6.256,
|
||||
"cpu_p50_ms": 0.38,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
20695,
|
||||
499,
|
||||
22,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 19482,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1623.403,
|
||||
"low_1pct_fps": 578.262,
|
||||
"low_01pct_fps": 373.717,
|
||||
"p50_ms": 0.586,
|
||||
"p95_ms": 0.95,
|
||||
"p99_ms": 1.357,
|
||||
"p999_ms": 2.158,
|
||||
"max_ms": 7.35,
|
||||
"cpu_p50_ms": 0.445,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
18729,
|
||||
713,
|
||||
38,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 20922,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1743.46,
|
||||
"low_1pct_fps": 571.485,
|
||||
"low_01pct_fps": 326.35,
|
||||
"p50_ms": 0.526,
|
||||
"p95_ms": 0.883,
|
||||
"p99_ms": 1.313,
|
||||
"p999_ms": 2.14,
|
||||
"max_ms": 12.027,
|
||||
"cpu_p50_ms": 0.379,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
20308,
|
||||
582,
|
||||
28,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 29855,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2487.851,
|
||||
"low_1pct_fps": 770.286,
|
||||
"low_01pct_fps": 421.717,
|
||||
"p50_ms": 0.365,
|
||||
"p95_ms": 0.619,
|
||||
"p99_ms": 0.948,
|
||||
"p999_ms": 1.717,
|
||||
"max_ms": 9.446,
|
||||
"cpu_p50_ms": 0.232,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
29607,
|
||||
233,
|
||||
13,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28766,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2397.145,
|
||||
"low_1pct_fps": 718.542,
|
||||
"low_01pct_fps": 426.929,
|
||||
"p50_ms": 0.374,
|
||||
"p95_ms": 0.662,
|
||||
"p99_ms": 1.016,
|
||||
"p999_ms": 1.858,
|
||||
"max_ms": 6.379,
|
||||
"cpu_p50_ms": 0.239,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
28457,
|
||||
293,
|
||||
14,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 28637,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2386.353,
|
||||
"low_1pct_fps": 638.666,
|
||||
"low_01pct_fps": 354.472,
|
||||
"p50_ms": 0.373,
|
||||
"p95_ms": 0.704,
|
||||
"p99_ms": 1.159,
|
||||
"p999_ms": 1.952,
|
||||
"max_ms": 14.388,
|
||||
"cpu_p50_ms": 0.233,
|
||||
"gpu_p50_ms": 0.157,
|
||||
"histogram": [
|
||||
28198,
|
||||
415,
|
||||
18,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 25968,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 2163.877,
|
||||
"low_1pct_fps": 570.949,
|
||||
"low_01pct_fps": 369.854,
|
||||
"p50_ms": 0.408,
|
||||
"p95_ms": 0.767,
|
||||
"p99_ms": 1.339,
|
||||
"p999_ms": 2.38,
|
||||
"max_ms": 3.329,
|
||||
"cpu_p50_ms": 0.271,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
25383,
|
||||
534,
|
||||
47,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 25843,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2153.6,
|
||||
"low_1pct_fps": 445.599,
|
||||
"low_01pct_fps": 177.4,
|
||||
"p50_ms": 0.413,
|
||||
"p95_ms": 0.725,
|
||||
"p99_ms": 1.309,
|
||||
"p999_ms": 3.451,
|
||||
"max_ms": 12.071,
|
||||
"cpu_p50_ms": 0.275,
|
||||
"gpu_p50_ms": 0.142,
|
||||
"histogram": [
|
||||
25375,
|
||||
373,
|
||||
56,
|
||||
22,
|
||||
1,
|
||||
7,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 27431,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2285.897,
|
||||
"low_1pct_fps": 645.355,
|
||||
"low_01pct_fps": 427.981,
|
||||
"p50_ms": 0.388,
|
||||
"p95_ms": 0.693,
|
||||
"p99_ms": 1.163,
|
||||
"p999_ms": 2.041,
|
||||
"max_ms": 3.068,
|
||||
"cpu_p50_ms": 0.254,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
27036,
|
||||
363,
|
||||
31,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 23536,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1961.326,
|
||||
"low_1pct_fps": 542.675,
|
||||
"low_01pct_fps": 331.281,
|
||||
"p50_ms": 0.436,
|
||||
"p95_ms": 0.856,
|
||||
"p99_ms": 1.425,
|
||||
"p999_ms": 2.22,
|
||||
"max_ms": 9.458,
|
||||
"cpu_p50_ms": 0.296,
|
||||
"gpu_p50_ms": 0.14,
|
||||
"histogram": [
|
||||
22819,
|
||||
671,
|
||||
40,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 23056,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1921.241,
|
||||
"low_1pct_fps": 495.61,
|
||||
"low_01pct_fps": 282.617,
|
||||
"p50_ms": 0.44,
|
||||
"p95_ms": 0.911,
|
||||
"p99_ms": 1.516,
|
||||
"p999_ms": 2.543,
|
||||
"max_ms": 14.342,
|
||||
"cpu_p50_ms": 0.299,
|
||||
"gpu_p50_ms": 0.141,
|
||||
"histogram": [
|
||||
22198,
|
||||
783,
|
||||
65,
|
||||
7,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 24009,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2000.69,
|
||||
"low_1pct_fps": 572.635,
|
||||
"low_01pct_fps": 275.859,
|
||||
"p50_ms": 0.436,
|
||||
"p95_ms": 0.819,
|
||||
"p99_ms": 1.274,
|
||||
"p999_ms": 2.085,
|
||||
"max_ms": 14.003,
|
||||
"cpu_p50_ms": 0.297,
|
||||
"gpu_p50_ms": 0.14,
|
||||
"histogram": [
|
||||
23421,
|
||||
560,
|
||||
22,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:55:59Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 2897.812,
|
||||
"stdev": 82.455,
|
||||
"min": 2844.677,
|
||||
"max": 2992.8,
|
||||
"ci95": 204.846
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 10503.454,
|
||||
"stdev": 493.592,
|
||||
"min": 9934.491,
|
||||
"max": 10816.992,
|
||||
"ci95": 1226.251
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3717.47,
|
||||
"stdev": 1330.649,
|
||||
"min": 2942.872,
|
||||
"max": 5253.954,
|
||||
"ci95": 3305.783
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 47525.333,
|
||||
"stdev": 1369.122,
|
||||
"min": 46133.0,
|
||||
"max": 48870.0,
|
||||
"ci95": 3401.363
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1163.541,
|
||||
"stdev": 3.485,
|
||||
"min": 1159.52,
|
||||
"max": 1165.693,
|
||||
"ci95": 8.658
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 415.992,
|
||||
"stdev": 15.742,
|
||||
"min": 399.062,
|
||||
"max": 430.188,
|
||||
"ci95": 39.108
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 215.549,
|
||||
"stdev": 34.678,
|
||||
"min": 177.857,
|
||||
"max": 246.101,
|
||||
"ci95": 86.151
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1833.432,
|
||||
"stdev": 4.415,
|
||||
"min": 1828.334,
|
||||
"max": 1835.997,
|
||||
"ci95": 10.969
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5455.049,
|
||||
"stdev": 593.897,
|
||||
"min": 4973.659,
|
||||
"max": 6118.723,
|
||||
"ci95": 1475.44
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 82758.0,
|
||||
"stdev": 413.547,
|
||||
"min": 82487.0,
|
||||
"max": 83234.0,
|
||||
"ci95": 1027.39
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1389.46,
|
||||
"stdev": 28.128,
|
||||
"min": 1371.096,
|
||||
"max": 1421.843,
|
||||
"ci95": 69.88
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 504.377,
|
||||
"stdev": 13.251,
|
||||
"min": 492.444,
|
||||
"max": 518.637,
|
||||
"ci95": 32.919
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 329.682,
|
||||
"stdev": 47.451,
|
||||
"min": 294.872,
|
||||
"max": 383.732,
|
||||
"ci95": 117.885
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1985.175,
|
||||
"stdev": 6.354,
|
||||
"min": 1978.907,
|
||||
"max": 1991.612,
|
||||
"ci95": 15.786
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4504.145,
|
||||
"stdev": 745.393,
|
||||
"min": 3650.589,
|
||||
"max": 5026.8,
|
||||
"ci95": 1851.809
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 74927.333,
|
||||
"stdev": 399.375,
|
||||
"min": 74476.0,
|
||||
"max": 75235.0,
|
||||
"ci95": 992.182
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1188.639,
|
||||
"stdev": 10.807,
|
||||
"min": 1177.005,
|
||||
"max": 1198.365,
|
||||
"ci95": 26.849
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 154.773,
|
||||
"stdev": 3.712,
|
||||
"min": 151.38,
|
||||
"max": 158.738,
|
||||
"ci95": 9.223
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 67.809,
|
||||
"stdev": 3.032,
|
||||
"min": 65.903,
|
||||
"max": 71.305,
|
||||
"ci95": 7.532
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2084.845,
|
||||
"stdev": 9.674,
|
||||
"min": 2074.009,
|
||||
"max": 2092.615,
|
||||
"ci95": 24.034
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 9048.431,
|
||||
"stdev": 344.33,
|
||||
"min": 8821.41,
|
||||
"max": 9444.624,
|
||||
"ci95": 855.432
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 89659.667,
|
||||
"stdev": 87.123,
|
||||
"min": 89561.0,
|
||||
"max": 89726.0,
|
||||
"ci95": 216.442
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1085.847,
|
||||
"stdev": 13.819,
|
||||
"min": 1070.378,
|
||||
"max": 1096.974,
|
||||
"ci95": 34.332
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 257.7,
|
||||
"stdev": 31.437,
|
||||
"min": 224.366,
|
||||
"max": 286.814,
|
||||
"ci95": 78.101
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 107.783,
|
||||
"stdev": 14.883,
|
||||
"min": 91.541,
|
||||
"max": 120.768,
|
||||
"ci95": 36.975
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2708.833,
|
||||
"stdev": 9.674,
|
||||
"min": 2697.997,
|
||||
"max": 2716.603,
|
||||
"ci95": 24.034
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2044.895,
|
||||
"stdev": 10.562,
|
||||
"min": 2032.875,
|
||||
"max": 2052.691,
|
||||
"ci95": 26.238
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1730.107,
|
||||
"stdev": 14.764,
|
||||
"min": 1717.52,
|
||||
"max": 1746.357,
|
||||
"ci95": 36.678
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 13986,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1165.41,
|
||||
"low_1pct_fps": 399.062,
|
||||
"low_01pct_fps": 177.857,
|
||||
"p50_ms": 0.822,
|
||||
"p95_ms": 1.21,
|
||||
"p99_ms": 1.691,
|
||||
"p999_ms": 3.499,
|
||||
"max_ms": 13.089,
|
||||
"cpu_p50_ms": 0.665,
|
||||
"gpu_p50_ms": 0.193,
|
||||
"histogram": [
|
||||
12380,
|
||||
1527,
|
||||
58,
|
||||
13,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 13915,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1159.52,
|
||||
"low_1pct_fps": 430.188,
|
||||
"low_01pct_fps": 246.101,
|
||||
"p50_ms": 0.809,
|
||||
"p95_ms": 1.212,
|
||||
"p99_ms": 1.702,
|
||||
"p999_ms": 3.291,
|
||||
"max_ms": 6.894,
|
||||
"cpu_p50_ms": 0.648,
|
||||
"gpu_p50_ms": 0.192,
|
||||
"histogram": [
|
||||
11996,
|
||||
1839,
|
||||
59,
|
||||
17,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 13989,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1165.693,
|
||||
"low_1pct_fps": 418.725,
|
||||
"low_01pct_fps": 222.688,
|
||||
"p50_ms": 0.816,
|
||||
"p95_ms": 1.22,
|
||||
"p99_ms": 1.692,
|
||||
"p999_ms": 3.264,
|
||||
"max_ms": 7.781,
|
||||
"cpu_p50_ms": 0.669,
|
||||
"gpu_p50_ms": 0.193,
|
||||
"histogram": [
|
||||
12321,
|
||||
1578,
|
||||
72,
|
||||
11,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 17063,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1421.843,
|
||||
"low_1pct_fps": 502.049,
|
||||
"low_01pct_fps": 294.872,
|
||||
"p50_ms": 0.666,
|
||||
"p95_ms": 0.935,
|
||||
"p99_ms": 1.549,
|
||||
"p999_ms": 2.692,
|
||||
"max_ms": 6.831,
|
||||
"cpu_p50_ms": 0.533,
|
||||
"gpu_p50_ms": 0.362,
|
||||
"histogram": [
|
||||
16417,
|
||||
596,
|
||||
40,
|
||||
7,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 16454,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1371.096,
|
||||
"low_1pct_fps": 492.444,
|
||||
"low_01pct_fps": 310.443,
|
||||
"p50_ms": 0.688,
|
||||
"p95_ms": 0.979,
|
||||
"p99_ms": 1.556,
|
||||
"p999_ms": 2.693,
|
||||
"max_ms": 4.596,
|
||||
"cpu_p50_ms": 0.553,
|
||||
"gpu_p50_ms": 0.36,
|
||||
"histogram": [
|
||||
15692,
|
||||
697,
|
||||
57,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 16506,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1375.442,
|
||||
"low_1pct_fps": 518.637,
|
||||
"low_01pct_fps": 383.732,
|
||||
"p50_ms": 0.675,
|
||||
"p95_ms": 1.017,
|
||||
"p99_ms": 1.605,
|
||||
"p999_ms": 2.369,
|
||||
"max_ms": 3.201,
|
||||
"cpu_p50_ms": 0.54,
|
||||
"gpu_p50_ms": 0.364,
|
||||
"histogram": [
|
||||
15628,
|
||||
833,
|
||||
44,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 14381,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1198.365,
|
||||
"low_1pct_fps": 154.2,
|
||||
"low_01pct_fps": 66.219,
|
||||
"p50_ms": 0.731,
|
||||
"p95_ms": 1.023,
|
||||
"p99_ms": 3.003,
|
||||
"p999_ms": 12.478,
|
||||
"max_ms": 18.863,
|
||||
"cpu_p50_ms": 0.596,
|
||||
"gpu_p50_ms": 0.315,
|
||||
"histogram": [
|
||||
13586,
|
||||
546,
|
||||
105,
|
||||
60,
|
||||
13,
|
||||
13,
|
||||
8,
|
||||
16,
|
||||
8,
|
||||
11,
|
||||
5,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 14287,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1190.548,
|
||||
"low_1pct_fps": 158.738,
|
||||
"low_01pct_fps": 71.305,
|
||||
"p50_ms": 0.741,
|
||||
"p95_ms": 1.041,
|
||||
"p99_ms": 2.9,
|
||||
"p999_ms": 12.0,
|
||||
"max_ms": 17.23,
|
||||
"cpu_p50_ms": 0.602,
|
||||
"gpu_p50_ms": 0.315,
|
||||
"histogram": [
|
||||
13450,
|
||||
591,
|
||||
109,
|
||||
57,
|
||||
13,
|
||||
8,
|
||||
10,
|
||||
11,
|
||||
14,
|
||||
10,
|
||||
7,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 14124,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1177.005,
|
||||
"low_1pct_fps": 151.38,
|
||||
"low_01pct_fps": 65.903,
|
||||
"p50_ms": 0.739,
|
||||
"p95_ms": 1.096,
|
||||
"p99_ms": 2.928,
|
||||
"p999_ms": 11.884,
|
||||
"max_ms": 19.375,
|
||||
"cpu_p50_ms": 0.604,
|
||||
"gpu_p50_ms": 0.314,
|
||||
"histogram": [
|
||||
13052,
|
||||
822,
|
||||
114,
|
||||
54,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
14,
|
||||
11,
|
||||
15,
|
||||
3,
|
||||
8,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 12845,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1070.378,
|
||||
"low_1pct_fps": 224.366,
|
||||
"low_01pct_fps": 91.541,
|
||||
"p50_ms": 0.825,
|
||||
"p95_ms": 1.362,
|
||||
"p99_ms": 2.325,
|
||||
"p999_ms": 9.621,
|
||||
"max_ms": 13.309,
|
||||
"cpu_p50_ms": 0.689,
|
||||
"gpu_p50_ms": 0.307,
|
||||
"histogram": [
|
||||
10937,
|
||||
1699,
|
||||
143,
|
||||
25,
|
||||
5,
|
||||
6,
|
||||
4,
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 13083,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1090.188,
|
||||
"low_1pct_fps": 261.921,
|
||||
"low_01pct_fps": 111.039,
|
||||
"p50_ms": 0.816,
|
||||
"p95_ms": 1.335,
|
||||
"p99_ms": 2.14,
|
||||
"p999_ms": 8.222,
|
||||
"max_ms": 11.95,
|
||||
"cpu_p50_ms": 0.681,
|
||||
"gpu_p50_ms": 0.306,
|
||||
"histogram": [
|
||||
11105,
|
||||
1812,
|
||||
112,
|
||||
21,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
11,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 13164,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1096.974,
|
||||
"low_1pct_fps": 286.814,
|
||||
"low_01pct_fps": 120.768,
|
||||
"p50_ms": 0.822,
|
||||
"p95_ms": 1.307,
|
||||
"p99_ms": 1.989,
|
||||
"p999_ms": 7.701,
|
||||
"max_ms": 9.429,
|
||||
"cpu_p50_ms": 0.687,
|
||||
"gpu_p50_ms": 0.307,
|
||||
"histogram": [
|
||||
11280,
|
||||
1758,
|
||||
82,
|
||||
13,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:37:03Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "v0.2.97",
|
||||
"commit": null
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 617.42",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 2950.343,
|
||||
"stdev": 162.732,
|
||||
"min": 2822.925,
|
||||
"max": 3133.657,
|
||||
"ci95": 404.282
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 6135.1,
|
||||
"stdev": 863.831,
|
||||
"min": 5547.861,
|
||||
"max": 7126.98,
|
||||
"ci95": 2146.05
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 985.519,
|
||||
"stdev": 28.184,
|
||||
"min": 956.244,
|
||||
"max": 1012.469,
|
||||
"ci95": 70.019
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 6908.0,
|
||||
"stdev": 9.849,
|
||||
"min": 6897.0,
|
||||
"max": 6916.0,
|
||||
"ci95": 24.468
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2941.608,
|
||||
"stdev": 79.283,
|
||||
"min": 2850.416,
|
||||
"max": 2994.188,
|
||||
"ci95": 196.965
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1271.318,
|
||||
"stdev": 58.797,
|
||||
"min": 1203.758,
|
||||
"max": 1310.917,
|
||||
"ci95": 146.072
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 839.261,
|
||||
"stdev": 37.982,
|
||||
"min": 795.415,
|
||||
"max": 862.067,
|
||||
"ci95": 94.36
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 363.984,
|
||||
"stdev": 0.926,
|
||||
"min": 363.449,
|
||||
"max": 365.053,
|
||||
"ci95": 2.299
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1350.752,
|
||||
"stdev": 293.128,
|
||||
"min": 1106.764,
|
||||
"max": 1675.913,
|
||||
"ci95": 728.23
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 8066.0,
|
||||
"stdev": 108.885,
|
||||
"min": 7986.0,
|
||||
"max": 8190.0,
|
||||
"ci95": 270.508
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3494.693,
|
||||
"stdev": 110.997,
|
||||
"min": 3389.894,
|
||||
"max": 3610.993,
|
||||
"ci95": 275.755
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1295.14,
|
||||
"stdev": 159.638,
|
||||
"min": 1148.468,
|
||||
"max": 1465.171,
|
||||
"ci95": 396.596
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 859.326,
|
||||
"stdev": 119.845,
|
||||
"min": 721.994,
|
||||
"max": 942.743,
|
||||
"ci95": 297.734
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 381.189,
|
||||
"stdev": 5.222,
|
||||
"min": 376.423,
|
||||
"max": 386.77,
|
||||
"ci95": 12.972
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1123.386,
|
||||
"stdev": 604.175,
|
||||
"min": 734.302,
|
||||
"max": 1819.414,
|
||||
"ci95": 1500.976
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 6613.0,
|
||||
"stdev": 135.632,
|
||||
"min": 6523.0,
|
||||
"max": 6769.0,
|
||||
"ci95": 336.955
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3346.003,
|
||||
"stdev": 59.312,
|
||||
"min": 3303.535,
|
||||
"max": 3413.77,
|
||||
"ci95": 147.352
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1357.991,
|
||||
"stdev": 95.364,
|
||||
"min": 1248.887,
|
||||
"max": 1425.451,
|
||||
"ci95": 236.917
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 911.822,
|
||||
"stdev": 94.717,
|
||||
"min": 802.528,
|
||||
"max": 969.989,
|
||||
"ci95": 235.308
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 391.636,
|
||||
"stdev": 5.435,
|
||||
"min": 387.424,
|
||||
"max": 397.771,
|
||||
"ci95": 13.502
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1588.954,
|
||||
"stdev": 458.924,
|
||||
"min": 1309.548,
|
||||
"max": 2118.606,
|
||||
"ci95": 1140.123
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7887.667,
|
||||
"stdev": 41.041,
|
||||
"min": 7862.0,
|
||||
"max": 7935.0,
|
||||
"ci95": 101.959
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3375.623,
|
||||
"stdev": 26.82,
|
||||
"min": 3352.309,
|
||||
"max": 3404.933,
|
||||
"ci95": 66.629
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1383.745,
|
||||
"stdev": 67.628,
|
||||
"min": 1324.071,
|
||||
"max": 1457.203,
|
||||
"ci95": 168.01
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 850.213,
|
||||
"stdev": 103.149,
|
||||
"min": 790.305,
|
||||
"max": 969.318,
|
||||
"ci95": 256.256
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 448.64,
|
||||
"stdev": 5.435,
|
||||
"min": 444.428,
|
||||
"max": 454.775,
|
||||
"ci95": 13.502
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1171.629,
|
||||
"stdev": 0.337,
|
||||
"min": 1171.398,
|
||||
"max": 1172.016,
|
||||
"ci95": 0.837
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1082.078,
|
||||
"stdev": 12.494,
|
||||
"min": 1069.117,
|
||||
"max": 1094.047,
|
||||
"ci95": 31.04
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 35764,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2980.22,
|
||||
"low_1pct_fps": 1310.917,
|
||||
"low_01pct_fps": 860.301,
|
||||
"p50_ms": 0.317,
|
||||
"p95_ms": 0.433,
|
||||
"p99_ms": 0.633,
|
||||
"p999_ms": 0.937,
|
||||
"max_ms": 2.807,
|
||||
"cpu_p50_ms": 0.192,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
35741,
|
||||
22,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 35931,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2994.188,
|
||||
"low_1pct_fps": 1299.278,
|
||||
"low_01pct_fps": 795.415,
|
||||
"p50_ms": 0.318,
|
||||
"p95_ms": 0.428,
|
||||
"p99_ms": 0.62,
|
||||
"p999_ms": 0.943,
|
||||
"max_ms": 5.797,
|
||||
"cpu_p50_ms": 0.191,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
35906,
|
||||
23,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 34206,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2850.416,
|
||||
"low_1pct_fps": 1203.758,
|
||||
"low_01pct_fps": 862.067,
|
||||
"p50_ms": 0.326,
|
||||
"p95_ms": 0.508,
|
||||
"p99_ms": 0.705,
|
||||
"p999_ms": 1.025,
|
||||
"max_ms": 1.398,
|
||||
"cpu_p50_ms": 0.196,
|
||||
"gpu_p50_ms": 0.063,
|
||||
"histogram": [
|
||||
34167,
|
||||
39,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 41800,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3483.193,
|
||||
"low_1pct_fps": 1271.782,
|
||||
"low_01pct_fps": 913.242,
|
||||
"p50_ms": 0.264,
|
||||
"p95_ms": 0.415,
|
||||
"p99_ms": 0.657,
|
||||
"p999_ms": 0.935,
|
||||
"max_ms": 1.482,
|
||||
"cpu_p50_ms": 0.144,
|
||||
"gpu_p50_ms": 0.075,
|
||||
"histogram": [
|
||||
41768,
|
||||
32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 43333,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3610.993,
|
||||
"low_1pct_fps": 1465.171,
|
||||
"low_01pct_fps": 942.743,
|
||||
"p50_ms": 0.263,
|
||||
"p95_ms": 0.355,
|
||||
"p99_ms": 0.561,
|
||||
"p999_ms": 0.859,
|
||||
"max_ms": 1.694,
|
||||
"cpu_p50_ms": 0.143,
|
||||
"gpu_p50_ms": 0.074,
|
||||
"histogram": [
|
||||
43310,
|
||||
23,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 40679,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3389.894,
|
||||
"low_1pct_fps": 1148.468,
|
||||
"low_01pct_fps": 721.994,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.446,
|
||||
"p99_ms": 0.67,
|
||||
"p999_ms": 1.183,
|
||||
"max_ms": 2.434,
|
||||
"cpu_p50_ms": 0.146,
|
||||
"gpu_p50_ms": 0.076,
|
||||
"histogram": [
|
||||
40590,
|
||||
87,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 39849,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3320.704,
|
||||
"low_1pct_fps": 1399.634,
|
||||
"low_01pct_fps": 969.989,
|
||||
"p50_ms": 0.281,
|
||||
"p95_ms": 0.404,
|
||||
"p99_ms": 0.601,
|
||||
"p999_ms": 0.875,
|
||||
"max_ms": 1.494,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
39831,
|
||||
18,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 40966,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3413.77,
|
||||
"low_1pct_fps": 1425.451,
|
||||
"low_01pct_fps": 962.948,
|
||||
"p50_ms": 0.278,
|
||||
"p95_ms": 0.372,
|
||||
"p99_ms": 0.581,
|
||||
"p999_ms": 0.857,
|
||||
"max_ms": 1.576,
|
||||
"cpu_p50_ms": 0.157,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40947,
|
||||
19,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 39643,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3303.535,
|
||||
"low_1pct_fps": 1248.887,
|
||||
"low_01pct_fps": 802.528,
|
||||
"p50_ms": 0.28,
|
||||
"p95_ms": 0.413,
|
||||
"p99_ms": 0.642,
|
||||
"p999_ms": 1.087,
|
||||
"max_ms": 2.215,
|
||||
"cpu_p50_ms": 0.158,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
39582,
|
||||
60,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 40436,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3369.626,
|
||||
"low_1pct_fps": 1369.962,
|
||||
"low_01pct_fps": 790.305,
|
||||
"p50_ms": 0.279,
|
||||
"p95_ms": 0.403,
|
||||
"p99_ms": 0.604,
|
||||
"p999_ms": 0.812,
|
||||
"max_ms": 10.313,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40425,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 40860,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3404.933,
|
||||
"low_1pct_fps": 1457.203,
|
||||
"low_01pct_fps": 969.318,
|
||||
"p50_ms": 0.278,
|
||||
"p95_ms": 0.389,
|
||||
"p99_ms": 0.574,
|
||||
"p999_ms": 0.814,
|
||||
"max_ms": 2.714,
|
||||
"cpu_p50_ms": 0.158,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40847,
|
||||
11,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 40229,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3352.309,
|
||||
"low_1pct_fps": 1324.071,
|
||||
"low_01pct_fps": 791.016,
|
||||
"p50_ms": 0.28,
|
||||
"p95_ms": 0.402,
|
||||
"p99_ms": 0.617,
|
||||
"p999_ms": 0.968,
|
||||
"max_ms": 3.746,
|
||||
"cpu_p50_ms": 0.159,
|
||||
"gpu_p50_ms": 0.069,
|
||||
"histogram": [
|
||||
40192,
|
||||
35,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:25:53Z",
|
||||
"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": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 10777.756,
|
||||
"stdev": 129.67,
|
||||
"min": 10636.92,
|
||||
"max": 10892.2,
|
||||
"ci95": 322.144
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 23477.423,
|
||||
"stdev": 346.614,
|
||||
"min": 23130.92,
|
||||
"max": 23824.148,
|
||||
"ci95": 861.106
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6007.033,
|
||||
"stdev": 104.227,
|
||||
"min": 5891.431,
|
||||
"max": 6093.825,
|
||||
"ci95": 258.936
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1282.11,
|
||||
"stdev": 65.207,
|
||||
"min": 1228.13,
|
||||
"max": 1354.56,
|
||||
"ci95": 161.997
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 343.465,
|
||||
"stdev": 9.96,
|
||||
"min": 335.223,
|
||||
"max": 354.532,
|
||||
"ci95": 24.744
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 131.669,
|
||||
"stdev": 4.692,
|
||||
"min": 126.427,
|
||||
"max": 135.476,
|
||||
"ci95": 11.656
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8669.311,
|
||||
"stdev": 434.39,
|
||||
"min": 8280.854,
|
||||
"max": 9138.35,
|
||||
"ci95": 1079.173
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1745.757,
|
||||
"stdev": 13.014,
|
||||
"min": 1734.41,
|
||||
"max": 1759.963,
|
||||
"ci95": 32.332
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 460.958,
|
||||
"stdev": 10.859,
|
||||
"min": 449.051,
|
||||
"max": 470.316,
|
||||
"ci95": 26.978
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 178.955,
|
||||
"stdev": 3.777,
|
||||
"min": 175.213,
|
||||
"max": 182.766,
|
||||
"ci95": 9.383
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 7056.368,
|
||||
"stdev": 117.329,
|
||||
"min": 6986.912,
|
||||
"max": 7191.833,
|
||||
"ci95": 291.485
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1815.056,
|
||||
"stdev": 47.875,
|
||||
"min": 1759.788,
|
||||
"max": 1843.745,
|
||||
"ci95": 118.937
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 514.215,
|
||||
"stdev": 57.205,
|
||||
"min": 452.445,
|
||||
"max": 565.368,
|
||||
"ci95": 142.117
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 191.842,
|
||||
"stdev": 23.217,
|
||||
"min": 175.546,
|
||||
"max": 218.426,
|
||||
"ci95": 57.68
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14094.582,
|
||||
"stdev": 399.624,
|
||||
"min": 13641.534,
|
||||
"max": 14397.0,
|
||||
"ci95": 992.801
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1499.768,
|
||||
"stdev": 22.197,
|
||||
"min": 1477.269,
|
||||
"max": 1521.651,
|
||||
"ci95": 55.146
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 408.466,
|
||||
"stdev": 16.114,
|
||||
"min": 395.097,
|
||||
"max": 426.358,
|
||||
"ci95": 40.032
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 139.743,
|
||||
"stdev": 3.969,
|
||||
"min": 136.465,
|
||||
"max": 144.156,
|
||||
"ci95": 9.86
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2495.607,
|
||||
"stdev": 75.339,
|
||||
"min": 2412.738,
|
||||
"max": 2559.965,
|
||||
"ci95": 187.166
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2326.611,
|
||||
"stdev": 15.702,
|
||||
"min": 2314.98,
|
||||
"max": 2344.473,
|
||||
"ci95": 39.01
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 14738,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1228.13,
|
||||
"low_1pct_fps": 335.223,
|
||||
"low_01pct_fps": 133.103,
|
||||
"p50_ms": 0.722,
|
||||
"p95_ms": 1.384,
|
||||
"p99_ms": 2.043,
|
||||
"p999_ms": 5.224,
|
||||
"max_ms": 11.459,
|
||||
"histogram": [
|
||||
12159,
|
||||
2421,
|
||||
128,
|
||||
14,
|
||||
1,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 16255,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1354.56,
|
||||
"low_1pct_fps": 354.532,
|
||||
"low_01pct_fps": 135.476,
|
||||
"p50_ms": 0.672,
|
||||
"p95_ms": 1.136,
|
||||
"p99_ms": 1.841,
|
||||
"p999_ms": 4.913,
|
||||
"max_ms": 16.94,
|
||||
"histogram": [
|
||||
14841,
|
||||
1298,
|
||||
86,
|
||||
13,
|
||||
2,
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 15164,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1263.64,
|
||||
"low_1pct_fps": 340.639,
|
||||
"low_01pct_fps": 126.427,
|
||||
"p50_ms": 0.708,
|
||||
"p95_ms": 1.299,
|
||||
"p99_ms": 1.895,
|
||||
"p999_ms": 5.173,
|
||||
"max_ms": 16.748,
|
||||
"histogram": [
|
||||
13026,
|
||||
2014,
|
||||
94,
|
||||
13,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 20915,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1742.897,
|
||||
"low_1pct_fps": 470.316,
|
||||
"low_01pct_fps": 182.766,
|
||||
"p50_ms": 0.5,
|
||||
"p95_ms": 0.936,
|
||||
"p99_ms": 1.437,
|
||||
"p999_ms": 2.77,
|
||||
"max_ms": 13.414,
|
||||
"histogram": [
|
||||
20067,
|
||||
788,
|
||||
44,
|
||||
4,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 21120,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1759.963,
|
||||
"low_1pct_fps": 449.051,
|
||||
"low_01pct_fps": 178.887,
|
||||
"p50_ms": 0.49,
|
||||
"p95_ms": 0.901,
|
||||
"p99_ms": 1.531,
|
||||
"p999_ms": 3.005,
|
||||
"max_ms": 12.907,
|
||||
"histogram": [
|
||||
20414,
|
||||
644,
|
||||
40,
|
||||
10,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 20813,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1734.41,
|
||||
"low_1pct_fps": 463.506,
|
||||
"low_01pct_fps": 175.213,
|
||||
"p50_ms": 0.496,
|
||||
"p95_ms": 0.924,
|
||||
"p99_ms": 1.414,
|
||||
"p999_ms": 2.733,
|
||||
"max_ms": 16.832,
|
||||
"histogram": [
|
||||
20049,
|
||||
707,
|
||||
41,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 22100,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1841.634,
|
||||
"low_1pct_fps": 565.368,
|
||||
"low_01pct_fps": 218.426,
|
||||
"p50_ms": 0.483,
|
||||
"p95_ms": 0.835,
|
||||
"p99_ms": 1.209,
|
||||
"p999_ms": 2.082,
|
||||
"max_ms": 14.627,
|
||||
"histogram": [
|
||||
21540,
|
||||
532,
|
||||
18,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 21118,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1759.788,
|
||||
"low_1pct_fps": 452.445,
|
||||
"low_01pct_fps": 175.546,
|
||||
"p50_ms": 0.489,
|
||||
"p95_ms": 0.915,
|
||||
"p99_ms": 1.532,
|
||||
"p999_ms": 2.811,
|
||||
"max_ms": 11.846,
|
||||
"histogram": [
|
||||
20308,
|
||||
755,
|
||||
36,
|
||||
9,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 22125,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1843.745,
|
||||
"low_1pct_fps": 524.832,
|
||||
"low_01pct_fps": 181.555,
|
||||
"p50_ms": 0.477,
|
||||
"p95_ms": 0.855,
|
||||
"p99_ms": 1.241,
|
||||
"p999_ms": 2.236,
|
||||
"max_ms": 12.478,
|
||||
"histogram": [
|
||||
21487,
|
||||
608,
|
||||
18,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 18005,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1500.383,
|
||||
"low_1pct_fps": 403.944,
|
||||
"low_01pct_fps": 136.465,
|
||||
"p50_ms": 0.591,
|
||||
"p95_ms": 1.058,
|
||||
"p99_ms": 1.592,
|
||||
"p999_ms": 3.186,
|
||||
"max_ms": 12.608,
|
||||
"histogram": [
|
||||
16864,
|
||||
1084,
|
||||
32,
|
||||
8,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 17728,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1477.269,
|
||||
"low_1pct_fps": 395.097,
|
||||
"low_01pct_fps": 138.609,
|
||||
"p50_ms": 0.598,
|
||||
"p95_ms": 1.042,
|
||||
"p99_ms": 1.561,
|
||||
"p999_ms": 5.033,
|
||||
"max_ms": 11.462,
|
||||
"histogram": [
|
||||
16612,
|
||||
1051,
|
||||
39,
|
||||
7,
|
||||
1,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 18260,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1521.651,
|
||||
"low_1pct_fps": 426.358,
|
||||
"low_01pct_fps": 144.156,
|
||||
"p50_ms": 0.585,
|
||||
"p95_ms": 1.015,
|
||||
"p99_ms": 1.458,
|
||||
"p999_ms": 3.609,
|
||||
"max_ms": 11.945,
|
||||
"histogram": [
|
||||
17267,
|
||||
943,
|
||||
26,
|
||||
6,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T19:27:56Z",
|
||||
"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": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 11038.922,
|
||||
"stdev": 118.058,
|
||||
"min": 10935.424,
|
||||
"max": 11167.506,
|
||||
"ci95": 293.295
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 39248.588,
|
||||
"stdev": 479.755,
|
||||
"min": 38964.834,
|
||||
"max": 39802.506,
|
||||
"ci95": 1191.875
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11176.953,
|
||||
"stdev": 356.144,
|
||||
"min": 10836.65,
|
||||
"max": 11547.064,
|
||||
"ci95": 884.781
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 925.977,
|
||||
"stdev": 6.727,
|
||||
"min": 918.832,
|
||||
"max": 932.189,
|
||||
"ci95": 16.713
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 250.092,
|
||||
"stdev": 1.868,
|
||||
"min": 248.431,
|
||||
"max": 252.114,
|
||||
"ci95": 4.64
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.692,
|
||||
"stdev": 4.466,
|
||||
"min": 88.423,
|
||||
"max": 97.332,
|
||||
"ci95": 11.095
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17335.638,
|
||||
"stdev": 687.588,
|
||||
"min": 16544.168,
|
||||
"max": 17785.772,
|
||||
"ci95": 1708.201
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 929.034,
|
||||
"stdev": 3.11,
|
||||
"min": 926.32,
|
||||
"max": 932.428,
|
||||
"ci95": 7.727
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 312.69,
|
||||
"stdev": 16.386,
|
||||
"min": 297.667,
|
||||
"max": 330.163,
|
||||
"ci95": 40.708
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 129.331,
|
||||
"stdev": 12.786,
|
||||
"min": 115.486,
|
||||
"max": 140.694,
|
||||
"ci95": 31.765
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12601.063,
|
||||
"stdev": 148.042,
|
||||
"min": 12430.182,
|
||||
"max": 12690.506,
|
||||
"ci95": 367.785
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1016.598,
|
||||
"stdev": 5.481,
|
||||
"min": 1010.482,
|
||||
"max": 1021.068,
|
||||
"ci95": 13.618
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 325.361,
|
||||
"stdev": 15.112,
|
||||
"min": 315.682,
|
||||
"max": 342.774,
|
||||
"ci95": 37.543
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 117.042,
|
||||
"stdev": 11.095,
|
||||
"min": 107.807,
|
||||
"max": 129.349,
|
||||
"ci95": 27.563
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 38513.008,
|
||||
"stdev": 958.119,
|
||||
"min": 37447.333,
|
||||
"max": 39303.226,
|
||||
"ci95": 2380.292
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 644.782,
|
||||
"stdev": 3.94,
|
||||
"min": 640.866,
|
||||
"max": 648.745,
|
||||
"ci95": 9.788
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 166.029,
|
||||
"stdev": 4.758,
|
||||
"min": 162.326,
|
||||
"max": 171.395,
|
||||
"ci95": 11.819
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 86.231,
|
||||
"stdev": 8.587,
|
||||
"min": 76.632,
|
||||
"max": 93.184,
|
||||
"ci95": 21.334
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2964.911,
|
||||
"stdev": 8.27,
|
||||
"min": 2959.813,
|
||||
"max": 2974.453,
|
||||
"ci95": 20.545
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2894.541,
|
||||
"stdev": 12.847,
|
||||
"min": 2879.928,
|
||||
"max": 2904.059,
|
||||
"ci95": 31.917
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 11123,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 926.911,
|
||||
"low_1pct_fps": 249.732,
|
||||
"low_01pct_fps": 92.322,
|
||||
"p50_ms": 0.989,
|
||||
"p95_ms": 1.492,
|
||||
"p99_ms": 2.33,
|
||||
"p999_ms": 7.409,
|
||||
"max_ms": 15.258,
|
||||
"histogram": [
|
||||
6224,
|
||||
4701,
|
||||
147,
|
||||
28,
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 11187,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 932.189,
|
||||
"low_1pct_fps": 252.114,
|
||||
"low_01pct_fps": 88.423,
|
||||
"p50_ms": 0.984,
|
||||
"p95_ms": 1.496,
|
||||
"p99_ms": 2.328,
|
||||
"p999_ms": 7.858,
|
||||
"max_ms": 16.843,
|
||||
"histogram": [
|
||||
6518,
|
||||
4486,
|
||||
138,
|
||||
20,
|
||||
9,
|
||||
1,
|
||||
1,
|
||||
6,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 11026,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 918.832,
|
||||
"low_1pct_fps": 248.431,
|
||||
"low_01pct_fps": 97.332,
|
||||
"p50_ms": 0.989,
|
||||
"p95_ms": 1.524,
|
||||
"p99_ms": 2.37,
|
||||
"p999_ms": 7.446,
|
||||
"max_ms": 16.573,
|
||||
"histogram": [
|
||||
6075,
|
||||
4740,
|
||||
162,
|
||||
23,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 11117,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 926.32,
|
||||
"low_1pct_fps": 310.239,
|
||||
"low_01pct_fps": 131.814,
|
||||
"p50_ms": 1.011,
|
||||
"p95_ms": 1.34,
|
||||
"p99_ms": 2.045,
|
||||
"p999_ms": 4.711,
|
||||
"max_ms": 14.077,
|
||||
"histogram": [
|
||||
3849,
|
||||
7150,
|
||||
78,
|
||||
26,
|
||||
5,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 11190,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 932.428,
|
||||
"low_1pct_fps": 330.163,
|
||||
"low_01pct_fps": 140.694,
|
||||
"p50_ms": 1.008,
|
||||
"p95_ms": 1.307,
|
||||
"p99_ms": 1.869,
|
||||
"p999_ms": 4.341,
|
||||
"max_ms": 13.489,
|
||||
"histogram": [
|
||||
4273,
|
||||
6822,
|
||||
60,
|
||||
20,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 11141,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 928.354,
|
||||
"low_1pct_fps": 297.667,
|
||||
"low_01pct_fps": 115.486,
|
||||
"p50_ms": 1.005,
|
||||
"p95_ms": 1.352,
|
||||
"p99_ms": 2.054,
|
||||
"p999_ms": 5.292,
|
||||
"max_ms": 14.339,
|
||||
"histogram": [
|
||||
4901,
|
||||
6118,
|
||||
83,
|
||||
23,
|
||||
3,
|
||||
2,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 12126,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1010.482,
|
||||
"low_1pct_fps": 317.626,
|
||||
"low_01pct_fps": 113.971,
|
||||
"p50_ms": 0.933,
|
||||
"p95_ms": 1.23,
|
||||
"p99_ms": 1.935,
|
||||
"p999_ms": 4.393,
|
||||
"max_ms": 14.824,
|
||||
"histogram": [
|
||||
9183,
|
||||
2830,
|
||||
82,
|
||||
17,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 12219,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1018.243,
|
||||
"low_1pct_fps": 342.774,
|
||||
"low_01pct_fps": 129.349,
|
||||
"p50_ms": 0.929,
|
||||
"p95_ms": 1.222,
|
||||
"p99_ms": 1.867,
|
||||
"p999_ms": 4.329,
|
||||
"max_ms": 10.33,
|
||||
"histogram": [
|
||||
9421,
|
||||
2710,
|
||||
60,
|
||||
15,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 12253,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1021.068,
|
||||
"low_1pct_fps": 315.682,
|
||||
"low_01pct_fps": 107.807,
|
||||
"p50_ms": 0.923,
|
||||
"p95_ms": 1.225,
|
||||
"p99_ms": 1.969,
|
||||
"p999_ms": 5.235,
|
||||
"max_ms": 19.524,
|
||||
"histogram": [
|
||||
9231,
|
||||
2904,
|
||||
92,
|
||||
10,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 7691,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 640.866,
|
||||
"low_1pct_fps": 164.367,
|
||||
"low_01pct_fps": 93.184,
|
||||
"p50_ms": 1.454,
|
||||
"p95_ms": 1.95,
|
||||
"p99_ms": 3.4,
|
||||
"p999_ms": 9.754,
|
||||
"max_ms": 14.995,
|
||||
"histogram": [
|
||||
36,
|
||||
7331,
|
||||
233,
|
||||
26,
|
||||
17,
|
||||
13,
|
||||
20,
|
||||
3,
|
||||
6,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 7737,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 644.736,
|
||||
"low_1pct_fps": 162.326,
|
||||
"low_01pct_fps": 76.632,
|
||||
"p50_ms": 1.454,
|
||||
"p95_ms": 1.905,
|
||||
"p99_ms": 3.676,
|
||||
"p999_ms": 9.926,
|
||||
"max_ms": 18.459,
|
||||
"histogram": [
|
||||
46,
|
||||
7399,
|
||||
199,
|
||||
24,
|
||||
21,
|
||||
24,
|
||||
10,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 7785,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 648.745,
|
||||
"low_1pct_fps": 171.395,
|
||||
"low_01pct_fps": 88.876,
|
||||
"p50_ms": 1.439,
|
||||
"p95_ms": 1.886,
|
||||
"p99_ms": 3.562,
|
||||
"p999_ms": 10.342,
|
||||
"max_ms": 12.927,
|
||||
"histogram": [
|
||||
57,
|
||||
7432,
|
||||
186,
|
||||
44,
|
||||
23,
|
||||
20,
|
||||
9,
|
||||
3,
|
||||
3,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:49:40Z",
|
||||
"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": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 11089.831,
|
||||
"stdev": 46.336,
|
||||
"min": 11036.36,
|
||||
"max": 11118.192,
|
||||
"ci95": 115.114
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 17643.831,
|
||||
"stdev": 47.672,
|
||||
"min": 17589.192,
|
||||
"max": 17676.942,
|
||||
"ci95": 118.433
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3603.773,
|
||||
"stdev": 161.342,
|
||||
"min": 3488.106,
|
||||
"max": 3788.086,
|
||||
"ci95": 400.828
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2103.54,
|
||||
"stdev": 40.465,
|
||||
"min": 2078.464,
|
||||
"max": 2150.222,
|
||||
"ci95": 100.529
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 577.326,
|
||||
"stdev": 33.693,
|
||||
"min": 557.613,
|
||||
"max": 616.23,
|
||||
"ci95": 83.704
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 195.53,
|
||||
"stdev": 18.899,
|
||||
"min": 181.495,
|
||||
"max": 217.019,
|
||||
"ci95": 46.951
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3282.612,
|
||||
"stdev": 48.569,
|
||||
"min": 3233.16,
|
||||
"max": 3330.246,
|
||||
"ci95": 120.661
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2424.739,
|
||||
"stdev": 26.074,
|
||||
"min": 2394.789,
|
||||
"max": 2442.383,
|
||||
"ci95": 64.777
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 732.987,
|
||||
"stdev": 11.926,
|
||||
"min": 724.144,
|
||||
"max": 746.55,
|
||||
"ci95": 29.627
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 249.926,
|
||||
"stdev": 8.826,
|
||||
"min": 241.293,
|
||||
"max": 258.934,
|
||||
"ci95": 21.928
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2705.954,
|
||||
"stdev": 173.487,
|
||||
"min": 2538.486,
|
||||
"max": 2884.891,
|
||||
"ci95": 431.0
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2431.99,
|
||||
"stdev": 33.071,
|
||||
"min": 2394.02,
|
||||
"max": 2454.493,
|
||||
"ci95": 82.159
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 764.065,
|
||||
"stdev": 14.782,
|
||||
"min": 747.287,
|
||||
"max": 775.168,
|
||||
"ci95": 36.723
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 261.954,
|
||||
"stdev": 14.724,
|
||||
"min": 252.457,
|
||||
"max": 278.916,
|
||||
"ci95": 36.58
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4982.865,
|
||||
"stdev": 115.523,
|
||||
"min": 4849.837,
|
||||
"max": 5057.932,
|
||||
"ci95": 286.997
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2538.05,
|
||||
"stdev": 67.405,
|
||||
"min": 2462.815,
|
||||
"max": 2592.938,
|
||||
"ci95": 167.458
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 760.16,
|
||||
"stdev": 13.503,
|
||||
"min": 746.001,
|
||||
"max": 772.895,
|
||||
"ci95": 33.547
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 240.897,
|
||||
"stdev": 17.872,
|
||||
"min": 224.391,
|
||||
"max": 259.878,
|
||||
"ci95": 44.401
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2052.982,
|
||||
"stdev": 57.756,
|
||||
"min": 1992.574,
|
||||
"max": 2107.66,
|
||||
"ci95": 143.487
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1903.921,
|
||||
"stdev": 62.254,
|
||||
"min": 1838.307,
|
||||
"max": 1962.156,
|
||||
"ci95": 154.659
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 25803,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2150.222,
|
||||
"low_1pct_fps": 616.23,
|
||||
"low_01pct_fps": 217.019,
|
||||
"p50_ms": 0.433,
|
||||
"p95_ms": 0.642,
|
||||
"p99_ms": 1.04,
|
||||
"p999_ms": 2.307,
|
||||
"max_ms": 7.466,
|
||||
"histogram": [
|
||||
25503,
|
||||
269,
|
||||
8,
|
||||
6,
|
||||
7,
|
||||
6,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 24942,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2078.464,
|
||||
"low_1pct_fps": 557.613,
|
||||
"low_01pct_fps": 181.495,
|
||||
"p50_ms": 0.45,
|
||||
"p95_ms": 0.665,
|
||||
"p99_ms": 1.1,
|
||||
"p999_ms": 2.305,
|
||||
"max_ms": 9.819,
|
||||
"histogram": [
|
||||
24615,
|
||||
288,
|
||||
17,
|
||||
2,
|
||||
4,
|
||||
9,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 24984,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2081.934,
|
||||
"low_1pct_fps": 558.135,
|
||||
"low_01pct_fps": 188.076,
|
||||
"p50_ms": 0.444,
|
||||
"p95_ms": 0.682,
|
||||
"p99_ms": 1.101,
|
||||
"p999_ms": 2.412,
|
||||
"max_ms": 11.567,
|
||||
"histogram": [
|
||||
24645,
|
||||
308,
|
||||
9,
|
||||
3,
|
||||
5,
|
||||
5,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 29245,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2437.044,
|
||||
"low_1pct_fps": 728.266,
|
||||
"low_01pct_fps": 249.55,
|
||||
"p50_ms": 0.38,
|
||||
"p95_ms": 0.555,
|
||||
"p99_ms": 0.892,
|
||||
"p999_ms": 1.851,
|
||||
"max_ms": 6.973,
|
||||
"histogram": [
|
||||
29066,
|
||||
154,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28738,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2394.789,
|
||||
"low_1pct_fps": 724.144,
|
||||
"low_01pct_fps": 241.293,
|
||||
"p50_ms": 0.384,
|
||||
"p95_ms": 0.571,
|
||||
"p99_ms": 0.907,
|
||||
"p999_ms": 1.833,
|
||||
"max_ms": 8.35,
|
||||
"histogram": [
|
||||
28573,
|
||||
140,
|
||||
6,
|
||||
3,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 29309,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2442.383,
|
||||
"low_1pct_fps": 746.55,
|
||||
"low_01pct_fps": 258.934,
|
||||
"p50_ms": 0.378,
|
||||
"p95_ms": 0.566,
|
||||
"p99_ms": 0.876,
|
||||
"p999_ms": 1.744,
|
||||
"max_ms": 10.291,
|
||||
"histogram": [
|
||||
29138,
|
||||
148,
|
||||
5,
|
||||
6,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 29454,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2454.493,
|
||||
"low_1pct_fps": 769.741,
|
||||
"low_01pct_fps": 252.457,
|
||||
"p50_ms": 0.378,
|
||||
"p95_ms": 0.55,
|
||||
"p99_ms": 0.853,
|
||||
"p999_ms": 1.471,
|
||||
"max_ms": 6.629,
|
||||
"histogram": [
|
||||
29323,
|
||||
110,
|
||||
2,
|
||||
2,
|
||||
7,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 28729,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2394.02,
|
||||
"low_1pct_fps": 747.287,
|
||||
"low_01pct_fps": 254.49,
|
||||
"p50_ms": 0.383,
|
||||
"p95_ms": 0.579,
|
||||
"p99_ms": 0.896,
|
||||
"p999_ms": 1.56,
|
||||
"max_ms": 7.367,
|
||||
"histogram": [
|
||||
28567,
|
||||
141,
|
||||
3,
|
||||
5,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 29370,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2447.458,
|
||||
"low_1pct_fps": 775.168,
|
||||
"low_01pct_fps": 278.916,
|
||||
"p50_ms": 0.376,
|
||||
"p95_ms": 0.559,
|
||||
"p99_ms": 0.887,
|
||||
"p999_ms": 1.567,
|
||||
"max_ms": 9.65,
|
||||
"histogram": [
|
||||
29209,
|
||||
141,
|
||||
3,
|
||||
9,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 31116,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2592.938,
|
||||
"low_1pct_fps": 761.584,
|
||||
"low_01pct_fps": 224.391,
|
||||
"p50_ms": 0.356,
|
||||
"p95_ms": 0.511,
|
||||
"p99_ms": 0.767,
|
||||
"p999_ms": 1.843,
|
||||
"max_ms": 8.478,
|
||||
"histogram": [
|
||||
31011,
|
||||
76,
|
||||
4,
|
||||
3,
|
||||
13,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 29554,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2462.815,
|
||||
"low_1pct_fps": 746.001,
|
||||
"low_01pct_fps": 238.423,
|
||||
"p50_ms": 0.366,
|
||||
"p95_ms": 0.579,
|
||||
"p99_ms": 0.849,
|
||||
"p999_ms": 1.75,
|
||||
"max_ms": 7.607,
|
||||
"histogram": [
|
||||
29430,
|
||||
101,
|
||||
2,
|
||||
3,
|
||||
9,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 30701,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2558.396,
|
||||
"low_1pct_fps": 772.895,
|
||||
"low_01pct_fps": 259.878,
|
||||
"p50_ms": 0.357,
|
||||
"p95_ms": 0.537,
|
||||
"p99_ms": 0.81,
|
||||
"p999_ms": 1.702,
|
||||
"max_ms": 6.682,
|
||||
"histogram": [
|
||||
30568,
|
||||
112,
|
||||
1,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T01:19:23Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7681.834,
|
||||
"stdev": 189.48,
|
||||
"min": 7538.777,
|
||||
"max": 7896.728,
|
||||
"ci95": 470.732
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 19898.834,
|
||||
"stdev": 192.981,
|
||||
"min": 19695.728,
|
||||
"max": 20079.777,
|
||||
"ci95": 479.431
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6127.947,
|
||||
"stdev": 26.756,
|
||||
"min": 6097.19,
|
||||
"max": 6145.854,
|
||||
"ci95": 66.471
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1218.486,
|
||||
"stdev": 26.337,
|
||||
"min": 1188.13,
|
||||
"max": 1235.248,
|
||||
"ci95": 65.43
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 318.468,
|
||||
"stdev": 6.292,
|
||||
"min": 313.2,
|
||||
"max": 325.435,
|
||||
"ci95": 15.632
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 126.59,
|
||||
"stdev": 1.844,
|
||||
"min": 124.744,
|
||||
"max": 128.431,
|
||||
"ci95": 4.58
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8548.814,
|
||||
"stdev": 152.302,
|
||||
"min": 8380.912,
|
||||
"max": 8678.071,
|
||||
"ci95": 378.37
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1912.359,
|
||||
"stdev": 130.238,
|
||||
"min": 1791.386,
|
||||
"max": 2050.215,
|
||||
"ci95": 323.555
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 401.907,
|
||||
"stdev": 35.911,
|
||||
"min": 379.849,
|
||||
"max": 443.345,
|
||||
"ci95": 89.216
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 149.26,
|
||||
"stdev": 9.983,
|
||||
"min": 139.809,
|
||||
"max": 159.701,
|
||||
"ci95": 24.801
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6953.478,
|
||||
"stdev": 276.636,
|
||||
"min": 6635.649,
|
||||
"max": 7140.072,
|
||||
"ci95": 687.258
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1860.512,
|
||||
"stdev": 116.512,
|
||||
"min": 1726.149,
|
||||
"max": 1933.597,
|
||||
"ci95": 289.454
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 446.336,
|
||||
"stdev": 56.993,
|
||||
"min": 382.26,
|
||||
"max": 491.369,
|
||||
"ci95": 141.589
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.934,
|
||||
"stdev": 11.839,
|
||||
"min": 152.493,
|
||||
"max": 176.062,
|
||||
"ci95": 29.413
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 15211.386,
|
||||
"stdev": 256.348,
|
||||
"min": 14938.008,
|
||||
"max": 15446.375,
|
||||
"ci95": 636.856
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1653.223,
|
||||
"stdev": 61.705,
|
||||
"min": 1584.652,
|
||||
"max": 1704.273,
|
||||
"ci95": 153.297
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 331.929,
|
||||
"stdev": 10.858,
|
||||
"min": 319.394,
|
||||
"max": 338.396,
|
||||
"ci95": 26.974
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 113.436,
|
||||
"stdev": 3.255,
|
||||
"min": 110.869,
|
||||
"max": 117.097,
|
||||
"ci95": 8.086
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2540.129,
|
||||
"stdev": 152.057,
|
||||
"min": 2373.152,
|
||||
"max": 2670.633,
|
||||
"ci95": 377.761
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2299.514,
|
||||
"stdev": 81.451,
|
||||
"min": 2207.129,
|
||||
"max": 2360.973,
|
||||
"ci95": 202.352
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 14785,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1232.081,
|
||||
"low_1pct_fps": 325.435,
|
||||
"low_01pct_fps": 128.431,
|
||||
"p50_ms": 0.742,
|
||||
"p95_ms": 1.265,
|
||||
"p99_ms": 2.032,
|
||||
"p999_ms": 5.653,
|
||||
"max_ms": 12.791,
|
||||
"histogram": [
|
||||
12845,
|
||||
1784,
|
||||
127,
|
||||
7,
|
||||
1,
|
||||
8,
|
||||
4,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 14258,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1188.13,
|
||||
"low_1pct_fps": 313.2,
|
||||
"low_01pct_fps": 126.595,
|
||||
"p50_ms": 0.757,
|
||||
"p95_ms": 1.349,
|
||||
"p99_ms": 2.12,
|
||||
"p999_ms": 5.541,
|
||||
"max_ms": 13.998,
|
||||
"histogram": [
|
||||
11994,
|
||||
2076,
|
||||
157,
|
||||
9,
|
||||
2,
|
||||
8,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 14823,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1235.248,
|
||||
"low_1pct_fps": 316.768,
|
||||
"low_01pct_fps": 124.744,
|
||||
"p50_ms": 0.744,
|
||||
"p95_ms": 1.199,
|
||||
"p99_ms": 2.047,
|
||||
"p999_ms": 5.628,
|
||||
"max_ms": 15.519,
|
||||
"histogram": [
|
||||
13214,
|
||||
1453,
|
||||
118,
|
||||
16,
|
||||
2,
|
||||
8,
|
||||
6,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 21497,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1791.386,
|
||||
"low_1pct_fps": 382.527,
|
||||
"low_01pct_fps": 139.809,
|
||||
"p50_ms": 0.474,
|
||||
"p95_ms": 0.931,
|
||||
"p99_ms": 1.646,
|
||||
"p999_ms": 5.274,
|
||||
"max_ms": 13.459,
|
||||
"histogram": [
|
||||
20598,
|
||||
799,
|
||||
66,
|
||||
9,
|
||||
3,
|
||||
8,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 24603,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2050.215,
|
||||
"low_1pct_fps": 443.345,
|
||||
"low_01pct_fps": 159.701,
|
||||
"p50_ms": 0.426,
|
||||
"p95_ms": 0.752,
|
||||
"p99_ms": 1.442,
|
||||
"p999_ms": 3.134,
|
||||
"max_ms": 13.282,
|
||||
"histogram": [
|
||||
24041,
|
||||
487,
|
||||
48,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 22746,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1895.476,
|
||||
"low_1pct_fps": 379.849,
|
||||
"low_01pct_fps": 148.269,
|
||||
"p50_ms": 0.456,
|
||||
"p95_ms": 0.829,
|
||||
"p99_ms": 1.561,
|
||||
"p999_ms": 4.176,
|
||||
"max_ms": 10.671,
|
||||
"histogram": [
|
||||
22092,
|
||||
523,
|
||||
91,
|
||||
17,
|
||||
4,
|
||||
7,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 23062,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1921.791,
|
||||
"low_1pct_fps": 465.379,
|
||||
"low_01pct_fps": 166.247,
|
||||
"p50_ms": 0.463,
|
||||
"p95_ms": 0.809,
|
||||
"p99_ms": 1.45,
|
||||
"p999_ms": 2.398,
|
||||
"max_ms": 11.808,
|
||||
"histogram": [
|
||||
22454,
|
||||
561,
|
||||
27,
|
||||
3,
|
||||
2,
|
||||
6,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 23204,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1933.597,
|
||||
"low_1pct_fps": 491.369,
|
||||
"low_01pct_fps": 176.062,
|
||||
"p50_ms": 0.466,
|
||||
"p95_ms": 0.766,
|
||||
"p99_ms": 1.406,
|
||||
"p999_ms": 2.229,
|
||||
"max_ms": 15.102,
|
||||
"histogram": [
|
||||
22699,
|
||||
469,
|
||||
19,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 20714,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1726.149,
|
||||
"low_1pct_fps": 382.26,
|
||||
"low_01pct_fps": 152.493,
|
||||
"p50_ms": 0.5,
|
||||
"p95_ms": 0.956,
|
||||
"p99_ms": 1.742,
|
||||
"p999_ms": 3.852,
|
||||
"max_ms": 12.888,
|
||||
"histogram": [
|
||||
19814,
|
||||
787,
|
||||
83,
|
||||
11,
|
||||
2,
|
||||
7,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 20049,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1670.745,
|
||||
"low_1pct_fps": 338.396,
|
||||
"low_01pct_fps": 117.097,
|
||||
"p50_ms": 0.502,
|
||||
"p95_ms": 0.949,
|
||||
"p99_ms": 1.673,
|
||||
"p999_ms": 6.456,
|
||||
"max_ms": 16.791,
|
||||
"histogram": [
|
||||
19187,
|
||||
753,
|
||||
70,
|
||||
8,
|
||||
1,
|
||||
6,
|
||||
12,
|
||||
4,
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 19016,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1584.652,
|
||||
"low_1pct_fps": 337.998,
|
||||
"low_01pct_fps": 112.343,
|
||||
"p50_ms": 0.531,
|
||||
"p95_ms": 1.082,
|
||||
"p99_ms": 1.717,
|
||||
"p999_ms": 5.939,
|
||||
"max_ms": 12.958,
|
||||
"histogram": [
|
||||
17849,
|
||||
1069,
|
||||
63,
|
||||
7,
|
||||
3,
|
||||
6,
|
||||
2,
|
||||
7,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 20452,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1704.273,
|
||||
"low_1pct_fps": 319.394,
|
||||
"low_01pct_fps": 110.869,
|
||||
"p50_ms": 0.496,
|
||||
"p95_ms": 0.911,
|
||||
"p99_ms": 1.706,
|
||||
"p999_ms": 5.938,
|
||||
"max_ms": 24.863,
|
||||
"histogram": [
|
||||
19694,
|
||||
626,
|
||||
82,
|
||||
14,
|
||||
3,
|
||||
14,
|
||||
5,
|
||||
4,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T19:06:49Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7518.874,
|
||||
"stdev": 42.968,
|
||||
"min": 7469.532,
|
||||
"max": 7548.049,
|
||||
"ci95": 106.748
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 35424.207,
|
||||
"stdev": 144.711,
|
||||
"min": 35318.049,
|
||||
"max": 35589.041,
|
||||
"ci95": 359.511
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11146.828,
|
||||
"stdev": 49.683,
|
||||
"min": 11094.798,
|
||||
"max": 11193.773,
|
||||
"ci95": 123.43
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 882.053,
|
||||
"stdev": 19.433,
|
||||
"min": 866.126,
|
||||
"max": 903.706,
|
||||
"ci95": 48.279
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 183.995,
|
||||
"stdev": 4.924,
|
||||
"min": 178.512,
|
||||
"max": 188.041,
|
||||
"ci95": 12.234
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 83.193,
|
||||
"stdev": 6.797,
|
||||
"min": 77.734,
|
||||
"max": 90.806,
|
||||
"ci95": 16.886
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18184.002,
|
||||
"stdev": 266.879,
|
||||
"min": 17880.667,
|
||||
"max": 18382.735,
|
||||
"ci95": 663.017
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1202.907,
|
||||
"stdev": 20.426,
|
||||
"min": 1179.9,
|
||||
"max": 1218.906,
|
||||
"ci95": 50.744
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 228.139,
|
||||
"stdev": 5.166,
|
||||
"min": 222.394,
|
||||
"max": 232.401,
|
||||
"ci95": 12.833
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 107.956,
|
||||
"stdev": 4.675,
|
||||
"min": 105.185,
|
||||
"max": 113.354,
|
||||
"ci95": 11.614
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13146.632,
|
||||
"stdev": 507.943,
|
||||
"min": 12572.118,
|
||||
"max": 13536.145,
|
||||
"ci95": 1261.902
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1223.169,
|
||||
"stdev": 13.11,
|
||||
"min": 1208.837,
|
||||
"max": 1234.556,
|
||||
"ci95": 32.569
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 236.27,
|
||||
"stdev": 6.77,
|
||||
"min": 228.686,
|
||||
"max": 241.702,
|
||||
"ci95": 16.818
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 99.089,
|
||||
"stdev": 8.354,
|
||||
"min": 91.897,
|
||||
"max": 108.253,
|
||||
"ci95": 20.755
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 41846.641,
|
||||
"stdev": 661.649,
|
||||
"min": 41435.666,
|
||||
"max": 42609.895,
|
||||
"ci95": 1643.76
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 815.008,
|
||||
"stdev": 24.644,
|
||||
"min": 797.565,
|
||||
"max": 843.201,
|
||||
"ci95": 61.224
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 55.371,
|
||||
"stdev": 5.128,
|
||||
"min": 50.389,
|
||||
"max": 60.633,
|
||||
"ci95": 12.739
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.966,
|
||||
"stdev": 1.103,
|
||||
"min": 6.848,
|
||||
"max": 9.053,
|
||||
"ci95": 2.74
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2892.492,
|
||||
"stdev": 29.54,
|
||||
"min": 2859.574,
|
||||
"max": 2916.691,
|
||||
"ci95": 73.387
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2799.159,
|
||||
"stdev": 12.256,
|
||||
"min": 2786.234,
|
||||
"max": 2810.613,
|
||||
"ci95": 30.447
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 10845,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 903.706,
|
||||
"low_1pct_fps": 185.432,
|
||||
"low_01pct_fps": 81.039,
|
||||
"p50_ms": 0.98,
|
||||
"p95_ms": 1.708,
|
||||
"p99_ms": 3.082,
|
||||
"p999_ms": 9.304,
|
||||
"max_ms": 15.487,
|
||||
"histogram": [
|
||||
6030,
|
||||
4403,
|
||||
297,
|
||||
49,
|
||||
25,
|
||||
16,
|
||||
8,
|
||||
2,
|
||||
6,
|
||||
2,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 10516,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 876.328,
|
||||
"low_1pct_fps": 188.041,
|
||||
"low_01pct_fps": 90.806,
|
||||
"p50_ms": 1.012,
|
||||
"p95_ms": 1.806,
|
||||
"p99_ms": 3.354,
|
||||
"p999_ms": 8.714,
|
||||
"max_ms": 14.39,
|
||||
"histogram": [
|
||||
4927,
|
||||
5158,
|
||||
292,
|
||||
70,
|
||||
33,
|
||||
13,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 10394,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 866.126,
|
||||
"low_1pct_fps": 178.512,
|
||||
"low_01pct_fps": 77.734,
|
||||
"p50_ms": 1.026,
|
||||
"p95_ms": 1.789,
|
||||
"p99_ms": 3.399,
|
||||
"p999_ms": 10.6,
|
||||
"max_ms": 15.389,
|
||||
"histogram": [
|
||||
4437,
|
||||
5552,
|
||||
273,
|
||||
65,
|
||||
18,
|
||||
28,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 14519,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1209.916,
|
||||
"low_1pct_fps": 232.401,
|
||||
"low_01pct_fps": 105.185,
|
||||
"p50_ms": 0.726,
|
||||
"p95_ms": 1.306,
|
||||
"p99_ms": 2.25,
|
||||
"p999_ms": 7.033,
|
||||
"max_ms": 13.303,
|
||||
"histogram": [
|
||||
13121,
|
||||
1197,
|
||||
103,
|
||||
36,
|
||||
24,
|
||||
19,
|
||||
4,
|
||||
3,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 14159,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1179.9,
|
||||
"low_1pct_fps": 222.394,
|
||||
"low_01pct_fps": 105.33,
|
||||
"p50_ms": 0.732,
|
||||
"p95_ms": 1.366,
|
||||
"p99_ms": 2.499,
|
||||
"p999_ms": 8.061,
|
||||
"max_ms": 11.797,
|
||||
"histogram": [
|
||||
12501,
|
||||
1356,
|
||||
195,
|
||||
45,
|
||||
25,
|
||||
14,
|
||||
6,
|
||||
3,
|
||||
9,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 14627,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1218.906,
|
||||
"low_1pct_fps": 229.622,
|
||||
"low_01pct_fps": 113.354,
|
||||
"p50_ms": 0.713,
|
||||
"p95_ms": 1.292,
|
||||
"p99_ms": 2.317,
|
||||
"p999_ms": 7.136,
|
||||
"max_ms": 12.041,
|
||||
"histogram": [
|
||||
13194,
|
||||
1224,
|
||||
97,
|
||||
47,
|
||||
27,
|
||||
21,
|
||||
2,
|
||||
6,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 14714,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1226.113,
|
||||
"low_1pct_fps": 238.423,
|
||||
"low_01pct_fps": 97.117,
|
||||
"p50_ms": 0.722,
|
||||
"p95_ms": 1.223,
|
||||
"p99_ms": 2.243,
|
||||
"p999_ms": 7.032,
|
||||
"max_ms": 13.21,
|
||||
"histogram": [
|
||||
13490,
|
||||
1027,
|
||||
115,
|
||||
29,
|
||||
18,
|
||||
13,
|
||||
7,
|
||||
3,
|
||||
3,
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 14507,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1208.837,
|
||||
"low_1pct_fps": 228.686,
|
||||
"low_01pct_fps": 91.897,
|
||||
"p50_ms": 0.719,
|
||||
"p95_ms": 1.256,
|
||||
"p99_ms": 2.354,
|
||||
"p999_ms": 7.473,
|
||||
"max_ms": 15.432,
|
||||
"histogram": [
|
||||
13124,
|
||||
1112,
|
||||
180,
|
||||
35,
|
||||
23,
|
||||
9,
|
||||
8,
|
||||
4,
|
||||
2,
|
||||
6,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 14815,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1234.556,
|
||||
"low_1pct_fps": 241.702,
|
||||
"low_01pct_fps": 108.253,
|
||||
"p50_ms": 0.721,
|
||||
"p95_ms": 1.2,
|
||||
"p99_ms": 2.197,
|
||||
"p999_ms": 6.689,
|
||||
"max_ms": 12.791,
|
||||
"histogram": [
|
||||
13565,
|
||||
1055,
|
||||
107,
|
||||
29,
|
||||
23,
|
||||
15,
|
||||
8,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 10119,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 843.201,
|
||||
"low_1pct_fps": 60.633,
|
||||
"low_01pct_fps": 9.053,
|
||||
"p50_ms": 0.891,
|
||||
"p95_ms": 1.845,
|
||||
"p99_ms": 3.91,
|
||||
"p999_ms": 8.209,
|
||||
"max_ms": 316.409,
|
||||
"histogram": [
|
||||
6956,
|
||||
2747,
|
||||
240,
|
||||
83,
|
||||
45,
|
||||
22,
|
||||
8,
|
||||
8,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 9693,
|
||||
"seconds": 12.052,
|
||||
"avg_fps": 804.258,
|
||||
"low_1pct_fps": 50.389,
|
||||
"low_01pct_fps": 6.848,
|
||||
"p50_ms": 0.906,
|
||||
"p95_ms": 1.924,
|
||||
"p99_ms": 4.157,
|
||||
"p999_ms": 8.578,
|
||||
"max_ms": 297.695,
|
||||
"histogram": [
|
||||
6578,
|
||||
2678,
|
||||
248,
|
||||
94,
|
||||
45,
|
||||
18,
|
||||
16,
|
||||
5,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 9571,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 797.565,
|
||||
"low_1pct_fps": 55.09,
|
||||
"low_01pct_fps": 7.997,
|
||||
"p50_ms": 0.929,
|
||||
"p95_ms": 1.944,
|
||||
"p99_ms": 4.495,
|
||||
"p999_ms": 7.764,
|
||||
"max_ms": 321.994,
|
||||
"histogram": [
|
||||
5954,
|
||||
3172,
|
||||
230,
|
||||
103,
|
||||
39,
|
||||
34,
|
||||
19,
|
||||
11,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-29T18:43:17Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 16,
|
||||
"os_version": "Microsoft Windows [Version 10.0.26200.8037]",
|
||||
"cpu": "AMD Ryzen 7 9800X3D 8-Core Processor",
|
||||
"ram_mb": 95943,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 5080/PCIe/SSE2",
|
||||
"vendor": "NVIDIA Corporation",
|
||||
"driver": "3.3.0 NVIDIA 617.42",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7676.526,
|
||||
"stdev": 305.763,
|
||||
"min": 7426.024,
|
||||
"max": 8017.247,
|
||||
"ci95": 759.617
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 13862.526,
|
||||
"stdev": 284.577,
|
||||
"min": 13569.024,
|
||||
"max": 14137.247,
|
||||
"ci95": 706.985
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3808.014,
|
||||
"stdev": 61.275,
|
||||
"min": 3737.989,
|
||||
"max": 3851.799,
|
||||
"ci95": 152.227
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2216.576,
|
||||
"stdev": 45.247,
|
||||
"min": 2166.285,
|
||||
"max": 2253.985,
|
||||
"ci95": 112.409
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 545.307,
|
||||
"stdev": 14.876,
|
||||
"min": 528.15,
|
||||
"max": 554.607,
|
||||
"ci95": 36.957
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 215.412,
|
||||
"stdev": 5.692,
|
||||
"min": 209.522,
|
||||
"max": 220.882,
|
||||
"ci95": 14.14
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3212.958,
|
||||
"stdev": 151.722,
|
||||
"min": 3076.655,
|
||||
"max": 3376.428,
|
||||
"ci95": 376.928
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2456.542,
|
||||
"stdev": 336.369,
|
||||
"min": 2068.162,
|
||||
"max": 2654.549,
|
||||
"ci95": 835.654
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 624.759,
|
||||
"stdev": 90.0,
|
||||
"min": 521.303,
|
||||
"max": 685.008,
|
||||
"ci95": 223.59
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 252.469,
|
||||
"stdev": 21.044,
|
||||
"min": 229.228,
|
||||
"max": 270.233,
|
||||
"ci95": 52.28
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2983.236,
|
||||
"stdev": 168.416,
|
||||
"min": 2788.777,
|
||||
"max": 3082.249,
|
||||
"ci95": 418.403
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2469.831,
|
||||
"stdev": 333.258,
|
||||
"min": 2085.121,
|
||||
"max": 2669.9,
|
||||
"ci95": 827.925
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 688.684,
|
||||
"stdev": 96.943,
|
||||
"min": 576.803,
|
||||
"max": 747.759,
|
||||
"ci95": 240.839
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 286.444,
|
||||
"stdev": 21.36,
|
||||
"min": 262.183,
|
||||
"max": 302.422,
|
||||
"ci95": 53.066
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6079.869,
|
||||
"stdev": 719.864,
|
||||
"min": 5542.196,
|
||||
"max": 6897.69,
|
||||
"ci95": 1788.385
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2605.12,
|
||||
"stdev": 326.424,
|
||||
"min": 2228.824,
|
||||
"max": 2812.075,
|
||||
"ci95": 810.948
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 699.63,
|
||||
"stdev": 87.846,
|
||||
"min": 599.44,
|
||||
"max": 763.45,
|
||||
"ci95": 218.239
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 264.461,
|
||||
"stdev": 28.459,
|
||||
"min": 239.602,
|
||||
"max": 295.503,
|
||||
"ci95": 70.701
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2120.426,
|
||||
"stdev": 116.406,
|
||||
"min": 2046.93,
|
||||
"max": 2254.637,
|
||||
"ci95": 289.191
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1912.642,
|
||||
"stdev": 38.925,
|
||||
"min": 1889.766,
|
||||
"max": 1957.586,
|
||||
"ci95": 96.702
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 27048,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2253.985,
|
||||
"low_1pct_fps": 553.164,
|
||||
"low_01pct_fps": 215.833,
|
||||
"p50_ms": 0.412,
|
||||
"p95_ms": 0.617,
|
||||
"p99_ms": 1.187,
|
||||
"p999_ms": 2.238,
|
||||
"max_ms": 9.792,
|
||||
"histogram": [
|
||||
26716,
|
||||
296,
|
||||
15,
|
||||
4,
|
||||
7,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 26754,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2229.458,
|
||||
"low_1pct_fps": 554.607,
|
||||
"low_01pct_fps": 220.882,
|
||||
"p50_ms": 0.417,
|
||||
"p95_ms": 0.626,
|
||||
"p99_ms": 1.212,
|
||||
"p999_ms": 2.448,
|
||||
"max_ms": 8.388,
|
||||
"histogram": [
|
||||
26423,
|
||||
293,
|
||||
16,
|
||||
9,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 25996,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2166.285,
|
||||
"low_1pct_fps": 528.15,
|
||||
"low_01pct_fps": 209.522,
|
||||
"p50_ms": 0.43,
|
||||
"p95_ms": 0.662,
|
||||
"p99_ms": 1.308,
|
||||
"p999_ms": 2.477,
|
||||
"max_ms": 11.033,
|
||||
"histogram": [
|
||||
25615,
|
||||
338,
|
||||
23,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 31855,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2654.549,
|
||||
"low_1pct_fps": 667.966,
|
||||
"low_01pct_fps": 257.945,
|
||||
"p50_ms": 0.341,
|
||||
"p95_ms": 0.519,
|
||||
"p99_ms": 0.932,
|
||||
"p999_ms": 1.761,
|
||||
"max_ms": 10.944,
|
||||
"histogram": [
|
||||
31558,
|
||||
275,
|
||||
6,
|
||||
2,
|
||||
7,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 31763,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2646.916,
|
||||
"low_1pct_fps": 685.008,
|
||||
"low_01pct_fps": 270.233,
|
||||
"p50_ms": 0.344,
|
||||
"p95_ms": 0.52,
|
||||
"p99_ms": 0.94,
|
||||
"p999_ms": 1.685,
|
||||
"max_ms": 11.406,
|
||||
"histogram": [
|
||||
31474,
|
||||
272,
|
||||
3,
|
||||
4,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 24818,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2068.162,
|
||||
"low_1pct_fps": 521.303,
|
||||
"low_01pct_fps": 229.228,
|
||||
"p50_ms": 0.439,
|
||||
"p95_ms": 0.767,
|
||||
"p99_ms": 1.284,
|
||||
"p999_ms": 2.351,
|
||||
"max_ms": 11.468,
|
||||
"histogram": [
|
||||
24356,
|
||||
410,
|
||||
36,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 32039,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2669.9,
|
||||
"low_1pct_fps": 741.491,
|
||||
"low_01pct_fps": 294.727,
|
||||
"p50_ms": 0.345,
|
||||
"p95_ms": 0.501,
|
||||
"p99_ms": 0.86,
|
||||
"p999_ms": 1.431,
|
||||
"max_ms": 9.257,
|
||||
"histogram": [
|
||||
31771,
|
||||
250,
|
||||
3,
|
||||
2,
|
||||
4,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 31854,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2654.471,
|
||||
"low_1pct_fps": 747.759,
|
||||
"low_01pct_fps": 302.422,
|
||||
"p50_ms": 0.347,
|
||||
"p95_ms": 0.503,
|
||||
"p99_ms": 0.853,
|
||||
"p999_ms": 1.404,
|
||||
"max_ms": 13.925,
|
||||
"histogram": [
|
||||
31580,
|
||||
261,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 25022,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2085.121,
|
||||
"low_1pct_fps": 576.803,
|
||||
"low_01pct_fps": 262.183,
|
||||
"p50_ms": 0.428,
|
||||
"p95_ms": 0.758,
|
||||
"p99_ms": 1.214,
|
||||
"p999_ms": 2.109,
|
||||
"max_ms": 9.206,
|
||||
"histogram": [
|
||||
24600,
|
||||
390,
|
||||
20,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 33745,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2812.075,
|
||||
"low_1pct_fps": 736.0,
|
||||
"low_01pct_fps": 258.279,
|
||||
"p50_ms": 0.327,
|
||||
"p95_ms": 0.471,
|
||||
"p99_ms": 0.808,
|
||||
"p999_ms": 1.689,
|
||||
"max_ms": 10.07,
|
||||
"histogram": [
|
||||
33516,
|
||||
205,
|
||||
7,
|
||||
0,
|
||||
7,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 33294,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2774.461,
|
||||
"low_1pct_fps": 763.45,
|
||||
"low_01pct_fps": 295.503,
|
||||
"p50_ms": 0.334,
|
||||
"p95_ms": 0.471,
|
||||
"p99_ms": 0.813,
|
||||
"p999_ms": 1.63,
|
||||
"max_ms": 8.512,
|
||||
"histogram": [
|
||||
33056,
|
||||
221,
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 26747,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2228.824,
|
||||
"low_1pct_fps": 599.44,
|
||||
"low_01pct_fps": 239.602,
|
||||
"p50_ms": 0.403,
|
||||
"p95_ms": 0.678,
|
||||
"p99_ms": 1.107,
|
||||
"p999_ms": 2.054,
|
||||
"max_ms": 9.178,
|
||||
"histogram": [
|
||||
26418,
|
||||
298,
|
||||
13,
|
||||
6,
|
||||
2,
|
||||
8,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-win-9800x3d-5080"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Horizon v0.2.97 vs vanilla 26.3 on Windows 11: Ryzen 7 9800X3D + RTX 5080 (2026-10-29, RD 8 and 32 rerun 2026-10-30)
|
||||
|
||||
The first `tools/perf_bench.py` run on Windows ([docs/benchmark.md](../../docs/benchmark.md)): Horizon, vanilla 26.3 and
|
||||
vanilla with performance mods, render distances 8, 16 and 32, 1280x720, three games each, every game from a fresh
|
||||
world. The reports are the `*-win-9800x3d-5080-rd*.json` files next to this page.
|
||||
|
||||
```sh
|
||||
python tools/perf_bench.py --table horizon-win-9800x3d-5080 vanilla-win-9800x3d-5080 vanilla-mods-win-9800x3d-5080
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
- **Machine:** AMD Ryzen 7 9800X3D (8 cores, 16 threads), 94 GB RAM (DDR5, EXPO off), NVIDIA GeForce RTX 5080
|
||||
(driver 617.42), Windows 11 Pro 26200, Balanced power plan, desktop. One 1920x1080 120 Hz display. The game windows
|
||||
were on the desktop of an unlocked session.
|
||||
- **Horizon:** release v0.2.97 as the launcher installs it (`--build %APPDATA%\horizon-launcher\versions\v0.2.97`,
|
||||
`--resources` its `.local\horizon-client\26.3`), Vulkan.
|
||||
- **Vanilla:** the 26.3 client jar with Fabric Loader 0.19.5 and the driver mod, Java 25.0.5 (Temurin), `-Xmx2G`
|
||||
with the launcher's G1 flags and the manifest's `-XX:StackShadowPages=32`, OpenGL 3.3.
|
||||
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0 and
|
||||
ImmediatelyFast 1.17.1 (`tools/benchmark-deps/mods.lock.json`).
|
||||
- **Commands:** `python tools/perf_bench.py --game <game> --label <game>-win-9800x3d-5080 --distances <d>
|
||||
--sizes 1280x720 --repeats 3` (plus `--build` and `--resources` for Horizon), with the games taking turns at
|
||||
each distance.
|
||||
- **Memory** is the working set on Windows (what Task Manager shows), sampled from outside the process. Horizon's
|
||||
GPU figure is its own Vulkan allocator total. `nvidia-smi` gives no per-process figure under WDDM, so vanilla has
|
||||
none.
|
||||
|
||||
## Load
|
||||
|
||||
Windows has no load average, so the reports have no `load_avg_1m`. Every game ran on an otherwise idle machine:
|
||||
|
||||
- **RD 8 and RD 32** (rerun on 2026-10-30, after the first pass had builds running alongside): a script started each
|
||||
game only once no `rustc`, `cargo`, `link` (or other compiler) process and no other game (`horizon-client`, `java`,
|
||||
`javaw`, Minecraft) was running, and the machine's CPU load had stayed under 15 % in every 5-second sample for
|
||||
30 seconds. During each game it sampled every 2 seconds the machine's CPU load and the CPU of everything outside the
|
||||
benchmark's own processes. A game during which a compiler or another game ran, or other processes used over 15 % of
|
||||
the machine for 10 seconds, was discarded and run again: two of the 20 games were (vanilla + mods at RD 32, a
|
||||
build started during one, background processes during the other).
|
||||
- **RD 16** (2026-10-29): no `rustc`, `cargo` or `link` process at any point, the load logged every 5 seconds.
|
||||
|
||||
| Game | RD | Mean / max CPU load (machine, the game included) | Other processes' CPU, mean / max | Compiler processes |
|
||||
|---|---|---|---|---|
|
||||
| Horizon | 8 | 18 % / 49 % | 6 % / 12 % | 0 |
|
||||
| Vanilla | 8 | 28 % / 75 % | 7 % / 20 % | 0 |
|
||||
| Vanilla + mods | 8 | 30 % / 81 % | 7 % / 20 % | 0 |
|
||||
| Horizon | 16 | 29 % / 80 % | – | 0 |
|
||||
| Vanilla | 16 | 41 % / 91 % | – | 0 |
|
||||
| Vanilla + mods | 16 | 51 % / 90 % | – | 0 |
|
||||
| Horizon | 32 | 36 % / 100 % | 5 % / 15 % | 0 |
|
||||
| Vanilla | 32 | 39 % / 73 % | 6 % / 18 % | 0 |
|
||||
| Vanilla + mods | 32 | 42 % / 88 % | 6 % / 21 % | 0 |
|
||||
|
||||
"Other processes" is the machine's load minus the benchmark's own processes (the game and `perf_bench.py`), in
|
||||
2-second samples. Most of it is the system's share of the game's own drawing (the graphics driver and the
|
||||
compositor), which no process of the benchmark is charged for; the desktop apps left open (a browser, chat apps)
|
||||
briefly reached 5 % at most. RD 16's load was logged from outside every 5 seconds, so it has no such split, and its mean includes the time between
|
||||
games. Compared with the first pass, the idle RD 8 runs give Horizon about 10 % higher averages and about 50 % higher
|
||||
1% lows, and vanilla up to 10 % higher averages.
|
||||
|
||||
## Results
|
||||
|
||||
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight at 16 blocks/s (average / 1% low /
|
||||
0.1% low):
|
||||
|
||||
| RD | scene | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 8 | forest | 3495 ±276 / 1295 ±397 / 859 ±298 | 2457 ±836 / 625 ±224 / 252 ±52 | 2425 ±65 / 733 ±30 / 250 ±22 |
|
||||
| 8 | mountain | 3376 ±67 / 1384 ±168 / 850 ±256 | 2605 ±811 / 700 ±218 / 264 ±71 | 2538 ±167 / 760 ±34 / 241 ±44 |
|
||||
| 8 | ocean | 3346 ±147 / 1358 ±237 / 912 ±235 | 2470 ±828 / 689 ±241 / 286 ±53 | 2432 ±82 / 764 ±37 / 262 ±37 |
|
||||
| 8 | village | 2942 ±197 / 1271 ±146 / 839 ±94 | 2217 ±112 / 545 ±37 / 215 ±14 | 2104 ±101 / 577 ±84 / 196 ±47 |
|
||||
| 16 | forest | 2424 ±138 / 709 ±165 / 401 ±100 | 1912 ±324 / 402 ±89 / 149 ±25 | 1746 ±32 / 461 ±27 / 179 ±9 |
|
||||
| 16 | mountain | 1961 ±99 / 537 ±96 / 297 ±75 | 1653 ±153 / 332 ±27 / 113 ±8 | 1500 ±55 / 408 ±40 / 140 ±10 |
|
||||
| 16 | ocean | 2201 ±183 / 554 ±251 / 325 ±326 | 1861 ±289 / 446 ±142 / 165 ±29 | 1815 ±119 / 514 ±142 / 192 ±58 |
|
||||
| 16 | village | 1712 ±192 / 593 ±80 / 358 ±69 | 1218 ±65 / 318 ±16 / 127 ±5 | 1282 ±162 / 343 ±25 / 132 ±12 |
|
||||
| 32 | forest | 1389 ±70 / 504 ±33 / 330 ±118 | 1203 ±51 / 228 ±13 / 108 ±12 | 929 ±8 / 313 ±41 / 129 ±32 |
|
||||
| 32 | mountain | 1086 ±34 / 258 ±78 / 108 ±37 | 815 ±61 / 55 ±13 / 8 ±3 | 645 ±10 / 166 ±12 / 86 ±21 |
|
||||
| 32 | ocean | 1189 ±27 / 155 ±9 / 68 ±8 | 1223 ±33 / 236 ±17 / 99 ±21 | 1017 ±14 / 325 ±38 / 117 ±28 |
|
||||
| 32 | village | 1164 ±9 / 416 ±39 / 216 ±86 | 882 ±48 / 184 ±12 / 83 ±17 | 926 ±17 / 250 ±5 / 93 ±11 |
|
||||
|
||||
Startup, loading and memory (means of 3 games):
|
||||
|
||||
| RD | | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 8 | start to title / in-world | 3.0 s / 6.1 s | 7.7 s / 13.9 s | 11.1 s / 17.6 s |
|
||||
| 8 | mean scene load | 1.3 s | 4.0 s | 3.6 s |
|
||||
| 8 | working set, steady / peak | 1082 / 1172 MB | 1913 / 2120 MB | 1904 / 2053 MB |
|
||||
| 16 | start to title / in-world | 3.2 s / 7.2 s | 7.7 s / 19.9 s | 10.8 s / 23.5 s |
|
||||
| 16 | mean scene load | 2.4 s | 9.2 s | 9.0 s |
|
||||
| 16 | working set, steady / peak | 1236 / 1459 MB | 2300 / 2540 MB | 2327 / 2496 MB |
|
||||
| 32 | start to title / in-world | 2.9 s / 10.5 s | 7.5 s / 35.4 s | 11.0 s / 39.2 s |
|
||||
| 32 | mean scene load | 5.7 s | 21.1 s | 19.9 s |
|
||||
| 32 | working set, steady / peak | 1730 / 2045 MB | 2799 / 2892 MB | 2895 / 2965 MB |
|
||||
|
||||
## What it shows
|
||||
|
||||
- At RD 8 and 16, Horizon's average FPS is 18–42 % above vanilla's in every scene. Its 1% lows are 1.2–2.3 times
|
||||
vanilla's and its 0.1% lows 2.0–3.9 times. On this CPU and GPU the performance mods leave vanilla's averages
|
||||
level or slightly lower, and raise its 1% lows by 6–23 %.
|
||||
- At RD 32, Horizon's average is 15–33 % above vanilla's over the forest, the mountain and the village, with 1% lows
|
||||
2.2–4.7 times vanilla's. Over the ocean its average is level with vanilla's, but its lows are lower than vanilla's
|
||||
and the modded client's (155 vs 236 and 325 FPS 1% low, 68 vs 99 and 117 FPS 0.1% low), on an idle machine too.
|
||||
That scene is worth a closer look.
|
||||
- Horizon reaches the title screen 2.4–2.6 times faster than vanilla (3.4–3.8 times faster than the modded client)
|
||||
and loads a scene's chunks 3–4 times faster. Its steady working set is 38–46 % smaller.
|
||||
- Each game uses its own graphics API: Horizon Vulkan, vanilla OpenGL. That difference is part of what's compared.
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands (a server mod:
|
||||
//! a player joining, chatting, typing them and leaving), and import no more
|
||||
//! than its capabilities.
|
||||
//! when given a key, and for a `.hmod` that it is content only.
|
||||
use horizon_mods::{
|
||||
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
|
||||
Package,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod has code to run (a content mod may have none).
|
||||
pub code: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
@@ -53,10 +37,6 @@ impl Report {
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"code": self.code,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,18 +80,8 @@ impl Report {
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
if self.kind == Kind::Mod {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -153,145 +123,26 @@ pub fn check(
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
if kind == Kind::Mod {
|
||||
content_only(path)?;
|
||||
}
|
||||
Ok(Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
code: false,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards. A content mod has no code to
|
||||
/// run: `inspect` already read its content as the game does.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let Some(wasm) = package.wasm.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
report.code = true;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
name: "Steve".into(),
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let snapshot = |t: u64| Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
players: vec![steve.clone()],
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snapshot(0));
|
||||
mods.player_joined(&steve);
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(snapshot(t));
|
||||
mods.tick(t);
|
||||
mods.server_tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
mods.player_chat(&steve, "hello");
|
||||
for name in &client {
|
||||
mods.command(name);
|
||||
}
|
||||
for name in &server {
|
||||
mods.server_command(&steve, name);
|
||||
}
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
mods.player_left(&steve);
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Tell { text, .. } => format!("tell: {text}"),
|
||||
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
|
||||
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::QuietCommands { lines, .. } => {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::SetBlocks {
|
||||
dimension, blocks, ..
|
||||
} => {
|
||||
format!("set {} block(s) in {dimension}", blocks.len())
|
||||
}
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
|
||||
}
|
||||
Output::Give { item, count, .. } => format!("gave {count} {item}"),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
});
|
||||
}
|
||||
let shapes = mods.hud_shapes().count();
|
||||
if shapes > 0 {
|
||||
report.output.push(format!("drew {shapes} HUD shape(s)"));
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
/// A mod is content only: WebAssembly code mods don't run any more, code mods are native
|
||||
/// (`.hnmod`, docs/native-mods.md). `inspect` already read its content as the game does.
|
||||
fn content_only(path: &Path) -> Result<(), String> {
|
||||
match Package::read(path)?.wasm {
|
||||
Some(_) => Err(
|
||||
"it has WebAssembly code, which Horizon doesn't run any more: make it a native mod (docs/native-mods.md)"
|
||||
.into(),
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -316,7 +167,7 @@ mod tests {
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
// A mod with WebAssembly code is refused: code mods are native.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
@@ -324,7 +175,7 @@ mod tests {
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
assert!(e.contains("native mod"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
|
||||
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
|
||||
//!
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `new <path> [--name "Name"]`: a content mod from the template.
|
||||
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
|
||||
//! and `data/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
|
||||
//! `tests/*.toml` scripts in the sandbox, with no game.
|
||||
//! the mod.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! (`.hmod`, `.hnmod`, `.hpack`, resource pack `.zip`, `.hshader`).
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
use horizon_mods::{Capability, Content, Manifest, Package, manifest::MANIFEST};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
process::{Command, ExitCode},
|
||||
};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -35,14 +26,13 @@ fn main() -> ExitCode {
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => new(&args[1..]),
|
||||
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
|
||||
Some("build") => build().map(|p| {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -83,31 +73,9 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
(
|
||||
"tests/hello.toml",
|
||||
include_str!("../template/tests/hello.toml"),
|
||||
),
|
||||
];
|
||||
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
let files = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
@@ -146,12 +114,12 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
if flag(args, "--template").is_some_and(|t| t != "content") {
|
||||
return Err(
|
||||
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
|
||||
);
|
||||
}
|
||||
for (file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
@@ -169,29 +137,24 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
|
||||
/// the manifest, the crate's component (none without a `Cargo.toml`: a
|
||||
/// content mod) and the files in `assets/` and `data/`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."), release)
|
||||
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
|
||||
/// manifest and the files in `assets/` and `data/`.
|
||||
fn build() -> Result<PathBuf, String> {
|
||||
build_in(Path::new("."))
|
||||
}
|
||||
|
||||
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
fn build_in(root: &Path) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
|
||||
let (wasm, target) = compile(root, release)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
(Some(wasm), target)
|
||||
} else if manifest.capabilities.contains(&Capability::Registries) {
|
||||
(None, root.join("target"))
|
||||
} else {
|
||||
if root.join("Cargo.toml").exists() {
|
||||
return Err("a Cargo.toml: code mods are native now (docs/native-mods.md)".into());
|
||||
}
|
||||
if !manifest.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
|
||||
"a content mod has capability registries in {MANIFEST}"
|
||||
));
|
||||
};
|
||||
}
|
||||
// The same checks as the game's, and the files in the order it lists them.
|
||||
let content = Content::read(root, &manifest)?;
|
||||
let mut reader = content.reader()?;
|
||||
@@ -200,53 +163,17 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
let dir = root.join("target/horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
|
||||
let bytes = Package::write_files(&manifest_text, None, &files)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
let out = cmd
|
||||
.stderr(Stdio::inherit())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"cargo build failed (is the target installed? rustup target add {TARGET})"
|
||||
));
|
||||
}
|
||||
let core = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
|
||||
.filter(|m| m["reason"] == "compiler-artifact")
|
||||
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
|
||||
.filter_map(|f| f.as_str().map(PathBuf::from))
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
Ok((wasm, target.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
@@ -278,74 +205,14 @@ fn check(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
|
||||
/// against the built mod, or the `.hmod` given with `--hmod`.
|
||||
fn test(args: &[String]) -> Result<(), String> {
|
||||
let hmod = match flag(args, "--hmod") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => build(!args.iter().any(|a| a == "--debug"))?,
|
||||
};
|
||||
let filter = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
|
||||
.map(|(_, a)| a.as_str());
|
||||
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| Some(e.ok()?.path()))
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
|
||||
.collect();
|
||||
scripts.sort();
|
||||
if scripts.is_empty() {
|
||||
return Err("no tests/*.toml to run".into());
|
||||
}
|
||||
println!(
|
||||
"running {} scripts against {}",
|
||||
scripts.len(),
|
||||
hmod.display()
|
||||
);
|
||||
let mut failed = 0;
|
||||
for path in &scripts {
|
||||
let result = std::fs::read_to_string(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| Script::parse(&text))
|
||||
.and_then(|script| {
|
||||
let mut game = TestGame::load(&hmod)?;
|
||||
script.run(&mut game)?;
|
||||
Ok(script.steps.len())
|
||||
});
|
||||
match result {
|
||||
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
println!("test {} ... FAILED\n {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
let passed = scripts.len() - failed;
|
||||
if failed > 0 {
|
||||
return Err(format!("{failed} failed, {passed} passed"));
|
||||
}
|
||||
println!("test result: ok. {passed} passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let path = build()?;
|
||||
let game = flag(args, "--game")
|
||||
.map(str::to_string)
|
||||
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
|
||||
.unwrap_or_else(|| "horizon-client".into());
|
||||
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
|
||||
println!(
|
||||
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
|
||||
path.display()
|
||||
);
|
||||
println!("Starting {game} with {}", path.display());
|
||||
let status = Command::new(&game)
|
||||
.arg("--dev-mod")
|
||||
.arg(&path)
|
||||
@@ -364,28 +231,6 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
|
||||
let path = dir.join("My-Mod");
|
||||
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(manifest.id.as_str(), manifest.name.as_str()),
|
||||
("my_mod", "My Mod")
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
|
||||
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
@@ -397,7 +242,7 @@ mod tests {
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path, true).unwrap();
|
||||
let hmod = build_in(&path).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
@@ -409,17 +254,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
assert!(!report.code);
|
||||
assert!(
|
||||
report.text().contains("no code: content only"),
|
||||
"{}",
|
||||
report.text()
|
||||
);
|
||||
// Without registries, a mod needs code.
|
||||
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
|
||||
let manifest = manifest.replace("[\"registries\"]", "[]");
|
||||
std::fs::write(path.join(MANIFEST), manifest).unwrap();
|
||||
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
|
||||
// Code is native now.
|
||||
std::fs::write(path.join("Cargo.toml"), "").unwrap();
|
||||
assert!(build_in(&path).unwrap_err().contains("native"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = {sdk}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -1,12 +0,0 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the SDK targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -1,19 +0,0 @@
|
||||
use horizon_mod_sdk::{Mod, Registration, chat};
|
||||
|
||||
struct MyMod {
|
||||
greeted: u32,
|
||||
}
|
||||
|
||||
impl Mod for MyMod {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.greeted += 1;
|
||||
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(MyMod);
|
||||
@@ -1,14 +0,0 @@
|
||||
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
|
||||
# steps happen in order, and `expect` checks what the mod showed during the step.
|
||||
# Every key: docs/mods.md, "Testing a mod".
|
||||
|
||||
[[step]]
|
||||
expect.commands = ["hello"]
|
||||
|
||||
[[step]]
|
||||
command = "hello world"
|
||||
expect.chat = ["Hello world! (1 so far)"]
|
||||
|
||||
[[step]]
|
||||
command = "hello again"
|
||||
expect.chat = ["Hello again! (2 so far)"]
|
||||
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
_ => string("minecraft:air"),
|
||||
},
|
||||
);
|
||||
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
f.put(
|
||||
"facing",
|
||||
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
|
||||
);
|
||||
f.float("progress", 0.0);
|
||||
f.flag("extending", false);
|
||||
f.flag("source", false);
|
||||
@@ -873,6 +877,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
|
||||
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
|
||||
#[test]
|
||||
fn moving_piston_fields_match_vanilla() {
|
||||
let out = canon(
|
||||
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
|
||||
facing:5,progress:0.5f,extending:1b,source:0b}",
|
||||
);
|
||||
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
|
||||
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
|
||||
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
|
||||
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
|
||||
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
|
||||
let out = canon(
|
||||
|
||||
@@ -195,6 +195,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
@@ -256,6 +257,28 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
.map(str::to_owned);
|
||||
style
|
||||
}
|
||||
|
||||
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
|
||||
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
|
||||
if let Some(packed) = value.as_i64() {
|
||||
return i32::try_from(packed).ok().map(|n| n as u32);
|
||||
}
|
||||
let rgba = value.as_array()?;
|
||||
if rgba.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let channels = rgba
|
||||
.iter()
|
||||
.map(|channel| {
|
||||
// ExtraCodecs.ARGB_COLOR_CODEC reads floats; ARGB.as8BitChannel
|
||||
// floors each normalized channel multiplied by 255.
|
||||
let value = channel.as_f64()? as f32;
|
||||
(value.is_finite() && (0.0..=1.0).contains(&value))
|
||||
.then_some((value * 255.0_f32).floor() as u32)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(channels[3] << 24 | channels[0] << 16 | channels[1] << 8 | channels[2])
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
|
||||
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
@@ -417,6 +440,23 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shadow_color_accepts_packed_and_rgba_styles() {
|
||||
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
|
||||
let text = to_text(&packed, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0xff00ff00));
|
||||
assert_eq!(text.component()["shadow_color"], -16711936);
|
||||
let rgba = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 1]});
|
||||
let text = to_text(&rgba, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0xffff007f));
|
||||
let translucent = serde_json::json!({"text": "A", "shadow_color": [1, 0, 0.5, 0.5]});
|
||||
let text = to_text(&translucent, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0x7fff007f));
|
||||
let no_shadow = serde_json::json!({"text": "A", "shadow_color": 0});
|
||||
let text = to_text(&no_shadow, &mut |_| Text::empty());
|
||||
assert_eq!(text.spans[0].style.shadow_color, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
|
||||
let component = serde_json::json!({
|
||||
|
||||
@@ -60,6 +60,9 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
"assets/horizon/lang/pl_pl.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/pl_pl.json")
|
||||
}
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
@@ -269,6 +272,16 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
// Every bundled string carries both languages: a key added to one file
|
||||
// and forgotten in the other shows as a key name on screen.
|
||||
for key in en.map().keys() {
|
||||
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
let pl = Language::load(&[&empty], "pl_pl");
|
||||
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
|
||||
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
|
||||
let uk = Language::load(&[&empty], "uk_ua");
|
||||
assert_eq!(
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
|
||||
@@ -112,6 +112,7 @@ pub fn style_from_json(v: &Value) -> Style {
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
s.bold = flag("bold");
|
||||
s.italic = flag("italic");
|
||||
@@ -1563,3 +1564,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
|
||||
/// name gets the `<id>.item` message, with the item's name as a third
|
||||
/// argument.
|
||||
pub fn death_message_args(
|
||||
key: &str,
|
||||
victim: &Text,
|
||||
cause: &Text,
|
||||
item: Option<&Text>,
|
||||
) -> (String, Vec<Text>) {
|
||||
match item {
|
||||
Some(item) => (
|
||||
format!("{key}.item"),
|
||||
vec![victim.clone(), cause.clone(), item.clone()],
|
||||
),
|
||||
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod death_message_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
|
||||
let victim = Text::literal("Steve");
|
||||
let cause = Text::literal("Zombie");
|
||||
// DamageSource.getLocalizedDeathMessage: the item variant, with the
|
||||
// item's name as the third argument.
|
||||
let (key, args) = death_message_args(
|
||||
"death.attack.arrow",
|
||||
&victim,
|
||||
&cause,
|
||||
Some(&Text::literal("My Sword")),
|
||||
);
|
||||
assert_eq!(key, "death.attack.arrow.item");
|
||||
assert_eq!(args.len(), 3);
|
||||
assert_eq!(args[0].plain(), "Steve");
|
||||
assert_eq!(args[1].plain(), "Zombie");
|
||||
assert_eq!(args[2].plain(), "My Sword");
|
||||
// Without a custom name the message keeps its two arguments.
|
||||
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
|
||||
assert_eq!(key, "death.attack.arrow");
|
||||
assert_eq!(args.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,8 @@ impl InlineObject {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Style {
|
||||
pub color: Option<u32>,
|
||||
/// Text component `shadow_color` as packed ARGB. Zero suppresses shadows.
|
||||
pub shadow_color: Option<u32>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub underlined: Option<bool>,
|
||||
@@ -116,6 +118,7 @@ impl Style {
|
||||
/// Fill unset fields from `base` (Style.applyTo).
|
||||
pub fn inherit(&mut self, base: &Style) {
|
||||
self.color = self.color.or(base.color);
|
||||
self.shadow_color = self.shadow_color.or(base.shadow_color);
|
||||
self.bold = self.bold.or(base.bold);
|
||||
self.italic = self.italic.or(base.italic);
|
||||
self.underlined = self.underlined.or(base.underlined);
|
||||
@@ -520,8 +523,9 @@ impl Text {
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
const STYLE_KEYS: [&str; 10] = [
|
||||
const STYLE_KEYS: [&str; 11] = [
|
||||
"color",
|
||||
"shadow_color",
|
||||
"bold",
|
||||
"italic",
|
||||
"underlined",
|
||||
@@ -542,6 +546,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
.map_or_else(|| format!("#{c:06X}"), |n| n.1.to_owned());
|
||||
out.push(("color".to_owned(), name.into()));
|
||||
}
|
||||
if let Some(shadow) = style.shadow_color {
|
||||
out.push(("shadow_color".to_owned(), Value::from(shadow as i32)));
|
||||
}
|
||||
for (key, flag) in [
|
||||
("bold", style.bold),
|
||||
("italic", style.italic),
|
||||
|
||||
@@ -64,6 +64,10 @@ pub struct BlockEntities {
|
||||
/// Positions whose block entity `take` took and `put` has not brought
|
||||
/// back.
|
||||
out: FxHashSet<Pos>,
|
||||
/// Positions a loaded chunk gave a new block entity over an old one: at
|
||||
/// the next tick their slot stays only if their block state ticks
|
||||
/// (`updateBlockEntityTicker`: rebind, or remove without a ticker).
|
||||
replaced: FxHashSet<Pos>,
|
||||
/// `Ticking` by block state id; empty until the game builds it.
|
||||
states: Vec<Ticking>,
|
||||
}
|
||||
@@ -239,15 +243,20 @@ impl BlockEntities {
|
||||
self.added.clear();
|
||||
self.pending.clear();
|
||||
self.out.clear();
|
||||
self.replaced.clear();
|
||||
self.freed = false;
|
||||
}
|
||||
/// A loaded or generated chunk's block entities, in the order the chunk
|
||||
/// lists them; they tick in its block entity map's order
|
||||
/// (`registerAllBlockEntitiesAfterLevelLoad`).
|
||||
/// (`registerAllBlockEntitiesAfterLevelLoad`). One replacing an entity
|
||||
/// already at its position (`LevelChunk.setBlockEntity`) drops that one;
|
||||
/// its slot is decided by the block state at the next tick.
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
self.map.insert(p, e);
|
||||
if self.map.insert(p, e).is_some() {
|
||||
self.replaced.insert(p);
|
||||
}
|
||||
}
|
||||
for p in order {
|
||||
self.add(p);
|
||||
@@ -291,6 +300,11 @@ impl BlockEntities {
|
||||
self.live.insert(pos, slot);
|
||||
Arc::make_mut(&mut self.tickers).push((pos, slot));
|
||||
}
|
||||
for pos in std::mem::take(&mut self.replaced) {
|
||||
if self.live.contains_key(&pos) && self.ticking(state(pos)) != Ticking::Yes {
|
||||
self.cancel(&pos);
|
||||
}
|
||||
}
|
||||
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
|
||||
return Arc::clone(&self.tickers);
|
||||
}
|
||||
@@ -390,4 +404,33 @@ mod tests {
|
||||
let order = chunk_map_order(many.iter().copied().chain(many.iter().copied()));
|
||||
assert_eq!(order.len(), 100);
|
||||
}
|
||||
|
||||
/// A chunk loaded over a block entity already there: the new one replaces
|
||||
/// it, and keeps the old slot only while its block state ticks.
|
||||
#[test]
|
||||
fn loaded_chunk_replaces_block_entity_by_state() {
|
||||
let mut b = level();
|
||||
let mut s = HashMap::new();
|
||||
let (p, q) = ((0, 0, 0), (1, 0, 0));
|
||||
for pos in [p, q] {
|
||||
s.insert(pos, 1);
|
||||
}
|
||||
// Two chunks, so the order is the order they load in.
|
||||
b.load_chunk(vec![(p, hopper())]);
|
||||
b.load_chunk(vec![(q, hopper())]);
|
||||
assert_eq!(order(&mut b, &s), [p, q]);
|
||||
// Replaced where the state ticks: the slot stays in its place.
|
||||
b.load_chunk(vec![(p, hopper())]);
|
||||
assert_eq!(order(&mut b, &s), [p, q]);
|
||||
// Replaced where the state has no ticker: the slot is freed, and the
|
||||
// new block entity is the one in the map.
|
||||
s.insert(p, 2);
|
||||
b.load_chunk(vec![(p, BlockEntity::Chest(vec![None; 27]))]);
|
||||
assert_eq!(order(&mut b, &s), [q]);
|
||||
assert!(matches!(b.get(&p), Some(BlockEntity::Chest(items)) if items.len() == 27));
|
||||
// Ticking again later appends it, as any new ticker does.
|
||||
s.insert(p, 1);
|
||||
b.state_changed(p, 2, 1);
|
||||
assert_eq!(order(&mut b, &s), [q, p]);
|
||||
}
|
||||
}
|
||||
@@ -50,20 +50,30 @@ pub fn tooltip(
|
||||
Category::Neutral,
|
||||
)];
|
||||
}
|
||||
def.effects
|
||||
.iter()
|
||||
effect_lines(def.effects.iter().copied(), scale, translate)
|
||||
}
|
||||
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
|
||||
/// scaled duration, for (effect id, duration, amplifier) entries.
|
||||
pub fn effect_lines<'a>(
|
||||
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
|
||||
scale: f32,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, Category)> {
|
||||
effects
|
||||
.into_iter()
|
||||
.map(|(e, duration, amp)| {
|
||||
let e = e.trim_start_matches("minecraft:");
|
||||
let mut line =
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
|
||||
if *amp > 0 {
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_str(
|
||||
&translate(&format!("potion.potency.{amp}"))
|
||||
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
|
||||
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
|
||||
);
|
||||
}
|
||||
let instant = effect(e).is_some_and(|d| d.instant);
|
||||
let scaled = (*duration as f32 * scale) as i32;
|
||||
let scaled = (duration as f32 * scale) as i32;
|
||||
if !instant && scaled > 20 {
|
||||
line = format!("{line} ({})", format_duration(scaled));
|
||||
}
|
||||
|
||||
@@ -479,11 +479,21 @@ impl DataStore {
|
||||
/// included, each function once).
|
||||
pub fn tag_functions(&self, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
self.expand(id, &mut out, 0);
|
||||
let mut seen_fns = std::collections::HashSet::new();
|
||||
let mut seen_tags = std::collections::HashSet::new();
|
||||
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
|
||||
out
|
||||
}
|
||||
fn expand(&self, id: &str, out: &mut Vec<String>, depth: usize) {
|
||||
if depth > 16 {
|
||||
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
|
||||
/// once, so reference cycles end and nesting depth is not capped.
|
||||
fn expand<'a>(
|
||||
&'a self,
|
||||
id: &'a str,
|
||||
out: &mut Vec<String>,
|
||||
seen_fns: &mut std::collections::HashSet<&'a str>,
|
||||
seen_tags: &mut std::collections::HashSet<&'a str>,
|
||||
) {
|
||||
if !seen_tags.insert(id) {
|
||||
return;
|
||||
}
|
||||
let Some(entries) = self.function_tags.get(id) else {
|
||||
@@ -491,9 +501,9 @@ impl DataStore {
|
||||
};
|
||||
for (e, required) in entries {
|
||||
match e.strip_prefix('#') {
|
||||
Some(inner) => self.expand(inner, out, depth + 1),
|
||||
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
|
||||
None => {
|
||||
if (self.functions.contains_key(e) || *required) && !out.contains(e) {
|
||||
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
|
||||
out.push(e.clone());
|
||||
}
|
||||
}
|
||||
@@ -767,6 +777,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
|
||||
let mut store = DataStore::default();
|
||||
let big: Vec<(String, bool)> = (0..50_000)
|
||||
.map(|i| (format!("t:f{}", i % 25_000), true))
|
||||
.collect();
|
||||
store.function_tags.insert("t:big".into(), big);
|
||||
let start = std::time::Instant::now();
|
||||
let got = store.tag_functions("t:big");
|
||||
assert_eq!(got.len(), 25_000);
|
||||
assert_eq!(got[0], "t:f0");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5));
|
||||
// A cycle terminates and keeps first-seen order.
|
||||
store.function_tags.insert(
|
||||
"t:a".into(),
|
||||
vec![("#t:b".into(), true), ("t:x".into(), true)],
|
||||
);
|
||||
store.function_tags.insert(
|
||||
"t:b".into(),
|
||||
vec![("#t:a".into(), true), ("t:y".into(), true)],
|
||||
);
|
||||
assert_eq!(store.tag_functions("t:a"), ["t:y", "t:x"]);
|
||||
// Nesting deeper than the old cap of 16 still resolves.
|
||||
for i in 0..40 {
|
||||
store
|
||||
.function_tags
|
||||
.insert(format!("d:{i}"), vec![(format!("#d:{}", i + 1), true)]);
|
||||
}
|
||||
store
|
||||
.function_tags
|
||||
.insert("d:40".into(), vec![("t:deep".into(), true)]);
|
||||
assert_eq!(store.tag_functions("d:0"), ["t:deep"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_packs_list_the_needed_kinds_in_walk_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-kinds-{}", std::process::id()));
|
||||
|
||||
@@ -57,6 +57,9 @@ struct Out {
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
|
||||
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
|
||||
@location(13) @interpolate(flat) tier: u32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -126,8 +129,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay, color, light, normal);
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = overlay >> 16u;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
@@ -279,6 +283,14 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
}
|
||||
|
||||
// HDR output: a light-giving particle's pixels marked in the frame's alpha the
|
||||
// way terrain.wgsl marks a light source's, drawn again after the entities with
|
||||
// only alpha written, so its colour stays as blended.
|
||||
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
|
||||
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
|
||||
@@ -389,3 +389,36 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
|
||||
let mut sky = environment.sample(day_time, None, None);
|
||||
apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
(15.0 - 15.0 * sky.sky_light_level).round() as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn overworld() -> Option<Environment> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
root.join("data/minecraft/timeline/day.json")
|
||||
.exists()
|
||||
.then(|| Environment::load(&root).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_outside_follows_time_and_weather_levels() {
|
||||
let Some(env) = overworld() else {
|
||||
return;
|
||||
};
|
||||
let dark = |time, rain, thunder| sky_darken(&env, time, rain, thunder) >= 4;
|
||||
assert!(!dark(6000, 0.0, 0.0), "clear noon");
|
||||
assert!(!dark(6000, 1.0, 0.0), "rainy noon");
|
||||
assert!(dark(6000, 1.0, 1.0), "thunderstorm at noon");
|
||||
assert!(dark(18000, 0.0, 0.0), "clear midnight");
|
||||
assert!(!dark(1000, 0.0, 0.0), "clear morning");
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,7 @@ pub struct Renderer {
|
||||
crosshair_group: wgpu::BindGroup,
|
||||
crosshair_globals: wgpu::Buffer,
|
||||
entity_pipeline: wgpu::RenderPipeline,
|
||||
hdr_mark_pipeline: wgpu::RenderPipeline,
|
||||
weather: weather::Weather,
|
||||
crumbling_pipeline: wgpu::RenderPipeline,
|
||||
shadow_blob_pipeline: wgpu::RenderPipeline,
|
||||
@@ -2127,6 +2128,18 @@ impl Renderer {
|
||||
"fs_energy_swirl",
|
||||
wgpu::ColorWrites::ALL,
|
||||
);
|
||||
// HDR output's mark on light-giving particles (entity.wgsl fs_hdr_mark).
|
||||
let hdr_mark_pipeline = entity_pipeline_with(
|
||||
"HDR particle mark",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
Default::default(),
|
||||
wgpu::BlendState::REPLACE,
|
||||
false,
|
||||
"fs_hdr_mark",
|
||||
wgpu::ColorWrites::ALPHA,
|
||||
);
|
||||
// Attribute 7: 6 is the entity vertex buffer's glint UV.
|
||||
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let lightning_vertex = [
|
||||
@@ -2640,6 +2653,7 @@ impl Renderer {
|
||||
crosshair_group,
|
||||
crosshair_globals,
|
||||
entity_pipeline,
|
||||
hdr_mark_pipeline,
|
||||
weather,
|
||||
crumbling_pipeline,
|
||||
shadow_blob_pipeline,
|
||||
@@ -4926,6 +4940,10 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
if self.hdr.is_some() {
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 {
|
||||
@@ -6201,6 +6219,8 @@ static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(Display
|
||||
pending: None,
|
||||
});
|
||||
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
// Only Wayland's HDR reports call it so far.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn log_display(line: String) {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
|
||||
@@ -60,6 +60,38 @@ struct Targets {
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub share: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
share: 0.2,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
share: 0.5,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 0.6,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 1.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
@@ -74,13 +106,14 @@ pub struct Hdr {
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
|
||||
pub black: f32,
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
}
|
||||
@@ -192,7 +225,7 @@ impl Hdr {
|
||||
);
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 32,
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -209,6 +242,7 @@ impl Hdr {
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
}
|
||||
}
|
||||
@@ -342,16 +376,21 @@ impl Hdr {
|
||||
return;
|
||||
};
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let params = [
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
self.black.clamp(0.0, paper),
|
||||
self.ui.clamp(0.25, 1.0),
|
||||
0.0,
|
||||
];
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -379,6 +418,24 @@ mod tests {
|
||||
use super::{Output, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
for source in [
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
|
||||
wgpu::SurfaceCapabilities {
|
||||
format_capabilities: offered
|
||||
|
||||
@@ -12,7 +12,7 @@ struct Params {
|
||||
peak: f32,
|
||||
// 1 when this frame drew a world.
|
||||
world: f32,
|
||||
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
|
||||
// 1 greys what isn't expanded and tints what is (HORIZON_HDR_DEBUG=1).
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
@@ -20,19 +20,23 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// the brightest pixels get, and the brightness where the lift starts and
|
||||
// where it is full.
|
||||
share: vec4<f32>,
|
||||
start: vec4<f32>,
|
||||
full: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
|
||||
// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the
|
||||
// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a
|
||||
// lantern's metal), and no channel goes past the peak. Brightness is the mean
|
||||
// of luminance and the brightest channel, so saturated light counts as bright.
|
||||
const LIFT_START: f32 = 0.18;
|
||||
const LIFT_FULL: f32 = 0.42;
|
||||
const STRENGTH: f32 = 0.7;
|
||||
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
|
||||
// `share` of the headroom, dimmer ones less down to none at its `start`
|
||||
// (lava's crust, a lantern's metal), and no channel goes past the peak.
|
||||
// Brightness is the mean of luminance and the brightest channel, so saturated
|
||||
// light counts as bright.
|
||||
|
||||
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
|
||||
// across its channels, as the vignette and other screen-wide tints darken it.
|
||||
@@ -99,24 +103,34 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine, and only where
|
||||
// nothing was drawn over them since: grey text over the white sun still looks
|
||||
// like the sun darkened, but it raised the frame's alpha.
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
|
||||
// holds, the lower the brighter, and only where nothing was drawn over them
|
||||
// since: grey text over the white sun still looks like the sun darkened, but
|
||||
// it raised the frame's alpha.
|
||||
var lifted = false;
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
|
||||
&& shows_world(c, w.rgb) {
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
|
||||
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
|
||||
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
|
||||
let lift = smoothstep(params.start[tier], params.full[tier], y);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
light *= max(gain, 1.0);
|
||||
if params.debug > 0.5 {
|
||||
light = mix(light, vec3(gain, 0.0, gain), 0.6);
|
||||
}
|
||||
lifted = true;
|
||||
}
|
||||
}
|
||||
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
|
||||
// colour with a magenta tint, so a lift shows on any colour.
|
||||
if params.debug > 0.5 && params.world > 0.5 {
|
||||
if lifted {
|
||||
light *= vec3(1.0, 0.6, 1.0);
|
||||
} else {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
|
||||
@@ -92,9 +92,9 @@ pub const MAP_SLOTS: usize = 256;
|
||||
/// The atlas slot the minimap extra draws into.
|
||||
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
|
||||
/// Vanilla registers a texture per skin. Here a skin evicted from its slot
|
||||
/// is not stored again, and its player falls back to the default skin, so
|
||||
/// the slots must cover a busy server's player list.
|
||||
/// Vanilla registers a texture per skin. Here the slots are shared
|
||||
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
|
||||
/// skin past them shows the default skin until a slot frees up.
|
||||
pub const SKIN_SLOTS: usize = 128;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
@@ -1108,6 +1108,7 @@ impl HudBatch {
|
||||
cursor,
|
||||
g,
|
||||
c,
|
||||
style.shadow_color,
|
||||
bold,
|
||||
style.is_italic(),
|
||||
style.underlined == Some(true),
|
||||
@@ -1120,8 +1121,12 @@ impl HudBatch {
|
||||
}
|
||||
self.patches.extend(font.take_patches());
|
||||
let white = font.white();
|
||||
let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the shadow's colour.
|
||||
let has_shadow = shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0));
|
||||
let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the default shadow colour.
|
||||
let shade = |c: u32| {
|
||||
let b = c.to_le_bytes();
|
||||
rgba(
|
||||
@@ -1132,20 +1137,42 @@ impl HudBatch {
|
||||
)
|
||||
};
|
||||
for &is_shadow in passes {
|
||||
for (gx, g, c, bold, italic, underline, strike, first, advance, object) in &glyphs {
|
||||
for (
|
||||
gx,
|
||||
g,
|
||||
c,
|
||||
custom_shadow,
|
||||
bold,
|
||||
italic,
|
||||
underline,
|
||||
strike,
|
||||
first,
|
||||
advance,
|
||||
object,
|
||||
) in &glyphs
|
||||
{
|
||||
if let Some(object) = object {
|
||||
if !is_shadow {
|
||||
self.draw_inline_object(atlas, object, *gx, y, alpha);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// In the world a glyph whose shadow is 0 has none (hasShadow).
|
||||
let has_shadow = shadow && !(world && shade(*c) == 0);
|
||||
let shadow_color = if let Some(argb) = custom_shadow {
|
||||
let text_alpha = (c >> 24) as u8 as u16;
|
||||
let shadow_alpha = (argb >> 24) as u8 as u16;
|
||||
let alpha = (shadow_alpha * text_alpha / 255) as u8;
|
||||
rgba((argb >> 16) as u8, (argb >> 8) as u8, *argb as u8, alpha)
|
||||
} else {
|
||||
shade(*c)
|
||||
};
|
||||
let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
&& !(world && shadow_color == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
}
|
||||
let (dx, col) = if is_shadow {
|
||||
(g.offset, shade(*c))
|
||||
(g.offset, shadow_color)
|
||||
} else {
|
||||
(0.0, *c)
|
||||
};
|
||||
@@ -2025,6 +2052,52 @@ mod tests {
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_shadow_color_overrides_text_call_shadow() {
|
||||
let root = two_glyph_font("component-shadow");
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let text = crate::text::Text::styled(
|
||||
"a",
|
||||
crate::text::Style {
|
||||
shadow_color: Some(0x8040_2010),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices.len(), 12);
|
||||
assert_eq!(batch.vertices[0].color, rgba(0x40, 0x20, 0x10, 0x80));
|
||||
batch.vertices.clear();
|
||||
batch.text_styled_world(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices.len(), 12);
|
||||
assert_eq!(batch.vertices[0].depth, 0.0);
|
||||
assert_eq!(batch.vertices[6].depth, 0.03);
|
||||
batch.vertices.clear();
|
||||
let no_shadow = crate::text::Text::styled(
|
||||
"a",
|
||||
crate::text::Style {
|
||||
shadow_color: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
batch.text_styled(&atlas, &no_shadow, 10.0, 10.0, u32::MAX, true);
|
||||
assert_eq!(batch.vertices.len(), 6);
|
||||
batch.vertices.clear();
|
||||
let gradient = crate::component::to_text(
|
||||
&serde_json::json!({
|
||||
"text": "",
|
||||
"extra": [
|
||||
{"text": "a", "color": "#ff0000"},
|
||||
{"text": "b", "color": "#0000ff"}
|
||||
]
|
||||
}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
batch.text_styled(&atlas, &gradient, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(batch.vertices[0].color, rgba(255, 0, 0, 255));
|
||||
assert_eq!(batch.vertices[6].color, rgba(0, 0, 255, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_object_draws_an_inline_head() {
|
||||
let root = two_glyph_font("inline-head");
|
||||
|
||||
@@ -123,9 +123,25 @@ mod vertex_tests {
|
||||
fn emission_keeps_the_colour_and_the_free_bits() {
|
||||
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
|
||||
assert_eq!(super::emitting(rgb, 0), rgb);
|
||||
assert_eq!(super::emitting(rgb, 9), 0xF656_3412);
|
||||
assert_eq!(super::emitting(rgb, 15), 0xF056_3412);
|
||||
assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb);
|
||||
assert_eq!(super::emitting(rgb, super::INCANDESCENT), 0xFD56_3412);
|
||||
assert_eq!(super::emitting(rgb, super::VERY_BRIGHT), 0xFB56_3412);
|
||||
assert_eq!(
|
||||
super::emitting(rgb, super::VERY_BRIGHT) | super::EMISSION_BITS,
|
||||
rgb
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn light_sources_shine_by_kind_not_light_level() {
|
||||
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
|
||||
assert_eq!(hdr_tier("glowstone", 15), BRIGHT);
|
||||
// A candle's flame is a particle: the block keeps only its wick.
|
||||
assert_eq!(hdr_tier("torch", 14), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("red_candle", 3), LOW);
|
||||
assert_eq!(hdr_tier("glow_lichen", 7), 0);
|
||||
assert_eq!(hdr_tier("stone", 0), 0);
|
||||
// Unlisted ones by their light level.
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
|
||||
}
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
@@ -263,6 +279,8 @@ enum Tint {
|
||||
/// `BlockTintSources.grassBlock`: grass in the world, none on its particles.
|
||||
GrassBlock,
|
||||
TallGrassUpper,
|
||||
SugarCane,
|
||||
LilyPad,
|
||||
Foliage,
|
||||
DryFoliage,
|
||||
Water,
|
||||
@@ -282,6 +300,8 @@ struct Info {
|
||||
/// `getShadeBrightness` (ambient occlusion contribution).
|
||||
shade: f32,
|
||||
emission: u8,
|
||||
/// `hdr_tier`.
|
||||
tier: u8,
|
||||
/// `emissiveRendering`: drawn and sampled at full brightness.
|
||||
emissive: bool,
|
||||
fluid: FluidState,
|
||||
@@ -329,6 +349,10 @@ pub struct MeshTables {
|
||||
lava_still: u16,
|
||||
lava_flow: u16,
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
}
|
||||
fn redstone_color(power: u8) -> u32 {
|
||||
// RedstoneWireBlock.COLORS
|
||||
let f = f32::from(power) / 15.0;
|
||||
@@ -377,10 +401,9 @@ impl MeshTables {
|
||||
([Tint::Grass; 2], 1)
|
||||
}
|
||||
}
|
||||
"fern" | "short_grass" | "potted_fern" | "bush" | "sugar_cane" => {
|
||||
([Tint::Grass; 2], 1)
|
||||
}
|
||||
"fern" | "short_grass" | "potted_fern" | "bush" => ([Tint::Grass; 2], 1),
|
||||
"grass_block" => ([Tint::GrassBlock; 2], 1),
|
||||
"sugar_cane" => ([Tint::SugarCane; 2], 1),
|
||||
"pink_petals" | "wildflowers" => ([Tint::None, Tint::Grass], 2),
|
||||
"spruce_leaves" => ([Tint::Constant(0x61_9961); 2], 1),
|
||||
"birch_leaves" => ([Tint::Constant(0x80_A755); 2], 1),
|
||||
@@ -395,7 +418,7 @@ impl MeshTables {
|
||||
([Tint::Constant(0xE0_C71C); 2], 1)
|
||||
}
|
||||
"melon_stem" | "pumpkin_stem" => ([Tint::Stem(prop("age")); 2], 1),
|
||||
"lily_pad" => ([Tint::Constant(0x20_8030); 2], 1),
|
||||
"lily_pad" => ([Tint::LilyPad; 2], 1),
|
||||
_ => ([Tint::None; 2], 0),
|
||||
};
|
||||
// getShadeBrightness overrides; the default is 0.2 for full collision shapes.
|
||||
@@ -450,6 +473,7 @@ impl MeshTables {
|
||||
permeable: !s.solid_render || s.dampening == 0,
|
||||
shade,
|
||||
emission: s.emission,
|
||||
tier: hdr_tier(name, s.emission),
|
||||
emissive,
|
||||
fluid: FluidState::from_block(state)?,
|
||||
model: s.render_shape == RenderShape::Model,
|
||||
@@ -491,6 +515,25 @@ impl MeshTables {
|
||||
tint_sources: i.tint_count,
|
||||
})
|
||||
}
|
||||
/// `BlockTintSource.color(state)`: block-model colour without a level.
|
||||
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
|
||||
/// Untinted faces, blank sources and invalid tint indices return `None`.
|
||||
pub fn default_tint(&self, state: BlockStateId, index: i32, grass: u32) -> Option<u32> {
|
||||
let info = self.info.get(state.0 as usize)?;
|
||||
if index < 0 || index >= i32::from(info.tint_count) {
|
||||
return None;
|
||||
}
|
||||
Some(match info.tints[index as usize] {
|
||||
Tint::None | Tint::SugarCane | Tint::Water => return None,
|
||||
Tint::Grass | Tint::GrassBlock | Tint::TallGrassUpper => grass & 0xFF_FFFF,
|
||||
Tint::Foliage => 0x48_B518,
|
||||
Tint::DryFoliage => 0x5C_3C32,
|
||||
Tint::LilyPad => 0x71_C35C,
|
||||
Tint::Constant(c) => c,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
Tint::Stem(age) => stem_color(age),
|
||||
})
|
||||
}
|
||||
/// `TerrainParticle`'s colour: tint source 0's `colorAsTerrainParticle`,
|
||||
/// which is the in-world (biome-blended) colour except on grass blocks,
|
||||
/// whose dirt crumbs stay untinted. `None` when the state has no source.
|
||||
@@ -513,6 +556,56 @@ impl MeshTables {
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
/// chunk mesher tints them (grass blocks included): `None` for an
|
||||
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
|
||||
pub fn block_tints(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
biomes: &Biomes,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
) -> impl Fn(i32) -> Option<u32> + use<> {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
});
|
||||
move |index| {
|
||||
let (colors, count) = sources?;
|
||||
if index < 0 || index >= count {
|
||||
return None;
|
||||
}
|
||||
colors[(index as usize).min(1)]
|
||||
}
|
||||
}
|
||||
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
|
||||
/// packing, the quad's own emission included) for `quad` of `state`
|
||||
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
|
||||
/// it: `light` is the world's stored light (sky << 4 | block) at a
|
||||
/// position. For block models drawn outside the chunk mesh.
|
||||
pub fn flat_light(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
quad: &horizon_assets::bake::ModelQuad,
|
||||
light: impl Fn([i32; 3]) -> u8,
|
||||
) -> u32 {
|
||||
let own = self.info.get(state.0 as usize);
|
||||
let full = own.is_some_and(|i| i.full);
|
||||
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
|
||||
let packed = light(at);
|
||||
let coords = match own {
|
||||
Some(own) => lighting::packed_coords(own, packed),
|
||||
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
|
||||
};
|
||||
lighting::with_emission(coords, quad.light_emission)
|
||||
}
|
||||
/// `BlockState.getOffset` for blocks with a random model offset.
|
||||
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
|
||||
offset(self.info.get(state.0 as usize)?, x, z)
|
||||
@@ -580,13 +673,37 @@ impl Padded {
|
||||
}
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold the light its face emits, for
|
||||
/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none.
|
||||
/// The byte's other four bits stay set.
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
|
||||
/// byte's other four bits stay set.
|
||||
pub const EMISSION_BITS: u32 = 0x0F00_0000;
|
||||
/// `color` marked as emitting light `level` (0 to 15).
|
||||
pub fn emitting(color: u32, level: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24
|
||||
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
|
||||
/// a torch's, it only lights less around it. 0 stays as in SDR.
|
||||
pub const LOW: u8 = 1;
|
||||
pub const INCANDESCENT: u8 = 2;
|
||||
pub const BRIGHT: u8 = 3;
|
||||
/// The sun and lightning.
|
||||
pub const VERY_BRIGHT: u8 = 4;
|
||||
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
|
||||
pub fn emitting(color: u32, tier: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
|
||||
}
|
||||
/// The HDR tier of block `name` (no namespace), which emits light `level`.
|
||||
pub fn hdr_tier(name: &str, level: u8) -> u8 {
|
||||
match name {
|
||||
_ if level == 0 => 0,
|
||||
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
|
||||
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
|
||||
| "sea_lantern" => INCANDESCENT,
|
||||
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
|
||||
// Its flame is a particle.
|
||||
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
|
||||
// A faint glow in the dark, as in SDR.
|
||||
"glow_lichen" => 0,
|
||||
_ if level >= 10 => INCANDESCENT,
|
||||
_ => LOW,
|
||||
}
|
||||
}
|
||||
const UNSET: u32 = u32::MAX;
|
||||
const NO_BIOME: u16 = u16::MAX;
|
||||
@@ -838,17 +955,15 @@ impl Mesher<'_> {
|
||||
}
|
||||
Some(match state.tints[(index as usize).min(1)] {
|
||||
Tint::None => return None,
|
||||
Tint::Grass | Tint::GrassBlock => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
|
||||
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
|
||||
Tint::Water => tints.get(Resolver::Water, x, y, z),
|
||||
Tint::Constant(c) => c,
|
||||
Tint::LilyPad => 0x20_8030,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
Tint::Stem(age) => {
|
||||
let a = u32::from(age);
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
}
|
||||
Tint::Stem(age) => stem_color(age),
|
||||
})
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -864,6 +979,7 @@ impl Mesher<'_> {
|
||||
state: &Info,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
let tier = state.tier.max(hdr_tier("", q.light_emission));
|
||||
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
|
||||
let directional = lighting::cardinal(self.nether_shading, shade_face);
|
||||
// Grey level per vertex before tinting, and vanilla light coords.
|
||||
@@ -903,7 +1019,7 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
face.layer as u16
|
||||
},
|
||||
emitting(pack_rgb(rgb), state.emission.max(q.light_emission)),
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -227,8 +227,11 @@ pub(super) fn tesselate(
|
||||
} else {
|
||||
0xFF_FFFF
|
||||
};
|
||||
// Lava emits light level 15.
|
||||
let emission = if water { 0 } else { 15 };
|
||||
let emission = if water {
|
||||
0
|
||||
} else {
|
||||
crate::mesher::INCANDESCENT
|
||||
};
|
||||
let color = |by: f32| {
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
|
||||
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
|
||||
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
|
||||
/// (block level << 4 | sky level << 20).
|
||||
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
|
||||
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
|
||||
}
|
||||
/// `coords` of a stored light byte (sky level << 4 | block level).
|
||||
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
|
||||
if state.emissive {
|
||||
return FULL_BRIGHT;
|
||||
}
|
||||
let packed = pad.light[pi(p[0], p[1], p[2])];
|
||||
let block = u32::from((packed & 15).max(state.emission));
|
||||
block << 4 | u32::from(packed >> 4) << 20
|
||||
}
|
||||
@@ -274,8 +277,28 @@ pub(super) fn flat(
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
) -> u32 {
|
||||
let cubic = shape(positions, face, own.full).cubic;
|
||||
coords(pad, own, if cubic { step(pos, face) } else { pos })
|
||||
coords(
|
||||
pad,
|
||||
own,
|
||||
flat_position(pos, positions, face, None, own.full),
|
||||
)
|
||||
}
|
||||
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
|
||||
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
|
||||
/// when the quad is cubic, else the block itself). `full` is the block's
|
||||
/// `isCollisionShapeFullBlock`.
|
||||
pub(super) fn flat_position(
|
||||
pos: [i32; 3],
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
cull: Option<D>,
|
||||
full: bool,
|
||||
) -> [i32; 3] {
|
||||
match cull {
|
||||
Some(d) => step(pos, d),
|
||||
None if shape(positions, face, full).cubic => step(pos, face),
|
||||
None => pos,
|
||||
}
|
||||
}
|
||||
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
|
||||
/// and smooth light coords, by vertex index.
|
||||
@@ -398,6 +421,39 @@ mod tests {
|
||||
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
|
||||
}
|
||||
#[test]
|
||||
fn flat_light_reads_the_neighbour_a_face_looks_at() {
|
||||
let pos = [4, 64, -2];
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
];
|
||||
// A cull face samples its neighbour, whatever the quad's shape.
|
||||
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
|
||||
assert_eq!(
|
||||
flat_position(pos, &inset, D::Up, Some(D::Up), false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
// A flush quad without a cull face samples its neighbour too: a block
|
||||
// drawn at a cell that is now opaque (a moving block, #3002) still
|
||||
// takes the light around it.
|
||||
assert_eq!(
|
||||
flat_position(pos, &full_top, D::Up, None, false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
let west = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
];
|
||||
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
|
||||
// An inset quad (a piston head's arm) takes the block's own light.
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
|
||||
}
|
||||
#[test]
|
||||
fn quad_shape_classifies_partial_and_cubic_faces() {
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
|
||||
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
|
||||
assert_eq!(tint("minecraft:pink_petals"), None);
|
||||
assert_eq!(tint("minecraft:stone"), None);
|
||||
}
|
||||
|
||||
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
|
||||
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
|
||||
/// as in the world, not left gray like its particles.
|
||||
#[test]
|
||||
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap();
|
||||
let center = Chunk::empty(0, 0);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
let tints = |name| {
|
||||
tables.block_tints(
|
||||
state(name),
|
||||
[5, 70, 9],
|
||||
®ion,
|
||||
&biomes,
|
||||
BiomeZoom::new(0),
|
||||
2,
|
||||
)
|
||||
};
|
||||
let grass = tints("minecraft:grass_block");
|
||||
assert!(grass(0).is_some());
|
||||
assert_eq!(grass(-1), None);
|
||||
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
|
||||
assert_eq!(tints("minecraft:stone")(0), None);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn info(solid: bool) -> Info {
|
||||
permeable: false,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: false,
|
||||
|
||||
@@ -254,7 +254,7 @@ impl Opt {
|
||||
Opt::NotificationDisplayTime => "options.notifications.display_time",
|
||||
Opt::Narrator => "options.narrator",
|
||||
Opt::TextBackground => "options.accessibility.text_background",
|
||||
Opt::ChatDelay => "Chat Delay",
|
||||
Opt::ChatDelay => "horizon.options.chat_delay",
|
||||
Opt::DistortionEffects => "options.screenEffectScale",
|
||||
Opt::FovEffects => "options.fovEffectScale",
|
||||
Opt::DarknessPulsing => "options.darknessEffectScale",
|
||||
@@ -673,25 +673,25 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "Health",
|
||||
gamepad::Lightbar::Dimension => "Dimension",
|
||||
gamepad::Lightbar::Off => "Off",
|
||||
Opt::PadLightbar => tr(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
|
||||
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
|
||||
gamepad::Lightbar::Off => "options.off",
|
||||
}),
|
||||
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "Auto",
|
||||
GlyphStyle::Xbox => "Xbox",
|
||||
GlyphStyle::PlayStation => "PlayStation",
|
||||
GlyphStyle::Nintendo => "Nintendo",
|
||||
Opt::PadGlyphs => tr(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "horizon.options.glyphs.auto",
|
||||
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "Focus",
|
||||
gamepad::MenuNav::Pointer => "Pointer",
|
||||
Opt::PadMenuNav => tr(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
|
||||
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
|
||||
}),
|
||||
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "Auto",
|
||||
gamepad::TextKeyboard::Horizon => "Horizon",
|
||||
gamepad::TextKeyboard::Steam => "Steam",
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
@@ -1273,22 +1273,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
Page::Graphics => "Graphics, simply: each setting says what it costs.",
|
||||
Page::GraphicsAdvanced => {
|
||||
"Every graphics setting, Horizon's included, with what each costs."
|
||||
}
|
||||
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
}
|
||||
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
Page::ShaderPacks => {
|
||||
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
|
||||
}
|
||||
Page::Mods => {
|
||||
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
|
||||
}
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1421,16 +1413,16 @@ impl OptionsUi {
|
||||
Page::Language => "options.language.title",
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "Horizon Settings",
|
||||
Page::Agent => "AI Agent",
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -1568,21 +1560,23 @@ impl OptionsUi {
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens a page built from vanilla's widgets.
|
||||
let open = |label: &str, page: Page| {
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
|
||||
};
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("Graphics...", Page::Graphics),
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
open("horizon.pages.open.graphics", Page::Graphics),
|
||||
Some(open(
|
||||
"horizon.pages.open.graphics_advanced",
|
||||
Page::GraphicsAdvanced,
|
||||
)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Shader Packs...", Page::ShaderPacks),
|
||||
Some(open("Controller Settings...", Page::Controller)),
|
||||
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
|
||||
Some(open("horizon.pages.open.controller", Page::Controller)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.mods", Page::Mods)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
@@ -1590,7 +1584,7 @@ impl OptionsUi {
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("AI Agent...", Page::Agent)
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -1609,7 +1603,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Copy Connection Details"),
|
||||
tr("horizon.agent.copy_details"),
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
@@ -1623,14 +1617,14 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Horizon Graphics..."),
|
||||
tr("horizon.pages.open.horizon_graphics"),
|
||||
Act::Push(Page::Graphics),
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Advanced Graphics..."),
|
||||
tr("horizon.pages.open.graphics_advanced"),
|
||||
Act::Push(Page::GraphicsAdvanced),
|
||||
)),
|
||||
)];
|
||||
@@ -1723,7 +1717,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Settings..."),
|
||||
tr("horizon.pages.open.controller"),
|
||||
Act::Push(Page::Controller),
|
||||
),
|
||||
None,
|
||||
@@ -1736,7 +1730,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Binds..."),
|
||||
tr("horizon.pages.open.controller_binds"),
|
||||
Act::Push(Page::PadBinds),
|
||||
),
|
||||
None,
|
||||
@@ -1794,9 +1788,7 @@ impl OptionsUi {
|
||||
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
|
||||
Page::Mods => {
|
||||
if s.mods.installed.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No mods: put .hmod files in the horizon-mods folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.mods.empty")];
|
||||
}
|
||||
s.mods
|
||||
.installed
|
||||
@@ -1806,9 +1798,9 @@ impl OptionsUi {
|
||||
let mut label = Text::literal(format!("{}: ", installed.title))
|
||||
.append(on_off(s.mods.is_on(i)));
|
||||
if installed.problem.is_some() {
|
||||
label = label.append(" (Stopped)");
|
||||
label = label.append(tr("horizon.mods.stopped"));
|
||||
} else if installed.dev {
|
||||
label = label.append(" (Dev)");
|
||||
label = label.append(tr("horizon.mods.dev"));
|
||||
}
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A native mod gets its settings page's button beside
|
||||
@@ -1819,7 +1811,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Settings"),
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
);
|
||||
Row::Buttons(
|
||||
@@ -1836,17 +1828,17 @@ impl OptionsUi {
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::ShaderPacks => {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No shader packs: put them in the shaderpacks folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.shader_packs.empty")];
|
||||
}
|
||||
s.shader_packs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, pack)| {
|
||||
let kind = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
|
||||
crate::shader_pack_list::Kind::Classic => "",
|
||||
crate::shader_pack_list::Kind::Horizon => {
|
||||
lang::get("horizon.shader_packs.kind_horizon")
|
||||
}
|
||||
crate::shader_pack_list::Kind::Classic => String::new(),
|
||||
};
|
||||
let label = Text::literal(format!("{}{kind}: ", pack.title))
|
||||
.append(on_off(s.horizon.shader_pack == pack.file));
|
||||
@@ -1906,8 +1898,8 @@ impl OptionsUi {
|
||||
// Vanilla's Telemetry Data and Credits slots: Horizon's
|
||||
// settings and its mods, as a mod adds its button here.
|
||||
// Credits & Attribution (WinScreen's credits) follows them.
|
||||
("Horizon Settings...", Some(Page::Horizon)),
|
||||
("Mods...", Some(Page::Mods)),
|
||||
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
@@ -2340,18 +2332,12 @@ impl OptionsUi {
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
|
||||
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
Text::literal(label),
|
||||
act,
|
||||
));
|
||||
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
} else {
|
||||
out.push(done(h - 27.0));
|
||||
@@ -2464,7 +2450,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
|
||||
tooltip = Some(atlas.font.split(&tr(text), 170.0));
|
||||
}
|
||||
let key = match &wd.kind {
|
||||
WKind::Button {
|
||||
@@ -2491,10 +2477,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& !opt.available()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::ToggleMod(i),
|
||||
@@ -2635,9 +2618,7 @@ impl OptionsUi {
|
||||
if search.value.is_empty() && !search.focused {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal("Search settings...")
|
||||
.with_color(text::GRAY)
|
||||
.italic(),
|
||||
&tr("horizon.options.search").with_color(text::GRAY).italic(),
|
||||
search.text_x(),
|
||||
search.text_y(),
|
||||
u32::MAX,
|
||||
@@ -3446,8 +3427,15 @@ mod tests {
|
||||
ui.show(Page::Mods);
|
||||
let empty = ui.layout(&s, size.0, size.1).0;
|
||||
assert!(empty.iter().any(|w| {
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
|
||||
}));
|
||||
// The empty page points at the bundled key; the English line behind it
|
||||
// is what a player reads, so both halves are checked here.
|
||||
let en = crate::lang::Language::load(&[], "en_us");
|
||||
assert!(
|
||||
en.get("horizon.mods.empty")
|
||||
.is_some_and(|line| line.contains(".hmod"))
|
||||
);
|
||||
let open_folder = empty
|
||||
.iter()
|
||||
.find(|w| {
|
||||
|
||||
@@ -581,7 +581,15 @@ fn build_overlay_uncached(
|
||||
Some(p) => link(p, &target)?,
|
||||
None => {
|
||||
if let Some(Some(z)) = zips.get(*owner) {
|
||||
fs::write(&target, z.read(inner)?)?;
|
||||
// One unreadable entry costs that file, not the
|
||||
// pack: an error here failed the build and the
|
||||
// reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(&target, bytes)?,
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: skipping unreadable pack entry {inner}: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3818,7 +3818,7 @@ impl ParticleEngine {
|
||||
layer: if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
u32::from(hdr_tier(p.ty)) << 16
|
||||
},
|
||||
color,
|
||||
light,
|
||||
@@ -3830,6 +3830,27 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames and
|
||||
/// glowing drops, not their smoke. Only opaque-layer particles are marked.
|
||||
fn hdr_tier(ty: ParticleType) -> u8 {
|
||||
use ParticleType as P;
|
||||
match ty {
|
||||
P::Flame
|
||||
| P::SoulFireFlame
|
||||
| P::CopperFireFlame
|
||||
| P::SmallFlame
|
||||
| P::Lava
|
||||
| P::DrippingLava
|
||||
| P::FallingLava
|
||||
| P::LandingLava
|
||||
| P::DrippingDripstoneLava
|
||||
| P::FallingDripstoneLava => crate::mesher::INCANDESCENT,
|
||||
P::DrippingObsidianTear | P::FallingObsidianTear | P::LandingObsidianTear => {
|
||||
crate::mesher::LOW
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
|
||||
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
|
||||
@@ -468,7 +468,10 @@ pub fn head_skin(
|
||||
{
|
||||
let location = skin_location(&hash);
|
||||
match downloads.state(Kind::Skin, &url) {
|
||||
Download::Ready if atlas(&location) => return location,
|
||||
Download::Ready if atlas(&location) => {
|
||||
downloads.drawn(&location);
|
||||
return location;
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
Download::Ready => {
|
||||
downloads.again(&location);
|
||||
@@ -681,6 +684,8 @@ pub struct Downloader {
|
||||
/// atlas uploads again (`again`); the disk cache backs anything dropped.
|
||||
images: std::collections::HashMap<String, Vec<u8>>,
|
||||
image_order: std::collections::VecDeque<String>,
|
||||
/// Skins and capes drawn from the atlas since `take_drawn`.
|
||||
drawn: std::collections::HashSet<String>,
|
||||
}
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
@@ -697,6 +702,7 @@ impl Downloader {
|
||||
queued: Default::default(),
|
||||
images: Default::default(),
|
||||
image_order: Default::default(),
|
||||
drawn: Default::default(),
|
||||
}
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time.
|
||||
@@ -801,6 +807,77 @@ impl Downloader {
|
||||
}
|
||||
std::mem::take(&mut self.ready)
|
||||
}
|
||||
/// A skin or cape in the atlas drawn this frame: `SkinSlots` keeps its slot.
|
||||
pub fn drawn(&mut self, location: &str) {
|
||||
if !self.drawn.contains(location) {
|
||||
self.drawn.insert(location.to_owned());
|
||||
}
|
||||
}
|
||||
/// The skins and capes drawn since the last call.
|
||||
pub fn take_drawn(&mut self) -> std::collections::HashSet<String> {
|
||||
std::mem::take(&mut self.drawn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which downloaded skins and capes hold the atlas's skin slots
|
||||
/// (`hud::SKIN_SLOTS`). Vanilla's `SkinManager.TextureCache.getOrLoad`
|
||||
/// registers a texture per skin and keeps it. Here the slots are shared: a
|
||||
/// new skin takes a free slot, else the least recently drawn skin's, but
|
||||
/// never one drawn in the last frame. With every slot drawn, the new skin
|
||||
/// waits (its player keeps the default skin) instead of pushing out one in
|
||||
/// use, which then came back by pushing out another, so players vanished
|
||||
/// for a frame and heads swapped textures as the slots went round.
|
||||
pub struct SkinSlots {
|
||||
capacity: usize,
|
||||
/// Stored locations and their slot, the least recently drawn first.
|
||||
slots: Vec<(String, usize)>,
|
||||
/// Drawn in the last frame, or stored since: kept from eviction.
|
||||
kept: std::collections::HashSet<String>,
|
||||
}
|
||||
impl SkinSlots {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
slots: Vec::new(),
|
||||
kept: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Whether `location` is in a slot.
|
||||
pub fn contains(&self, location: &str) -> bool {
|
||||
self.slots.iter().any(|(l, _)| l == location)
|
||||
}
|
||||
/// A new frame: the skins `drawn` in the last one
|
||||
/// (`Downloader::take_drawn`) become the most recently drawn, and keep
|
||||
/// their slots through this frame's `place`s.
|
||||
pub fn next_frame(&mut self, drawn: std::collections::HashSet<String>) {
|
||||
let (recent, old): (Vec<_>, Vec<_>) = std::mem::take(&mut self.slots)
|
||||
.into_iter()
|
||||
.partition(|(l, _)| drawn.contains(l));
|
||||
self.slots = old;
|
||||
self.slots.extend(recent);
|
||||
self.kept = drawn;
|
||||
}
|
||||
/// A slot for `location`: its own, a free one, else the least recently
|
||||
/// drawn one not kept, with the location it held. `None` while every
|
||||
/// slot is kept.
|
||||
pub fn place(&mut self, location: String) -> Option<(usize, Option<String>)> {
|
||||
if let Some((_, slot)) = self.slots.iter().find(|(l, _)| *l == location) {
|
||||
return Some((*slot, None));
|
||||
}
|
||||
let (slot, evicted) = if self.slots.len() < self.capacity {
|
||||
(self.slots.len(), None)
|
||||
} else {
|
||||
let i = self
|
||||
.slots
|
||||
.iter()
|
||||
.position(|(l, _)| !self.kept.contains(l))?;
|
||||
let (old, slot) = self.slots.remove(i);
|
||||
(slot, Some(old))
|
||||
};
|
||||
self.kept.insert(location.clone());
|
||||
self.slots.push((location, slot));
|
||||
Some((slot, evicted))
|
||||
}
|
||||
}
|
||||
|
||||
/// A GET over HTTP or HTTPS (textures.minecraft.net serves both; vanilla
|
||||
@@ -999,6 +1076,63 @@ mod tests {
|
||||
assert!(super::resolve_profile(&super::NameOrId::Name("Notch".into()), &doc).is_none());
|
||||
}
|
||||
|
||||
/// More skins drawn every frame than there are slots (#3829): a frame's
|
||||
/// uploads used to push out skins drawn the frame before, which then
|
||||
/// uploaded again and pushed out others, so the drawn skins went missing
|
||||
/// in turn. Those drawn keep their slots; the rest wait.
|
||||
#[test]
|
||||
fn skins_drawn_last_frame_keep_their_slots() {
|
||||
let mut d = super::Downloader::new(std::env::temp_dir().join("horizon-test-skin-slots"));
|
||||
let skins: Vec<String> = (0..6).map(|i| format!("skins/{i}")).collect();
|
||||
for (i, s) in skins.iter().enumerate() {
|
||||
d.remember(s.clone(), vec![i as u8]);
|
||||
}
|
||||
let mut slots = super::SkinSlots::new(4);
|
||||
let mut last: Option<Vec<String>> = None;
|
||||
for frame in 0..8 {
|
||||
// draw_tab_list: the downloads that finished go to the atlas.
|
||||
slots.next_frame(d.take_drawn());
|
||||
let mut uploads = 0;
|
||||
for (location, _) in d.take_ready() {
|
||||
if slots.place(location).is_some() {
|
||||
uploads += 1;
|
||||
}
|
||||
}
|
||||
// update_remote_skins: every skin is drawn, from its slot, or
|
||||
// asked for again while it has none.
|
||||
let mut shown = Vec::new();
|
||||
for s in &skins {
|
||||
if slots.contains(s) {
|
||||
d.drawn(s);
|
||||
shown.push(s.clone());
|
||||
} else {
|
||||
d.again(s);
|
||||
}
|
||||
}
|
||||
if frame >= 2 {
|
||||
assert_eq!(uploads, 0, "frame {frame} uploaded again");
|
||||
assert_eq!(Some(&shown), last.as_ref(), "frame {frame} lost a skin");
|
||||
}
|
||||
// The first frame asks for the skins; from the next, four show.
|
||||
assert_eq!(shown.len(), if frame == 0 { 0 } else { 4 });
|
||||
last = Some(shown);
|
||||
}
|
||||
// A skin no longer drawn gives up its slot to a waiting one.
|
||||
let mut slots = super::SkinSlots::new(2);
|
||||
slots.next_frame(Default::default());
|
||||
assert_eq!(slots.place("skins/a".into()), Some((0, None)));
|
||||
assert_eq!(slots.place("skins/b".into()), Some((1, None)));
|
||||
assert_eq!(slots.place("skins/c".into()), None);
|
||||
slots.next_frame(["skins/b".to_owned()].into());
|
||||
assert_eq!(
|
||||
slots.place("skins/c".into()),
|
||||
Some((0, Some("skins/a".into())))
|
||||
);
|
||||
// Stored this frame, kept through it.
|
||||
assert_eq!(slots.place("skins/d".into()), None);
|
||||
assert!(slots.contains("skins/b") && slots.contains("skins/c"));
|
||||
}
|
||||
|
||||
/// A skin evicted from the atlas re-uploads (`again`), once per ask.
|
||||
#[test]
|
||||
fn evicted_skins_upload_again() {
|
||||
|
||||
@@ -275,6 +275,11 @@ pub struct Streaming {
|
||||
generation_scan: GenerationScan,
|
||||
/// Proto chunks: `None` while checked out to a decoration or save job.
|
||||
protos: FxHashMap<(i32, i32), Option<Proto>>,
|
||||
/// The light jobs' views of proto chunks past the area (`light_region`),
|
||||
/// built once per proto: each is a neighbour of up to three columns and
|
||||
/// waits in failed regions until the last one loads. Dropped whenever
|
||||
/// the proto is checked out, replaced or removed.
|
||||
light_views: FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
decorated: FxHashSet<(i32, i32)>,
|
||||
decorating: usize,
|
||||
/// Save jobs in flight.
|
||||
@@ -444,8 +449,14 @@ impl GenerationScan {
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
}
|
||||
}
|
||||
// Forced chunks and their generation neighbourhood.
|
||||
let reach = margin - 1;
|
||||
// Forced chunks and their generation neighbourhood: the ring that
|
||||
// is decorated (or finished, without decoration) and the one its
|
||||
// 3x3 needs.
|
||||
let reach = if input.decorates {
|
||||
FORCED_TERRAIN_RING
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for dz in -reach..=reach {
|
||||
for dx in -reach..=reach {
|
||||
@@ -610,6 +621,7 @@ impl Streaming {
|
||||
generating: Default::default(),
|
||||
generation_scan: GenerationScan::default(),
|
||||
protos: Default::default(),
|
||||
light_views: Default::default(),
|
||||
decorated: Default::default(),
|
||||
decorating: 0,
|
||||
saving: 0,
|
||||
@@ -770,6 +782,7 @@ impl Streaming {
|
||||
self.decorated.insert((x, z));
|
||||
for (i, proto) in region.into_iter().enumerate() {
|
||||
let key = (x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1);
|
||||
self.light_views.remove(&key);
|
||||
self.protos.insert(key, Some(proto));
|
||||
self.written.insert(key);
|
||||
}
|
||||
@@ -780,6 +793,7 @@ impl Streaming {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.saving -= 1;
|
||||
for (key, proto) in protos {
|
||||
self.light_views.remove(&key);
|
||||
self.protos.insert(key, Some(proto));
|
||||
}
|
||||
}
|
||||
@@ -787,7 +801,11 @@ impl Streaming {
|
||||
fn save_checked_out(&mut self, keys: impl IntoIterator<Item = (i32, i32)>) {
|
||||
let protos: Vec<((i32, i32), Proto)> = keys
|
||||
.into_iter()
|
||||
.filter_map(|k| Some((k, self.protos.get_mut(&k)?.take()?)))
|
||||
.filter_map(|k| {
|
||||
let proto = self.protos.get_mut(&k)?.take()?;
|
||||
self.light_views.remove(&k);
|
||||
Some((k, proto))
|
||||
})
|
||||
.collect();
|
||||
if !protos.is_empty() {
|
||||
self.saving += 1;
|
||||
@@ -856,7 +874,7 @@ impl Streaming {
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
/// vanilla lights its last FULL ring against neighbours at FEATURES.
|
||||
fn light_region(&self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
@@ -864,8 +882,14 @@ impl Streaming {
|
||||
region[dz][dx] = Some(match world.chunks.get(&n) {
|
||||
Some(chunk) => chunk.clone(),
|
||||
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
|
||||
let proto = self.protos.get(&n)?.as_ref()?;
|
||||
Arc::new((self.light_view)(proto)?)
|
||||
if let Some(view) = self.light_views.get(&n) {
|
||||
view.clone()
|
||||
} else {
|
||||
let proto = self.protos.get(&n)?.as_ref()?;
|
||||
let view = Arc::new((self.light_view)(proto)?);
|
||||
self.light_views.insert(n, view.clone());
|
||||
view
|
||||
}
|
||||
}
|
||||
None => return None,
|
||||
});
|
||||
@@ -975,6 +999,7 @@ impl Streaming {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.generating.remove(&(x, z));
|
||||
if self.in_range(x, z, 4) || self.near_finished(x, z, 2) {
|
||||
self.light_views.remove(&(x, z));
|
||||
self.protos.insert((x, z), Some(proto));
|
||||
if decorated || !self.decorates {
|
||||
self.decorated.insert((x, z));
|
||||
@@ -1083,10 +1108,10 @@ impl Streaming {
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges
|
||||
.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
|
||||
&& !ranges.forced.iter().any(|f| {
|
||||
(x - f.0).abs() <= FORCED_TERRAIN_RING
|
||||
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
|
||||
})
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
@@ -1101,6 +1126,7 @@ impl Streaming {
|
||||
written.push(key);
|
||||
} else {
|
||||
self.protos.remove(&key);
|
||||
self.light_views.remove(&key);
|
||||
}
|
||||
}
|
||||
self.save_checked_out(written);
|
||||
@@ -1130,6 +1156,7 @@ impl Streaming {
|
||||
.remove(&(x, z))
|
||||
.flatten()
|
||||
.expect("present proto");
|
||||
self.light_views.remove(&(x, z));
|
||||
self.finishing.insert((x, z));
|
||||
self.written.remove(&(x, z));
|
||||
self.submit(Some((x, z)), 0, Job::Finish(proto));
|
||||
@@ -1228,6 +1255,7 @@ impl Streaming {
|
||||
let region: Vec<Proto> = keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
self.light_views.remove(k);
|
||||
self.protos
|
||||
.insert(*k, None)
|
||||
.flatten()
|
||||
@@ -1606,6 +1634,14 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
|
||||
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
|
||||
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
|
||||
}
|
||||
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
|
||||
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
|
||||
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
|
||||
/// the column's status (`ChunkLevel.generationStatus`): full (33) two rings
|
||||
/// out, features (34) at three, terrain (35) at four.
|
||||
const FORCED_FULL_RING: i32 = 2;
|
||||
const FORCED_FEATURES_RING: i32 = 3;
|
||||
const FORCED_TERRAIN_RING: i32 = 4;
|
||||
/// The areas kept loaded: circles around the player and `extra`, and forced chunks.
|
||||
struct Ranges<'a> {
|
||||
center: (i32, i32),
|
||||
@@ -1861,7 +1897,16 @@ impl Ranges<'_> {
|
||||
if dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
let reach = (margin - 1).max(0);
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
|
||||
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
|
||||
// vanilla status ring the caller asks for.
|
||||
let reach = match margin {
|
||||
1 => FORCED_FULL_RING,
|
||||
2 => FORCED_FEATURES_RING,
|
||||
3 => FORCED_TERRAIN_RING,
|
||||
_ => margin + 1,
|
||||
};
|
||||
self.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
@@ -2130,9 +2175,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let forced_reach = if input.decorates {
|
||||
FORCED_TERRAIN_RING
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for dz in -(margin - 1)..=(margin - 1) {
|
||||
for dx in -(margin - 1)..=(margin - 1) {
|
||||
for dz in -forced_reach..=forced_reach {
|
||||
for dx in -forced_reach..=forced_reach {
|
||||
let (x, z) = (fx + dx, fz + dz);
|
||||
if missing(x, z) {
|
||||
wanted.push((input.ranges.distance(x, z), x, z));
|
||||
@@ -2534,6 +2584,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/forceload`ed chunk holds a FORCED ticket at level 31
|
||||
/// (`ChunkMap.FORCED_TICKET_LEVEL`): the level rises one per ring
|
||||
/// (`ChunkTracker.computeLevelFromNeighbor`) and picks the column's
|
||||
/// status (`ChunkLevel.generationStatus`). So vanilla finishes the two
|
||||
/// rings around it (33), gives the third its features (34) and the
|
||||
/// fourth its terrain (35), and the rings past that are left alone.
|
||||
#[test]
|
||||
fn forced_chunks_reach_vanillas_rings() {
|
||||
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
|
||||
let r = Ranges {
|
||||
// The player's own area, far away.
|
||||
center: (-400, 300),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
|
||||
for x in 40 - 6..=40 + 6 {
|
||||
for z in -40 - 6..=-40 + 6 {
|
||||
// The full ring: the column becomes a level chunk.
|
||||
assert_eq!(r.in_range(x, z, 1), level(x, z) <= 33, "({x}, {z})");
|
||||
// The features ring, and the terrain ring.
|
||||
assert_eq!(r.in_range(x, z, 2), level(x, z) <= 34, "({x}, {z})");
|
||||
assert_eq!(r.in_range(x, z, 3), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.requested(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.chunk_kept(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.unloaded(x, z, true), level(x, z) > 35, "({x}, {z})");
|
||||
}
|
||||
}
|
||||
// Generation reaches the terrain ring: the features ring is
|
||||
// decorated from it.
|
||||
let mut fixture = GenerationFixture::new(2, true);
|
||||
fixture.center = (-400, 300);
|
||||
fixture.forced = forced;
|
||||
let candidates = GenerationScan::default()
|
||||
.candidates(fixture.input(), usize::MAX)
|
||||
.expect("a scan");
|
||||
for (dx, dz) in [(-4, 0), (4, 0), (0, -4), (0, 4), (3, 3)] {
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.any(|&(_, x, z)| (x, z) == (40 + dx, -40 + dz)),
|
||||
"the terrain ring at ({}, {}) is not generated",
|
||||
40 + dx,
|
||||
-40 + dz
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -146,9 +146,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
// Alpha 0 tells HDR output (hdr.wgsl) a light-emitting block's bright pixels
|
||||
// may shine. Nothing else reads the frame's alpha.
|
||||
let emissive = select(1.0, 0.0, in.emission > 0u);
|
||||
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
|
||||
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
|
||||
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
|
||||
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
|
||||
@@ -77,19 +77,31 @@ impl Zip {
|
||||
// A clone shares the bytes and the central directory: reading needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
|
||||
let (method, size) = (entry.compression(), entry.size() as usize);
|
||||
let method = entry.compression();
|
||||
let mut raw = Vec::with_capacity((entry.compressed_size() as usize).min(self.len));
|
||||
entry.read_to_end(&mut raw)?;
|
||||
match method {
|
||||
CompressionMethod::Stored => Ok(raw),
|
||||
CompressionMethod::Deflated => miniz_oxide::inflate::decompress_to_vec_with_limit(
|
||||
&raw,
|
||||
size.max(1)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(1024)
|
||||
.min(MAX_ENTRY),
|
||||
)
|
||||
.map_err(|_| invalid("corrupt deflate stream")),
|
||||
CompressionMethod::Deflated => {
|
||||
// Java's ZipFile trusts neither size an entry declares: it
|
||||
// inflates the stream to its end and keeps what comes out.
|
||||
// Packs in the wild (a Nexo-generated one served through
|
||||
// Dropbox, for one) carry central-directory sizes that point
|
||||
// at other files, and miniz_oxide, which refuses input past
|
||||
// the stream's end, failed whole entries with "corrupt
|
||||
// deflate stream" and with them the whole pack. Only the
|
||||
// size limit stands between the archive and a zip bomb.
|
||||
let decoder = flate2::read::DeflateDecoder::new(raw.as_slice());
|
||||
let mut decoded = Vec::with_capacity(raw.len().min(MAX_ENTRY));
|
||||
decoder
|
||||
.take(MAX_ENTRY as u64 + 1)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|_| invalid("corrupt deflate stream"))?;
|
||||
if decoded.len() > MAX_ENTRY {
|
||||
return Err(invalid("entry inflates past the size limit"));
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
_ => Err(invalid("unsupported zip compression")),
|
||||
}
|
||||
}
|
||||
@@ -214,6 +226,70 @@ mod tests {
|
||||
assert!(zip.read("missing").is_err());
|
||||
}
|
||||
|
||||
/// The offset of the central-directory header of `name`.
|
||||
fn central_header(bytes: &[u8], name: &str) -> usize {
|
||||
let mut at = 0;
|
||||
while let Some(i) = bytes[at..]
|
||||
.windows(4)
|
||||
.position(|s| s == [0x50, 0x4b, 0x01, 0x02])
|
||||
{
|
||||
let p = at + i;
|
||||
let nlen = u16::from_le_bytes(bytes[p + 28..p + 30].try_into().unwrap()) as usize;
|
||||
if &bytes[p + 46..p + 46 + nlen] == name.as_bytes() {
|
||||
return p;
|
||||
}
|
||||
at = p + 4;
|
||||
}
|
||||
panic!("no central header for {name}");
|
||||
}
|
||||
|
||||
/// Java's `ZipFile` inflates the stream and keeps what comes out, so a
|
||||
/// central directory that names a size other than the stream's (the
|
||||
/// Nexo pack Dropbox serves for one server does) changes nothing.
|
||||
#[test]
|
||||
fn reads_a_deflate_stream_larger_than_its_declared_size() {
|
||||
use std::io::Write;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.start_file(
|
||||
"large.txt",
|
||||
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(&vec![b'x'; 4096]).unwrap();
|
||||
let mut bytes = writer.finish().unwrap().into_inner();
|
||||
let central = central_header(&bytes, "large.txt");
|
||||
bytes[central + 24..central + 28].copy_from_slice(&1u32.to_le_bytes());
|
||||
|
||||
let zip = Zip::from_bytes(bytes).unwrap();
|
||||
assert_eq!(zip.read("large.txt").unwrap(), vec![b'x'; 4096]);
|
||||
}
|
||||
|
||||
/// Some pack writers leave an entry's compressed size longer than its
|
||||
/// deflate stream, so the bytes past the stream's end are part of it.
|
||||
/// Java inflates to the stream's end and ignores them; miniz_oxide
|
||||
/// refused the input and failed the entry, and with it the whole pack.
|
||||
#[test]
|
||||
fn reads_past_trailing_bytes_after_the_deflate_stream() {
|
||||
let mut bytes = written();
|
||||
let name = "Pack-main/assets/minecraft/lang/en_us.json";
|
||||
let central = central_header(&bytes, name);
|
||||
let local =
|
||||
u32::from_le_bytes(bytes[central + 42..central + 46].try_into().unwrap()) as usize;
|
||||
for at in [central + 20, local + 18] {
|
||||
let csize = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
bytes[at..at + 4].copy_from_slice(&(csize + 8).to_le_bytes());
|
||||
}
|
||||
|
||||
let zip = Zip::from_bytes(bytes).unwrap();
|
||||
assert_eq!(
|
||||
zip.read(name).unwrap(),
|
||||
"{\"a\":\"b\"}".repeat(100).as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
/// Loka's server pack (#3413) names its metadata `pack.mcmeta/`.
|
||||
/// `ZipFile.getEntry("pack.mcmeta")` falls back to that folder entry and
|
||||
/// vanilla reads its bytes; a file of the exact name still comes first.
|
||||
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
"horizon.mods.dev": " (Dev)",
|
||||
"horizon.mods.settings": "Settings",
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Copy Connection Details",
|
||||
"horizon.multiplayer.join": "Join",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
"horizon.multiplayer.join_friend": "Join a Friend",
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Kontroler",
|
||||
"horizon.options.vibration": "Wibracje",
|
||||
"horizon.options.adaptive_triggers": "Adaptacyjne spusty",
|
||||
"horizon.options.lightbar": "Pasek świetlny",
|
||||
"horizon.options.look_sensitivity": "Czułość rozglądania",
|
||||
"horizon.options.invert_x": "Odwróć oś X rozglądania",
|
||||
"horizon.options.invert_y": "Odwróć oś Y rozglądania",
|
||||
"horizon.options.sneak_button": "Przycisk skradania",
|
||||
"horizon.options.move_deadzone": "Martwa strefa ruchu",
|
||||
"horizon.options.look_deadzone": "Martwa strefa rozglądania",
|
||||
"horizon.options.button_glyphs": "Symbole przycisków",
|
||||
"horizon.options.menu_navigation": "Nawigacja po menu",
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa"
|
||||
}
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
|
||||
"horizon.options.chat_delay": "Задержка чата",
|
||||
"horizon.options.lightbar.health": "Здоровье",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
|
||||
"horizon.options.search": "Поиск настроек...",
|
||||
"horizon.pages.mods": "Моды",
|
||||
"horizon.pages.shader_packs": "Шейдерпаки",
|
||||
"horizon.pages.controller": "Настройки контроллера",
|
||||
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
|
||||
"horizon.pages.extras": "Дополнения Horizon",
|
||||
"horizon.pages.settings": "Настройки Horizon",
|
||||
"horizon.pages.agent": "ИИ-агент",
|
||||
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
|
||||
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
|
||||
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
|
||||
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
|
||||
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
|
||||
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
|
||||
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
|
||||
"horizon.pages.open.graphics": "Графика...",
|
||||
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
|
||||
"horizon.pages.open.shader_packs": "Шейдерпаки...",
|
||||
"horizon.pages.open.controller": "Настройки контроллера...",
|
||||
"horizon.pages.open.extras": "Дополнения...",
|
||||
"horizon.pages.open.mods": "Моды...",
|
||||
"horizon.pages.open.agent": "ИИ-агент...",
|
||||
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Назначения кнопок...",
|
||||
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
|
||||
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
|
||||
"horizon.mods.stopped": " (остановлен)",
|
||||
"horizon.mods.dev": " (разработка)",
|
||||
"horizon.mods.settings": "Настройки",
|
||||
"horizon.mods.open_folder": "Открыть папку модов",
|
||||
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Скопировать данные подключения",
|
||||
"horizon.multiplayer.join": "Подключиться",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
|
||||
"horizon.multiplayer.join_friend": "Играть с другом",
|
||||
"horizon.multiplayer.invite_code": "Код приглашения",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Block-model tints without a level, including blank and invalid tint indices.
|
||||
use horizon_client::{
|
||||
assets::Assets, blocks::BlockFacts, item_render::ItemRenderer, mesher::MeshTables, models,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let input: Value = serde_json::from_slice(&fs::read(&args[1])?)?;
|
||||
let root = Path::new(input["resources"].as_str().unwrap());
|
||||
let assets = Assets::load(root)?;
|
||||
let render = assets.reload(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
let grass = ItemRenderer::load(root)?.grass_color(0.5, 1.0) as u32;
|
||||
let mut output = BufWriter::new(fs::File::create(&args[2])?);
|
||||
for row in input["cases"].as_array().unwrap() {
|
||||
let state = row[0].as_u64().unwrap() as u32;
|
||||
let index = row[1].as_i64().unwrap() as i32;
|
||||
let color = tables
|
||||
.default_tint(BlockStateId(state), index, grass)
|
||||
.map_or(-1, |rgb| (0xFF00_0000 | rgb) as i32);
|
||||
writeln!(output, "{state} {index} {color}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -150,7 +150,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
mob.extra.crouching = flag("isCrouching");
|
||||
mob.extra.passenger = flag("isPassenger");
|
||||
if flag("isHoldingBow") {
|
||||
mob.extra.main_hand = Some("minecraft:bow".into());
|
||||
mob.extra.set_main_hand(Some("minecraft:bow".into()));
|
||||
}
|
||||
}
|
||||
Species::IronGolem => {
|
||||
@@ -298,7 +298,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
}
|
||||
}
|
||||
Species::Witch if flag("isHoldingItem") => {
|
||||
mob.extra.main_hand = Some("minecraft:potion".into());
|
||||
mob.extra.set_main_hand(Some("minecraft:potion".into()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -386,7 +386,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| kind.model.clone());
|
||||
let mut mob = Mob::new(7, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
|
||||
mob.extra.main_hand = None;
|
||||
mob.extra.set_main_hand(None);
|
||||
let case = &v["case"];
|
||||
let f = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
|
||||
if baby {
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
|
||||
let index = match *slot {
|
||||
"mainhand" => {
|
||||
m.extra.main_hand = item;
|
||||
m.extra.set_main_hand(item);
|
||||
Some(gear::MAIN)
|
||||
}
|
||||
"offhand" => {
|
||||
|
||||
@@ -19,6 +19,7 @@ mod block_entity_anim_fixture;
|
||||
mod block_entity_render_fixture;
|
||||
mod block_entity_tickers_fixture;
|
||||
mod block_particles_fixture;
|
||||
mod block_tint_probe;
|
||||
mod block_transform_fixture;
|
||||
mod breaking_fixture;
|
||||
mod camera_probe;
|
||||
@@ -113,6 +114,7 @@ probes! {
|
||||
block_particles_fixture,
|
||||
block_transform_fixture,
|
||||
breaking_fixture,
|
||||
block_tint_probe,
|
||||
camera_probe,
|
||||
squid_client_probe,
|
||||
creative_mining_probe,
|
||||
|
||||
@@ -198,6 +198,15 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"Float" => "FloatGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"RandomStroll" => "RandomStrollGoal",
|
||||
// AbstractFish's private subclass of RandomSwimmingGoal.
|
||||
"RandomSwimming"
|
||||
if matches!(
|
||||
m.species(),
|
||||
Species::Cod | Species::Salmon | Species::TropicalFish | Species::Pufferfish
|
||||
) =>
|
||||
{
|
||||
"FishSwimGoal"
|
||||
}
|
||||
"RandomSwimming" => "RandomSwimmingGoal",
|
||||
"LookAtPlayer" => "LookAtPlayerGoal",
|
||||
"RandomLookAround" => "RandomLookAroundGoal",
|
||||
|
||||
@@ -7,6 +7,36 @@ pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut output = String::new();
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
if let Some(name) = line.strip_prefix("name\t") {
|
||||
let components = horizon_client::item_data::command_components(&format!(
|
||||
"[minecraft:custom_name={name}]"
|
||||
))?;
|
||||
let (_, extra) = horizon_client::item_data::decode_components(Some(&components));
|
||||
let stack = ItemStack {
|
||||
extra,
|
||||
..ItemStack::new("minecraft:stick", 1)
|
||||
};
|
||||
let text = horizon_client::item_tooltip::styled_name(&stack, || "unused".into());
|
||||
output.push_str("name\t");
|
||||
for span in text.spans {
|
||||
for c in span.text.chars() {
|
||||
let style = &span.style;
|
||||
write!(
|
||||
output,
|
||||
"{},{},{},{},{},{},{}|",
|
||||
c as u32,
|
||||
style.color.unwrap_or(horizon_client::text::WHITE),
|
||||
style.is_bold(),
|
||||
style.is_italic(),
|
||||
style.underlined == Some(true),
|
||||
style.strikethrough == Some(true),
|
||||
style.obfuscated == Some(true)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
output.push('\n');
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
let hide_tooltip: bool = fields[0].parse()?;
|
||||
let creative: bool = fields[1].parse()?;
|
||||
|
||||
@@ -164,6 +164,8 @@ pub struct ArrowState {
|
||||
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
|
||||
pub fired_from_weapon: Option<crate::items::ItemStack>,
|
||||
pub no_physics: bool,
|
||||
/// `Arrow.ID_EFFECT_COLOR` as a client has it from its server (-1: none).
|
||||
pub effect_color: i32,
|
||||
/// `AbstractArrow.getPierceLevel`.
|
||||
pub pierce: u8,
|
||||
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
|
||||
@@ -214,6 +216,7 @@ impl ArrowState {
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
fired_from_weapon: None,
|
||||
no_physics: false,
|
||||
effect_color: -1,
|
||||
pierce: 0,
|
||||
pierced: Vec::new(),
|
||||
fire_ticks: 0,
|
||||
@@ -458,6 +461,10 @@ pub enum EntityHit {
|
||||
Refused,
|
||||
/// The entity turned it first (`Entity.deflection`: breezes).
|
||||
Deflected,
|
||||
/// The target teleported away from it (`EnderMan.hurtServer` returns true,
|
||||
/// `onHitEntity` returns before the hit sound and the discard): the arrow
|
||||
/// flies on and falls to the ground.
|
||||
Dodged,
|
||||
}
|
||||
/// The rest of the tick of an arrow that hit an entity: whether to keep it.
|
||||
pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: EntityHit) -> bool {
|
||||
@@ -480,6 +487,7 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
|
||||
}
|
||||
}
|
||||
EntityHit::Refused => ticker.turn_back(),
|
||||
EntityHit::Dodged => {}
|
||||
EntityHit::Deflected => {
|
||||
// The deflection comes before `onHitEntity` adds the entity to
|
||||
// `piercingIgnoreEntityIds`.
|
||||
|
||||
@@ -263,7 +263,8 @@ mod tests {
|
||||
crate::item_data::OMINOUS_LAYERS.len()
|
||||
);
|
||||
assert!(tooltip_layers(&banner).is_empty());
|
||||
banner.data_mut().custom_name = Some("Captain's Standard".into());
|
||||
banner.data_mut().custom_name =
|
||||
Some(horizon_world::nbt::Tag::String("Captain's Standard".into()));
|
||||
assert!(tooltip_layers(&banner).is_empty());
|
||||
banner.data_mut().ominous_banner = false;
|
||||
assert_eq!(tooltip_layers(&banner), layers(&banner));
|
||||
|
||||
@@ -54,6 +54,28 @@ pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.useOn` on a spawner: the type it sets (`getType`: the egg's
|
||||
/// entity data id, else the egg's own type) and, for an op
|
||||
/// (`PlayerList.isOp`), that entity data for `Spawner.setEntityData`; anyone
|
||||
/// else sets only the type (`setEntityId`).
|
||||
pub fn spawn_egg_on_spawner(
|
||||
stack: &crate::items::ItemStack,
|
||||
op: bool,
|
||||
) -> (String, Option<Compound>) {
|
||||
let data = match stack.data().and_then(|d| d.other("minecraft:entity_data")) {
|
||||
Some(Tag::Compound(c)) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
let id = match data.and_then(|c| c.get(&JavaString::from("id"))) {
|
||||
Some(Tag::String(id)) => id.to_string_lossy(),
|
||||
_ => {
|
||||
let kind = stack.item.trim_end_matches("_spawn_egg");
|
||||
format!("minecraft:{}", kind.trim_start_matches("minecraft:"))
|
||||
}
|
||||
};
|
||||
(id, data.filter(|_| op).cloned())
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
@@ -466,6 +488,35 @@ mod tests {
|
||||
assert_eq!(place(&chest, &patch, true), chest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spawn_egg_sets_its_data_type_and_an_ops_data() {
|
||||
let egg = |entity_data: Option<&str>| {
|
||||
let mut stack = crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1);
|
||||
if let Some(text) = entity_data {
|
||||
let components = snbt(&format!("{{\"minecraft:entity_data\":{text}}}"));
|
||||
(stack.damage, stack.extra) =
|
||||
crate::item_data::decode_components(Some(&components));
|
||||
}
|
||||
stack
|
||||
};
|
||||
// A plain egg: its own type, no data, op or not.
|
||||
assert_eq!(
|
||||
spawn_egg_on_spawner(&egg(None), true),
|
||||
("minecraft:zombie".into(), None)
|
||||
);
|
||||
// Entity data: its type for anyone (getType), its data only for an op.
|
||||
let custom = egg(Some("{id:\"minecraft:skeleton\",CustomName:\"Guard\"}"));
|
||||
let (id, data) = spawn_egg_on_spawner(&custom, false);
|
||||
assert_eq!((id.as_str(), data), ("minecraft:skeleton", None));
|
||||
let (id, data) = spawn_egg_on_spawner(&custom, true);
|
||||
assert_eq!(id, "minecraft:skeleton");
|
||||
let data = data.expect("an op's egg carries its data");
|
||||
assert_eq!(
|
||||
data.get(&JavaString::from("CustomName")),
|
||||
Some(&Tag::String("Guard".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_data_names_its_spawn_and_op_only_types() {
|
||||
let item = |text: &str| {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! `BlockPattern`: a grid of block predicates found in the world in any of
|
||||
//! its 24 orientations (upright, upside down, lying flat, turned sideways).
|
||||
//! The wither is built from these.
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
/// A grid of predicate keys: `aisles[z][y]` is a row whose chars are the `x`
|
||||
/// cells (`BlockPatternBuilder.aisle`). A space matches anything.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BlockPattern {
|
||||
pub aisles: &'static [&'static [&'static str]],
|
||||
}
|
||||
|
||||
/// `WitherSkullBlock.getOrCreateWitherFull`: '^' a wither skeleton skull,
|
||||
/// '#' a wither summon base block, '~' air.
|
||||
pub const WITHER_FULL: BlockPattern = BlockPattern {
|
||||
aisles: &[&["^^^", "###", "~#~"]],
|
||||
};
|
||||
/// `WitherSkullBlock.getOrCreateWitherBase`.
|
||||
pub const WITHER_BASE: BlockPattern = BlockPattern {
|
||||
aisles: &[&[" ", "###", "~#~"]],
|
||||
};
|
||||
/// `BlockPattern.BlockPatternMatch`: where a pattern was found and how it is turned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PatternMatch {
|
||||
pub front_top_left: [i32; 3],
|
||||
pub forwards: Direction,
|
||||
pub up: Direction,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub depth: i32,
|
||||
}
|
||||
|
||||
impl PatternMatch {
|
||||
/// `BlockPatternMatch.getBlock(x, y, z)`'s position.
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> [i32; 3] {
|
||||
translate_and_rotate(self.front_top_left, self.forwards, self.up, x, y, z)
|
||||
}
|
||||
/// The front layer, column by column (`CarvedPumpkinBlock.clearPatternBlocks`
|
||||
/// and `updatePatternBlocks`).
|
||||
pub fn front_cells(&self) -> Vec<[i32; 3]> {
|
||||
(0..self.width)
|
||||
.flat_map(|x| (0..self.height).map(move |y| (x, y)))
|
||||
.map(|(x, y)| self.block(x, y, 0))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockPattern.translateAndRotate`: `x` runs along forwards × up, `y` down
|
||||
/// against up, `z` along forwards.
|
||||
pub fn translate_and_rotate(
|
||||
pos: [i32; 3],
|
||||
forwards: Direction,
|
||||
up: Direction,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
) -> [i32; 3] {
|
||||
let f = forwards.normal();
|
||||
let u = up.normal();
|
||||
let right = [
|
||||
f[1] * u[2] - f[2] * u[1],
|
||||
f[2] * u[0] - f[0] * u[2],
|
||||
f[0] * u[1] - f[1] * u[0],
|
||||
];
|
||||
std::array::from_fn(|i| pos[i] - u[i] * y + right[i] * x + f[i] * z)
|
||||
}
|
||||
|
||||
impl BlockPattern {
|
||||
pub fn width(&self) -> i32 {
|
||||
self.aisles
|
||||
.first()
|
||||
.and_then(|a| a.first())
|
||||
.map_or(0, |row| row.len() as i32)
|
||||
}
|
||||
pub fn height(&self) -> i32 {
|
||||
self.aisles.first().map_or(0, |a| a.len() as i32)
|
||||
}
|
||||
pub fn depth(&self) -> i32 {
|
||||
self.aisles.len() as i32
|
||||
}
|
||||
/// `BlockPattern.matches`: every cell of the pattern with its front top
|
||||
/// left at `corner`, turned by `forwards` and `up`, passes `test(key, pos)`.
|
||||
pub fn matches(
|
||||
&self,
|
||||
corner: [i32; 3],
|
||||
forwards: Direction,
|
||||
up: Direction,
|
||||
test: &mut impl FnMut(char, [i32; 3]) -> bool,
|
||||
) -> Option<PatternMatch> {
|
||||
for x in 0..self.width() {
|
||||
for y in 0..self.height() {
|
||||
for z in 0..self.depth() {
|
||||
let key = self.aisles[z as usize][y as usize]
|
||||
.as_bytes()
|
||||
.get(x as usize)
|
||||
.map_or(' ', |&b| b as char);
|
||||
if key != ' ' && !test(key, translate_and_rotate(corner, forwards, up, x, y, z))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(PatternMatch {
|
||||
front_top_left: corner,
|
||||
forwards,
|
||||
up,
|
||||
width: self.width(),
|
||||
height: self.height(),
|
||||
depth: self.depth(),
|
||||
})
|
||||
}
|
||||
/// `BlockPattern.find`: the first match whose front top left corner lies
|
||||
/// in the cube from `pos` to `pos + size - 1` (x fastest, then y, then z),
|
||||
/// trying every forwards and up pair (`Direction.values()` order) that is
|
||||
/// not along one axis.
|
||||
pub fn find(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
mut test: impl FnMut(char, [i32; 3]) -> bool,
|
||||
) -> Option<PatternMatch> {
|
||||
let size = self.width().max(self.height()).max(self.depth());
|
||||
for dz in 0..size {
|
||||
for dy in 0..size {
|
||||
for dx in 0..size {
|
||||
let corner = [pos[0] + dx, pos[1] + dy, pos[2] + dz];
|
||||
for forwards in Direction::ALL {
|
||||
for up in Direction::ALL {
|
||||
if up == forwards || up == forwards.opposite() {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = self.matches(corner, forwards, up, &mut test) {
|
||||
return Some(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A world of named blocks; anything not listed is air.
|
||||
fn world(blocks: &[([i32; 3], char)]) -> HashMap<[i32; 3], char> {
|
||||
blocks.iter().copied().collect()
|
||||
}
|
||||
/// Each key tests for its own char in the fixture, '~' for air.
|
||||
fn finder(w: &HashMap<[i32; 3], char>) -> impl FnMut(char, [i32; 3]) -> bool + '_ {
|
||||
move |key, p| match key {
|
||||
'~' => !w.contains_key(&p),
|
||||
k => w.get(&p) == Some(&k),
|
||||
}
|
||||
}
|
||||
/// The wither T laid out along `right` and `down` from `top_left`.
|
||||
fn wither(top_left: [i32; 3], right: [i32; 3], down: [i32; 3]) -> Vec<([i32; 3], char)> {
|
||||
let at = |x: i32, y: i32| std::array::from_fn(|i| top_left[i] + right[i] * x + down[i] * y);
|
||||
let mut v: Vec<_> = (0..3).map(|x| (at(x, 0), '^')).collect();
|
||||
v.extend((0..3).map(|x| (at(x, 1), '#')));
|
||||
v.push((at(1, 2), '#'));
|
||||
v
|
||||
}
|
||||
fn found(blocks: &[([i32; 3], char)], placed: [i32; 3]) -> Option<PatternMatch> {
|
||||
let w = world(blocks);
|
||||
WITHER_FULL.find(placed, finder(&w))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_axis_of_rotation_is_tried() {
|
||||
// Each forwards/up pair puts x, y and z along three different axes.
|
||||
for f in Direction::ALL {
|
||||
for u in Direction::ALL {
|
||||
if u == f || u == f.opposite() {
|
||||
continue;
|
||||
}
|
||||
let cells: Vec<_> = (0..2)
|
||||
.flat_map(|x| (0..2).flat_map(move |y| (0..2).map(move |z| (x, y, z))))
|
||||
.map(|(x, y, z)| translate_and_rotate([0; 3], f, u, x, y, z))
|
||||
.collect();
|
||||
let mut unique = cells.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), 8, "{f:?} {u:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wither_builds_in_any_orientation() {
|
||||
let x = [1, 0, 0];
|
||||
let y = [0, 1, 0];
|
||||
let z = [0, 0, 1];
|
||||
let neg = |v: [i32; 3]| v.map(|c| -c);
|
||||
// (right, down) for each layout: upright along X and Z, upside
|
||||
// down, lying flat both ways, and the T turned on its side.
|
||||
let layouts = [
|
||||
("upright along x", x, neg(y)),
|
||||
("upright along z", z, neg(y)),
|
||||
("upside down", x, y),
|
||||
("flat, skulls north", x, z),
|
||||
("flat, skulls east", z, neg(x)),
|
||||
("sideways, skulls west", y, x),
|
||||
("sideways, skulls south", y, neg(z)),
|
||||
];
|
||||
for (name, right, down) in layouts {
|
||||
let top_left = [10, 64, -5];
|
||||
let blocks = wither(top_left, right, down);
|
||||
for skull in 0..3 {
|
||||
let placed: [i32; 3] = std::array::from_fn(|i| top_left[i] + right[i] * skull);
|
||||
let m = found(&blocks, placed).unwrap_or_else(|| panic!("{name}, skull {skull}"));
|
||||
// The wither stands on the middle of the bottom row.
|
||||
let middle: [i32; 3] =
|
||||
std::array::from_fn(|i| top_left[i] + right[i] + down[i] * 2);
|
||||
assert_eq!(m.block(1, 2, 0), middle, "{name}");
|
||||
let mut cells = m.front_cells();
|
||||
cells.sort();
|
||||
let mut expected: Vec<[i32; 3]> = (0..3)
|
||||
.flat_map(|c| (0..3).map(move |r| (c, r)))
|
||||
.map(|(c, r)| std::array::from_fn(|i| top_left[i] + right[i] * c + down[i] * r))
|
||||
.collect();
|
||||
expected.sort();
|
||||
assert_eq!(cells, expected, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wither_faces_along_the_pattern_normal() {
|
||||
// WitherSkullBlock.checkSpawn turns the wither by forwards' axis,
|
||||
// the normal of the T: an upright T along X faces along Z.
|
||||
let along_x = wither([0, 66, 0], [1, 0, 0], [0, -1, 0]);
|
||||
let m = found(&along_x, [0, 66, 0]).unwrap();
|
||||
assert!(matches!(m.forwards, Direction::North | Direction::South));
|
||||
let along_z = wither([0, 66, 0], [0, 0, 1], [0, -1, 0]);
|
||||
let m = found(&along_z, [0, 66, 0]).unwrap();
|
||||
assert!(matches!(m.forwards, Direction::West | Direction::East));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wither_needs_air_beside_the_leg() {
|
||||
let mut blocks = wither([0, 66, 0], [1, 0, 0], [0, -1, 0]);
|
||||
blocks.push(([0, 64, 0], 's'));
|
||||
assert_eq!(found(&blocks, [1, 66, 0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_base_pattern_ignores_the_skull_row() {
|
||||
let base = world(&[
|
||||
([0, 64, 0], '#'),
|
||||
([1, 64, 0], '#'),
|
||||
([2, 64, 0], '#'),
|
||||
([1, 63, 0], '#'),
|
||||
]);
|
||||
assert!(WITHER_BASE.find([0, 65, 0], finder(&base)).is_some());
|
||||
assert!(WITHER_FULL.find([0, 65, 0], finder(&base)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Identity of the ordered block-state registry used by chunk packets.
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Mutex, OnceLock},
|
||||
};
|
||||
|
||||
pub const CHANNEL: &str = "horizon:block_registry";
|
||||
pub const MARKER: &str = "horizon-content-blocks";
|
||||
pub const MISMATCH: &str =
|
||||
"Content block registry mismatch: install the same content blocks as the server.";
|
||||
|
||||
/// Hash the network order and each state's name and properties, not its gameplay facts.
|
||||
pub fn identity(root: &Path) -> Result<[u8; 32], String> {
|
||||
static IDENTITIES: OnceLock<Mutex<HashMap<PathBuf, [u8; 32]>>> = OnceLock::new();
|
||||
let mut identities = IDENTITIES.get_or_init(Default::default).lock().unwrap();
|
||||
if let Some(hash) = identities.get(root) {
|
||||
return Ok(*hash);
|
||||
}
|
||||
let bytes = std::fs::read(root.join("facts.json")).map_err(|e| e.to_string())?;
|
||||
let hash = hash_facts(&bytes)?;
|
||||
identities.insert(root.to_owned(), hash);
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], String> {
|
||||
let facts: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
|
||||
let states = facts
|
||||
.get("states")
|
||||
.and_then(|s| s.as_array())
|
||||
.ok_or("facts.json has no block states")?;
|
||||
let mut hash = Sha256::new();
|
||||
for row in states {
|
||||
let state = row
|
||||
.as_array()
|
||||
.and_then(|r| r.get(4))
|
||||
.ok_or("facts.json has an invalid block state")?;
|
||||
let encoded = serde_json::to_vec(state).map_err(|e| e.to_string())?;
|
||||
hash.update((encoded.len() as u64).to_le_bytes());
|
||||
hash.update(encoded);
|
||||
}
|
||||
Ok(hash.finalize().into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::hash_facts;
|
||||
|
||||
#[test]
|
||||
fn identity_tracks_order_and_properties_not_gameplay_data() {
|
||||
let facts = |a: &str, b: &str| format!(r#"{{"states":[["x",0,0,0,{a}],["y",0,0,0,{b}]]}}"#);
|
||||
let a = r#"{"id":"demo:a","properties":{"facing":"north"}}"#;
|
||||
let b = r#"{"id":"demo:b"}"#;
|
||||
let first = hash_facts(facts(a, b).as_bytes()).unwrap();
|
||||
assert_eq!(first, hash_facts(facts(a, b).as_bytes()).unwrap());
|
||||
assert_ne!(first, hash_facts(facts(b, a).as_bytes()).unwrap());
|
||||
assert_ne!(first, hash_facts(facts(a, a).as_bytes()).unwrap());
|
||||
assert_ne!(
|
||||
first,
|
||||
hash_facts(facts(&a.replace("north", "south"), b).as_bytes()).unwrap()
|
||||
);
|
||||
assert_ne!(
|
||||
first,
|
||||
hash_facts(format!(r#"{{"states":[["x",0,0,0,{a}]]}}"#).as_bytes()).unwrap()
|
||||
);
|
||||
assert!(hash_facts(br#"{"states":[[0,0,0,0,"minecraft:stone"]]}"#).is_ok());
|
||||
assert!(hash_facts(br#"{"states":[[]]}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,18 @@ pub struct Template {
|
||||
pub damage: u32,
|
||||
pub extra: Option<Box<StackData>>,
|
||||
}
|
||||
/// `ItemStackTemplate.CODEC` count: absent means 1, present must be an
|
||||
/// integer in `1..=99` (`ExtraCodecs.intRange(1, 99)`), else the recipe is
|
||||
/// not loaded.
|
||||
pub(crate) fn template_count(v: &Value) -> Option<u32> {
|
||||
match v.get("count") {
|
||||
None => Some(1),
|
||||
Some(c) => c
|
||||
.as_u64()
|
||||
.filter(|n| (1..=99).contains(n))
|
||||
.map(|n| n as u32),
|
||||
}
|
||||
}
|
||||
impl Template {
|
||||
fn parse(v: &Value) -> Option<Self> {
|
||||
let (damage, extra) = match v.get("components") {
|
||||
@@ -101,7 +113,7 @@ impl Template {
|
||||
};
|
||||
Some(Self {
|
||||
item: v["id"].as_str().map(Arc::from),
|
||||
count: v["count"].as_u64().unwrap_or(1) as u32,
|
||||
count: template_count(v)?,
|
||||
damage,
|
||||
extra,
|
||||
})
|
||||
@@ -1640,6 +1652,27 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::banner::BannerLayer;
|
||||
|
||||
#[test]
|
||||
fn result_count_is_bounded_like_vanilla() {
|
||||
let parse = |s: &str| Template::parse(&serde_json::from_str(s).unwrap());
|
||||
assert_eq!(parse(r#"{"id":"minecraft:stone"}"#).unwrap().count, 1);
|
||||
let top = parse(r#"{"id":"minecraft:stone","count":99}"#);
|
||||
assert_eq!(top.unwrap().count, 99);
|
||||
for bad in [
|
||||
"0",
|
||||
"100",
|
||||
"2147483648",
|
||||
"4294967295",
|
||||
"4294967296",
|
||||
"-1",
|
||||
"1.5",
|
||||
"\"2\"",
|
||||
] {
|
||||
let json = format!(r#"{{"id":"minecraft:stone","count":{bad}}}"#);
|
||||
assert!(parse(&json).is_none(), "count {bad} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
fn ingredient(items: &[&str]) -> Ingredient {
|
||||
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! End gateway travel for the entities that step into one:
|
||||
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
|
||||
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
|
||||
//! (the entity goes through on the next tick), and
|
||||
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
|
||||
use crate::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// The End gateway travel of one entity.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Traveller {
|
||||
/// The entity's portal process and cooldown (`Entity.portalProcess`).
|
||||
state: PortalState,
|
||||
}
|
||||
|
||||
impl Traveller {
|
||||
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
|
||||
/// through it on the next tick.
|
||||
pub fn enter(&mut self, gateway: BlockPos) {
|
||||
self.state.set_inside(gateway, ENTITY_DELAY);
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal`: the entity goes through the gateway it entered
|
||||
/// (the End gateway has no transition time), or stays where it is.
|
||||
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, ENTITY_DELAY)
|
||||
}
|
||||
|
||||
/// Whether the entity's travel is over: it has no portal process left and
|
||||
/// its portal cooldown has run out.
|
||||
pub fn is_done(&self) -> bool {
|
||||
self.state.cooldown == 0 && self.state.process.is_none()
|
||||
}
|
||||
|
||||
/// The entity's portal cooldown, the ticks it stays out of a portal.
|
||||
pub fn cooldown(&self) -> i32 {
|
||||
self.state.cooldown
|
||||
}
|
||||
}
|
||||
|
||||
/// The End gateway block an entity's box swept through
|
||||
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
|
||||
pub fn swept_gateway(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
mut is_gateway: impl FnMut(BlockPos) -> bool,
|
||||
) -> Option<BlockPos> {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = horizon_core::geometry::Aabb::new(
|
||||
Vec3::new(to.x - half, to.y, to.z - half),
|
||||
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
|
||||
)
|
||||
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
|
||||
let mut found = None;
|
||||
let _ =
|
||||
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
|
||||
if is_gateway(p) {
|
||||
found = Some(p);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
|
||||
/// lands at a gateway's exit.
|
||||
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gateway of the issue: a block at [702, 100, 0] in the End.
|
||||
const GATEWAY: [i32; 3] = [702, 100, 0];
|
||||
|
||||
fn at(x: f64, y: f64, z: f64) -> Vec3 {
|
||||
Vec3::new(x, y, z)
|
||||
}
|
||||
|
||||
fn is_gateway(p: BlockPos) -> bool {
|
||||
[p.x, p.y, p.z] == GATEWAY
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
|
||||
// A dropped item, 0.25 wide, standing in the gateway block.
|
||||
let item = at(702.5, 100.5, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(item, item, 0.25, 0.25, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
// A cow, 0.9 wide and 1.4 tall, in the same block.
|
||||
let cow = at(702.5, 100.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_out_of_the_gateway_is_not_in_it() {
|
||||
// Two blocks above the gateway: nothing to travel through.
|
||||
let above = at(702.5, 102.5, 0.5);
|
||||
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
|
||||
// Beside the gateway, outside its block.
|
||||
let beside = at(704.5, 100.5, 0.5);
|
||||
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_falling_through_the_gateway_goes_through_it() {
|
||||
// A cow falling past the gateway block passes through it
|
||||
// (Entity.checkInsideBlocks visits the blocks along the way).
|
||||
let from = at(702.5, 103.0, 0.5);
|
||||
let to = at(702.5, 99.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(from, to, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
// Nothing happens while the entity is outside the gateway.
|
||||
assert_eq!(traveller.travel(true), None);
|
||||
// It enters the gateway, and goes through on the next tick.
|
||||
traveller.enter(gateway);
|
||||
assert_eq!(traveller.travel(true), Some(gateway));
|
||||
// Its portal cooldown keeps it from going back at once.
|
||||
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
|
||||
assert!(!traveller.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter(gateway);
|
||||
// A mob that cannot use a portal does not go through.
|
||||
assert_eq!(traveller.travel(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
|
||||
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
|
||||
// centre, the same spot the player arrives at.
|
||||
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
|
||||
}
|
||||
}
|
||||
@@ -872,15 +872,48 @@ impl ArmorStand {
|
||||
Vec3::new(p.x + w, p.y + h, p.z + w),
|
||||
)
|
||||
}
|
||||
/// LivingEntity.travelInAir (armor stands have no other movement): move
|
||||
/// by the velocity (a landing stops the fall,
|
||||
/// `Block.updateEntityMovementAfterFallOn`), then gravity 0.08 and the
|
||||
/// 0.98 drag, so a stand at rest on the ground keeps -0.0784 as vanilla's.
|
||||
pub fn tick(&mut self, scene: &mut crate::collisions::SceneCollisions) {
|
||||
/// `isInWater`: some water block of the stand's box has its surface
|
||||
/// (`FluidState.getHeight`, a full block under more water) at or above the
|
||||
/// box's bottom, as `Entity.updateFluidHeightAndDoFluidPushing` counts it.
|
||||
pub fn in_water(&self, world: &World, fluids: &crate::motion::FluidResources) -> bool {
|
||||
let (lo, hi) = (self.bounds().min_position(), self.bounds().max_position());
|
||||
let water = |x: i32, y: i32, z: i32| {
|
||||
let state = fluids.fluid(world.block(x, y, z));
|
||||
(state.kind() == horizon_world::fluid::FluidKind::Water).then_some(state)
|
||||
};
|
||||
(lo.x.floor() as i32..=hi.x.floor() as i32).any(|x| {
|
||||
(lo.y.floor() as i32..hi.y.ceil() as i32).any(|y| {
|
||||
(lo.z.floor() as i32..=hi.z.floor() as i32).any(|z| {
|
||||
water(x, y, z).is_some_and(|state| {
|
||||
let height = if water(x, y + 1, z).is_some() {
|
||||
1.0
|
||||
} else {
|
||||
state.own_height()
|
||||
};
|
||||
f64::from(y) + f64::from(height) >= lo.y
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// LivingEntity.travel (armor stands have no other movement): move by
|
||||
/// the velocity (a landing stops the fall,
|
||||
/// `Block.updateEntityMovementAfterFallOn`), let the blocks it is inside
|
||||
/// push it (`push`: a bubble column), then in water the 0.8 slowdown and
|
||||
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08 and the 0.98
|
||||
/// drag, so a stand at rest on the ground keeps -0.0784 as vanilla's.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
scene: &mut crate::collisions::SceneCollisions,
|
||||
in_water: bool,
|
||||
push: &mut dyn FnMut(Aabb, &mut Vec3),
|
||||
) {
|
||||
if self.marker {
|
||||
return;
|
||||
}
|
||||
let wanted = self.velocity;
|
||||
// `travelInFluid` reads `isFalling` before the move and any push.
|
||||
let falling = wanted.y <= 0.0;
|
||||
let mut y = wanted.y;
|
||||
if let Ok(moved) = scene.resolve(self.bounds(), wanted, 0.0, self.on_ground) {
|
||||
self.position = Vec3::new(
|
||||
@@ -893,7 +926,23 @@ impl ArmorStand {
|
||||
y = 0.0;
|
||||
}
|
||||
}
|
||||
self.velocity = Vec3::new(0.0, (y - 0.08) * f64::from(0.98f32), 0.0);
|
||||
let mut velocity = Vec3::new(0.0, y, 0.0);
|
||||
push(self.bounds(), &mut velocity);
|
||||
self.velocity = if in_water {
|
||||
// travelInWater: slowDown 0.8, then `getFluidFallingAdjustedMovement`
|
||||
// with gravity / 16 (the stand is falling while its fall is not
|
||||
// already nearly stopped).
|
||||
let y = velocity.y * f64::from(0.8f32);
|
||||
let adjusted =
|
||||
if falling && (y - 0.005).abs() >= 0.003 && (y - 0.08 / 16.0).abs() < 0.003 {
|
||||
-0.003
|
||||
} else {
|
||||
y - 0.08 / 16.0
|
||||
};
|
||||
Vec3::new(0.0, adjusted, 0.0)
|
||||
} else {
|
||||
Vec3::new(0.0, (velocity.y - 0.08) * f64::from(0.98f32), 0.0)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -759,6 +759,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if slot == "mainhand"
|
||||
&& let Some(stack) = m.extra.main_hand_stack()
|
||||
{
|
||||
let mut stack = stack.clone();
|
||||
if let Some(g) = crate::mobs::gear::gear(m) {
|
||||
stack.data_mut().enchantments = g.slots[index].clone();
|
||||
stack.data_mut().trim = g.trims[index].clone();
|
||||
}
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// Enchanted or trimmed gear is saved with its components.
|
||||
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
|
||||
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
|
||||
@@ -782,10 +793,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
match item {
|
||||
Some(id) => {
|
||||
let keep = matches!(
|
||||
equipment.get(&slot.into()).and_then(|e| e.get("id")),
|
||||
Some(Tag::String(s)) if s.to_string_lossy() == **id
|
||||
);
|
||||
let keep = slot != "mainhand"
|
||||
&& matches!(
|
||||
equipment.get(&slot.into()).and_then(|e| e.get("id")),
|
||||
Some(Tag::String(s)) if s.to_string_lossy() == **id
|
||||
);
|
||||
if slot == "head"
|
||||
&& &**id == crate::mobs::raider::BANNER
|
||||
&& crate::mobs::raider::is_raider(m.species())
|
||||
@@ -989,10 +1001,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
m.extra.main_hand = eq
|
||||
.get("mainhand")
|
||||
.and_then(|i| text(i.get("id")))
|
||||
.map(Into::into);
|
||||
m.extra
|
||||
.set_main_hand_stack(eq.get("mainhand").and_then(crate::save::item_from_nbt));
|
||||
m.extra.off_hand = eq
|
||||
.get("offhand")
|
||||
.and_then(|i| text(i.get("id")))
|
||||
@@ -1019,7 +1029,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
} else {
|
||||
// Registration hands out default weapons (finalizeSpawn does in
|
||||
// vanilla); a loaded mob holds only what its file lists.
|
||||
m.extra.main_hand = None;
|
||||
m.extra.set_main_hand(None);
|
||||
}
|
||||
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
|
||||
// Mob.readAdditionalSaveData overrides what the constructor set (copper
|
||||
@@ -2605,7 +2615,10 @@ pub fn bucket_data(m: &Mob) -> Tag {
|
||||
}
|
||||
pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
let mut data = crate::item_data::StackData {
|
||||
custom_name: m.custom_name.clone(),
|
||||
custom_name: m
|
||||
.custom_name
|
||||
.as_ref()
|
||||
.map(|name| Tag::String(name.as_str().into())),
|
||||
..Default::default()
|
||||
};
|
||||
data.set_other("minecraft:bucket_entity_data", Some(bucket_data(m)));
|
||||
@@ -2644,8 +2657,8 @@ 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(name) = stack.custom_name() {
|
||||
m.custom_name = Some(name);
|
||||
}
|
||||
if let Some(data) = components.other("minecraft:bucket_entity_data") {
|
||||
if let Some(v) = number(data.get("Age")) {
|
||||
@@ -2817,7 +2830,7 @@ mod axolotl_nbt_tests {
|
||||
assert_eq!(restored.health, 5.0);
|
||||
m.glowing = false;
|
||||
m.add_effect("glowing", 200, 0);
|
||||
assert!(m.has_effect("glowing"));
|
||||
assert!(m.has_effect("minecraft:glowing"));
|
||||
assert!(bucket_data(&m).get("Glowing").is_none());
|
||||
m.glowing = true;
|
||||
assert_eq!(number(bucket_data(&m).get("Glowing")), Some(1));
|
||||
@@ -3133,3 +3146,56 @@ mod neutral_anger_tests {
|
||||
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod held_item_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<_, _> = data
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(id, kind)| (id, Arc::new(kind)))
|
||||
.collect();
|
||||
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
|
||||
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
|
||||
sword.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Sword".into()));
|
||||
mob.extra.set_main_hand_stack(Some(sword));
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
|
||||
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
|
||||
assert_eq!(
|
||||
loaded
|
||||
.extra
|
||||
.main_hand_stack()
|
||||
.unwrap()
|
||||
.custom_name()
|
||||
.as_deref(),
|
||||
Some("Old Sword")
|
||||
);
|
||||
loaded
|
||||
.extra
|
||||
.set_main_hand(Some("minecraft:diamond_sword".into()));
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
|
||||
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
|
||||
assert_eq!(
|
||||
restored.extra.main_hand.as_deref(),
|
||||
Some("minecraft:diamond_sword")
|
||||
);
|
||||
assert!(
|
||||
restored
|
||||
.extra
|
||||
.main_hand_stack()
|
||||
.unwrap()
|
||||
.custom_name()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1655,6 +1655,9 @@ struct Game<'a> {
|
||||
/// Mobs a player hurt (`LivingEntity.lastHurtByPlayer`), with the guest
|
||||
/// it was by connection (None: the host).
|
||||
last_hurt_by_player: HashMap<u32, Option<u32>>,
|
||||
/// Animals a player fed into love (`Animal.loveCause`), with the guest it was by
|
||||
/// connection (None: the host). Breeding credits them, wherever they are.
|
||||
love_cause: HashMap<u32, Option<u32>>,
|
||||
/// Mobs whose death (`LivingEntity.die`) has been handled.
|
||||
died: HashSet<u32>,
|
||||
/// Client-ticked block-entity animation state (`game/block_entity_anims.rs`).
|
||||
@@ -1830,7 +1833,7 @@ struct Game<'a> {
|
||||
/// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads
|
||||
/// and mannequins) resolve through the services in the background.
|
||||
skin_profiles: RefCell<horizon_client::skins::Resolver>,
|
||||
skin_slots: Vec<(String, usize)>,
|
||||
skin_slots: horizon_client::skins::SkinSlots,
|
||||
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
|
||||
trim_slots: Vec<(String, usize)>,
|
||||
cart_models: minecarts::CartModels,
|
||||
@@ -1984,6 +1987,8 @@ struct Game<'a> {
|
||||
lan_acting_name: Option<String>,
|
||||
/// Their UUID (`Player.getUUID`).
|
||||
lan_acting_uuid: Option<u128>,
|
||||
/// Their permission level: an op's above 0 (`PlayerList.isOp`).
|
||||
lan_acting_permission: Option<u8>,
|
||||
/// Command feedback to forward to a guest.
|
||||
chat_capture: Option<Vec<horizon_client::text::Text>>,
|
||||
/// Packets for the guest acting now, sent once its action returns.
|
||||
@@ -2116,6 +2121,21 @@ fn view_vector(yaw: f32, pitch: f32) -> [f64; 3] {
|
||||
let (y, p) = (f64::from(yaw).to_radians(), f64::from(pitch).to_radians());
|
||||
[-y.sin() * p.cos(), -p.sin(), y.cos() * p.cos()]
|
||||
}
|
||||
/// `Camera.setRotation` (`rotationYXZ(π - yaw, -pitch, 0)`) for the player's
|
||||
/// view, turned round in third-person front view (`Camera.setup`).
|
||||
fn camera_rotation(camera_type: perspective::CameraType, yaw: f32, pitch: f32) -> glam::Quat {
|
||||
let (view_yaw, view_pitch) = if camera_type == perspective::CameraType::ThirdPersonFront {
|
||||
(yaw + 180.0, -pitch)
|
||||
} else {
|
||||
(yaw, pitch)
|
||||
};
|
||||
glam::Quat::from_euler(
|
||||
glam::EulerRot::YXZ,
|
||||
std::f32::consts::PI - view_yaw.to_radians(),
|
||||
-view_pitch.to_radians(),
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn eye_position(&self) -> Option<[f64; 3]> {
|
||||
let p = self.player.as_ref()?;
|
||||
@@ -2348,7 +2368,7 @@ impl Game<'_> {
|
||||
pub(crate) fn sync_player_body(&mut self) {
|
||||
let glider = self.glider_worn();
|
||||
let sleeping = self.sleeping.is_some();
|
||||
let item_use_speed = self
|
||||
let item_in_use = self
|
||||
.using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == shield::OFFHAND_SLOT {
|
||||
@@ -2357,8 +2377,9 @@ impl Game<'_> {
|
||||
self.inventory.get(slot)?.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.map(|i| i.use_speed);
|
||||
.and_then(|s| self.res.items().get(&s.item));
|
||||
let item_use_speed = item_in_use.map(|i| i.use_speed);
|
||||
let item_use_slows_sprint = item_in_use.is_some_and(|i| !i.can_sprint);
|
||||
let respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
@@ -2374,6 +2395,7 @@ impl Game<'_> {
|
||||
player.pitch = self.pitch;
|
||||
player.auto_jump_enabled = self.settings.auto_jump;
|
||||
player.item_use_speed = item_use_speed;
|
||||
player.item_use_slows_sprint = item_use_slows_sprint;
|
||||
player.respiration = respiration;
|
||||
player.glider = glider;
|
||||
let _ = player.set_sleeping(sleeping);
|
||||
@@ -3100,6 +3122,10 @@ impl Game<'_> {
|
||||
self.portals.sections.remove(&key);
|
||||
self.block_ticks
|
||||
.remove_chunk(horizon_core::position::ChunkPos::new(key.0, key.1));
|
||||
// Its moving pistons were saved with it and load back with it.
|
||||
self.block_ticks
|
||||
.moving_pistons
|
||||
.retain(|(p, _)| (p.x >> 4, p.z >> 4) != key);
|
||||
}
|
||||
self.tick_chunks.recycle_added(added);
|
||||
}
|
||||
@@ -3311,10 +3337,20 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
|
||||
.collect();
|
||||
self.world.moving_piston_fallback = previous
|
||||
let mut fallback: Vec<_> = previous
|
||||
.into_iter()
|
||||
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
|
||||
.collect();
|
||||
// A finished piston stays drawn until the chunk mesh shows the block
|
||||
// it placed, however many ticks that takes.
|
||||
let older = std::mem::take(&mut self.world.moving_piston_fallback);
|
||||
for (pos, piston) in older {
|
||||
let replaced = current.iter().chain(&fallback).any(|(p, _)| *p == pos);
|
||||
if !replaced && self.section_mesh_pending(pos) {
|
||||
fallback.push((pos, piston));
|
||||
}
|
||||
}
|
||||
self.world.moving_piston_fallback = fallback;
|
||||
self.world.moving_pistons = current;
|
||||
}
|
||||
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
|
||||
@@ -3326,8 +3362,17 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
let more =
|
||||
self.with_level(|b, level, ticks| b.scheduled_step(level, ticks, fluids));
|
||||
// Due ticks run against one level while each leaves nothing for
|
||||
// the game to apply (level events, spilled items or orbs): the
|
||||
// next one would see the same level built again.
|
||||
let more = self.with_level(|b, level, ticks| {
|
||||
loop {
|
||||
let more = b.scheduled_step(level, ticks, fluids);
|
||||
if !more || !level.events.is_empty() || !level.spill.is_empty() {
|
||||
break more;
|
||||
}
|
||||
}
|
||||
});
|
||||
if !self.level_events.is_empty() {
|
||||
self.process_level_events()?;
|
||||
}
|
||||
@@ -4120,13 +4165,7 @@ impl Game<'_> {
|
||||
hardness != 0.0
|
||||
};
|
||||
if wears && per_block > 0 && max > 0 {
|
||||
let loss = self.wear(&stack, per_block);
|
||||
if let Some(held) = self.inventory[self.selected].as_mut() {
|
||||
held.damage += loss;
|
||||
if held.damage >= max {
|
||||
self.inventory[self.selected] = None;
|
||||
}
|
||||
}
|
||||
self.damage_held(per_block);
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
}
|
||||
@@ -5700,7 +5739,10 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("entity_portals", || self.handle_entity_portals());
|
||||
horizon_client::spans::time("entity_portals", || {
|
||||
self.handle_entity_portals();
|
||||
self.handle_end_gateway_portals();
|
||||
});
|
||||
horizon_client::spans::time("tick_items", || self.tick_items());
|
||||
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
|
||||
}
|
||||
@@ -5723,6 +5765,11 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_portal", || self.tick_nether_portal())?;
|
||||
if running {
|
||||
horizon_client::spans::time("tick_end", || self.tick_end())?;
|
||||
horizon_client::spans::time("end_gateways", || {
|
||||
self.find_end_gateway_portals();
|
||||
self.resolve_end_gateway_arrivals()
|
||||
})?;
|
||||
self.keep_gateway_destinations();
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("tick_falling", || self.tick_falling())?;
|
||||
@@ -8181,18 +8228,7 @@ impl Game<'_> {
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
let (view_yaw, view_pitch) =
|
||||
if self.camera_type == perspective::CameraType::ThirdPersonFront {
|
||||
(self.yaw + 180.0, -self.pitch)
|
||||
} else {
|
||||
(self.yaw, self.pitch)
|
||||
};
|
||||
glam::Quat::from_euler(
|
||||
glam::EulerRot::YXZ,
|
||||
std::f32::consts::PI - view_yaw.to_radians(),
|
||||
-view_pitch.to_radians(),
|
||||
0.0,
|
||||
)
|
||||
camera_rotation(self.camera_type, self.yaw, self.pitch)
|
||||
}
|
||||
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
|
||||
fn entity_geometry(
|
||||
@@ -8322,6 +8358,7 @@ impl Game<'_> {
|
||||
self.guest_model_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
self.block_item_display_geometry(camera, alpha, &mut out);
|
||||
let mut see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
see_through.clear();
|
||||
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
@@ -8350,20 +8387,23 @@ impl Game<'_> {
|
||||
p.z + (q.z - p.z) * a,
|
||||
)
|
||||
};
|
||||
let mut casters: Vec<(glam::DVec3, &str)> =
|
||||
let mut casters: Vec<(glam::DVec3, &str, f32)> =
|
||||
horizon_client::shadows::mob_casters(&self.mobs, a).collect();
|
||||
let fixed = |id: &'static str| (id, horizon_client::shadows::shadow_params(id).0);
|
||||
let (item, item_radius) = fixed("minecraft:item");
|
||||
casters.extend(
|
||||
self.dropped
|
||||
.iter()
|
||||
.map(|i| (lerp(i.previous, i.position), "minecraft:item")),
|
||||
.map(|i| (lerp(i.previous, i.position), item, item_radius)),
|
||||
);
|
||||
let (orb, orb_radius) = fixed("minecraft:experience_orb");
|
||||
casters.extend(
|
||||
self.orbs
|
||||
.iter()
|
||||
.map(|o| (lerp(o.previous, o.position), "minecraft:experience_orb")),
|
||||
.map(|o| (lerp(o.previous, o.position), orb, orb_radius)),
|
||||
);
|
||||
for (pos, id) in casters {
|
||||
let (radius, strength) = horizon_client::shadows::shadow_params(id);
|
||||
for (pos, id, radius) in casters {
|
||||
let strength = horizon_client::shadows::shadow_params(id).1;
|
||||
horizon_client::shadows::shadow(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -8707,13 +8747,12 @@ impl Game<'_> {
|
||||
{
|
||||
return darken;
|
||||
}
|
||||
let mut sky = self
|
||||
.res
|
||||
.environment()
|
||||
.sample(self.world.day_time, None, None);
|
||||
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32;
|
||||
let darken = horizon_client::environment::sky_darken(
|
||||
self.res.environment(),
|
||||
self.world.day_time,
|
||||
rain,
|
||||
thunder,
|
||||
);
|
||||
self.sky_darken_memo.set(Some((key, darken)));
|
||||
darken
|
||||
}
|
||||
@@ -9391,7 +9430,8 @@ impl Game<'_> {
|
||||
path: save_root.join("icon.png"),
|
||||
last_attempt: None,
|
||||
});
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root)
|
||||
.is_some_and(|w| w.preset == horizon_client::save::WorldPreset::Flat);
|
||||
self.block_ticks = Ticks::new(self.level.seed as i32);
|
||||
self.tick_chunks.clear();
|
||||
self.clear_entities();
|
||||
@@ -9692,6 +9732,12 @@ impl Game<'_> {
|
||||
self.saved_for_exit = true;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
|
||||
remote.session.disconnect();
|
||||
self.saved_for_exit = true;
|
||||
return Ok(());
|
||||
}
|
||||
self.close_container();
|
||||
if self.remote.is_none() {
|
||||
self.stats.custom("leave_game", 1);
|
||||
@@ -9699,15 +9745,14 @@ impl Game<'_> {
|
||||
self.lan_close(false);
|
||||
self.free_player_beds()?;
|
||||
self.save_game()?;
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
|
||||
remote.session.disconnect();
|
||||
}
|
||||
self.saved_for_exit = true;
|
||||
Ok(())
|
||||
}
|
||||
fn close_world(&mut self) -> Result<()> {
|
||||
self.clear_waypoints();
|
||||
if self.external_server() {
|
||||
return self.leave_server(None);
|
||||
}
|
||||
self.begin_closing_save(false)
|
||||
}
|
||||
fn finish_close_world(&mut self) -> Result<()> {
|
||||
@@ -9899,6 +9944,7 @@ impl Game<'_> {
|
||||
let (origin, dir) = self.aim()?;
|
||||
let mut t = 0.0;
|
||||
let mut last = origin.map(|v| v.floor() as i32);
|
||||
let mut first = true;
|
||||
while t <= self.reach() {
|
||||
let p = [
|
||||
origin[0] + dir[0] * t,
|
||||
@@ -9906,7 +9952,9 @@ impl Game<'_> {
|
||||
origin[2] + dir[2] * t,
|
||||
];
|
||||
let b = p.map(|v| v.floor() as i32);
|
||||
if b != last {
|
||||
// `BlockGetter.traverseBlocks` visits the block the ray starts in
|
||||
// first: the eyes' own water, under water (#3787).
|
||||
if first || b != last {
|
||||
let state = self.world.block(b[0], b[1], b[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
let face = [last[0] - b[0], last[1] - b[1], last[2] - b[2]];
|
||||
@@ -9916,13 +9964,25 @@ impl Game<'_> {
|
||||
&& !f.waterlogged
|
||||
&& f.render_shape != horizon_client::blocks::RenderShape::Model
|
||||
{
|
||||
return Some(Hit {
|
||||
pos: b,
|
||||
face,
|
||||
point: p,
|
||||
});
|
||||
}
|
||||
if !self.res.facts().is_air(state) && f.fluid == Fluid::None {
|
||||
if !first {
|
||||
return Some(Hit {
|
||||
pos: b,
|
||||
face,
|
||||
point: p,
|
||||
});
|
||||
}
|
||||
let above = self.world.block(b[0], b[1] + 1, b[2]);
|
||||
let same_above = self.res.facts().get(above).fluid == f.fluid;
|
||||
if let Some((face, point)) =
|
||||
clip_start_source(origin, dir, self.reach(), b, same_above)
|
||||
{
|
||||
return Some(Hit {
|
||||
pos: b,
|
||||
face,
|
||||
point,
|
||||
});
|
||||
}
|
||||
} else if !first && !self.res.facts().is_air(state) && f.fluid == Fluid::None {
|
||||
return Some(Hit {
|
||||
pos: b,
|
||||
face,
|
||||
@@ -9930,6 +9990,7 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
last = b;
|
||||
first = false;
|
||||
}
|
||||
t += 0.05;
|
||||
}
|
||||
@@ -10175,7 +10236,7 @@ impl Game<'_> {
|
||||
self.post_block_game_event("minecraft:block_change", at, None, Some(new));
|
||||
}
|
||||
}
|
||||
fn damage_held(&mut self, amount: u32) {
|
||||
pub(super) fn damage_held(&mut self, amount: u32) {
|
||||
if self.creative {
|
||||
return;
|
||||
}
|
||||
@@ -11099,11 +11160,7 @@ impl Game<'_> {
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
|
||||
/// highest block in `blocks_motion_in_heightmap` or holding a fluid.
|
||||
fn rain_height(&self, x: i32, z: i32) -> i32 {
|
||||
use horizon_world::{heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates};
|
||||
let properties = &self.pack.assets.properties;
|
||||
self.world.motion_blocking(x, z, |s| {
|
||||
HeightmapType::MotionBlocking.is_opaque(properties.heightmap_state(s))
|
||||
})
|
||||
render_tick::motion_blocking_height(&self.world, &self.pack.assets.properties, x, z)
|
||||
}
|
||||
fn tick_weather_effects(&mut self) {
|
||||
if self.flash > 0 {
|
||||
@@ -11216,8 +11273,6 @@ impl Game<'_> {
|
||||
let Some((position, yaw)) = self.player.as_ref().map(|p| (p.position, p.yaw)) else {
|
||||
return;
|
||||
};
|
||||
let time = self.world.day_time.rem_euclid(24000);
|
||||
let night = (12542..23460).contains(&time) || self.world.weather.thundering;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let props = self
|
||||
.pack
|
||||
@@ -11310,7 +11365,7 @@ impl Game<'_> {
|
||||
.or(self.spawn)
|
||||
};
|
||||
}
|
||||
if !night {
|
||||
if !(self.dim == Dim::Overworld && self.sky_darken() >= 4) {
|
||||
self.tell_sleeper(
|
||||
"block.minecraft.bed.no_sleep",
|
||||
"You can sleep only at night or during thunderstorms",
|
||||
@@ -11389,7 +11444,8 @@ impl Game<'_> {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = Vec3::new(
|
||||
f64::from(head[0]) + 0.5,
|
||||
f64::from(head[1]) + 0.5625,
|
||||
// setPosToBed: the bed's top (getSleepHeight) and 0.125 above it.
|
||||
f64::from(head[1]) + 0.5625 + 0.125,
|
||||
f64::from(head[2]) + 0.5,
|
||||
);
|
||||
p.velocity = Vec3::ZERO;
|
||||
@@ -11664,13 +11720,21 @@ impl Game<'_> {
|
||||
&& let Some(stack) = self.slot_view(slot)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&stack, false);
|
||||
self.draw_stack_tooltip_with(batch, &stack, self.cursor.0, self.cursor.1, tabs);
|
||||
self.draw_stack_tooltip_with(
|
||||
batch,
|
||||
&stack,
|
||||
false,
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
tabs,
|
||||
);
|
||||
}
|
||||
} else if self.carried.is_none()
|
||||
&& let Some((item, grid)) = hovered
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&item, grid);
|
||||
self.draw_stack_tooltip_with(batch, &item, self.cursor.0, self.cursor.1, tabs);
|
||||
// A `CustomCreativeSlot`: TooltipFlag.asCreative.
|
||||
self.draw_stack_tooltip_with(batch, &item, grid, self.cursor.0, self.cursor.1, tabs);
|
||||
}
|
||||
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
}
|
||||
@@ -12617,7 +12681,6 @@ impl Game<'_> {
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F2 => self.take_screenshot(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing => self.cycle_camera(),
|
||||
KeyCode::KeyE
|
||||
if self.screen == Screen::Creative && self.creative_screen.tab().search => {}
|
||||
@@ -13113,6 +13176,18 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A screenshot is a window shortcut too: vanilla takes it before a
|
||||
// screen or a text box consumes the key (`handleGlobalKeyPress`), so
|
||||
// it works with chat, a sign or a book open.
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& event.state == ElementState::Pressed
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
self.last_input = Instant::now();
|
||||
if self.screenshot_shortcut(key, event.repeat) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// KeyboardHandler.keyPress: the debug keys come before the game's.
|
||||
if let WindowEvent::KeyboardInput {
|
||||
event: key_event, ..
|
||||
@@ -13247,7 +13322,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.creative_screen.drag_scrollbar(self.cursor.1 - oy);
|
||||
}
|
||||
if self.screen == Screen::Inventory {
|
||||
self.loom_drag();
|
||||
self.station_drag();
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative) {
|
||||
self.inventory_drag_move();
|
||||
@@ -13556,7 +13631,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
Screen::Inventory => {
|
||||
self.inventory_mouse_release(button == MouseButton::Right);
|
||||
self.loom_release();
|
||||
self.station_release();
|
||||
}
|
||||
Screen::Chat if pressed && button == MouseButton::Left => self.chat_click(),
|
||||
Screen::Creative if pressed => {
|
||||
@@ -14344,6 +14419,7 @@ impl<'a> Game<'a> {
|
||||
attack_ticks: 100,
|
||||
attack_item: None,
|
||||
last_hurt_by_player: HashMap::new(),
|
||||
love_cause: HashMap::new(),
|
||||
died: HashSet::new(),
|
||||
be_anims: {
|
||||
let mut anims = horizon_client::block_entity_anim::BlockEntityAnims::default();
|
||||
@@ -14455,7 +14531,7 @@ impl<'a> Game<'a> {
|
||||
.join("assets/skins"),
|
||||
)),
|
||||
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
|
||||
skin_slots: Vec::new(),
|
||||
skin_slots: horizon_client::skins::SkinSlots::new(horizon_client::hud::SKIN_SLOTS),
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
@@ -14542,6 +14618,7 @@ impl<'a> Game<'a> {
|
||||
guest_entity_events: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
lan_acting_permission: None,
|
||||
chat_capture: None,
|
||||
admin_feedback: Vec::new(),
|
||||
console_broadcast: None,
|
||||
@@ -16070,6 +16147,64 @@ fn keyboard_button(playing: bool, action: KeyAction) -> Option<bool> {
|
||||
playing.then_some(action != KeyAction::Release)
|
||||
}
|
||||
|
||||
/// `VoxelShape.clip` of a fluid source's shape for a ray that starts in the
|
||||
/// source's own block `cell`: the face and point it hits, in `pick_fluid`'s
|
||||
/// `Hit` terms. `FlowingFluid.getShape` is the whole block under the same
|
||||
/// fluid and `getOwnHeight` (8/9) tall otherwise. A start inside the shape
|
||||
/// (tested 1e-7 of the way along) hits at the start, on the face opposite
|
||||
/// `Direction.getApproximateNearest(dir)`; from above it, a ray going down
|
||||
/// enters through the top while still in this block.
|
||||
fn clip_start_source(
|
||||
eye: [f64; 3],
|
||||
dir: [f64; 3],
|
||||
reach: f64,
|
||||
cell: [i32; 3],
|
||||
same_above: bool,
|
||||
) -> Option<([i32; 3], [f64; 3])> {
|
||||
let height = if same_above {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(8.0f32 / 9.0f32)
|
||||
};
|
||||
let probe_y = eye[1] + dir[1] * reach * 1.0e-7;
|
||||
if probe_y - f64::from(cell[1]) < height {
|
||||
// Direction.values() order; the first of equal dot products wins.
|
||||
let normals: [[i32; 3]; 6] = [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
];
|
||||
let mut nearest = normals[2];
|
||||
let mut best = f64::NEG_INFINITY;
|
||||
for n in normals {
|
||||
let dot =
|
||||
f64::from(n[0]) * dir[0] + f64::from(n[1]) * dir[1] + f64::from(n[2]) * dir[2];
|
||||
if dot > best {
|
||||
best = dot;
|
||||
nearest = n;
|
||||
}
|
||||
}
|
||||
return Some((nearest.map(|v| -v), eye));
|
||||
}
|
||||
if dir[1] >= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let t = (f64::from(cell[1]) + height - eye[1]) / dir[1];
|
||||
if t > reach {
|
||||
return None;
|
||||
}
|
||||
let point = [
|
||||
eye[0] + dir[0] * t,
|
||||
eye[1] + dir[1] * t,
|
||||
eye[2] + dir[2] * t,
|
||||
];
|
||||
(point[0].floor() as i32 == cell[0] && point[2].floor() as i32 == cell[2])
|
||||
.then_some(([0, 1, 0], point))
|
||||
}
|
||||
|
||||
fn reset_world_progress(adv: &mut advancements::AdvState, stats: &mut stats::Stats) {
|
||||
*adv = Default::default();
|
||||
*stats = Default::default();
|
||||
@@ -16300,6 +16435,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Eyes under water: the ray hits the source the eyes are in, at the eyes
|
||||
/// (#3787), whichever way it looks.
|
||||
#[test]
|
||||
fn a_ray_starting_inside_a_source_hits_it_at_the_start() {
|
||||
let eye = [0.5, 65.62, 0.5];
|
||||
let down = [0.0, -1.0, 0.0];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, down, 4.5, [0, 65, 0], true),
|
||||
Some(([0, 1, 0], eye))
|
||||
);
|
||||
// Without water above, 0.62 is still below the surface (8/9).
|
||||
assert_eq!(
|
||||
clip_start_source(eye, [0.0, 1.0, 0.0], 4.5, [0, 65, 0], false),
|
||||
Some(([0, -1, 0], eye))
|
||||
);
|
||||
// Looking ahead, slightly down: SOUTH's dot product is largest.
|
||||
let ahead = [0.0, -0.2f64, 0.98f64.sqrt()];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, ahead, 4.5, [0, 65, 0], true),
|
||||
Some(([0, 0, -1], eye))
|
||||
);
|
||||
}
|
||||
|
||||
/// Eyes above a surface source's 8/9: a ray going down enters it through
|
||||
/// the top inside the block; level or rising rays miss it.
|
||||
#[test]
|
||||
fn a_ray_starting_above_a_source_surface_enters_through_the_top() {
|
||||
let eye = [0.5, 65.95, 0.5];
|
||||
let hit = clip_start_source(eye, [0.0, -1.0, 0.0], 4.5, [0, 65, 0], false);
|
||||
let (face, point) = hit.unwrap();
|
||||
assert_eq!(face, [0, 1, 0]);
|
||||
assert!((point[1] - (65.0 + f64::from(8.0f32 / 9.0f32))).abs() < 1e-12);
|
||||
assert_eq!(
|
||||
clip_start_source(eye, [1.0, 0.0, 0.0], 4.5, [0, 65, 0], false),
|
||||
None
|
||||
);
|
||||
// Leaves the block sideways before it comes down to the surface.
|
||||
let shallow = [0.995f64.sqrt(), -0.0707, 0.0];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, shallow, 4.5, [0, 65, 0], false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_drop_requires_physical_ctrl_even_with_sprint_latched() {
|
||||
for sprint in [false, true] {
|
||||
|
||||
@@ -565,7 +565,7 @@ impl Game<'_> {
|
||||
"hardcore": options.hardcore,
|
||||
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
|
||||
"allow_commands": options.commands(*creative),
|
||||
"superflat": options.flat,
|
||||
"world_type": options.world_type.id(),
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
@@ -578,7 +578,7 @@ impl Game<'_> {
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.flat,
|
||||
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
|
||||
@@ -219,7 +219,7 @@ impl Game<'_> {
|
||||
let holds = m.extra.main_hand.is_some();
|
||||
if !holds && !item.is_empty() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.main_hand = Some(item.into());
|
||||
m.extra.set_main_hand(Some(item.into()));
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
|
||||
@@ -65,8 +65,13 @@ impl ArmorStandAssets {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(part) = load("minecraft:elytra#main") {
|
||||
player.insert("elytra", part);
|
||||
for (key, name) in [
|
||||
("minecraft:elytra#main", "elytra"),
|
||||
("minecraft:elytra_baby#main", "elytra_baby"),
|
||||
] {
|
||||
if let Some(part) = load(key) {
|
||||
player.insert(name, part);
|
||||
}
|
||||
}
|
||||
let mut equipment = HashMap::new();
|
||||
let mut trim_overrides = HashMap::new();
|
||||
@@ -315,9 +320,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `ArmorStandItem.useOn`.
|
||||
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
|
||||
return false;
|
||||
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
|
||||
/// not on a bottom face, and nothing in the new stand's box. The block it
|
||||
/// stands in and the stand.
|
||||
pub(super) fn armor_stand_spawn(&self, hit: &super::Hit) -> Option<([i32; 3], ArmorStand)> {
|
||||
if hit.face == [0, -1, 0] {
|
||||
return None;
|
||||
}
|
||||
// BlockPlaceContext: a replaceable clicked block is replaced.
|
||||
let clicked = self
|
||||
@@ -356,8 +364,18 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.any(|o| overlaps(o.bounds(), bounds))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((pos, stand))
|
||||
}
|
||||
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:armor_stand" {
|
||||
return false;
|
||||
}
|
||||
let Some((pos, stand)) = self.armor_stand_spawn(hit) else {
|
||||
return false;
|
||||
};
|
||||
let foot = stand.position;
|
||||
self.next_entity += 1;
|
||||
self.audio.play(
|
||||
"minecraft:entity.armor_stand.place",
|
||||
@@ -370,6 +388,8 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
// ArmorStandItem.useOn returns SUCCESS: the hand swings.
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The armor stand under the crosshair, its distance and the hit point.
|
||||
@@ -584,7 +604,18 @@ impl Game<'_> {
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
a.tick(&mut scene);
|
||||
let in_water = a.in_water(&self.world, &self.res.motion().fluids);
|
||||
let (world, registry, facts, collision) = (
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
a.tick(&mut scene, in_water, &mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world, registry, facts, collision, bounds, velocity,
|
||||
);
|
||||
});
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
}
|
||||
@@ -779,6 +810,18 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// WingsLayer: ArmorStandRenderer adds it after the armour layers. A stand
|
||||
// never crouches or glides, so its wings rest folded.
|
||||
self.wings_geometry(
|
||||
a.equipment[4].as_ref().map(|s| &*s.item),
|
||||
None,
|
||||
a.small,
|
||||
base,
|
||||
horizon_client::mobs::ElytraAnimation::RESTING,
|
||||
false,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +586,11 @@ impl Game<'_> {
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let up = BlockPos::new(p.x, top.floor() as i32, p.z);
|
||||
// A server's client has no live biome features to ask: it answers from
|
||||
// the chunks and the weather it was sent.
|
||||
if self.remote.is_some() {
|
||||
return self.raining_at(p) || self.raining_at(up);
|
||||
}
|
||||
self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up))
|
||||
}
|
||||
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
|
||||
@@ -782,6 +787,7 @@ impl Game<'_> {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
if self.gui.atlas.sprites.contains_key(&location) {
|
||||
let _ = downloads.state(skins::Kind::Skin, &url);
|
||||
downloads.drawn(&location);
|
||||
return Some(location);
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
|
||||
@@ -1199,6 +1199,20 @@ impl Game<'_> {
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
}
|
||||
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
|
||||
/// `key.screenshot` mapping (F2 by default) takes the picture before a
|
||||
/// screen or a text box sees the key, and the debug modifier suppresses it.
|
||||
pub(super) fn screenshot_shortcut(&mut self, key: KeyCode, repeat: bool) -> bool {
|
||||
if repeat || self.debug_keys.modifier_down {
|
||||
return false;
|
||||
}
|
||||
if horizon_client::keybinds::key_name(key) != Some(self.settings.keys.get("key.screenshot"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.take_screenshot();
|
||||
true
|
||||
}
|
||||
/// After a frame: report a finished F2 screenshot in chat.
|
||||
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
|
||||
let Some(path) = self.ui.screenshot_message.take() else {
|
||||
|
||||
@@ -304,6 +304,16 @@ impl Game<'_> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The display component of a custom-named held stack.
|
||||
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
let mut stack = m.extra.main_hand_stack()?.clone();
|
||||
stack.custom_name()?;
|
||||
if let Some(g) = horizon_client::mobs::gear::gear(m) {
|
||||
stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone();
|
||||
}
|
||||
Some(self.stack_display_name(&stack))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
@@ -357,8 +367,16 @@ impl Game<'_> {
|
||||
}
|
||||
// `getLocalizedDeathMessage`: the causing entity's name, or the
|
||||
// direct entity's when nothing caused the damage.
|
||||
if let Some((_, name)) = killer {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
if let Some((id, name)) = killer {
|
||||
// DamageSource.getLocalizedDeathMessage: the cause's item with a
|
||||
// custom name gets the `<id>.item` message.
|
||||
let (key, args) = horizon_client::scoreboard::death_message_args(
|
||||
&key,
|
||||
&me,
|
||||
&name,
|
||||
self.mob_main_hand_name(id).as_ref(),
|
||||
);
|
||||
return Text::translate(&key, &args);
|
||||
}
|
||||
if let Some(name) = direct {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
|
||||
@@ -1031,9 +1031,9 @@ impl Game<'_> {
|
||||
pub(super) fn stack_display_name(&self, s: &ItemStack) -> Text {
|
||||
let item = &*s.item;
|
||||
let empty = item == "minecraft:air" || s.count == 0;
|
||||
let custom = s.custom_name();
|
||||
let custom = s.data().and_then(|data| data.custom_name.as_ref());
|
||||
let hover = if let Some(c) = custom {
|
||||
Text::literal(c)
|
||||
horizon_client::component::name_text(c)
|
||||
} else if let Some(title) = s
|
||||
.data()
|
||||
.and_then(|d| d.written.as_ref())
|
||||
|
||||
@@ -223,10 +223,9 @@ impl Game<'_> {
|
||||
// getHoverName().getString(): the custom or item name, no brackets.
|
||||
return Err(err(
|
||||
"commands.enchant.failed.incompatible",
|
||||
&[&stack.custom_name().map_or_else(
|
||||
|| self.pack.names.item(&stack.item).to_string(),
|
||||
str::to_owned,
|
||||
)],
|
||||
&[&stack
|
||||
.custom_name()
|
||||
.unwrap_or_else(|| self.pack.names.item(&stack.item).to_string())],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,9 +235,9 @@ impl Game<'_> {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) else {
|
||||
return false;
|
||||
};
|
||||
let item = stack.map(|s| s.item);
|
||||
let item = stack.as_ref().map(|s| s.item.clone());
|
||||
match *key {
|
||||
"mainhand" => m.extra.main_hand = item,
|
||||
"mainhand" => m.extra.set_main_hand_stack(stack),
|
||||
"offhand" => m.extra.off_hand = item,
|
||||
"head" | "body" => m.extra.head = item,
|
||||
"chest" => m.extra.chest = item,
|
||||
@@ -430,9 +430,10 @@ impl Game<'_> {
|
||||
c.insert("minecraft:item_name".into(), name);
|
||||
});
|
||||
} else {
|
||||
let t = horizon_client::packs::component(&m["name"]);
|
||||
stack.extra.get_or_insert_with(Default::default).custom_name =
|
||||
Some(t.plain());
|
||||
// SetNameFunction.run preserves the component when no entity resolver is requested.
|
||||
stack
|
||||
.data_mut()
|
||||
.set_loot_name("minecraft:custom_name", &m["name"]);
|
||||
}
|
||||
}
|
||||
"minecraft:set_enchantments" => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! `/place template` (PlaceCommand): a saved structure template placed with
|
||||
//! rotation, mirroring and integrity.
|
||||
use super::{Arg, Context, Game, R, err};
|
||||
use horizon_client::{save, text::Text};
|
||||
use super::{Arg, Context, Game, R, Screen, err};
|
||||
use horizon_client::{containers::BlockEntity, save, text::Text};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::{
|
||||
compression::Compression,
|
||||
nbt::{self, Limits, Tag},
|
||||
registry::BlockStateId,
|
||||
worldgen::structure::{Mirror, Rotation, StateTransforms, StructureTemplate},
|
||||
};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -182,6 +183,28 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The barrier step of `StructureTemplate.placeInWorld`, run where a block
|
||||
/// with saved data goes, even over the same state: `LevelChunk.setBlockState`
|
||||
/// removes what is there, with no side effects. A removed jukebox stops its
|
||||
/// song (`place_state` does that itself when the block's name changes), and
|
||||
/// a container screen open on it closes.
|
||||
fn clear_template_block_entity(&mut self, at: [i32; 3], state: BlockStateId) {
|
||||
let key = (at[0], at[1], at[2]);
|
||||
self.world.block_names.remove(&key);
|
||||
let Some(replaced) = self.world.block_entities.remove(&key) else {
|
||||
return;
|
||||
};
|
||||
let previous = self.world.block(at[0], at[1], at[2]);
|
||||
let same_name = self.res.facts().get(previous).name == self.res.facts().get(state).name;
|
||||
if same_name && matches!(replaced, BlockEntity::Jukebox(_)) {
|
||||
self.jukebox_removed(at);
|
||||
}
|
||||
if self.container == Some(key) {
|
||||
self.container_opener_event(key, false);
|
||||
self.container = None;
|
||||
self.screen = Screen::Playing;
|
||||
}
|
||||
}
|
||||
pub(super) fn cmd_place(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(Arg::Resource(id)) = ctx.get("template").cloned() else {
|
||||
return Ok(0);
|
||||
@@ -235,6 +258,9 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let state = transforms.rotate(transforms.mirror(b.state, mirror), rotation);
|
||||
if b.nbt.is_some() {
|
||||
self.clear_template_block_entity(at, state);
|
||||
}
|
||||
if self.place_state(at, state, strict).is_ok() {
|
||||
placed += 1;
|
||||
}
|
||||
|
||||
@@ -849,6 +849,10 @@ impl Game<'_> {
|
||||
|
||||
fn control_key_down(&mut self, key: KeyCode) -> Result<()> {
|
||||
self.physical_keys.insert(key);
|
||||
// The window's shortcuts come before any screen, here too.
|
||||
if self.screenshot_shortcut(key, false) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen == Screen::Chat {
|
||||
self.chat_key_code(key, None);
|
||||
return Ok(());
|
||||
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::{
|
||||
Damage, DamageKind, Mob, MobEvent, MobWorld, PlayerView, Seen, SpawnSetup, Species, Who,
|
||||
ai::{eating_sound, feed_animal},
|
||||
ai::{eating_sound, feed_animal, takes_food},
|
||||
farm::Climate,
|
||||
spawning::{self, BiomeFacts},
|
||||
},
|
||||
@@ -807,6 +807,7 @@ impl Game<'_> {
|
||||
self.set_bed_occupied(bed, false);
|
||||
}
|
||||
let killer = self.last_hurt_by_player.remove(&m.id);
|
||||
self.love_cause.remove(&m.id);
|
||||
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
|
||||
let mut dying = m.clone();
|
||||
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
|
||||
@@ -1618,7 +1619,13 @@ impl Game<'_> {
|
||||
self.pop_resource(pos, d);
|
||||
}
|
||||
}
|
||||
self.world.set(pos[0], pos[1], pos[2], self.world.air)?;
|
||||
// Level.destroyBlock: setBlock(fluidState.createLegacyBlock(), 3),
|
||||
// so water stays water and a waterlogged block leaves its water.
|
||||
let b = self.res.behaviours();
|
||||
let left = b.legacy_block(b.fluid(old));
|
||||
if left != old {
|
||||
self.level_set(pos, left)?;
|
||||
}
|
||||
}
|
||||
MobEvent::Particles { kind, at, count } => {
|
||||
// Mob particle bursts (hearts, smoke, splashes) around a point.
|
||||
@@ -1915,9 +1922,9 @@ impl Game<'_> {
|
||||
let Some(p) = self.mobs.iter().find(|m| m.id == parent).cloned() else {
|
||||
return;
|
||||
};
|
||||
// Animal.finalizeSpawnChildFromBreeding: the player who fed them.
|
||||
if p.extra.love_player {
|
||||
self.stats.custom("animals_bred", 1);
|
||||
// Animal.finalizeSpawnChildFromBreeding: the player who fed the parent,
|
||||
// or else the one who fed the partner.
|
||||
if let Some(cause) = breeding_credit(&mut self.love_cause, parent, partner) {
|
||||
let parent = self.mob_entity(&p);
|
||||
let partner_view = self
|
||||
.mobs
|
||||
@@ -1941,7 +1948,14 @@ impl Game<'_> {
|
||||
) {
|
||||
child.kind = "minecraft:mule".into();
|
||||
}
|
||||
self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
|
||||
let who = cause.map_or(
|
||||
super::commands::Target::Player,
|
||||
super::commands::Target::Guest,
|
||||
);
|
||||
self.for_player(who, |g| {
|
||||
g.stats.custom("animals_bred", 1);
|
||||
g.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
|
||||
});
|
||||
}
|
||||
if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) {
|
||||
q.extra.age = 6000;
|
||||
@@ -2132,8 +2146,10 @@ impl Game<'_> {
|
||||
if d.x * d.x + d.y * d.y + d.z * d.z >= 2500.0 || p.health.dead_or_dying() {
|
||||
return false;
|
||||
}
|
||||
let active = self.potions.effects.get("minecraft:mining_fatigue");
|
||||
if active.is_some_and(|e| e.amplifier >= 2 && e.duration >= 1200) {
|
||||
// The player's effects are kept by their bare name. A curse as strong
|
||||
// that won't end within the next minute stays (endsWithin(1199)).
|
||||
let active = self.potions.effects.get("mining_fatigue");
|
||||
if active.is_some_and(|e| e.amplifier >= 2 && !e.ends_within(1199)) {
|
||||
return false;
|
||||
}
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
@@ -2958,7 +2974,15 @@ impl Game<'_> {
|
||||
_ => 0.0,
|
||||
};
|
||||
self.hit_special(id, amount, by_player, false);
|
||||
return Ok(());
|
||||
// ThrowableProjectile.onHit goes on after onHitEntity: a pearl
|
||||
// still takes its owner to where it broke (into the crystal's
|
||||
// blast), an egg still hatches, a snowball still breaks.
|
||||
if !matches!(
|
||||
p.kind,
|
||||
ProjectileKind::EnderPearl | ProjectileKind::Egg | ProjectileKind::Snowball
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if p.arrow.is_some()
|
||||
&& let Hit::Entity {
|
||||
@@ -3029,6 +3053,7 @@ impl Game<'_> {
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let player_fire = self.player.as_ref().map(|pl| pl.fire_ticks);
|
||||
let mut dodged = false;
|
||||
// AbstractArrow.onHitEntity: a burning arrow sets its target alight.
|
||||
if p.arrow.as_ref().is_some_and(|a| a.on_fire()) {
|
||||
self.handle_mob_events(vec![MobEvent::Ignite {
|
||||
@@ -3054,6 +3079,11 @@ impl Game<'_> {
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage(amount, source));
|
||||
dodged = !hurt
|
||||
&& self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
|
||||
m.species() == Species::Enderman
|
||||
&& horizon_client::mobs::enderman::dodges(m, &source)
|
||||
});
|
||||
if p.owner == Some(Who::Player) {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
if hurt && let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
@@ -3171,7 +3201,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.finish_arrow_hit(arrow, EntityHit::Refused);
|
||||
}
|
||||
// Mobs' refusals don't follow hurtServer yet: the arrow is gone.
|
||||
// EnderMan.hurtServer teleported away: the arrow flies on.
|
||||
(false, Who::Mob(_)) if dodged => {
|
||||
self.finish_arrow_hit(arrow, EntityHit::Dodged);
|
||||
}
|
||||
// Mobs' other refusals don't follow hurtServer yet: the arrow is gone.
|
||||
(false, Who::Mob(_)) => {}
|
||||
}
|
||||
}
|
||||
@@ -3261,22 +3295,34 @@ impl Game<'_> {
|
||||
Hit::Entity { who, .. } => match who {
|
||||
Who::Player => {
|
||||
if !self.creative {
|
||||
// SmallFireball.onHitEntity: remember the fire, ignite,
|
||||
// and restore it when the hurt is refused (a raised shield).
|
||||
let remaining = self.player.as_ref().map(|p| p.fire_ticks);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.ignite_for_ticks(100)?;
|
||||
}
|
||||
self.mob_hits_player(
|
||||
let hurt = self.mob_hits_player(
|
||||
5.0,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:fireball",
|
||||
);
|
||||
if !hurt
|
||||
&& let Some(remaining) = remaining
|
||||
&& let Some(pl) = self.player.as_mut()
|
||||
{
|
||||
pl.set_remaining_fire_ticks(remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
let remaining = m.fire_ticks;
|
||||
if !m.kind.flags.fire_immune {
|
||||
m.ignite_for_ticks(100);
|
||||
}
|
||||
m.damage(5.0, source);
|
||||
if !m.damage(5.0, source) {
|
||||
m.fire_ticks = remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3838,10 +3884,11 @@ impl Game<'_> {
|
||||
}
|
||||
consume = true;
|
||||
}
|
||||
// Wolf.mobInteract: Animal.mobInteract first breeds an adult or
|
||||
// grows a baby with the food; only when it did not does the owner
|
||||
// make the pet sit or stand.
|
||||
Species::Wolf
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
|
||||
if m.extra.tame && m.extra.owner == Some(acting) && !(is_food && takes_food(m)) =>
|
||||
{
|
||||
m.extra.sitting = !m.extra.sitting;
|
||||
m.ai.jumping = false;
|
||||
@@ -3922,10 +3969,11 @@ impl Game<'_> {
|
||||
consume = true;
|
||||
eat = eating_sound(m, &mut self.world.random);
|
||||
}
|
||||
// Cat.mobInteract: Animal.mobInteract first breeds an adult or
|
||||
// grows a baby with the food; only when it did not does the owner
|
||||
// make the pet sit or stand.
|
||||
Species::Cat
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
|
||||
if m.extra.tame && m.extra.owner == Some(acting) && !(is_food && takes_food(m)) =>
|
||||
{
|
||||
m.extra.sitting = !m.extra.sitting;
|
||||
self.world.modified.insert((
|
||||
@@ -4092,6 +4140,7 @@ impl Game<'_> {
|
||||
// Animal.setInLove: entity event 18 (a baby that
|
||||
// only grew is not in love).
|
||||
if self.mobs[index].extra.in_love > 0 {
|
||||
self.love_cause.insert(self.mobs[index].id, self.lan_acting);
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
}
|
||||
}
|
||||
@@ -4245,14 +4294,7 @@ impl Game<'_> {
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage);
|
||||
if max > 0 {
|
||||
let loss = self.wear(&stack, 16);
|
||||
let slot = self.held_slot();
|
||||
if let Some(held) = self.stack_in_mut(slot).as_mut() {
|
||||
held.damage += loss;
|
||||
if held.damage >= max {
|
||||
*self.stack_in_mut(slot) = None;
|
||||
}
|
||||
}
|
||||
self.damage_held(16);
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -4371,8 +4413,12 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
// SpawnEggItem.useOn on a spawner sets its mob.
|
||||
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &id) {
|
||||
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
|
||||
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
|
||||
stack,
|
||||
self.acting_player_is_op(),
|
||||
);
|
||||
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
|
||||
if done {
|
||||
self.swing_arm();
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
@@ -4567,6 +4613,7 @@ impl Game<'_> {
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
@@ -4606,14 +4653,7 @@ impl Game<'_> {
|
||||
self.elder_apparition_geometry(alpha, out);
|
||||
self.spawner_geometry(camera, alpha, out);
|
||||
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
|
||||
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
|
||||
let forward = glam::Vec3::new(
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
);
|
||||
let right = forward.cross(glam::Vec3::Y).normalize_or_zero();
|
||||
let up = right.cross(forward).normalize_or_zero();
|
||||
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
|
||||
let live = self.creatures.projectiles.iter().map(|a| {
|
||||
let at = [
|
||||
a.previous.x + (a.position.x - a.previous.x) * alpha,
|
||||
@@ -4651,7 +4691,15 @@ impl Game<'_> {
|
||||
let Some(model) = self.arrow_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/projectiles/arrow").copied() else {
|
||||
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
|
||||
let texture = if a.is_spectral_arrow() {
|
||||
"entity/projectiles/arrow_spectral"
|
||||
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
|
||||
"entity/projectiles/arrow_tipped"
|
||||
} else {
|
||||
"entity/projectiles/arrow"
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(texture).copied() else {
|
||||
continue;
|
||||
};
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
@@ -4831,6 +4879,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrownItemRenderer.submit` turns the item by `CameraRenderState.orientation`
|
||||
/// (`Camera.setRotation`): the billboard's right and up axes are the camera's.
|
||||
/// They stay continuous at pitch ±90, and in third-person front view they are
|
||||
/// the turned-round camera's, not the player's.
|
||||
fn thrown_item_axes(
|
||||
camera_type: super::perspective::CameraType,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
) -> (glam::Vec3, glam::Vec3) {
|
||||
let orientation = super::camera_rotation(camera_type, yaw, pitch);
|
||||
(orientation * glam::Vec3::X, orientation * glam::Vec3::Y)
|
||||
}
|
||||
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
|
||||
/// bucket picks up.
|
||||
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
|
||||
@@ -4986,3 +5046,67 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::perspective::CameraType;
|
||||
use super::thrown_item_axes;
|
||||
|
||||
#[test]
|
||||
fn thrown_item_billboard_does_not_flip_at_straight_up_or_down() {
|
||||
for yaw in [0.0f32, 37.5, 90.0, 180.0, -135.0] {
|
||||
for (edge, near) in [(-90.0f32, -89.9f32), (90.0, 89.9)] {
|
||||
let (right_edge, up_edge) = thrown_item_axes(CameraType::FirstPerson, yaw, edge);
|
||||
let (right_near, up_near) = thrown_item_axes(CameraType::FirstPerson, yaw, near);
|
||||
assert!(
|
||||
right_edge.dot(right_near) > 0.99 && up_edge.dot(up_near) > 0.99,
|
||||
"yaw {yaw}, pitch {edge}: {right_edge}/{up_edge} vs {right_near}/{up_near} at {near}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_billboard_faces_the_front_camera() {
|
||||
// Camera.setup turns the front camera round (yaw + 180, -pitch): its
|
||||
// right is the player's left.
|
||||
let (right, up) = thrown_item_axes(CameraType::FirstPerson, 0.0, 0.0);
|
||||
assert!(right.abs_diff_eq(glam::Vec3::NEG_X, 1e-5), "{right}");
|
||||
assert!(up.abs_diff_eq(glam::Vec3::Y, 1e-5), "{up}");
|
||||
let (right, up) = thrown_item_axes(CameraType::ThirdPersonFront, 0.0, 0.0);
|
||||
assert!(right.abs_diff_eq(glam::Vec3::X, 1e-5), "{right}");
|
||||
assert!(up.abs_diff_eq(glam::Vec3::Y, 1e-5), "{up}");
|
||||
let (right, _) = thrown_item_axes(CameraType::ThirdPersonBack, 0.0, 0.0);
|
||||
assert!(right.abs_diff_eq(glam::Vec3::NEG_X, 1e-5), "{right}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Animal.finalizeSpawnChildFromBreeding`'s player: the one who fed the
|
||||
/// parent, or else the one who fed the partner (`getLoveCause`). `Some(None)`
|
||||
/// is the host, `Some(Some(conn))` a guest. Both animals are spent.
|
||||
fn breeding_credit(
|
||||
love_cause: &mut std::collections::HashMap<u32, Option<u32>>,
|
||||
parent: u32,
|
||||
partner: u32,
|
||||
) -> Option<Option<u32>> {
|
||||
let from_parent = love_cause.remove(&parent);
|
||||
let from_partner = love_cause.remove(&partner);
|
||||
from_parent.or(from_partner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod breeding_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_player_who_fed_either_animal_is_credited() {
|
||||
let mut love = std::collections::HashMap::from([(1, Some(7)), (2, Some(9))]);
|
||||
// The parent's feeder first, and both animals are spent.
|
||||
assert_eq!(breeding_credit(&mut love, 1, 2), Some(Some(7)));
|
||||
assert!(love.is_empty());
|
||||
// Only the partner was fed: still credited.
|
||||
let mut love = std::collections::HashMap::from([(2, None)]);
|
||||
assert_eq!(breeding_credit(&mut love, 1, 2), Some(None));
|
||||
// Neither was fed by a player (a bred pair loaded from a save).
|
||||
assert_eq!(breeding_credit(&mut love, 1, 2), None);
|
||||
}
|
||||
}
|
||||
@@ -391,15 +391,14 @@ impl Lists {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset`.
|
||||
const WORLD_PRESETS: [&str; 7] = [
|
||||
"minecraft:normal",
|
||||
"minecraft:flat",
|
||||
"minecraft:large_biomes",
|
||||
"minecraft:amplified",
|
||||
"minecraft:single_biome_surface",
|
||||
"minecraft:debug_all_block_states",
|
||||
"minecraft:flat_all_dimensions",
|
||||
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
|
||||
/// names, before the presets had their own ids.
|
||||
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
("default", horizon_client::save::WorldPreset::Normal),
|
||||
(
|
||||
"largebiomes",
|
||||
horizon_client::save::WorldPreset::LargeBiomes,
|
||||
),
|
||||
];
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
@@ -416,11 +415,18 @@ impl Game<'_> {
|
||||
/// DedicatedServerProperties.createDimensions for a world that doesn't
|
||||
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
|
||||
/// and `generate-structures`, written before the world opens so that
|
||||
/// streaming reads them. Horizon generates the normal and flat presets.
|
||||
/// streaming reads them.
|
||||
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
|
||||
let level_type = &props.level_type;
|
||||
let preset = parse_identifier(level_type)
|
||||
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
|
||||
.as_deref()
|
||||
.and_then(horizon_client::save::WorldPreset::of_id)
|
||||
.or_else(|| {
|
||||
LEGACY_PRESET_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == level_type)
|
||||
.map(|(_, preset)| *preset)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
@@ -429,18 +435,9 @@ impl Game<'_> {
|
||||
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
|
||||
),
|
||||
);
|
||||
"minecraft:normal".to_owned()
|
||||
horizon_client::save::WorldPreset::Normal
|
||||
});
|
||||
let flat = preset == "minecraft:flat";
|
||||
if !flat && preset != "minecraft:normal" {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
"WARN",
|
||||
&format!(
|
||||
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
|
||||
),
|
||||
);
|
||||
}
|
||||
let flat = preset == horizon_client::save::WorldPreset::Flat;
|
||||
let settings = if flat {
|
||||
let biomes = self.res.biomes();
|
||||
let (settings, errors) = horizon_client::flatgen::settings_from_json(
|
||||
@@ -456,7 +453,7 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
let options = horizon_client::save::WorldGen {
|
||||
flat,
|
||||
preset,
|
||||
structures: props.generate_structures,
|
||||
bonus_chest: false,
|
||||
};
|
||||
|
||||
@@ -296,7 +296,7 @@ pub(super) fn mob_set_slot(m: &mut Mob, slot: Slot, stack: ItemStack) {
|
||||
let item: Arc<str> = stack.item.clone();
|
||||
let x = &mut m.extra;
|
||||
match slot {
|
||||
Slot::MainHand => x.main_hand = Some(item),
|
||||
Slot::MainHand => x.set_main_hand(Some(item)),
|
||||
Slot::OffHand => x.off_hand = Some(item),
|
||||
Slot::Feet => x.feet = Some(item),
|
||||
Slot::Legs => x.legs = Some(item),
|
||||
@@ -1456,30 +1456,14 @@ impl Game<'_> {
|
||||
if equipped { CLICK } else { FAIL },
|
||||
))
|
||||
}
|
||||
/// `WitherSkullBlock.getOrCreateWitherBase().find(level, pos)`: the soul
|
||||
/// sand T below the row `head` is in.
|
||||
/// `WitherSkullBlock.getOrCreateWitherBase().find(level, pos)`: a soul
|
||||
/// sand T for the skull row, in any orientation.
|
||||
fn can_build_wither(&self, head: Pos) -> bool {
|
||||
let soul = |p: Pos| {
|
||||
matches!(
|
||||
&*self.dispenser_block_name(p),
|
||||
"minecraft:soul_sand" | "minecraft:soul_soil"
|
||||
)
|
||||
};
|
||||
let air = |p: Pos| self.res.facts().is_air(self.block_at(p));
|
||||
for (ax, az) in [(1, 0), (0, 1)] {
|
||||
for col in 0..3 {
|
||||
let left = (head.0 - ax * col, head.1, head.2 - az * col);
|
||||
let at = |c: i32, row: i32| (left.0 + ax * c, left.1 - row, left.2 + az * c);
|
||||
if (0..3).all(|c| soul(at(c, 1)))
|
||||
&& soul(at(1, 2))
|
||||
&& air(at(0, 2))
|
||||
&& air(at(2, 2))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
self.find_wither_pattern(
|
||||
horizon_client::block_pattern::WITHER_BASE,
|
||||
[head.0, head.1, head.2],
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
fn dispense_carved_pumpkin(
|
||||
&mut self,
|
||||
|
||||
@@ -1963,16 +1963,8 @@ impl Game<'_> {
|
||||
if zone.intersects(p.bounds) {
|
||||
let (xd, zd) = (p.position.x - bc, p.position.z - bz);
|
||||
let dd = (xd * xd + zd * zd).max(0.1);
|
||||
self.as_fight_player(p.who, |g| {
|
||||
if let Some(pl) = g.player.as_mut() {
|
||||
pl.velocity.x += xd / dd * 4.0;
|
||||
pl.velocity.y += 0.2;
|
||||
pl.velocity.z += zd / dd * 4.0;
|
||||
}
|
||||
if !sitting {
|
||||
g.dragon_hurts_player("minecraft:mob_attack", 5.0);
|
||||
}
|
||||
});
|
||||
let push = Vec3::new(xd / dd * 4.0, f64::from(0.2_f32), zd / dd * 4.0);
|
||||
self.as_fight_player(p.who, |g| g.dragon_wing_knock_back(push, sitting));
|
||||
}
|
||||
}
|
||||
// hurt: the head and the neck.
|
||||
@@ -2288,10 +2280,37 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.knockBack` on the player in a wing's zone: pushed away
|
||||
/// from the body, and hurt for 5 unless the dragon is perched
|
||||
/// (`isSitting`) or a mob hurt the player within the last 2 ticks
|
||||
/// (`getLastHurtByMobTimestamp() < tickCount - 2`).
|
||||
fn dragon_wing_knock_back(&mut self, push: Vec3, sitting: bool) {
|
||||
let now = self.world.game_time;
|
||||
let hurt_lately = self.player_hurt_by().is_some_and(|(_, at)| at >= now - 2);
|
||||
let hurts = !sitting && !hurt_lately && !self.creative;
|
||||
if hurts {
|
||||
self.note_dragon_attacker("minecraft:mob_attack");
|
||||
}
|
||||
let (health, rules) = (&self.motion.health, self.world.health_rules);
|
||||
let id = health.damage.id("minecraft:mob_attack");
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
push_seen_with_hurt(p, push, |p| {
|
||||
if let Some(id) = id.filter(|_| hurts) {
|
||||
let _ = p.hurt(health, rules, id, 5.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Damage from the dragon, which is its causing entity
|
||||
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
|
||||
/// death message names it, or the cloud once the dragon is gone.
|
||||
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
|
||||
self.note_dragon_attacker(damage_type);
|
||||
self.hurt_player_by(damage_type, amount);
|
||||
}
|
||||
/// The causing entity of the dragon's next `damage_type` damage, for the
|
||||
/// death message.
|
||||
fn note_dragon_attacker(&mut self, damage_type: &str) {
|
||||
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
|
||||
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
|
||||
self.note_named_attacker(damage_type, id, name);
|
||||
@@ -2299,7 +2318,6 @@ impl Game<'_> {
|
||||
let name = Text::translate("entity.minecraft.area_effect_cloud", &[]);
|
||||
self.note_direct_entity(damage_type, name);
|
||||
}
|
||||
self.hurt_player_by(damage_type, amount);
|
||||
}
|
||||
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
|
||||
/// (or a server's packets) put on this client's overlay.
|
||||
@@ -3603,6 +3621,28 @@ fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out:
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.push` on the server's copy of a player, then `hurt`. A player's
|
||||
/// own client hears of its velocity only when a hurt marks it
|
||||
/// (`LivingEntity.hurtServer`'s `markHurt`, sent by
|
||||
/// `ServerEntity.sendChanges`); `push` alone only sets `needsSync`, which
|
||||
/// reaches the players tracking them, not them. So the push moves the player
|
||||
/// only together with a hurt that lands outside the hurt cooldown, and is
|
||||
/// dropped otherwise.
|
||||
fn push_seen_with_hurt(
|
||||
p: &mut horizon_client::motion::PlayerMotion,
|
||||
push: Vec3,
|
||||
hurt: impl FnOnce(&mut horizon_client::motion::PlayerMotion),
|
||||
) {
|
||||
let before = p.velocity;
|
||||
let marked_before = std::mem::take(&mut p.health.sync_velocity);
|
||||
p.velocity = before.add_vector(push);
|
||||
hurt(p);
|
||||
if !p.health.sync_velocity {
|
||||
p.velocity = before;
|
||||
}
|
||||
p.health.sync_velocity |= marked_before;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3747,6 +3787,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2893: a player under a wing is pushed only when the wing's hurt
|
||||
/// lands, not on every tick they stay there, and never under a perched
|
||||
/// dragon's wing.
|
||||
#[test]
|
||||
fn a_wing_pushes_the_player_only_with_a_landed_hurt() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let resources = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let (health, rules) = (&resources.health, Default::default());
|
||||
let id = health.damage.id("minecraft:mob_attack").unwrap();
|
||||
let push = Vec3::new(0.5, f64::from(0.2_f32), -0.25);
|
||||
let wing = |p: &mut horizon_client::motion::PlayerMotion, sitting: bool| {
|
||||
push_seen_with_hurt(p, push, |p| {
|
||||
if !sitting {
|
||||
let _ = p.hurt(health, rules, id, 5.0);
|
||||
}
|
||||
});
|
||||
};
|
||||
// In flight: the first tick's hurt lands and carries the push.
|
||||
let mut p = horizon_client::motion::PlayerMotion::new(&resources, Vec3::ZERO).unwrap();
|
||||
wing(&mut p, false);
|
||||
assert_eq!(p.velocity, push);
|
||||
assert_eq!(p.health.health(), 15.0);
|
||||
// The next 9 ticks are inside the hurt cooldown: no hurt, no push.
|
||||
for _ in 0..9 {
|
||||
p.health.advance_clock();
|
||||
wing(&mut p, false);
|
||||
}
|
||||
assert_eq!(p.velocity, push, "pushed again with no hurt");
|
||||
assert_eq!(p.health.health(), 15.0);
|
||||
// Ten ticks on, the next hurt lands and pushes again.
|
||||
p.health.advance_clock();
|
||||
wing(&mut p, false);
|
||||
assert_eq!(p.velocity, push.add_vector(push));
|
||||
assert_eq!(p.health.health(), 10.0);
|
||||
// Under a perched dragon's wing nothing reaches the player.
|
||||
let mut p = horizon_client::motion::PlayerMotion::new(&resources, Vec3::ZERO).unwrap();
|
||||
for _ in 0..20 {
|
||||
wing(&mut p, true);
|
||||
}
|
||||
assert_eq!(p.velocity, Vec3::ZERO, "pushed by a perched dragon");
|
||||
assert!(!p.health.sync_velocity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dragon_targets_any_player_it_can_attack() {
|
||||
let at = Vec3::new(0.0, 64.0, 0.0);
|
||||
|
||||
@@ -480,6 +480,15 @@ impl Game<'_> {
|
||||
.is_some_and(|i| s.damage >= i.max_damage)
|
||||
{
|
||||
self.armor[slot] = None;
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.audio.play(
|
||||
"minecraft:entity.item.break",
|
||||
Some(eye),
|
||||
0.8,
|
||||
0.8 + self.world.random.next_float() * 0.4,
|
||||
);
|
||||
}
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
|
||||
//! inactive until the dragon dies.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::nether_portal::ENTITY_DELAY;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
@@ -16,10 +17,18 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// A list of doubles, as an NBT list of them.
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
/// An entity travelling through an End gateway: its saved compound and the
|
||||
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct GatewayArrival {
|
||||
/// The entity's compound, as it waits for the exit's chunks.
|
||||
tag: Tag,
|
||||
/// The gateway's exit, or the search for one.
|
||||
job: GatewayJob,
|
||||
/// The spot to load before the job is resolved.
|
||||
target: Vec3,
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
@@ -94,6 +115,11 @@ pub(super) struct EndState {
|
||||
job: Option<GatewayJob>,
|
||||
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
|
||||
gateway_cooldown: u32,
|
||||
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
|
||||
/// The entities that went through a gateway, waiting for its exit.
|
||||
arrivals: Vec<GatewayArrival>,
|
||||
/// The chunks kept loaded for those arrivals.
|
||||
areas: Vec<(i32, i32)>,
|
||||
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
|
||||
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
|
||||
/// Thrown eyes of ender in flight.
|
||||
@@ -947,6 +973,17 @@ impl Game<'_> {
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// The exit a gateway already leads to: its block entity, or the side file
|
||||
/// that kept it for a gateway whose chunk is not loaded.
|
||||
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let saved = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
@@ -1043,13 +1080,7 @@ impl Game<'_> {
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
};
|
||||
let bottom_center = horizon_client::end_gateway::bottom_center;
|
||||
let find_exit = |game: &Self, exit: [i32; 3]| {
|
||||
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
|
||||
[p[0], p[1] + 1, p[2]]
|
||||
@@ -1059,6 +1090,14 @@ impl Game<'_> {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
// The player and the entities may resolve the same gateway: the
|
||||
// first one to arrive finds the exit, the others use it.
|
||||
let known = self.known_gateway_exit(gateway);
|
||||
if let Some((exit, exact)) = known {
|
||||
return Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})));
|
||||
}
|
||||
// findExitPortalXZPosTentative over the loaded chunks.
|
||||
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero();
|
||||
@@ -1420,6 +1459,194 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- End gateway travel for the entities ----
|
||||
|
||||
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
|
||||
/// gateway blocks their box swept through. A gateway that is cooling down
|
||||
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
|
||||
pub(super) fn find_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let items: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, e.position, 0.25, 0.25))
|
||||
.collect();
|
||||
let mobs: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
|
||||
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
|
||||
== "minecraft:end_gateway"
|
||||
});
|
||||
if let Some(pos) = found {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
for (id, pos) in inside {
|
||||
// One entity at a time: the gateway cools down for 40 ticks.
|
||||
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
|
||||
continue;
|
||||
}
|
||||
// An entity that just came through any portal stays out of this one.
|
||||
if self.restart_portal_cooldown(id) {
|
||||
continue;
|
||||
}
|
||||
self.end.gateways.entry(id).or_default().enter(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` for the entities in an End gateway: they go
|
||||
/// through to the gateway's exit, where they wait for its chunks.
|
||||
pub(super) fn handle_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let ids: Vec<(u32, bool)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.end.gateways.retain(|id, _| present.contains(id));
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, allowed) in ids {
|
||||
let Some(state) = self.end.gateways.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
// The End gateway has no transition time (Portal.getPortalTransitionTime).
|
||||
if let Some(entry) = state.travel(allowed) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.is_done() {
|
||||
self.end.gateways.remove(&id);
|
||||
}
|
||||
}
|
||||
for (id, entry) in leaving {
|
||||
self.send_through_gateway(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.teleportToPortalDestination` for an entity that goes through an
|
||||
/// End gateway: it is saved and waits for the exit's chunks.
|
||||
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
|
||||
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
|
||||
&self.dropped[i],
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
);
|
||||
self.dropped.remove(i);
|
||||
self.forget_entity(id);
|
||||
tag
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let mut tag = self.mob_tree_nbt(i);
|
||||
// Entity.removeAfterChangingDimensions drops the leash.
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
// The mobs riding it travel with it (their tags are in its compound).
|
||||
let mut gone = vec![id];
|
||||
let mut k = 0;
|
||||
while k < gone.len() {
|
||||
let v = gone[k];
|
||||
gone.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
self.mobs.retain(|m| !gone.contains(&m.id));
|
||||
for g in gone {
|
||||
self.forget_entity(g);
|
||||
}
|
||||
tag
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
self.end.arrivals.push(GatewayArrival { tag, job, target });
|
||||
}
|
||||
|
||||
/// The entities that reached a gateway's exit: they land where the
|
||||
/// gateway leads, once its chunks have loaded.
|
||||
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return Ok(());
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.end.arrivals.len() {
|
||||
let (cx, cz) = (
|
||||
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
|
||||
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
|
||||
let at = match self.resolve_gateway(job, target) {
|
||||
Ok(at) => at,
|
||||
Err(e) => {
|
||||
// The traveller waits for the exit: the failure is retried.
|
||||
log::warn!("End gateway: {e}");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut tag = self.end.arrivals.remove(i).tag;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
|
||||
// setPortalCooldown before the teleport.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
match self
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter_mut()
|
||||
.find(|(k, _)| *k == key)
|
||||
{
|
||||
Some((_, tags)) => tags.push(tag),
|
||||
None => self.world.loaded_entities.push((key, vec![tag])),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunks the entities waiting to arrive need, kept loaded like the
|
||||
/// areas a server keeps around its players.
|
||||
pub(super) fn keep_gateway_destinations(&mut self) {
|
||||
let areas = &self.end.areas;
|
||||
self.streaming
|
||||
.extra
|
||||
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
|
||||
let mut areas = Vec::new();
|
||||
for a in &self.end.arrivals {
|
||||
let c = (
|
||||
(a.target.x.floor() as i32) >> 4,
|
||||
(a.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !areas.contains(&c) {
|
||||
areas.push(c);
|
||||
self.streaming.extra.push((c, 1));
|
||||
}
|
||||
}
|
||||
self.end.areas = areas;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -382,6 +382,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Mobs. `WitherBoss.hurtServer`: a wither shrugs off an undead cause.
|
||||
let cause = self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.species());
|
||||
// Mobs, but not the one exploding (`getEntities(source, box)`): a
|
||||
// wither's spawn blast neither hurts nor pushes it.
|
||||
let spared = exploding.filter(|_| self.combat.explosion_source_named);
|
||||
@@ -417,7 +424,9 @@ impl Game<'_> {
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
if hurtable && !m.damage(damage(dist, exposure), source) {
|
||||
let shielded =
|
||||
cause.is_some_and(|c| horizon_client::mobs::wither::shielded_from(m.species(), c));
|
||||
if hurtable && (shielded || !m.damage(damage(dist, exposure), source)) {
|
||||
continue;
|
||||
}
|
||||
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`: its
|
||||
@@ -687,7 +696,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
self.pending.attach(pos, &mut stack);
|
||||
add_or_append_stack(stacks, stack, pos);
|
||||
let items = self.res.items();
|
||||
add_or_append_stack(stacks, stack, pos, &|s| {
|
||||
items.get(&s.item).map_or(64, |i| i.max_stack)
|
||||
});
|
||||
}
|
||||
}
|
||||
self.pending.clear(pos);
|
||||
@@ -708,15 +720,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerExplosion.addOrAppendStack` with `ItemEntity.merge(.., 16)`.
|
||||
/// `ServerExplosion.addOrAppendStack`: `ItemEntity.areMergable` (same item and
|
||||
/// components, combined count within the item's max stack size), then
|
||||
/// `ItemEntity.merge(.., 16)`.
|
||||
fn add_or_append_stack(
|
||||
stacks: &mut Vec<([i32; 3], ItemStack)>,
|
||||
mut stack: ItemStack,
|
||||
pos: [i32; 3],
|
||||
max_stack: &dyn Fn(&ItemStack) -> u32,
|
||||
) {
|
||||
let max = i64::from(max_stack(&stack));
|
||||
for (_, existing) in stacks.iter_mut() {
|
||||
if existing.item == stack.item && existing.damage == stack.damage && existing.count < 16 {
|
||||
let moved = (16 - existing.count).min(stack.count);
|
||||
if existing.same_item_same_components(&stack)
|
||||
&& i64::from(existing.count) + i64::from(stack.count) <= max
|
||||
{
|
||||
let moved = (max.min(16) - i64::from(existing.count))
|
||||
.min(i64::from(stack.count))
|
||||
.max(0) as u32;
|
||||
existing.count += moved;
|
||||
stack.count -= moved;
|
||||
if stack.count == 0 {
|
||||
@@ -754,12 +774,50 @@ mod tests {
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let max = |s: &ItemStack| {
|
||||
if &*s.item == "minecraft:diamond_sword" {
|
||||
1
|
||||
} else {
|
||||
64
|
||||
}
|
||||
};
|
||||
let mut stacks = Vec::new();
|
||||
for _ in 0..20 {
|
||||
add_or_append_stack(&mut stacks, stack("minecraft:dirt"), [0, 0, 0]);
|
||||
add_or_append_stack(&mut stacks, stack("minecraft:dirt"), [0, 0, 0], &max);
|
||||
}
|
||||
add_or_append_stack(&mut stacks, stack("minecraft:stone"), [1, 0, 0]);
|
||||
add_or_append_stack(&mut stacks, stack("minecraft:stone"), [1, 0, 0], &max);
|
||||
let counts: Vec<u32> = stacks.iter().map(|(_, s)| s.count).collect();
|
||||
assert_eq!(counts, vec![16, 4, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstackable_drops_stay_separate() {
|
||||
let sword = ItemStack {
|
||||
item: "minecraft:diamond_sword".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let max = |_: &ItemStack| 1;
|
||||
let mut stacks = Vec::new();
|
||||
add_or_append_stack(&mut stacks, sword.clone(), [0, 0, 0], &max);
|
||||
add_or_append_stack(&mut stacks, sword, [0, 0, 0], &max);
|
||||
let counts: Vec<u32> = stacks.iter().map(|(_, s)| s.count).collect();
|
||||
assert_eq!(counts, vec![1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_with_different_damage_stay_separate() {
|
||||
let pick = |damage| ItemStack {
|
||||
item: "minecraft:iron_pickaxe".into(),
|
||||
count: 1,
|
||||
damage,
|
||||
extra: None,
|
||||
};
|
||||
let max = |_: &ItemStack| 64;
|
||||
let mut stacks = Vec::new();
|
||||
add_or_append_stack(&mut stacks, pick(0), [0, 0, 0], &max);
|
||||
add_or_append_stack(&mut stacks, pick(5), [0, 0, 0], &max);
|
||||
assert_eq!(stacks.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -16,37 +16,32 @@ impl Game<'_> {
|
||||
"minecraft:carved_pumpkin" | "minecraft:jack_o_lantern"
|
||||
)
|
||||
}
|
||||
/// The blocks of the golem whose head is at `head` and its spawn base
|
||||
/// (`BlockPattern.find`), snow golems first.
|
||||
/// The blocks of the golem completed at `head` and its spawn base
|
||||
/// (`BlockPattern.find`), snow golems first: the snow column ("^", "#",
|
||||
/// "#") spawns at its bottom, the iron T ("~^~", "###", "~#~") under its
|
||||
/// head. The blocks come column by column, top first, as
|
||||
/// `clearPatternBlocks` visits them.
|
||||
fn find_golem(&self, head: [i32; 3]) -> Option<(&'static str, Vec<[i32; 3]>, [i32; 3])> {
|
||||
let below = |n: i32| [head[0], head[1] - n, head[2]];
|
||||
if self.golem_block(below(1)) == "minecraft:snow_block"
|
||||
&& self.golem_block(below(2)) == "minecraft:snow_block"
|
||||
{
|
||||
// The pattern ("^", "#", "#") is one column.
|
||||
return Some((
|
||||
"minecraft:snow_golem",
|
||||
vec![head, below(1), below(2)],
|
||||
below(2),
|
||||
));
|
||||
let air = |p: [i32; 3]| self.res.facts().is_air(self.world.block(p[0], p[1], p[2]));
|
||||
let snow = |c: char, p: [i32; 3]| match c {
|
||||
'^' => self.is_golem_head(p),
|
||||
_ => self.golem_block(p) == "minecraft:snow_block",
|
||||
};
|
||||
let iron = |c: char, p: [i32; 3]| match c {
|
||||
'^' => self.is_golem_head(p),
|
||||
'#' => self.golem_block(p) == "minecraft:iron_block",
|
||||
_ => air(p),
|
||||
};
|
||||
if let Some(cell) = find_pattern(head, &["^", "#", "#"], snow) {
|
||||
let blocks = (0..3).map(|y| cell(0, y)).collect();
|
||||
return Some(("minecraft:snow_golem", blocks, cell(0, 2)));
|
||||
}
|
||||
for (ax, az) in [(1, 0), (0, 1)] {
|
||||
let side = |s: i32, row: i32| [head[0] + ax * s, head[1] - row, head[2] + az * s];
|
||||
let arms = (-1..=1).all(|s| self.golem_block(side(s, 1)) == "minecraft:iron_block");
|
||||
let air = |p: [i32; 3]| self.res.facts().is_air(self.world.block(p[0], p[1], p[2]));
|
||||
if arms
|
||||
&& self.golem_block(side(0, 2)) == "minecraft:iron_block"
|
||||
&& [-1, 1].iter().all(|&s| air(side(s, 0)) && air(side(s, 2)))
|
||||
{
|
||||
// Every cell of ("~^~", "###", "~#~"), the air ones included.
|
||||
let blocks = (-1..=1)
|
||||
.flat_map(|s| (0..3).map(move |row| (s, row)))
|
||||
.map(|(s, row)| side(s, row))
|
||||
.collect();
|
||||
return Some(("minecraft:iron_golem", blocks, side(0, 2)));
|
||||
}
|
||||
}
|
||||
None
|
||||
let cell = find_pattern(head, &["~^~", "###", "~#~"], iron)?;
|
||||
let blocks = (0..3)
|
||||
.flat_map(|x| (0..3).map(move |y| (x, y)))
|
||||
.map(|(x, y)| cell(x, y))
|
||||
.collect();
|
||||
Some(("minecraft:iron_golem", blocks, cell(1, 2)))
|
||||
}
|
||||
/// `CarvedPumpkinBlock.canSpawnGolem`: a snow or iron golem base, or a
|
||||
/// copper block (`#minecraft:copper`) below.
|
||||
@@ -85,24 +80,25 @@ impl Game<'_> {
|
||||
{
|
||||
m.extra.player_created = true;
|
||||
}
|
||||
self.trigger_golem_summoned(id);
|
||||
self.trigger_summoned(id, 5.0);
|
||||
self.update_pattern_blocks(&blocks)
|
||||
}
|
||||
/// `CarvedPumpkinBlock.spawnGolemInWorld`: SUMMONED_ENTITY for each player
|
||||
/// whose box is within 5 blocks of the golem's box (the local player here).
|
||||
fn trigger_golem_summoned(&mut self, id: Option<u32>) {
|
||||
let Some(golem) = id
|
||||
/// SUMMONED_ENTITY for each player whose box is within `reach` blocks of
|
||||
/// the built mob's box (the local player here): 5 for golems
|
||||
/// (`CarvedPumpkinBlock.spawnGolemInWorld`), 50 for the wither
|
||||
/// (`WitherSkullBlock.checkSpawn`).
|
||||
pub(super) fn trigger_summoned(&mut self, id: Option<u32>, reach: f64) {
|
||||
let Some(built) = id
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
let half = f64::from(golem.width()) / 2.0;
|
||||
let height = f64::from(golem.height());
|
||||
let reach = 5.0;
|
||||
let half = f64::from(built.width()) / 2.0;
|
||||
let height = f64::from(built.height());
|
||||
let [x, y, z] = me.position;
|
||||
let p = golem.position;
|
||||
let p = built.position;
|
||||
// The player's box is 0.6 wide and 1.8 tall.
|
||||
let near = x + 0.3 >= p.x - half - reach
|
||||
&& x - 0.3 <= p.x + half + reach
|
||||
@@ -111,7 +107,7 @@ impl Game<'_> {
|
||||
&& z + 0.3 >= p.z - half - reach
|
||||
&& z - 0.3 <= p.z + half + reach;
|
||||
if near {
|
||||
let view = self.mob_entity(&golem);
|
||||
let view = self.mob_entity(&built);
|
||||
self.trigger_entity("minecraft:summoned_entity", "entity", &view);
|
||||
}
|
||||
}
|
||||
@@ -220,7 +216,7 @@ impl Game<'_> {
|
||||
f64::from(head[2]) + 0.5,
|
||||
];
|
||||
let id = self.create_triggered("minecraft:copper_golem", at, 0.0);
|
||||
self.trigger_golem_summoned(id);
|
||||
self.trigger_summoned(id, 5.0);
|
||||
self.update_pattern_blocks(&cells)?;
|
||||
let direction = match facing.as_str() {
|
||||
"south" => horizon_core::geometry::Direction::South,
|
||||
@@ -251,3 +247,130 @@ impl Game<'_> {
|
||||
self.process_level_events()
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockPattern.find` for a one-aisle pattern (`rows`, top first): the
|
||||
/// pattern's corner at every block from `pos` to `pos + size - 1` (x fastest,
|
||||
/// then y, then z), turned every way (`forwards`, then `up`, in `Direction`
|
||||
/// order, never along each other), until every cell passes `test`. The match
|
||||
/// gives the block of column x, row y (`BlockPatternMatch.getBlock(x, y, 0)`).
|
||||
fn find_pattern(
|
||||
pos: [i32; 3],
|
||||
rows: &[&str],
|
||||
test: impl Fn(char, [i32; 3]) -> bool,
|
||||
) -> Option<impl Fn(i32, i32) -> [i32; 3]> {
|
||||
// Direction.values(): down, up, north, south, west, east.
|
||||
const DIRECTIONS: [[i32; 3]; 6] = [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
];
|
||||
let size = rows.len().max(rows[0].len()) as i32;
|
||||
let corners =
|
||||
(0..size).flat_map(|z| (0..size).flat_map(move |y| (0..size).map(move |x| [x, y, z])));
|
||||
for d in corners {
|
||||
let origin = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
|
||||
for forwards in DIRECTIONS {
|
||||
for up in DIRECTIONS {
|
||||
if up == forwards || up == forwards.map(|v| -v) {
|
||||
continue;
|
||||
}
|
||||
// translateAndRotate: right is forwards × up, rows go down `up`.
|
||||
let right = [
|
||||
forwards[1] * up[2] - forwards[2] * up[1],
|
||||
forwards[2] * up[0] - forwards[0] * up[2],
|
||||
forwards[0] * up[1] - forwards[1] * up[0],
|
||||
];
|
||||
let cell =
|
||||
move |x: i32, y: i32| [0, 1, 2].map(|i| origin[i] - up[i] * y + right[i] * x);
|
||||
let matches = rows.iter().enumerate().all(|(y, row)| {
|
||||
row.chars()
|
||||
.enumerate()
|
||||
.all(|(x, c)| test(c, cell(x as i32, y as i32)))
|
||||
});
|
||||
if matches {
|
||||
return Some(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::find_pattern;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const SNOW: [&str; 3] = ["^", "#", "#"];
|
||||
const IRON: [&str; 3] = ["~^~", "###", "~#~"];
|
||||
|
||||
/// `find_pattern` over `blocks` (anything else is air): the cells of the
|
||||
/// match, column by column, top first.
|
||||
fn find(rows: &[&str], head: [i32; 3], blocks: &[([i32; 3], char)]) -> Option<Vec<[i32; 3]>> {
|
||||
let world: HashMap<_, _> = blocks.iter().copied().collect();
|
||||
let at = |p: [i32; 3]| world.get(&p).copied().unwrap_or('~');
|
||||
let cell = find_pattern(head, rows, |c, p| at(p) == c)?;
|
||||
let width = rows[0].len() as i32;
|
||||
Some(
|
||||
(0..width)
|
||||
.flat_map(|x| (0..rows.len() as i32).map(move |y| (x, y)))
|
||||
.map(|(x, y)| cell(x, y))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snow_golem_column_counts_upright_lying_and_upside_down() {
|
||||
let head = [5, 64, 5];
|
||||
for step in [
|
||||
[0, -1, 0],
|
||||
[1, 0, 0],
|
||||
[-1, 0, 0],
|
||||
[0, 0, 1],
|
||||
[0, 0, -1],
|
||||
[0, 1, 0],
|
||||
] {
|
||||
let at = |n: i32| [0, 1, 2].map(|i| head[i] + step[i] * n);
|
||||
let blocks = [(at(0), '^'), (at(1), '#'), (at(2), '#')];
|
||||
assert_eq!(
|
||||
find(&SNOW, head, &blocks),
|
||||
Some(vec![at(0), at(1), at(2)]),
|
||||
"snow blocks going {step:?} from the head"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bent_or_short_snow_column_is_no_golem() {
|
||||
let head = [0, 0, 0];
|
||||
let bent = [(head, '^'), ([0, -1, 0], '#'), ([1, -1, 0], '#')];
|
||||
assert_eq!(find(&SNOW, head, &bent), None);
|
||||
assert_eq!(find(&SNOW, head, &[(head, '^'), ([0, -1, 0], '#')]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_iron_golem_t_counts_lying_on_its_back() {
|
||||
// Head at the origin, body along +x, arms along z, all at y 0.
|
||||
let head = [0, 0, 0];
|
||||
let blocks = [
|
||||
(head, '^'),
|
||||
([1, 0, -1], '#'),
|
||||
([1, 0, 0], '#'),
|
||||
([1, 0, 1], '#'),
|
||||
([2, 0, 0], '#'),
|
||||
];
|
||||
let cells = find(&IRON, head, &blocks).expect("a lying iron golem");
|
||||
// Column 1, rows 0 to 2: the head, the body and the legs.
|
||||
assert_eq!(&cells[3..6], &[[0, 0, 0], [1, 0, 0], [2, 0, 0]]);
|
||||
let mut arms_out = blocks.to_vec();
|
||||
arms_out.push(([2, 0, 1], '#'));
|
||||
assert_eq!(
|
||||
find(&IRON, head, &arms_out),
|
||||
None,
|
||||
"a block beside the legs"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -521,6 +521,11 @@ fn mob_meta(
|
||||
defaults.push((collar, serializer::INT, Value::Int(red)));
|
||||
// Wolf.DATA_ANGER_END_TIME: the game time the anger ends, -1 when calm.
|
||||
if kind == "minecraft:wolf" {
|
||||
// Wolf.DATA_INTERESTED_ID: BegGoal's flag, the head tilt.
|
||||
if e.flag {
|
||||
meta.push((20, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((20, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if e.anger > 0 {
|
||||
let end = game_time + i64::from(e.anger);
|
||||
meta.push((22, serializer::LONG, Value::Long(end)));
|
||||
@@ -2160,11 +2165,8 @@ impl Game<'_> {
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.health.hurt_animation_time);
|
||||
m.extra.main_hand = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.map(|s| s.item.clone());
|
||||
m.extra
|
||||
.set_main_hand_stack(g.inventory.get(g.selected).cloned().flatten());
|
||||
m.extra.off_hand = g.offhand.as_ref().map(|s| s.item.clone());
|
||||
[m.extra.head, m.extra.chest, m.extra.legs, m.extra.feet] = g
|
||||
.armor
|
||||
@@ -2293,6 +2295,14 @@ impl Game<'_> {
|
||||
);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
self.player_pumpkin_geometry(
|
||||
m,
|
||||
host.hub.guest(*conn).and_then(|g| g.armor[0].as_ref()),
|
||||
camera,
|
||||
alpha,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
|
||||
let guest = host.hub.guest(*conn);
|
||||
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
|
||||
|
||||
@@ -173,7 +173,9 @@ pub(super) struct GuestSwap {
|
||||
/// The guest's own damage record, in place while they act.
|
||||
combat: super::combat::CombatTracker,
|
||||
/// The guest whose turn this one nests in, if any.
|
||||
acting: (Option<u32>, Option<String>, Option<u128>),
|
||||
acting: (Option<u32>, Option<String>, Option<u128>, Option<u8>),
|
||||
/// That turn's `known_movement`.
|
||||
known_movement: Vec3,
|
||||
/// The packets queued for that guest.
|
||||
packets: Vec<play::Clientbound>,
|
||||
/// The entity events queued for that guest.
|
||||
@@ -401,10 +403,13 @@ impl Game<'_> {
|
||||
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
|
||||
let marks = std::mem::replace(&mut self.adv.marks, std::mem::take(&mut play.marks));
|
||||
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
|
||||
let [x, y, z] = g.known_movement;
|
||||
let known_movement = std::mem::replace(&mut self.known_movement, Vec3::new(x, y, z));
|
||||
let acting = (
|
||||
self.lan_acting.replace(conn),
|
||||
self.lan_acting_name.replace(g.name.clone()),
|
||||
self.lan_acting_uuid.replace(g.uuid),
|
||||
self.lan_acting_permission.replace(g.permission),
|
||||
);
|
||||
let packets = std::mem::take(&mut self.guest_packets);
|
||||
let entity_events = std::mem::take(&mut self.guest_entity_events);
|
||||
@@ -475,6 +480,7 @@ impl Game<'_> {
|
||||
marks,
|
||||
death_cause,
|
||||
acting,
|
||||
known_movement,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
@@ -537,6 +543,7 @@ impl Game<'_> {
|
||||
death_cause,
|
||||
combat,
|
||||
acting,
|
||||
known_movement,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
@@ -581,7 +588,13 @@ impl Game<'_> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
(self.lan_acting, self.lan_acting_name, self.lan_acting_uuid) = acting;
|
||||
(
|
||||
self.lan_acting,
|
||||
self.lan_acting_name,
|
||||
self.lan_acting_uuid,
|
||||
self.lan_acting_permission,
|
||||
) = acting;
|
||||
self.known_movement = known_movement;
|
||||
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
|
||||
// An ender chest the guest opened: the menu shows their inventory
|
||||
// itself (lan_open_container), not a stand-in block entity.
|
||||
@@ -738,7 +751,8 @@ impl Game<'_> {
|
||||
// LivingEntity.startSleeping: in the bed, still.
|
||||
let at = [
|
||||
f64::from(head[0]) + 0.5,
|
||||
f64::from(head[1]) + 0.5625,
|
||||
// setPosToBed: the bed's top (getSleepHeight) and 0.125 above it.
|
||||
f64::from(head[1]) + 0.5625 + 0.125,
|
||||
f64::from(head[2]) + 0.5,
|
||||
];
|
||||
g.position = at;
|
||||
@@ -1250,14 +1264,7 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (p.yaw, p.pitch);
|
||||
// ServerPlayer.getKnownMovement: the move their client just sent,
|
||||
// which a throw adds to the projectile.
|
||||
let movement = g
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
g.guest_use = Some(GuestUse::Item(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
@@ -1784,14 +1791,8 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let at = g.position;
|
||||
// ServerPlayer.getKnownMovement: the move their client just sent.
|
||||
let movement = g
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
let sound = self
|
||||
.guest_hit_by(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
if g.cannot_attack_with_item(g.attack_ticks as i32, 5) {
|
||||
None
|
||||
} else {
|
||||
@@ -2241,13 +2242,7 @@ impl Game<'_> {
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
if digging.is_some() || using || delayed {
|
||||
// ServerPlayer.getKnownMovement: the move their client just sent.
|
||||
let movement = host.hub.guests[gi]
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
g.attack_ticks = g.attack_ticks.saturating_add(1);
|
||||
if let Some((pos, face)) = digging {
|
||||
g.forced_hit = Some(Hit {
|
||||
|
||||
@@ -10,7 +10,7 @@ use horizon_client::lan::{
|
||||
playerdata::ints_uuid,
|
||||
};
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{nbt::Tag, registry::BlockStateId};
|
||||
|
||||
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
|
||||
EntityState {
|
||||
@@ -124,8 +124,12 @@ impl Game<'_> {
|
||||
e.meta = vehicle_meta(c.hurt_time, c.hurt_dir, c.damage);
|
||||
match c.kind {
|
||||
// MinecartFurnace.DATA_ID_FUEL.
|
||||
super::minecarts::CartKind::Furnace if c.fuel > 0 => {
|
||||
e.meta.push((13, serializer::BOOLEAN, Value::Bool(true)));
|
||||
super::minecarts::CartKind::Furnace => {
|
||||
e.defaults
|
||||
.push((13, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if c.fuel > 0 {
|
||||
e.meta.push((13, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// MinecartCommandBlock.DATA_ID_COMMAND_NAME.
|
||||
super::minecarts::CartKind::CommandBlock => {
|
||||
@@ -287,18 +291,100 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// DATA_ITEM, `ItemStack.EMPTY` by default.
|
||||
for o in self.ominous_spawners.iter().filter(|o| o.id != 0) {
|
||||
let mut e = state(
|
||||
o.id,
|
||||
"minecraft:ominous_item_spawner",
|
||||
o.position,
|
||||
0.0,
|
||||
false,
|
||||
);
|
||||
e.meta.push((
|
||||
8,
|
||||
serializer::ITEM_STACK,
|
||||
Value::Item(hub.stack(&Some(o.item.clone()))),
|
||||
));
|
||||
out.push(e);
|
||||
}
|
||||
self.lan_projectile_states(host_entity, hub, out);
|
||||
for o in &self.others {
|
||||
let p = o.position;
|
||||
let mut e = state(o.id, &o.kind, [p.x, p.y, p.z], o.yaw, false);
|
||||
e.pitch = o.pitch;
|
||||
e.head_yaw = o.yaw;
|
||||
if let Some(tag) = self.persist.raw.get(&o.id) {
|
||||
self.lan_display_data(tag, hub, &mut e);
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The synched data of a text, block or item display kept unsimulated,
|
||||
/// from its compound (`Display.readAdditionalSaveData`), with the
|
||||
/// defaults to send when a value goes back to one.
|
||||
fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
|
||||
use horizon_client::text_display::{display_data, text_display_data};
|
||||
let empty = Tag::Compound(Default::default());
|
||||
let (meta, defaults) = match e.kind.as_str() {
|
||||
"minecraft:text_display" => (
|
||||
text_display_data(tag, false),
|
||||
text_display_data(&empty, true),
|
||||
),
|
||||
"minecraft:block_display" => {
|
||||
// DATA_BLOCK_STATE_ID, air by default.
|
||||
let state = |t: Option<BlockStateId>| {
|
||||
(
|
||||
23,
|
||||
serializer::BLOCK_STATE,
|
||||
Value::Id(t.map_or(0, |s| s.0 as i32)),
|
||||
)
|
||||
};
|
||||
let air = self.pack.assets.registry.default_state("minecraft:air");
|
||||
let shown = tag.get("block_state").and_then(|t| self.state_from_nbt(t));
|
||||
(vec![state(shown.or(air))], vec![state(air)])
|
||||
}
|
||||
"minecraft:item_display" => {
|
||||
// DATA_ITEM_STACK and DATA_ITEM_DISPLAY_ID (`ItemDisplayContext`).
|
||||
let item = tag
|
||||
.get("item")
|
||||
.and_then(horizon_client::save::item_from_nbt);
|
||||
let context = match tag.get("item_display") {
|
||||
Some(Tag::String(s)) => match s.to_string_lossy().as_str() {
|
||||
"thirdperson_lefthand" => 1,
|
||||
"thirdperson_righthand" => 2,
|
||||
"firstperson_lefthand" => 3,
|
||||
"firstperson_righthand" => 4,
|
||||
"head" => 5,
|
||||
"gui" => 6,
|
||||
"ground" => 7,
|
||||
"fixed" => 8,
|
||||
"on_shelf" => 9,
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
};
|
||||
(
|
||||
vec![
|
||||
(23, serializer::ITEM_STACK, Value::Item(hub.stack(&item))),
|
||||
(24, serializer::BYTE, Value::Byte(context)),
|
||||
],
|
||||
vec![
|
||||
(23, serializer::ITEM_STACK, Value::Item(None)),
|
||||
(24, serializer::BYTE, Value::Byte(0)),
|
||||
],
|
||||
)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
e.meta = display_data(tag, false);
|
||||
e.meta.extend(meta);
|
||||
e.defaults = display_data(&empty, true);
|
||||
e.defaults.extend(defaults);
|
||||
}
|
||||
|
||||
/// `ClientboundAddEntityPacket(Entity, ServerEntity, int)`: the entity's
|
||||
/// own UUID (`Entity.getUUID`), the one `/data`, saves and UUID
|
||||
/// selectors use, so a client can name it back (the crosshair UUID that
|
||||
@@ -347,6 +433,9 @@ impl Game<'_> {
|
||||
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
|
||||
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
|
||||
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.ominous_spawners
|
||||
.iter_mut()
|
||||
.for_each(|o| give(&mut o.id));
|
||||
self.potions.thrown_ids().for_each(&mut give);
|
||||
self.end.eye_ids().for_each(&mut give);
|
||||
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
|
||||
@@ -398,7 +487,12 @@ impl Game<'_> {
|
||||
}
|
||||
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let kind = p.kind.id();
|
||||
// A spectral arrow is its own entity type.
|
||||
let kind = if p.is_spectral_arrow() {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
p.kind.id()
|
||||
};
|
||||
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
|
||||
e.pitch = p.pitch;
|
||||
e.velocity = at(p.velocity);
|
||||
@@ -417,6 +511,11 @@ impl Game<'_> {
|
||||
a.map_or(0, |a| a.pierce),
|
||||
a.is_some_and(|a| a.in_ground) || p.stuck,
|
||||
));
|
||||
// Arrow.ID_EFFECT_COLOR, -1 by default.
|
||||
let color = p.arrow_effect_color();
|
||||
if color != -1 {
|
||||
e.meta.push((11, serializer::INT, Value::Int(color)));
|
||||
}
|
||||
}
|
||||
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
|
||||
// AbstractArrow's flags.
|
||||
|
||||
@@ -61,6 +61,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.love_cause.insert(self.mobs[index].id, self.lan_acting);
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
}
|
||||
self.llama_sound(index, "entity.llama.eat", 1.0);
|
||||
|
||||
@@ -229,14 +229,7 @@ impl Game<'_> {
|
||||
&& max > 0
|
||||
&& !self.creative
|
||||
{
|
||||
let loss = self.wear(stack, per_attack);
|
||||
let cell = self.held_mut();
|
||||
if let Some(held) = cell.as_mut() {
|
||||
held.damage += loss;
|
||||
if held.damage >= max {
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
self.damage_held(per_attack);
|
||||
}
|
||||
}
|
||||
/// `MaceItem.hurtEnemy` for a smash: the fall stops and its damage is
|
||||
|
||||
@@ -80,9 +80,10 @@ pub(super) struct Minecart {
|
||||
pub hurt_dir: i32,
|
||||
pub damage: f32,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
/// Furnace minecarts: fuel ticks and the push direction.
|
||||
/// Furnace minecarts: fuel ticks and `MinecartFurnace.push`. Its y part
|
||||
/// stays between ticks (it sets the push's length), only saving drops it.
|
||||
pub fuel: i32,
|
||||
pub push: [f64; 2],
|
||||
pub push: Vec3,
|
||||
/// TNT minecarts: ticks until the explosion, -1 when not primed.
|
||||
pub fuse: i32,
|
||||
/// Hopper minecarts: an unpowered activator rail enables them.
|
||||
@@ -99,6 +100,17 @@ pub(super) const HEIGHT: f64 = 0.7;
|
||||
fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
}
|
||||
/// `MinecartFurnace.calculateNewPushAlong`: the push turned onto the
|
||||
/// movement's direction, at the push's own length (3D, so it holds from tick
|
||||
/// to tick while the movement has a vertical part).
|
||||
fn push_along(push: Vec3, movement: Vec3) -> Vec3 {
|
||||
if push.horizontal_distance_squared() > 1.0e-4 && movement.horizontal_distance_squared() > 0.001
|
||||
{
|
||||
push.projected_on(movement).normalize().scale(push.length())
|
||||
} else {
|
||||
push
|
||||
}
|
||||
}
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
@@ -184,7 +196,7 @@ impl Minecart {
|
||||
damage: 0.0,
|
||||
items: vec![None; kind.slots()],
|
||||
fuel: 0,
|
||||
push: [0.0; 2],
|
||||
push: Vec3::ZERO,
|
||||
fuse: -1,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
@@ -372,22 +384,9 @@ impl Game<'_> {
|
||||
let mut v = c.velocity;
|
||||
let water = self.cart_in_water(c);
|
||||
if c.kind == CartKind::Furnace {
|
||||
let push = c.push;
|
||||
if push[0] * push[0] + push[1] * push[1] > 1.0e-7 {
|
||||
// calculateNewPushAlong
|
||||
let len = push[0].hypot(push[1]);
|
||||
if len * len > 1.0e-4 && v.x * v.x + v.z * v.z > 0.001 {
|
||||
let d = v.x * v.x + v.y * v.y + v.z * v.z;
|
||||
let t = (push[0] * v.x + push[1] * v.z) / d;
|
||||
let (px, py, pz) = (v.x * t, v.y * t, v.z * t);
|
||||
let n = (px * px + py * py + pz * pz).sqrt();
|
||||
c.push = if n > 1.0e-4 {
|
||||
[px / n * len, pz / n * len]
|
||||
} else {
|
||||
[0.0, 0.0]
|
||||
};
|
||||
}
|
||||
v = Vec3::new(v.x * 0.8 + c.push[0], 0.0, v.z * 0.8 + c.push[1]);
|
||||
if c.push.length_squared() > 1.0e-7 {
|
||||
c.push = push_along(c.push, v);
|
||||
v = Vec3::new(v.x * 0.8 + c.push.x, 0.0, v.z * 0.8 + c.push.z);
|
||||
if water {
|
||||
v = Vec3::new(v.x * 0.1, 0.0, v.z * 0.1);
|
||||
}
|
||||
@@ -587,7 +586,7 @@ impl Game<'_> {
|
||||
c.fuel -= 1;
|
||||
}
|
||||
if c.fuel <= 0 {
|
||||
c.push = [0.0; 2];
|
||||
c.push = Vec3::ZERO;
|
||||
}
|
||||
if c.fuel > 0 && self.world.random.next_int_bound(4).unwrap_or(1) == 0 {
|
||||
let at = [c.position.x, c.position.y + 0.8, c.position.z];
|
||||
@@ -1083,7 +1082,7 @@ impl Game<'_> {
|
||||
if fuel && c.fuel + 3600 <= 32000 {
|
||||
c.fuel += 3600;
|
||||
let p = self.player.as_ref().map_or(c.position, |p| p.position);
|
||||
c.push = [c.position.x - p.x, c.position.z - p.z];
|
||||
c.push = Vec3::new(c.position.x - p.x, 0.0, c.position.z - p.z);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -1230,7 +1229,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Baked block faces with terrain shade or block-item diffuse lighting.
|
||||
/// Baked block-state faces with default block tints and terrain shade or diffuse lighting.
|
||||
pub(super) fn block_model_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
@@ -1240,18 +1239,24 @@ impl Game<'_> {
|
||||
diffuse: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.block_model_vertices_tinted(state, seed, m, light, diffuse, 0xFF_FFFF, out);
|
||||
// BlockTintSource.color(state) with no level: each tinted face's default colour.
|
||||
let grass = self.pack.item_renderer.grass_color(0.5, 1.0) as u32;
|
||||
let tables = &self.pack.context.tables;
|
||||
let tint = |index: i32| tables.default_tint(state, index, grass);
|
||||
self.tinted_block_model_vertices(state, seed, m, |_| light, diffuse, tint, out);
|
||||
}
|
||||
/// `block_model_vertices` with `tint` (RGB) on the faces that have a tint index.
|
||||
/// `block_model_vertices` with each face multiplied by `tint` of its
|
||||
/// tint index (RGB, `None` for untinted faces) and lit by `light` of
|
||||
/// each quad.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn block_model_vertices_tinted(
|
||||
pub(super) fn tinted_block_model_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
m: glam::Mat4,
|
||||
light: u32,
|
||||
light: impl Fn(&horizon_assets::bake::ModelQuad) -> u32,
|
||||
diffuse: bool,
|
||||
tint: u32,
|
||||
tint: impl Fn(i32) -> Option<u32>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
|
||||
@@ -1274,17 +1279,17 @@ impl Game<'_> {
|
||||
D::North | D::South => 204,
|
||||
D::West | D::East => 153,
|
||||
};
|
||||
let color = if diffuse {
|
||||
let mut color = if diffuse {
|
||||
u32::MAX
|
||||
} else {
|
||||
0xFF00_0000 | shade << 16 | shade << 8 | shade
|
||||
};
|
||||
let color = if face.quad.tint_index >= 0 {
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
horizon_client::hud_layout::multiply(color, tint)
|
||||
} else {
|
||||
color
|
||||
};
|
||||
if let Some(t) = tint(face.quad.tint_index) {
|
||||
color = horizon_client::hud_layout::multiply(
|
||||
color,
|
||||
horizon_client::mobs::render::abgr(t),
|
||||
);
|
||||
}
|
||||
let normal = if diffuse {
|
||||
use horizon_client::item_render::{direction_vector, transform_normal};
|
||||
let n = transform_normal(m, direction_vector(face.quad.facing));
|
||||
@@ -1293,6 +1298,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
[0; 4]
|
||||
};
|
||||
let light = light(&face.quad);
|
||||
let light = if diffuse {
|
||||
let emission = u32::from(face.quad.light_emission) * 16;
|
||||
(light & 0xFF) | (light >> 8 & 0xFF).max(emission) << 8
|
||||
@@ -1534,8 +1540,8 @@ impl Minecart {
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push[0].to_bits()));
|
||||
put("PushZ", Tag::Double(self.push[1].to_bits()));
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
@@ -1581,7 +1587,8 @@ impl Minecart {
|
||||
}
|
||||
c.enabled = int("Enabled").unwrap_or(1) != 0;
|
||||
c.fuel = int("Fuel").unwrap_or(0);
|
||||
c.push = [double("PushX"), double("PushZ")];
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
Some(c)
|
||||
}
|
||||
@@ -1626,4 +1633,40 @@ mod tests {
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn furnace_push_keeps_its_length_under_gravity() {
|
||||
// Gravity gives the movement a vertical part. The push turns onto the
|
||||
// movement at the same 3D length every tick, as vanilla's does.
|
||||
let movement = Vec3::new(0.2, -0.04, 0.0);
|
||||
let mut push = Vec3::new(0.5, 0.0, 0.0);
|
||||
for _ in 0..1000 {
|
||||
push = push_along(push, movement);
|
||||
}
|
||||
assert!((push.length() - 0.5).abs() < 1e-12);
|
||||
assert!((push.x - 0.5 * 0.2 / movement.length()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_furnace_saves_its_push_without_y() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let generated = Tag::compound([
|
||||
("id", Tag::String("minecraft:furnace_minecart".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(
|
||||
[0.5f64, 40.5, 0.5]
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.to_vec(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Furnace).unwrap();
|
||||
cart.push = Vec3::new(0.5, 0.7, 0.25);
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("PushX"), Some(&Tag::Double(0.5f64.to_bits())));
|
||||
assert_eq!(saved.get("PushZ"), Some(&Tag::Double(0.25f64.to_bits())));
|
||||
let loaded = Minecart::from_nbt(&saved, 1, CartKind::Furnace).unwrap();
|
||||
assert_eq!(loaded.push, Vec3::new(0.5, 0.0, 0.25));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
|
||||
//! block an enderman holds (`CarriedBlockLayer`), drawn as baked block models.
|
||||
//! Also the block models mobs wear (`SnowGolemHeadLayer`,
|
||||
//! `MushroomCowMushroomLayer`).
|
||||
//! block state an enderman holds (`CarriedBlockLayer`). Also the block
|
||||
//! models mobs wear (`SnowGolemHeadLayer`, `MushroomCowMushroomLayer`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
@@ -9,6 +8,16 @@ use horizon_client::{
|
||||
mobs::{Mob, Species, render},
|
||||
};
|
||||
|
||||
/// `CarriedBlockLayer.submit`: block models use coordinates in [0, 1].
|
||||
fn carried_block_matrix(base: glam::Mat4) -> glam::Mat4 {
|
||||
base * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
|
||||
* glam::Mat4::from_rotation_x(20.0f32.to_radians())
|
||||
* glam::Mat4::from_rotation_y(45.0f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.1875, 0.25))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5))
|
||||
* glam::Mat4::from_rotation_y(90.0f32.to_radians())
|
||||
}
|
||||
|
||||
/// `CustomHeadLayer.translateToHead` and the banner's `head` display.
|
||||
fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
|
||||
head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
@@ -25,6 +34,42 @@ fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
|
||||
pub(super) const APPARITION_TICKS: i32 = 30;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CustomHeadLayer` for a pumpkin worn by a LAN guest drawn on the
|
||||
/// in-frame host, whose model has no worn stacks for `head_item_geometry`.
|
||||
pub(super) fn player_pumpkin_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
stack: Option<&ItemStack>,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(stack) = stack.filter(|s| &*s.item == "minecraft:carved_pumpkin") else {
|
||||
return;
|
||||
};
|
||||
let Some(head) =
|
||||
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pose = head
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
|
||||
let owner = super::item_draw::owner_at(
|
||||
[mob.position.x, mob.position.y, mob.position.z],
|
||||
mob.body_yaw,
|
||||
&mob.kind.id,
|
||||
);
|
||||
let state = self.resolve_item(
|
||||
stack,
|
||||
horizon_assets::item_model::DisplayContext::Head,
|
||||
Some(&owner),
|
||||
mob.id as i32 + 5,
|
||||
);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
/// Count the elder guardian's apparition down.
|
||||
pub(super) fn tick_apparition(&mut self) {
|
||||
self.creatures.elder_apparition = (self.creatures.elder_apparition - 1).max(0);
|
||||
@@ -150,22 +195,17 @@ impl Game<'_> {
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
self.head_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.golem_flower_geometry(mob, camera, alpha, light, out);
|
||||
if mob.species() == Species::Enderman
|
||||
&& let Some(state) = mob.extra.carried
|
||||
&& let Some((_, tint)) = self.block_face_sprite(state)
|
||||
{
|
||||
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint;
|
||||
// the block's baked model (seed 42) on the unit cube, not centred.
|
||||
let base = render::base_transform(mob, alpha as f32, camera);
|
||||
let t = base
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
|
||||
* glam::Mat4::from_rotation_x(20.0f32.to_radians())
|
||||
* glam::Mat4::from_rotation_y(45.0f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.1875, 0.25))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5))
|
||||
* glam::Mat4::from_rotation_y(90.0f32.to_radians());
|
||||
self.block_model_vertices_tinted(state, 42, t, light, true, tint, out);
|
||||
let t = carried_block_matrix(base);
|
||||
// Preserve cross models (flowers, fungi) and state properties (facing).
|
||||
self.block_model_vertices(state, 42, t, light, true, out);
|
||||
}
|
||||
}
|
||||
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
|
||||
@@ -357,4 +397,158 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CustomHeadLayer`: non-armour items worn in the head slot (carved
|
||||
/// pumpkins, mob skulls and player heads, or custom blocks) on players
|
||||
/// and mobs.
|
||||
pub(super) fn head_item_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
lit: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if horizon_client::mobs::trader::is_invisible(mob) {
|
||||
return;
|
||||
}
|
||||
let species = mob.species();
|
||||
if species == Species::SnowGolem
|
||||
|| horizon_client::mobs::species::body_slot(species)
|
||||
|| horizon_client::mobs::equine::is_equine(species)
|
||||
|| horizon_client::mobs::raider::is_captain(mob)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let local_player = self
|
||||
.player_model
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.id == mob.id && p.kind.id == mob.kind.id);
|
||||
let mut stack = if local_player {
|
||||
let Some(stack) = &self.armor[0] else { return };
|
||||
stack.clone()
|
||||
} else if let Some(stack) = &mob.extra.armor[0] {
|
||||
stack.clone()
|
||||
} else if let Some(head) = &mob.extra.head {
|
||||
ItemStack::new(head, 1)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
// Helmets are drawn by HumanoidArmorLayer, not CustomHeadLayer.
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_asset.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !local_player
|
||||
&& horizon_client::mobs::gear::enchanted(mob, horizon_client::mobs::gear::HEAD)
|
||||
{
|
||||
stack.enchant("minecraft:unbreaking", 1);
|
||||
}
|
||||
let Some(head) =
|
||||
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(kind) = super::armor_stands::skull_kind(&stack.item) {
|
||||
let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
|
||||
let special = horizon_assets::item_model::SpecialModel {
|
||||
kind: if kind == "player" {
|
||||
horizon_assets::item_model::SpecialKind::PlayerHead
|
||||
} else {
|
||||
horizon_assets::item_model::SpecialKind::Head {
|
||||
kind: kind.to_owned(),
|
||||
texture: None,
|
||||
animation: 0.0,
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut sink = horizon_client::item_render::WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: if lit {
|
||||
Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
glint: self.glint(),
|
||||
};
|
||||
let profile = stack
|
||||
.extra
|
||||
.as_deref()
|
||||
.and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile"))
|
||||
.map(|(_, t)| horizon_client::scoreboard::tag_to_json(t))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let has_foil = stack.data().is_some_and(|d| !d.enchantments.is_empty());
|
||||
self.pack.item_renderer.submit_special(
|
||||
&special,
|
||||
&profile,
|
||||
has_foil,
|
||||
pose,
|
||||
&self.gui.atlas.sprites,
|
||||
&self.item_host(),
|
||||
&mut sink,
|
||||
);
|
||||
return;
|
||||
}
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
let pose = head
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
|
||||
let owner = super::item_draw::owner_at(
|
||||
[mob.position.x, mob.position.y, mob.position.z],
|
||||
mob.body_yaw,
|
||||
&mob.kind.id,
|
||||
);
|
||||
let seed = mob.id as i32 + 5;
|
||||
let state = self.resolve_item(&stack, DisplayContext::Head, Some(&owner), seed);
|
||||
if lit {
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
} else {
|
||||
self.submit_item_unlit(&state, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
#[test]
|
||||
fn carried_block_vertices_match_vanilla_block_coordinates() {
|
||||
// Captured from 26.3 CarriedBlockLayer.submit with an identity PoseStack:
|
||||
// origin, three unit axes, opposite corner, and the block's center.
|
||||
let samples = [
|
||||
(Vec3::ZERO, Vec3::new(0.3535534, 0.8636924, -0.68587124)),
|
||||
(Vec3::X, Vec3::new(0.0, 0.9846148, -1.0181028)),
|
||||
(Vec3::Y, Vec3::new(0.3535534, 0.3938461, -0.8568813)),
|
||||
(Vec3::Z, Vec3::new(0.0, 0.74277, -0.35363975)),
|
||||
(Vec3::ONE, Vec3::new(-0.3535534, 0.3938461, -0.85688126)),
|
||||
(Vec3::splat(0.5), Vec3::new(0.0, 0.6287693, -0.77137625)),
|
||||
];
|
||||
for base in [
|
||||
Mat4::IDENTITY,
|
||||
Mat4::from_translation(Vec3::new(3.0, 2.0, -7.0))
|
||||
* Mat4::from_rotation_y(1.2)
|
||||
* Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0)),
|
||||
] {
|
||||
let block = carried_block_matrix(base);
|
||||
for (point, expected) in samples {
|
||||
let actual = block.transform_point3(point);
|
||||
let expected = base.transform_point3(expected);
|
||||
assert!(
|
||||
actual.abs_diff_eq(expected, 1e-6),
|
||||
"{actual:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,13 +182,13 @@ impl Game<'_> {
|
||||
0
|
||||
};
|
||||
if level > 0 {
|
||||
m.extra.main_hand = Some("minecraft:crossbow".into());
|
||||
m.extra.set_main_hand(Some("minecraft:crossbow".into()));
|
||||
gear::set(m, MAIN, Vec::new());
|
||||
gear::upgrade(m, MAIN, "minecraft:quick_charge", level);
|
||||
}
|
||||
}
|
||||
RaiderType::Vindicator => {
|
||||
m.extra.main_hand = Some("minecraft:iron_axe".into());
|
||||
m.extra.set_main_hand(Some("minecraft:iron_axe".into()));
|
||||
gear::set(m, MAIN, Vec::new());
|
||||
if enchant {
|
||||
let level = if wave > raid::num_groups(2) { 2 } else { 1 };
|
||||
|
||||
@@ -293,6 +293,7 @@ impl Game<'_> {
|
||||
self.audio.play(sound, Some(at), 1.0, pitch);
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.love_cause.insert(id, self.lan_acting);
|
||||
self.mob_event_particles(id, 18);
|
||||
}
|
||||
self.consume_held();
|
||||
|
||||
Loaded 100 of 410 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user